mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Remove GenericCallLinkCoordinator, merging it into CallScreen. (#3181)
* Remove incorrect message send call and fix typo. * Add overlay coordinator presentation to the NavigationRootCoordinator. * Remove GenericCallLinkCoordinator, merging it into CallScreen. This will allow for picture in picture on call links when available.
This commit is contained in:
@@ -212,7 +212,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
||||
if let userSessionFlowCoordinator {
|
||||
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
|
||||
} else {
|
||||
navigationRootCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)))
|
||||
presentCallScreen(genericCallLink: url)
|
||||
}
|
||||
case .userProfile(let userID):
|
||||
if isExternalURL {
|
||||
@@ -648,6 +648,31 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
||||
|
||||
elementCallService.setClientProxy(userSession.clientProxy)
|
||||
}
|
||||
|
||||
private func presentCallScreen(genericCallLink url: URL) {
|
||||
let configuration = ElementCallConfiguration(genericCallLink: url)
|
||||
|
||||
let callScreenCoordinator = CallScreenCoordinator(parameters: .init(elementCallService: elementCallService,
|
||||
configuration: configuration,
|
||||
elementCallPictureInPictureEnabled: false,
|
||||
appHooks: appHooks))
|
||||
|
||||
callScreenCoordinator.actions
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .pictureInPictureStarted, .pictureInPictureStopped:
|
||||
// Don't allow PiP when signed out - the user could login at which point we'd
|
||||
// need to hand over the call from here to the user session flow coordinator.
|
||||
MXLog.error("Picture in Picture not supported before login.")
|
||||
case .dismiss:
|
||||
navigationRootCoordinator.setOverlayCoordinator(nil)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationRootCoordinator.setOverlayCoordinator(callScreenCoordinator, animated: false)
|
||||
}
|
||||
|
||||
private func configureNotificationManager() {
|
||||
notificationManager.setUserSession(userSession)
|
||||
|
||||
@@ -457,6 +457,7 @@ private struct NavigationSplitCoordinatorView: View {
|
||||
}
|
||||
}
|
||||
.animation(.elementDefault, value: navigationSplitCoordinator.overlayPresentationMode)
|
||||
.animation(.elementDefault, value: navigationSplitCoordinator.overlayModule)
|
||||
}
|
||||
// Handle `horizontalSizeClass` changes breaking the navigation bar
|
||||
// https://github.com/element-hq/element-x-ios/issues/617
|
||||
|
||||
@@ -56,6 +56,25 @@ class NavigationRootCoordinator: ObservableObject, CoordinatorProtocol, CustomSt
|
||||
sheetModule?.coordinator
|
||||
}
|
||||
|
||||
@Published fileprivate var overlayModule: NavigationModule? {
|
||||
didSet {
|
||||
if let oldValue {
|
||||
logPresentationChange("Remove overlay", oldValue)
|
||||
oldValue.tearDown()
|
||||
}
|
||||
|
||||
if let overlayModule {
|
||||
logPresentationChange("Set overlay", overlayModule)
|
||||
overlayModule.coordinator?.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The currently displayed overlay coordinator
|
||||
var overlayCoordinator: (any CoordinatorProtocol)? {
|
||||
overlayModule?.coordinator
|
||||
}
|
||||
|
||||
/// Sets or replaces the presented coordinator
|
||||
/// - Parameter coordinator: the coordinator to display
|
||||
func setRootCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) {
|
||||
@@ -90,6 +109,31 @@ class NavigationRootCoordinator: ObservableObject, CoordinatorProtocol, CustomSt
|
||||
sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
||||
}
|
||||
}
|
||||
|
||||
/// Present an overlay on top of the split view
|
||||
/// - Parameters:
|
||||
/// - coordinator: the coordinator to display
|
||||
/// - animated: whether the transition should be animated
|
||||
/// - dismissalCallback: called when the overlay has been dismissed, programatically or otherwise
|
||||
func setOverlayCoordinator(_ coordinator: (any CoordinatorProtocol)?,
|
||||
animated: Bool = true,
|
||||
dismissalCallback: (() -> Void)? = nil) {
|
||||
guard let coordinator else {
|
||||
overlayModule = nil
|
||||
return
|
||||
}
|
||||
|
||||
if overlayModule?.coordinator === coordinator {
|
||||
fatalError("Cannot use the same coordinator more than once")
|
||||
}
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = !animated
|
||||
|
||||
withTransaction(transaction) {
|
||||
overlayModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoordinatorProtocol
|
||||
|
||||
@@ -127,5 +171,14 @@ private struct NavigationRootCoordinatorView: View {
|
||||
.sheet(item: $rootCoordinator.sheetModule) { module in
|
||||
module.coordinator?.toPresentable()
|
||||
}
|
||||
.overlay {
|
||||
Group {
|
||||
if let coordinator = rootCoordinator.overlayModule?.coordinator {
|
||||
coordinator.toPresentable()
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.elementDefault, value: rootCoordinator.overlayModule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +268,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .userProfile(let userID):
|
||||
stateMachine.processEvent(.showUserProfileScreen(userID: userID), userInfo: .init(animated: animated))
|
||||
case .call(let roomID):
|
||||
Task {
|
||||
await presentCallScreen(roomID: roomID)
|
||||
}
|
||||
Task { await presentCallScreen(roomID: roomID) }
|
||||
case .genericCallLink(let url):
|
||||
navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
|
||||
presentCallScreen(genericCallLink: url)
|
||||
case .settings, .chatBackupSettings:
|
||||
settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
|
||||
case .oidcCallback:
|
||||
@@ -558,23 +556,39 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
// MARK: Calls
|
||||
|
||||
private var callScreenPictureInPictureController: AVPictureInPictureController?
|
||||
private func presentCallScreen(genericCallLink url: URL) {
|
||||
presentCallScreen(configuration: .init(genericCallLink: url))
|
||||
}
|
||||
|
||||
private func presentCallScreen(roomID: String) async {
|
||||
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
|
||||
return
|
||||
}
|
||||
|
||||
presentCallScreen(roomProxy: roomProxy)
|
||||
}
|
||||
|
||||
private func presentCallScreen(roomProxy: RoomProxyProtocol) {
|
||||
guard elementCallService.ongoingCallRoomID != roomProxy.id else {
|
||||
let colorScheme: ColorScheme = appMediator.windowManager.mainWindow.traitCollection.userInterfaceStyle == .light ? .light : .dark
|
||||
presentCallScreen(configuration: .init(roomProxy: roomProxy,
|
||||
clientProxy: userSession.clientProxy,
|
||||
clientID: InfoPlistReader.main.bundleIdentifier,
|
||||
elementCallBaseURL: appSettings.elementCallBaseURL,
|
||||
elementCallBaseURLOverride: appSettings.elementCallBaseURLOverride,
|
||||
colorScheme: colorScheme))
|
||||
}
|
||||
|
||||
private var callScreenPictureInPictureController: AVPictureInPictureController?
|
||||
private func presentCallScreen(configuration: ElementCallConfiguration) {
|
||||
guard elementCallService.ongoingCallRoomID != configuration.callID else {
|
||||
MXLog.info("Returning to existing call.")
|
||||
callScreenPictureInPictureController?.stopPictureInPicture()
|
||||
return
|
||||
}
|
||||
|
||||
let colorScheme: ColorScheme = appMediator.windowManager.mainWindow.traitCollection.userInterfaceStyle == .light ? .light : .dark
|
||||
let callScreenCoordinator = CallScreenCoordinator(parameters: .init(elementCallService: elementCallService,
|
||||
clientProxy: userSession.clientProxy,
|
||||
roomProxy: roomProxy,
|
||||
clientID: InfoPlistReader.main.bundleIdentifier,
|
||||
elementCallBaseURL: appSettings.elementCallBaseURL,
|
||||
elementCallBaseURLOverride: appSettings.elementCallBaseURLOverride,
|
||||
configuration: configuration,
|
||||
elementCallPictureInPictureEnabled: appSettings.elementCallPictureInPictureEnabled,
|
||||
colorScheme: colorScheme,
|
||||
appHooks: appHooks))
|
||||
|
||||
callScreenCoordinator.actions
|
||||
@@ -600,14 +614,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
analytics.track(screen: .RoomCall)
|
||||
}
|
||||
|
||||
private func presentCallScreen(roomID: String) async {
|
||||
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
|
||||
return
|
||||
}
|
||||
|
||||
presentCallScreen(roomProxy: roomProxy)
|
||||
}
|
||||
|
||||
private func dismissCallScreenIfNeeded() {
|
||||
guard navigationSplitCoordinator.sheetCoordinator is CallScreenCoordinator else {
|
||||
return
|
||||
|
||||
@@ -5142,17 +5142,17 @@ class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol {
|
||||
return startBaseURLClientIDColorSchemeReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - sendMessage
|
||||
//MARK: - handleMessage
|
||||
|
||||
var sendMessageUnderlyingCallsCount = 0
|
||||
var sendMessageCallsCount: Int {
|
||||
var handleMessageUnderlyingCallsCount = 0
|
||||
var handleMessageCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return sendMessageUnderlyingCallsCount
|
||||
return handleMessageUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = sendMessageUnderlyingCallsCount
|
||||
returnValue = handleMessageUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
@@ -5160,29 +5160,29 @@ class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol {
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
sendMessageUnderlyingCallsCount = newValue
|
||||
handleMessageUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
sendMessageUnderlyingCallsCount = newValue
|
||||
handleMessageUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var sendMessageCalled: Bool {
|
||||
return sendMessageCallsCount > 0
|
||||
var handleMessageCalled: Bool {
|
||||
return handleMessageCallsCount > 0
|
||||
}
|
||||
var sendMessageReceivedMessage: String?
|
||||
var sendMessageReceivedInvocations: [String] = []
|
||||
var handleMessageReceivedMessage: String?
|
||||
var handleMessageReceivedInvocations: [String] = []
|
||||
|
||||
var sendMessageUnderlyingReturnValue: Result<Bool, ElementCallWidgetDriverError>!
|
||||
var sendMessageReturnValue: Result<Bool, ElementCallWidgetDriverError>! {
|
||||
var handleMessageUnderlyingReturnValue: Result<Bool, ElementCallWidgetDriverError>!
|
||||
var handleMessageReturnValue: Result<Bool, ElementCallWidgetDriverError>! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return sendMessageUnderlyingReturnValue
|
||||
return handleMessageUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: Result<Bool, ElementCallWidgetDriverError>? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = sendMessageUnderlyingReturnValue
|
||||
returnValue = handleMessageUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
@@ -5190,26 +5190,26 @@ class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol {
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
sendMessageUnderlyingReturnValue = newValue
|
||||
handleMessageUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
sendMessageUnderlyingReturnValue = newValue
|
||||
handleMessageUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var sendMessageClosure: ((String) async -> Result<Bool, ElementCallWidgetDriverError>)?
|
||||
var handleMessageClosure: ((String) async -> Result<Bool, ElementCallWidgetDriverError>)?
|
||||
|
||||
func sendMessage(_ message: String) async -> Result<Bool, ElementCallWidgetDriverError> {
|
||||
sendMessageCallsCount += 1
|
||||
sendMessageReceivedMessage = message
|
||||
func handleMessage(_ message: String) async -> Result<Bool, ElementCallWidgetDriverError> {
|
||||
handleMessageCallsCount += 1
|
||||
handleMessageReceivedMessage = message
|
||||
DispatchQueue.main.async {
|
||||
self.sendMessageReceivedInvocations.append(message)
|
||||
self.handleMessageReceivedInvocations.append(message)
|
||||
}
|
||||
if let sendMessageClosure = sendMessageClosure {
|
||||
return await sendMessageClosure(message)
|
||||
if let handleMessageClosure = handleMessageClosure {
|
||||
return await handleMessageClosure(message)
|
||||
} else {
|
||||
return sendMessageReturnValue
|
||||
return handleMessageReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11129,17 +11129,17 @@ class RoomProxyMock: RoomProxyProtocol {
|
||||
return elementCallWidgetDriverDeviceIDReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - sendCallNotificationIfNeeeded
|
||||
//MARK: - sendCallNotificationIfNeeded
|
||||
|
||||
var sendCallNotificationIfNeeededUnderlyingCallsCount = 0
|
||||
var sendCallNotificationIfNeeededCallsCount: Int {
|
||||
var sendCallNotificationIfNeededUnderlyingCallsCount = 0
|
||||
var sendCallNotificationIfNeededCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return sendCallNotificationIfNeeededUnderlyingCallsCount
|
||||
return sendCallNotificationIfNeededUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = sendCallNotificationIfNeeededUnderlyingCallsCount
|
||||
returnValue = sendCallNotificationIfNeededUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
@@ -11147,27 +11147,27 @@ class RoomProxyMock: RoomProxyProtocol {
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
sendCallNotificationIfNeeededUnderlyingCallsCount = newValue
|
||||
sendCallNotificationIfNeededUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
sendCallNotificationIfNeeededUnderlyingCallsCount = newValue
|
||||
sendCallNotificationIfNeededUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var sendCallNotificationIfNeeededCalled: Bool {
|
||||
return sendCallNotificationIfNeeededCallsCount > 0
|
||||
var sendCallNotificationIfNeededCalled: Bool {
|
||||
return sendCallNotificationIfNeededCallsCount > 0
|
||||
}
|
||||
|
||||
var sendCallNotificationIfNeeededUnderlyingReturnValue: Result<Void, RoomProxyError>!
|
||||
var sendCallNotificationIfNeeededReturnValue: Result<Void, RoomProxyError>! {
|
||||
var sendCallNotificationIfNeededUnderlyingReturnValue: Result<Void, RoomProxyError>!
|
||||
var sendCallNotificationIfNeededReturnValue: Result<Void, RoomProxyError>! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return sendCallNotificationIfNeeededUnderlyingReturnValue
|
||||
return sendCallNotificationIfNeededUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: Result<Void, RoomProxyError>? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = sendCallNotificationIfNeeededUnderlyingReturnValue
|
||||
returnValue = sendCallNotificationIfNeededUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
@@ -11175,22 +11175,22 @@ class RoomProxyMock: RoomProxyProtocol {
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
sendCallNotificationIfNeeededUnderlyingReturnValue = newValue
|
||||
sendCallNotificationIfNeededUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
sendCallNotificationIfNeeededUnderlyingReturnValue = newValue
|
||||
sendCallNotificationIfNeededUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var sendCallNotificationIfNeeededClosure: (() async -> Result<Void, RoomProxyError>)?
|
||||
var sendCallNotificationIfNeededClosure: (() async -> Result<Void, RoomProxyError>)?
|
||||
|
||||
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError> {
|
||||
sendCallNotificationIfNeeededCallsCount += 1
|
||||
if let sendCallNotificationIfNeeededClosure = sendCallNotificationIfNeeededClosure {
|
||||
return await sendCallNotificationIfNeeededClosure()
|
||||
func sendCallNotificationIfNeeded() async -> Result<Void, RoomProxyError> {
|
||||
sendCallNotificationIfNeededCallsCount += 1
|
||||
if let sendCallNotificationIfNeededClosure = sendCallNotificationIfNeededClosure {
|
||||
return await sendCallNotificationIfNeededClosure()
|
||||
} else {
|
||||
return sendCallNotificationIfNeeededReturnValue
|
||||
return sendCallNotificationIfNeededReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - matrixToPermalink
|
||||
|
||||
@@ -153,7 +153,7 @@ extension RoomProxyMock {
|
||||
widgetDriver.startBaseURLClientIDColorSchemeReturnValue = .success(url)
|
||||
|
||||
elementCallWidgetDriverDeviceIDReturnValue = widgetDriver
|
||||
sendCallNotificationIfNeeededReturnValue = .success(())
|
||||
sendCallNotificationIfNeededReturnValue = .success(())
|
||||
|
||||
matrixToPermalinkReturnValue = .success(.homeDirectory)
|
||||
matrixToEventPermalinkReturnValue = .success(.homeDirectory)
|
||||
|
||||
@@ -20,13 +20,8 @@ import SwiftUI
|
||||
|
||||
struct CallScreenCoordinatorParameters {
|
||||
let elementCallService: ElementCallServiceProtocol
|
||||
let clientProxy: ClientProxyProtocol
|
||||
let roomProxy: RoomProxyProtocol
|
||||
let clientID: String
|
||||
let elementCallBaseURL: URL
|
||||
let elementCallBaseURLOverride: URL?
|
||||
let configuration: ElementCallConfiguration
|
||||
let elementCallPictureInPictureEnabled: Bool
|
||||
let colorScheme: ColorScheme
|
||||
let appHooks: AppHooks
|
||||
}
|
||||
|
||||
@@ -50,13 +45,8 @@ final class CallScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
init(parameters: CallScreenCoordinatorParameters) {
|
||||
viewModel = CallScreenViewModel(elementCallService: parameters.elementCallService,
|
||||
clientProxy: parameters.clientProxy,
|
||||
roomProxy: parameters.roomProxy,
|
||||
clientID: parameters.clientID,
|
||||
elementCallBaseURL: parameters.elementCallBaseURL,
|
||||
elementCallBaseURLOverride: parameters.elementCallBaseURLOverride,
|
||||
configuration: parameters.configuration,
|
||||
elementCallPictureInPictureEnabled: parameters.elementCallPictureInPictureEnabled,
|
||||
colorScheme: parameters.colorScheme,
|
||||
appHooks: parameters.appHooks)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ typealias CallScreenViewModelType = StateStoreViewModel<CallScreenViewState, Cal
|
||||
|
||||
class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol {
|
||||
private let elementCallService: ElementCallServiceProtocol
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
private let configuration: ElementCallConfiguration
|
||||
private let isPictureInPictureEnabled: Bool
|
||||
|
||||
private let widgetDriver: ElementCallWidgetDriverProtocol
|
||||
@@ -42,35 +42,28 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
||||
/// - callBaseURL: Which Element Call instance should be used
|
||||
/// - clientID: Something to identify the current client on the Element Call side
|
||||
init(elementCallService: ElementCallServiceProtocol,
|
||||
clientProxy: ClientProxyProtocol,
|
||||
roomProxy: RoomProxyProtocol,
|
||||
clientID: String,
|
||||
elementCallBaseURL: URL,
|
||||
elementCallBaseURLOverride: URL?,
|
||||
configuration: ElementCallConfiguration,
|
||||
elementCallPictureInPictureEnabled: Bool,
|
||||
colorScheme: ColorScheme,
|
||||
appHooks: AppHooks) {
|
||||
guard let deviceID = clientProxy.deviceID else { fatalError("Missing device ID for the call.") }
|
||||
|
||||
self.elementCallService = elementCallService
|
||||
self.roomProxy = roomProxy
|
||||
self.configuration = configuration
|
||||
isPictureInPictureEnabled = elementCallPictureInPictureEnabled
|
||||
|
||||
widgetDriver = roomProxy.elementCallWidgetDriver(deviceID: deviceID)
|
||||
switch configuration.kind {
|
||||
case .genericCallLink(let url):
|
||||
widgetDriver = GenericCallLinkWidgetDriver(url: url)
|
||||
case .roomCall(let roomProxy, let clientProxy, _, _, _, _):
|
||||
guard let deviceID = clientProxy.deviceID else { fatalError("Missing device ID for the call.") }
|
||||
widgetDriver = roomProxy.elementCallWidgetDriver(deviceID: deviceID)
|
||||
}
|
||||
|
||||
super.init(initialViewState: CallScreenViewState(messageHandler: Self.eventHandlerName,
|
||||
script: Self.eventHandlerInjectionScript,
|
||||
certificateValidator: appHooks.certificateValidatorHook))
|
||||
|
||||
state.bindings.javaScriptMessageHandler = { [weak self] message in
|
||||
guard let self,
|
||||
let message = message as? String else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await self.widgetDriver.sendMessage(message)
|
||||
}
|
||||
guard let self, let message = message as? String else { return }
|
||||
Task { await self.widgetDriver.handleMessage(message) }
|
||||
}
|
||||
|
||||
elementCallService.actions
|
||||
@@ -80,8 +73,8 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
||||
|
||||
switch action {
|
||||
case let .setAudioEnabled(enabled, roomID):
|
||||
guard roomID == roomProxy.id else {
|
||||
MXLog.error("Received mute request for a different room: \(roomID) != \(roomProxy.id)")
|
||||
guard roomID == configuration.callID else {
|
||||
MXLog.error("Received mute request for a different room: \(roomID) != \(configuration.callID)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,48 +107,11 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
||||
case .callEnded:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .mediaStateChanged(let audioEnabled, _):
|
||||
elementCallService.setAudioEnabled(audioEnabled, roomID: roomProxy.id)
|
||||
elementCallService.setAudioEnabled(audioEnabled, roomID: configuration.callID)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Wait for room states to be up to date before starting the call and notifying others
|
||||
syncUpdateCancellable = clientProxy.actionsPublisher
|
||||
.filter(\.isSyncUpdate)
|
||||
.timeout(.seconds(5), scheduler: DispatchQueue.main)
|
||||
.first() // Timeout will make the publisher complete, use first to handle both branches in the same place
|
||||
.sink(receiveCompletion: { [weak self] _ in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let baseURL = if let elementCallBaseURLOverride {
|
||||
elementCallBaseURLOverride
|
||||
} else if case .success(let wellKnown) = await clientProxy.getElementWellKnown(), let wellKnownCall = wellKnown?.call {
|
||||
wellKnownCall.widgetURL
|
||||
} else {
|
||||
elementCallBaseURL
|
||||
}
|
||||
|
||||
switch await widgetDriver.start(baseURL: baseURL, clientID: clientID, colorScheme: colorScheme) {
|
||||
case .success(let url):
|
||||
state.url = url
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed starting ElementCall Widget Driver with error: \(error)")
|
||||
state.bindings.alertInfo = .init(id: UUID(), title: L10n.errorUnknown, primaryButton: .init(title: L10n.actionOk, action: { [weak self] in
|
||||
self?.actionsSubject.send(.dismiss)
|
||||
}))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await elementCallService.setupCallSession(roomID: roomProxy.id, roomDisplayName: roomProxy.roomTitle)
|
||||
|
||||
_ = await roomProxy.sendCallNotificationIfNeeeded()
|
||||
|
||||
syncUpdateCancellable = nil
|
||||
}
|
||||
}, receiveValue: { _ in })
|
||||
|
||||
// Use did start otherwise there's a black box left on the screen during the pip controller animation.
|
||||
NotificationCenter.default.publisher(for: .init("AVPictureInPictureControllerDidStartNotification"))
|
||||
.sink { [weak self] notification in
|
||||
@@ -172,6 +128,8 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
||||
Task { try await self.state.bindings.javaScriptEvaluator?("controls.disableCompatPip()") }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
setupCall()
|
||||
}
|
||||
|
||||
override func process(viewAction: CallScreenViewAction) {
|
||||
@@ -194,6 +152,54 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setupCall() {
|
||||
switch configuration.kind {
|
||||
case .genericCallLink(let url):
|
||||
state.url = url
|
||||
// We need widget messaging to work before enabling CallKit, otherwise mute, hangup etc do nothing.
|
||||
|
||||
case .roomCall(let roomProxy, let clientProxy, let clientID, let elementCallBaseURL, let elementCallBaseURLOverride, let colorScheme):
|
||||
// Wait for room states to be up to date before starting the call and notifying others
|
||||
syncUpdateCancellable = clientProxy.actionsPublisher
|
||||
.filter(\.isSyncUpdate)
|
||||
.timeout(.seconds(5), scheduler: DispatchQueue.main)
|
||||
.first() // Timeout will make the publisher complete, use first to handle both branches in the same place
|
||||
.sink(receiveCompletion: { [weak self] _ in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let baseURL = if let elementCallBaseURLOverride {
|
||||
elementCallBaseURLOverride
|
||||
} else if case .success(let wellKnown) = await clientProxy.getElementWellKnown(), let wellKnownCall = wellKnown?.call {
|
||||
wellKnownCall.widgetURL
|
||||
} else {
|
||||
elementCallBaseURL
|
||||
}
|
||||
|
||||
switch await widgetDriver.start(baseURL: baseURL, clientID: clientID, colorScheme: colorScheme) {
|
||||
case .success(let url):
|
||||
state.url = url
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed starting ElementCall Widget Driver with error: \(error)")
|
||||
state.bindings.alertInfo = .init(id: UUID(),
|
||||
title: L10n.errorUnknown,
|
||||
primaryButton: .init(title: L10n.actionOk) {
|
||||
self.actionsSubject.send(.dismiss)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await elementCallService.setupCallSession(roomID: roomProxy.id, roomDisplayName: roomProxy.roomTitle)
|
||||
|
||||
_ = await roomProxy.sendCallNotificationIfNeeded()
|
||||
|
||||
syncUpdateCancellable = nil
|
||||
}
|
||||
}, receiveValue: { _ in })
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBackwardsNavigation() {
|
||||
#if targetEnvironment(simulator)
|
||||
if UIDevice.current.isPhone {
|
||||
@@ -237,7 +243,6 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
||||
do {
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let json = String(decoding: data, as: UTF8.self)
|
||||
_ = await widgetDriver.sendMessage(json)
|
||||
|
||||
await postJSONToWidget(json)
|
||||
} catch {
|
||||
|
||||
@@ -34,11 +34,11 @@ struct CallScreen: View {
|
||||
Image(systemSymbol: .chevronBackward)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.offset(y: -8)
|
||||
// .padding(.leading, -8) // Fixes the button alignment, but harder to tap.
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -50,8 +50,6 @@ struct CallScreen: View {
|
||||
// This URL is stable, forces view reloads if this representable is ever reused for another url
|
||||
.id(context.viewState.url)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.presentationDragIndicator(.visible)
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,7 +209,7 @@ struct CallScreen_Previews: PreviewProvider {
|
||||
clientProxy.deviceID = "call-device-id"
|
||||
|
||||
let roomProxy = RoomProxyMock()
|
||||
roomProxy.sendCallNotificationIfNeeededReturnValue = .success(())
|
||||
roomProxy.sendCallNotificationIfNeededReturnValue = .success(())
|
||||
|
||||
let widgetDriver = ElementCallWidgetDriverMock()
|
||||
widgetDriver.underlyingMessagePublisher = .init()
|
||||
@@ -221,13 +219,13 @@ struct CallScreen_Previews: PreviewProvider {
|
||||
roomProxy.elementCallWidgetDriverDeviceIDReturnValue = widgetDriver
|
||||
|
||||
return CallScreenViewModel(elementCallService: ElementCallServiceMock(.init()),
|
||||
clientProxy: clientProxy,
|
||||
roomProxy: roomProxy,
|
||||
clientID: "io.element.elementx",
|
||||
elementCallBaseURL: "https://call.element.io",
|
||||
elementCallBaseURLOverride: nil,
|
||||
configuration: .init(roomProxy: roomProxy,
|
||||
clientProxy: clientProxy,
|
||||
clientID: "io.element.elementx",
|
||||
elementCallBaseURL: "https://call.element.io",
|
||||
elementCallBaseURLOverride: nil,
|
||||
colorScheme: .light),
|
||||
elementCallPictureInPictureEnabled: false,
|
||||
colorScheme: .light,
|
||||
appHooks: AppHooks())
|
||||
}()
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
//
|
||||
// Copyright 2023 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 SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct GenericCallLinkCoordinatorParameters {
|
||||
let url: URL
|
||||
}
|
||||
|
||||
private enum GenericCallLinkQueryParameters {
|
||||
static let appPrompt = "appPrompt"
|
||||
static let confineToRoom = "confineToRoom"
|
||||
}
|
||||
|
||||
class GenericCallLinkCoordinator: CoordinatorProtocol {
|
||||
private let parameters: GenericCallLinkCoordinatorParameters
|
||||
|
||||
init(parameters: GenericCallLinkCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(WebView(url: parameters.url)
|
||||
// This URL is stable, forces view reloads if this representable is ever reused for another url
|
||||
.id(parameters.url)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.presentationDragIndicator(.visible))
|
||||
}
|
||||
}
|
||||
|
||||
private struct WebView: UIViewRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
context.coordinator.webView
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(url: url)
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
webView.load(URLRequest(url: context.coordinator.url))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate {
|
||||
let url: URL
|
||||
private(set) var webView: WKWebView!
|
||||
|
||||
init(url: URL) {
|
||||
if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) {
|
||||
var fragmentQueryItems = urlComponents.fragmentQueryItems ?? []
|
||||
|
||||
fragmentQueryItems.removeAll { $0.name == GenericCallLinkQueryParameters.appPrompt }
|
||||
fragmentQueryItems.removeAll { $0.name == GenericCallLinkQueryParameters.confineToRoom }
|
||||
|
||||
fragmentQueryItems.append(.init(name: GenericCallLinkQueryParameters.appPrompt, value: "false"))
|
||||
fragmentQueryItems.append(.init(name: GenericCallLinkQueryParameters.confineToRoom, value: "true"))
|
||||
|
||||
urlComponents.fragmentQueryItems = fragmentQueryItems
|
||||
|
||||
if let adjustedURL = urlComponents.url {
|
||||
self.url = adjustedURL
|
||||
} else {
|
||||
MXLog.error("Failed adjusting URL with components: \(urlComponents)")
|
||||
self.url = url
|
||||
}
|
||||
} else {
|
||||
MXLog.error("Failed constructing URL components for url: \(url)")
|
||||
self.url = url
|
||||
}
|
||||
|
||||
super.init()
|
||||
|
||||
let configuration = WKWebViewConfiguration()
|
||||
|
||||
configuration.allowsInlineMediaPlayback = true
|
||||
configuration.allowsPictureInPictureMediaPlayback = true
|
||||
|
||||
webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.uiDelegate = self
|
||||
}
|
||||
|
||||
// MARK: - WKUIDelegate
|
||||
|
||||
func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin: WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision {
|
||||
// Don't allow permissions for domains different than what the call was started on
|
||||
guard origin.host == url.host else {
|
||||
return .deny
|
||||
}
|
||||
|
||||
return .grant
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
||||
// Allow any content from the main URL.
|
||||
if navigationAction.request.url?.host == url.host {
|
||||
return .allow
|
||||
}
|
||||
|
||||
// Additionally allow any embedded content such as captchas.
|
||||
if let targetFrame = navigationAction.targetFrame, !targetFrame.isMainFrame {
|
||||
return .allow
|
||||
}
|
||||
|
||||
// Otherwise the request is invalid.
|
||||
return .cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// Copyright 2024 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 SwiftUI
|
||||
|
||||
private enum GenericCallLinkQueryParameters {
|
||||
static let appPrompt = "appPrompt"
|
||||
static let confineToRoom = "confineToRoom"
|
||||
}
|
||||
|
||||
/// Information about how a call should be configured.
|
||||
struct ElementCallConfiguration {
|
||||
enum Kind {
|
||||
case genericCallLink(URL)
|
||||
case roomCall(roomProxy: RoomProxyProtocol,
|
||||
clientProxy: ClientProxyProtocol,
|
||||
clientID: String,
|
||||
elementCallBaseURL: URL,
|
||||
elementCallBaseURLOverride: URL?,
|
||||
colorScheme: ColorScheme)
|
||||
}
|
||||
|
||||
/// The type of call being configured i.e. whether it's an external URL or an internal room call.
|
||||
let kind: Kind
|
||||
|
||||
/// Creates a configuration for an external call URL.
|
||||
init(genericCallLink url: URL) {
|
||||
if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) {
|
||||
var fragmentQueryItems = urlComponents.fragmentQueryItems ?? []
|
||||
|
||||
fragmentQueryItems.removeAll { $0.name == GenericCallLinkQueryParameters.appPrompt }
|
||||
fragmentQueryItems.removeAll { $0.name == GenericCallLinkQueryParameters.confineToRoom }
|
||||
|
||||
fragmentQueryItems.append(.init(name: GenericCallLinkQueryParameters.appPrompt, value: "false"))
|
||||
fragmentQueryItems.append(.init(name: GenericCallLinkQueryParameters.confineToRoom, value: "true"))
|
||||
|
||||
urlComponents.fragmentQueryItems = fragmentQueryItems
|
||||
|
||||
if let adjustedURL = urlComponents.url {
|
||||
kind = .genericCallLink(adjustedURL)
|
||||
} else {
|
||||
MXLog.error("Failed adjusting URL with components: \(urlComponents)")
|
||||
kind = .genericCallLink(url)
|
||||
}
|
||||
} else {
|
||||
MXLog.error("Failed constructing URL components for url: \(url)")
|
||||
kind = .genericCallLink(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a configuration for an internal room call.
|
||||
init(roomProxy: RoomProxyProtocol,
|
||||
clientProxy: ClientProxyProtocol,
|
||||
clientID: String,
|
||||
elementCallBaseURL: URL,
|
||||
elementCallBaseURLOverride: URL?,
|
||||
colorScheme: ColorScheme) {
|
||||
kind = .roomCall(roomProxy: roomProxy,
|
||||
clientProxy: clientProxy,
|
||||
clientID: clientID,
|
||||
elementCallBaseURL: elementCallBaseURL,
|
||||
elementCallBaseURLOverride: elementCallBaseURLOverride,
|
||||
colorScheme: colorScheme)
|
||||
}
|
||||
|
||||
/// A string representing the call being configured.
|
||||
var callID: String {
|
||||
switch kind {
|
||||
case .genericCallLink(let url):
|
||||
url.absoluteString
|
||||
case .roomCall(let roomProxy, _, _, _, _, _):
|
||||
roomProxy.id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ class ElementCallWidgetDriver: WidgetCapabilitiesProvider, ElementCallWidgetDriv
|
||||
return .success(url)
|
||||
}
|
||||
|
||||
func sendMessage(_ message: String) async -> Result<Bool, ElementCallWidgetDriverError> {
|
||||
func handleMessage(_ message: String) async -> Result<Bool, ElementCallWidgetDriverError> {
|
||||
guard let widgetDriver else {
|
||||
return .failure(.driverNotSetup)
|
||||
}
|
||||
|
||||
@@ -40,5 +40,6 @@ protocol ElementCallWidgetDriverProtocol {
|
||||
|
||||
func start(baseURL: URL, clientID: String, colorScheme: ColorScheme) async -> Result<URL, ElementCallWidgetDriverError>
|
||||
|
||||
func sendMessage(_ message: String) async -> Result<Bool, ElementCallWidgetDriverError>
|
||||
/// Passes a message from the Widget to the SDK to handle, returning a Bool that represents whether or not the widget driver is still running.
|
||||
func handleMessage(_ message: String) async -> Result<Bool, ElementCallWidgetDriverError>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// Copyright 2024 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
|
||||
|
||||
class GenericCallLinkWidgetDriver: ElementCallWidgetDriverProtocol {
|
||||
private let url: URL
|
||||
|
||||
let widgetID = UUID().uuidString
|
||||
let messagePublisher = PassthroughSubject<String, Never>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<ElementCallWidgetDriverAction, Never> = .init()
|
||||
var actions: AnyPublisher<ElementCallWidgetDriverAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
func start(baseURL: URL, clientID: String, colorScheme: ColorScheme) async -> Result<URL, ElementCallWidgetDriverError> {
|
||||
MXLog.error("Nothing to start, use the configuration's URL directly instead.")
|
||||
return .success(url)
|
||||
}
|
||||
|
||||
func handleMessage(_ message: String) async -> Result<Bool, ElementCallWidgetDriverError> {
|
||||
// The web view doesn't send us messages through the Widget API, so nothing to implement (yet?).
|
||||
.failure(.driverNotSetup)
|
||||
}
|
||||
}
|
||||
@@ -609,7 +609,7 @@ class RoomProxy: RoomProxyProtocol {
|
||||
ElementCallWidgetDriver(room: room, deviceID: deviceID)
|
||||
}
|
||||
|
||||
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError> {
|
||||
func sendCallNotificationIfNeeded() async -> Result<Void, RoomProxyError> {
|
||||
do {
|
||||
try await room.sendCallNotificationIfNeeded()
|
||||
return .success(())
|
||||
|
||||
@@ -141,7 +141,7 @@ protocol RoomProxyProtocol {
|
||||
func canUserJoinCall(userID: String) async -> Result<Bool, RoomProxyError>
|
||||
func elementCallWidgetDriver(deviceID: String) -> ElementCallWidgetDriverProtocol
|
||||
|
||||
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError>
|
||||
func sendCallNotificationIfNeeded() async -> Result<Void, RoomProxyError>
|
||||
|
||||
// MARK: - Permalinks
|
||||
|
||||
|
||||
Reference in New Issue
Block a user