mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Use native video call picture in picture! (#3190)
* Use native video call picture in picture! * Handle isSupported, isPossible and web view errors. * Use new canEnterPip method. * Hide a room's Call button when already joined to the call. * Tweak feature flag description. * PR comments.
This commit is contained in:
@@ -665,6 +665,7 @@
|
|||||||
95690DDD9D547D3D842ACBE3 /* AnalyticsSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */; };
|
95690DDD9D547D3D842ACBE3 /* AnalyticsSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */; };
|
||||||
9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */; };
|
9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */; };
|
||||||
95E7B236F7116CACE05A6BC9 /* BlockedUsersScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16D0F226B1819D017531647 /* BlockedUsersScreenCoordinator.swift */; };
|
95E7B236F7116CACE05A6BC9 /* BlockedUsersScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16D0F226B1819D017531647 /* BlockedUsersScreenCoordinator.swift */; };
|
||||||
|
9603EEF6DE980BB1D15D4707 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A3E8741D199CD1A37F4CBF /* UIView.swift */; };
|
||||||
962A4F8AD6312804E2C6BB6E /* PhotoLibraryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */; };
|
962A4F8AD6312804E2C6BB6E /* PhotoLibraryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */; };
|
||||||
964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B902EA6CD3296B0E10EE432B /* HomeScreen.swift */; };
|
964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B902EA6CD3296B0E10EE432B /* HomeScreen.swift */; };
|
||||||
968A5B890004526AB58A217C /* AvatarSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24B88AD3D1599E8CB1376E0 /* AvatarSize.swift */; };
|
968A5B890004526AB58A217C /* AvatarSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24B88AD3D1599E8CB1376E0 /* AvatarSize.swift */; };
|
||||||
@@ -1203,6 +1204,7 @@
|
|||||||
054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionUITests.swift; sourceTree = "<group>"; };
|
054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionUITests.swift; sourceTree = "<group>"; };
|
||||||
05512FB13987D221B7205DE0 /* HomeScreenRecoveryKeyConfirmationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRecoveryKeyConfirmationBanner.swift; sourceTree = "<group>"; };
|
05512FB13987D221B7205DE0 /* HomeScreenRecoveryKeyConfirmationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRecoveryKeyConfirmationBanner.swift; sourceTree = "<group>"; };
|
||||||
05596E4A11A8C9346E9E54AE /* SoftLogoutScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenCoordinator.swift; sourceTree = "<group>"; };
|
05596E4A11A8C9346E9E54AE /* SoftLogoutScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
05A3E8741D199CD1A37F4CBF /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
|
||||||
05AF58372CA884A789EB9C5A /* AppMediatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMediatorProtocol.swift; sourceTree = "<group>"; };
|
05AF58372CA884A789EB9C5A /* AppMediatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMediatorProtocol.swift; sourceTree = "<group>"; };
|
||||||
05F598B1B346DAF223651C91 /* LoginScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenCoordinator.swift; sourceTree = "<group>"; };
|
05F598B1B346DAF223651C91 /* LoginScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||||
0685156EB62D7E243F097CFC /* ServerSelectionScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
0685156EB62D7E243F097CFC /* ServerSelectionScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||||
@@ -3194,6 +3196,7 @@
|
|||||||
40B21E611DADDEF00307E7AC /* String.swift */,
|
40B21E611DADDEF00307E7AC /* String.swift */,
|
||||||
A40C19719687984FD9478FBE /* Task.swift */,
|
A40C19719687984FD9478FBE /* Task.swift */,
|
||||||
287FC98AF2664EAD79C0D902 /* UIDevice.swift */,
|
287FC98AF2664EAD79C0D902 /* UIDevice.swift */,
|
||||||
|
05A3E8741D199CD1A37F4CBF /* UIView.swift */,
|
||||||
BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */,
|
BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */,
|
||||||
227AC5D71A4CE43512062243 /* URL.swift */,
|
227AC5D71A4CE43512062243 /* URL.swift */,
|
||||||
2BFDCA5A09EE70BC17F2EFA7 /* URLComponents.swift */,
|
2BFDCA5A09EE70BC17F2EFA7 /* URLComponents.swift */,
|
||||||
@@ -6809,6 +6812,7 @@
|
|||||||
384D6B9A7DFD7260139D6852 /* UITestsNotificationCenter.swift in Sources */,
|
384D6B9A7DFD7260139D6852 /* UITestsNotificationCenter.swift in Sources */,
|
||||||
22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */,
|
22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */,
|
||||||
706289B086B0A6B0C211763F /* UITestsSignalling.swift in Sources */,
|
706289B086B0A6B0C211763F /* UITestsSignalling.swift in Sources */,
|
||||||
|
9603EEF6DE980BB1D15D4707 /* UIView.swift in Sources */,
|
||||||
D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */,
|
D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */,
|
||||||
071A017E415AD378F2961B11 /* URL.swift in Sources */,
|
071A017E415AD378F2961B11 /* URL.swift in Sources */,
|
||||||
6FD8053301C5FEFA82D2F246 /* URLComponents.swift in Sources */,
|
6FD8053301C5FEFA82D2F246 /* URLComponents.swift in Sources */,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ final class AppSettings {
|
|||||||
case publicSearchEnabled
|
case publicSearchEnabled
|
||||||
case fuzzyRoomListSearchEnabled
|
case fuzzyRoomListSearchEnabled
|
||||||
case pinningEnabled
|
case pinningEnabled
|
||||||
|
case elementCallPictureInPictureEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
|
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
|
||||||
@@ -285,8 +286,8 @@ final class AppSettings {
|
|||||||
@UserPreference(key: UserDefaultsKeys.pinningEnabled, defaultValue: false, storageType: .userDefaults(store))
|
@UserPreference(key: UserDefaultsKeys.pinningEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||||
var pinningEnabled
|
var pinningEnabled
|
||||||
|
|
||||||
// Not user configurable as it depends on work in EC too.
|
@UserPreference(key: UserDefaultsKeys.elementCallPictureInPictureEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||||
let elementCallPictureInPictureEnabled = false
|
var elementCallPictureInPictureEnabled
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
|
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
|
||||||
private let navigationStackCoordinator: NavigationStackCoordinator
|
private let navigationStackCoordinator: NavigationStackCoordinator
|
||||||
private let emojiProvider: EmojiProviderProtocol
|
private let emojiProvider: EmojiProviderProtocol
|
||||||
|
private let ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>
|
||||||
private let appMediator: AppMediatorProtocol
|
private let appMediator: AppMediatorProtocol
|
||||||
private let appSettings: AppSettings
|
private let appSettings: AppSettings
|
||||||
private let analytics: AnalyticsService
|
private let analytics: AnalyticsService
|
||||||
@@ -90,6 +91,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
|
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
|
||||||
navigationStackCoordinator: NavigationStackCoordinator,
|
navigationStackCoordinator: NavigationStackCoordinator,
|
||||||
emojiProvider: EmojiProviderProtocol,
|
emojiProvider: EmojiProviderProtocol,
|
||||||
|
ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>,
|
||||||
appMediator: AppMediatorProtocol,
|
appMediator: AppMediatorProtocol,
|
||||||
appSettings: AppSettings,
|
appSettings: AppSettings,
|
||||||
analytics: AnalyticsService,
|
analytics: AnalyticsService,
|
||||||
@@ -100,6 +102,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
self.roomTimelineControllerFactory = roomTimelineControllerFactory
|
self.roomTimelineControllerFactory = roomTimelineControllerFactory
|
||||||
self.navigationStackCoordinator = navigationStackCoordinator
|
self.navigationStackCoordinator = navigationStackCoordinator
|
||||||
self.emojiProvider = emojiProvider
|
self.emojiProvider = emojiProvider
|
||||||
|
self.ongoingCallRoomIDPublisher = ongoingCallRoomIDPublisher
|
||||||
self.appMediator = appMediator
|
self.appMediator = appMediator
|
||||||
self.appSettings = appSettings
|
self.appSettings = appSettings
|
||||||
self.analytics = analytics
|
self.analytics = analytics
|
||||||
@@ -580,6 +583,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
|
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
|
||||||
emojiProvider: emojiProvider,
|
emojiProvider: emojiProvider,
|
||||||
completionSuggestionService: completionSuggestionService,
|
completionSuggestionService: completionSuggestionService,
|
||||||
|
ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher,
|
||||||
appMediator: appMediator,
|
appMediator: appMediator,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
composerDraftService: composerDraftService)
|
composerDraftService: composerDraftService)
|
||||||
@@ -1350,6 +1354,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
roomTimelineControllerFactory: roomTimelineControllerFactory,
|
roomTimelineControllerFactory: roomTimelineControllerFactory,
|
||||||
navigationStackCoordinator: navigationStackCoordinator,
|
navigationStackCoordinator: navigationStackCoordinator,
|
||||||
emojiProvider: emojiProvider,
|
emojiProvider: emojiProvider,
|
||||||
|
ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher,
|
||||||
appMediator: appMediator,
|
appMediator: appMediator,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
analytics: analytics,
|
analytics: analytics,
|
||||||
|
|||||||
@@ -477,6 +477,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
roomTimelineControllerFactory: roomTimelineControllerFactory,
|
roomTimelineControllerFactory: roomTimelineControllerFactory,
|
||||||
navigationStackCoordinator: detailNavigationStackCoordinator,
|
navigationStackCoordinator: detailNavigationStackCoordinator,
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
|
ongoingCallRoomIDPublisher: elementCallService.ongoingCallRoomIDPublisher,
|
||||||
appMediator: appMediator,
|
appMediator: appMediator,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
analytics: analytics,
|
analytics: analytics,
|
||||||
@@ -580,7 +581,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
|
|
||||||
private var callScreenPictureInPictureController: AVPictureInPictureController?
|
private var callScreenPictureInPictureController: AVPictureInPictureController?
|
||||||
private func presentCallScreen(configuration: ElementCallConfiguration) {
|
private func presentCallScreen(configuration: ElementCallConfiguration) {
|
||||||
guard elementCallService.ongoingCallRoomID != configuration.callID else {
|
guard elementCallService.ongoingCallRoomIDPublisher.value != configuration.callRoomID else {
|
||||||
MXLog.info("Returning to existing call.")
|
MXLog.info("Returning to existing call.")
|
||||||
callScreenPictureInPictureController?.stopPictureInPicture()
|
callScreenPictureInPictureController?.stopPictureInPicture()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,12 +17,15 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct ElementCallServiceMockConfiguration { }
|
struct ElementCallServiceMockConfiguration {
|
||||||
|
var ongoingCallRoomID: String?
|
||||||
|
}
|
||||||
|
|
||||||
extension ElementCallServiceMock {
|
extension ElementCallServiceMock {
|
||||||
convenience init(_ configuration: ElementCallServiceMockConfiguration) {
|
convenience init(_ configuration: ElementCallServiceMockConfiguration) {
|
||||||
self.init()
|
self.init()
|
||||||
|
|
||||||
underlyingActions = PassthroughSubject().eraseToAnyPublisher()
|
underlyingActions = PassthroughSubject().eraseToAnyPublisher()
|
||||||
|
underlyingOngoingCallRoomIDPublisher = .init(.init(configuration.ongoingCallRoomID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4894,7 +4894,11 @@ class ElementCallServiceMock: ElementCallServiceProtocol {
|
|||||||
set(value) { underlyingActions = value }
|
set(value) { underlyingActions = value }
|
||||||
}
|
}
|
||||||
var underlyingActions: AnyPublisher<ElementCallServiceAction, Never>!
|
var underlyingActions: AnyPublisher<ElementCallServiceAction, Never>!
|
||||||
var ongoingCallRoomID: String?
|
var ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never> {
|
||||||
|
get { return underlyingOngoingCallRoomIDPublisher }
|
||||||
|
set(value) { underlyingOngoingCallRoomIDPublisher = value }
|
||||||
|
}
|
||||||
|
var underlyingOngoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>!
|
||||||
|
|
||||||
//MARK: - setClientProxy
|
//MARK: - setClientProxy
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
extension UIView {
|
||||||
|
func addMatchedSubview(_ subview: UIView) {
|
||||||
|
addSubview(subview)
|
||||||
|
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
subview.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
subview.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ struct CallScreenCoordinatorParameters {
|
|||||||
|
|
||||||
enum CallScreenCoordinatorAction {
|
enum CallScreenCoordinatorAction {
|
||||||
/// The call is still ongoing but the user wishes to navigate around the app.
|
/// The call is still ongoing but the user wishes to navigate around the app.
|
||||||
case pictureInPictureStarted(AVPictureInPictureController?)
|
case pictureInPictureStarted(AVPictureInPictureController)
|
||||||
/// The call is hidden and the user wishes to return to it.
|
/// The call is hidden and the user wishes to return to it.
|
||||||
case pictureInPictureStopped
|
case pictureInPictureStopped
|
||||||
/// The call is finished and the screen is done with.
|
/// The call is finished and the screen is done with.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import AVKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum CallScreenViewModelAction {
|
enum CallScreenViewModelAction {
|
||||||
case pictureInPictureStarted(AVPictureInPictureController?)
|
case pictureInPictureStarted(AVPictureInPictureController)
|
||||||
case pictureInPictureStopped
|
case pictureInPictureStopped
|
||||||
case dismiss
|
case dismiss
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,7 @@ struct CallScreenViewState: BindableState {
|
|||||||
struct Bindings {
|
struct Bindings {
|
||||||
var javaScriptMessageHandler: ((Any) -> Void)?
|
var javaScriptMessageHandler: ((Any) -> Void)?
|
||||||
var javaScriptEvaluator: ((String) async throws -> Any)?
|
var javaScriptEvaluator: ((String) async throws -> Any)?
|
||||||
|
var requestPictureInPictureHandler: (() async -> Result<AVPictureInPictureController, CallScreenError>)?
|
||||||
|
|
||||||
var alertInfo: AlertInfo<UUID>?
|
var alertInfo: AlertInfo<UUID>?
|
||||||
}
|
}
|
||||||
@@ -43,4 +44,10 @@ struct Bindings {
|
|||||||
enum CallScreenViewAction {
|
enum CallScreenViewAction {
|
||||||
case urlChanged(URL?)
|
case urlChanged(URL?)
|
||||||
case navigateBack
|
case navigateBack
|
||||||
|
case pictureInPictureWillStop
|
||||||
|
case endCall
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CallScreenError: Error {
|
||||||
|
case pictureInPictureNotAvailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
|||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case let .setAudioEnabled(enabled, roomID):
|
case let .setAudioEnabled(enabled, roomID):
|
||||||
guard roomID == configuration.callID else {
|
guard roomID == configuration.callRoomID else {
|
||||||
MXLog.error("Received mute request for a different room: \(roomID) != \(configuration.callID)")
|
MXLog.error("Received mute request for a different room: \(roomID) != \(configuration.callRoomID)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,28 +107,11 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
|||||||
case .callEnded:
|
case .callEnded:
|
||||||
actionsSubject.send(.dismiss)
|
actionsSubject.send(.dismiss)
|
||||||
case .mediaStateChanged(let audioEnabled, _):
|
case .mediaStateChanged(let audioEnabled, _):
|
||||||
elementCallService.setAudioEnabled(audioEnabled, roomID: configuration.callID)
|
elementCallService.setAudioEnabled(audioEnabled, roomID: configuration.callRoomID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// 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
|
|
||||||
guard let self else { return }
|
|
||||||
let controller = notification.object as? AVPictureInPictureController
|
|
||||||
actionsSubject.send(.pictureInPictureStarted(controller))
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
NotificationCenter.default.publisher(for: .init("AVPictureInPictureControllerWillStopNotification"))
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
actionsSubject.send(.pictureInPictureStopped)
|
|
||||||
Task { try await self.state.bindings.javaScriptEvaluator?("controls.disableCompatPip()") }
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
setupCall()
|
setupCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +121,11 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
|||||||
guard let url else { return }
|
guard let url else { return }
|
||||||
MXLog.info("URL changed to: \(url)")
|
MXLog.info("URL changed to: \(url)")
|
||||||
case .navigateBack:
|
case .navigateBack:
|
||||||
handleBackwardsNavigation()
|
Task { await handleBackwardsNavigation() }
|
||||||
|
case .pictureInPictureWillStop:
|
||||||
|
actionsSubject.send(.pictureInPictureStopped)
|
||||||
|
case .endCall:
|
||||||
|
actionsSubject.send(.dismiss)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,26 +187,19 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleBackwardsNavigation() {
|
private func handleBackwardsNavigation() async {
|
||||||
#if targetEnvironment(simulator)
|
guard state.url != nil,
|
||||||
if UIDevice.current.isPhone {
|
isPictureInPictureEnabled,
|
||||||
MXLog.warning("The iPhone simulator doesn't support PiP.")
|
let requestPictureInPictureHandler = state.bindings.requestPictureInPictureHandler else {
|
||||||
actionsSubject.send(.dismiss)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
guard isPictureInPictureEnabled, state.url != nil else {
|
|
||||||
actionsSubject.send(.dismiss)
|
actionsSubject.send(.dismiss)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
switch await requestPictureInPictureHandler() {
|
||||||
try await state.bindings.javaScriptEvaluator?("controls.enableCompatPip()")
|
case .success(let controller):
|
||||||
// Enable this check when implemented on web.
|
actionsSubject.send(.pictureInPictureStarted(controller))
|
||||||
// if result as? Bool != true {
|
case .failure:
|
||||||
// actionsSubject.send(.dismiss)
|
actionsSubject.send(.dismiss)
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import AVKit
|
||||||
import Combine
|
import Combine
|
||||||
import SFSafeSymbols
|
import SFSafeSymbols
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -46,7 +47,7 @@ struct CallScreen: View {
|
|||||||
if context.viewState.url == nil {
|
if context.viewState.url == nil {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else {
|
} else {
|
||||||
WebView(url: context.viewState.url, viewModelContext: context)
|
CallView(url: context.viewState.url, viewModelContext: context)
|
||||||
// This URL is stable, forces view reloads if this representable is ever reused for another url
|
// This URL is stable, forces view reloads if this representable is ever reused for another url
|
||||||
.id(context.viewState.url)
|
.id(context.viewState.url)
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
@@ -54,44 +55,52 @@ struct CallScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct WebView: UIViewRepresentable {
|
private struct CallView: UIViewRepresentable {
|
||||||
|
/// The top-level view this representable displays. It wraps the web view when picture in picture isn't running.
|
||||||
|
typealias WebViewWrapper = UIView
|
||||||
|
|
||||||
let url: URL?
|
let url: URL?
|
||||||
let viewModelContext: CallScreenViewModel.Context
|
let viewModelContext: CallScreenViewModel.Context
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WebViewWrapper {
|
||||||
context.coordinator.webView
|
context.coordinator.webViewWrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(viewModelContext: viewModelContext)
|
Coordinator(viewModelContext: viewModelContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
func updateUIView(_ callWebView: WebViewWrapper, context: Context) {
|
||||||
if let url {
|
if let url {
|
||||||
context.coordinator.url = url
|
context.coordinator.load(url)
|
||||||
let request = URLRequest(url: url)
|
|
||||||
webView.load(request)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate {
|
class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, AVPictureInPictureControllerDelegate {
|
||||||
private weak var viewModelContext: CallScreenViewModel.Context?
|
private weak var viewModelContext: CallScreenViewModel.Context?
|
||||||
private let certificateValidator: CertificateValidatorHookProtocol
|
private let certificateValidator: CertificateValidatorHookProtocol
|
||||||
|
|
||||||
private(set) var webView: WKWebView!
|
private var webView: WKWebView!
|
||||||
|
private var pictureInPictureController: AVPictureInPictureController?
|
||||||
|
private let pictureInPictureViewController: AVPictureInPictureVideoCallViewController
|
||||||
|
|
||||||
var url: URL!
|
/// The view to be shown in the app. This will contain the web view when picture in picture isn't running.
|
||||||
|
let webViewWrapper = WebViewWrapper(frame: .zero)
|
||||||
|
|
||||||
|
private var url: URL!
|
||||||
|
|
||||||
init(viewModelContext: CallScreenViewModel.Context) {
|
init(viewModelContext: CallScreenViewModel.Context) {
|
||||||
self.viewModelContext = viewModelContext
|
self.viewModelContext = viewModelContext
|
||||||
certificateValidator = viewModelContext.viewState.certificateValidator
|
certificateValidator = viewModelContext.viewState.certificateValidator
|
||||||
|
pictureInPictureViewController = AVPictureInPictureVideoCallViewController()
|
||||||
|
pictureInPictureViewController.preferredContentSize = CGSize(width: 1920, height: 1080)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { // Avoid `Publishing changes from within view update warnings`
|
||||||
// Avoid `Publishing changes from within view update warnings`
|
viewModelContext.javaScriptEvaluator = self.evaluateJavaScript
|
||||||
viewModelContext.javaScriptEvaluator = self.evaluateJavaScript(_:)
|
viewModelContext.requestPictureInPictureHandler = self.requestPictureInPicture
|
||||||
}
|
}
|
||||||
|
|
||||||
let configuration = WKWebViewConfiguration()
|
let configuration = WKWebViewConfiguration()
|
||||||
@@ -120,6 +129,21 @@ private struct WebView: UIViewRepresentable {
|
|||||||
webView.isOpaque = false
|
webView.isOpaque = false
|
||||||
webView.backgroundColor = .compound.bgCanvasDefault
|
webView.backgroundColor = .compound.bgCanvasDefault
|
||||||
webView.scrollView.backgroundColor = .compound.bgCanvasDefault
|
webView.scrollView.backgroundColor = .compound.bgCanvasDefault
|
||||||
|
|
||||||
|
webViewWrapper.addMatchedSubview(webView)
|
||||||
|
|
||||||
|
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||||
|
let pictureInPictureController = AVPictureInPictureController(contentSource: .init(activeVideoCallSourceView: webViewWrapper,
|
||||||
|
contentViewController: pictureInPictureViewController))
|
||||||
|
pictureInPictureController.delegate = self
|
||||||
|
self.pictureInPictureController = pictureInPictureController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(_ url: URL) {
|
||||||
|
self.url = url
|
||||||
|
let request = URLRequest(url: url)
|
||||||
|
webView.load(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluateJavaScript(_ script: String) async throws -> Any? {
|
func evaluateJavaScript(_ script: String) async throws -> Any? {
|
||||||
@@ -181,6 +205,57 @@ private struct WebView: UIViewRepresentable {
|
|||||||
viewModelContext?.send(viewAction: .urlChanged(webView.url))
|
viewModelContext?.send(viewAction: .urlChanged(webView.url))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Picture in Picture
|
||||||
|
|
||||||
|
func requestPictureInPicture() async -> Result<AVPictureInPictureController, CallScreenError> {
|
||||||
|
guard let pictureInPictureController,
|
||||||
|
pictureInPictureController.isPictureInPicturePossible,
|
||||||
|
case .success(true) = await webViewCanEnterPictureInPicture() else {
|
||||||
|
return .failure(.pictureInPictureNotAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pictureInPictureController.startPictureInPicture()
|
||||||
|
return .success(pictureInPictureController)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPictureInPicture() {
|
||||||
|
pictureInPictureController?.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
Task { @MainActor in
|
||||||
|
// We move the view via the delegate so it works when you background the app without calling requestPictureInPicture
|
||||||
|
pictureInPictureViewController.view.addMatchedSubview(webView)
|
||||||
|
_ = try? await evaluateJavaScript("controls.enablePip()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
Task { await viewModelContext?.send(viewAction: .pictureInPictureWillStop) }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
Task { @MainActor in
|
||||||
|
webViewWrapper.addMatchedSubview(webView)
|
||||||
|
_ = try? await evaluateJavaScript("controls.disablePip()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the web view can do picture in picture or not (e.g. it is showing an error or the page didn't load).
|
||||||
|
private func webViewCanEnterPictureInPicture() async -> Result<Bool, CallScreenError> {
|
||||||
|
do {
|
||||||
|
guard let canEnterPictureInPicture = try await evaluateJavaScript("controls.canEnterPip()") as? Bool else {
|
||||||
|
MXLog.error("canEnterPip returned an unexpected value, skipping picture in picture.")
|
||||||
|
return .failure(.pictureInPictureNotAvailable)
|
||||||
|
}
|
||||||
|
MXLog.info("canEnterPip returned \(canEnterPictureInPicture)")
|
||||||
|
return .success(canEnterPictureInPicture)
|
||||||
|
} catch {
|
||||||
|
MXLog.error("Error checking canEnterPip: \(error)")
|
||||||
|
return .failure(.pictureInPictureNotAvailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Avoids retain loops between the configuration and webView coordinator
|
/// Avoids retain loops between the configuration and webView coordinator
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct RoomScreenCoordinatorParameters {
|
|||||||
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
|
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
|
||||||
let emojiProvider: EmojiProviderProtocol
|
let emojiProvider: EmojiProviderProtocol
|
||||||
let completionSuggestionService: CompletionSuggestionServiceProtocol
|
let completionSuggestionService: CompletionSuggestionServiceProtocol
|
||||||
|
let ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>
|
||||||
let appMediator: AppMediatorProtocol
|
let appMediator: AppMediatorProtocol
|
||||||
let appSettings: AppSettings
|
let appSettings: AppSettings
|
||||||
let composerDraftService: ComposerDraftServiceProtocol
|
let composerDraftService: ComposerDraftServiceProtocol
|
||||||
@@ -64,6 +65,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
|||||||
init(parameters: RoomScreenCoordinatorParameters) {
|
init(parameters: RoomScreenCoordinatorParameters) {
|
||||||
roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
|
roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
|
||||||
mediaProvider: parameters.mediaProvider,
|
mediaProvider: parameters.mediaProvider,
|
||||||
|
ongoingCallRoomIDPublisher: parameters.ongoingCallRoomIDPublisher,
|
||||||
appMediator: parameters.appMediator,
|
appMediator: parameters.appMediator,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
analyticsService: ServiceLocator.shared.analytics)
|
analyticsService: ServiceLocator.shared.analytics)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ struct RoomScreenViewState: BindableState {
|
|||||||
|
|
||||||
var canJoinCall = false
|
var canJoinCall = false
|
||||||
var hasOngoingCall: Bool
|
var hasOngoingCall: Bool
|
||||||
|
var shouldShowCallButton = true
|
||||||
|
|
||||||
var bindings: RoomScreenViewStateBindings
|
var bindings: RoomScreenViewStateBindings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
|
|
||||||
init(roomProxy: JoinedRoomProxyProtocol,
|
init(roomProxy: JoinedRoomProxyProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
mediaProvider: MediaProviderProtocol,
|
||||||
|
ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>,
|
||||||
appMediator: AppMediatorProtocol,
|
appMediator: AppMediatorProtocol,
|
||||||
appSettings: AppSettings,
|
appSettings: AppSettings,
|
||||||
analyticsService: AnalyticsService) {
|
analyticsService: AnalyticsService) {
|
||||||
@@ -68,7 +69,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
bindings: .init()),
|
bindings: .init()),
|
||||||
mediaProvider: mediaProvider)
|
mediaProvider: mediaProvider)
|
||||||
|
|
||||||
setupSubscriptions()
|
setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func process(viewAction: RoomScreenViewAction) {
|
override func process(viewAction: RoomScreenViewAction) {
|
||||||
@@ -93,7 +94,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
state.lastScrollDirection = direction
|
state.lastScrollDirection = direction
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupSubscriptions() {
|
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {
|
||||||
let roomInfoSubscription = roomProxy
|
let roomInfoSubscription = roomProxy
|
||||||
.actionsPublisher
|
.actionsPublisher
|
||||||
.filter { $0 == .roomInfoUpdate }
|
.filter { $0 == .roomInfoUpdate }
|
||||||
@@ -135,6 +136,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
self?.setupPinnedEventsTimelineProviderIfNeeded()
|
self?.setupPinnedEventsTimelineProviderIfNeeded()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
ongoingCallRoomIDPublisher
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] ongoingCallRoomID in
|
||||||
|
guard let self else { return }
|
||||||
|
state.shouldShowCallButton = ongoingCallRoomID != roomProxy.id
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) {
|
private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) {
|
||||||
@@ -186,6 +195,7 @@ extension RoomScreenViewModel {
|
|||||||
static func mock(roomProxyMock: JoinedRoomProxyMock) -> RoomScreenViewModel {
|
static func mock(roomProxyMock: JoinedRoomProxyMock) -> RoomScreenViewModel {
|
||||||
RoomScreenViewModel(roomProxy: roomProxyMock,
|
RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
analyticsService: ServiceLocator.shared.analytics)
|
analyticsService: ServiceLocator.shared.analytics)
|
||||||
|
|||||||
@@ -175,8 +175,10 @@ struct RoomScreen: View {
|
|||||||
|
|
||||||
if !ProcessInfo.processInfo.isiOSAppOnMac {
|
if !ProcessInfo.processInfo.isiOSAppOnMac {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
callButton
|
if roomContext.viewState.shouldShowCallButton {
|
||||||
.disabled(roomContext.viewState.canJoinCall == false)
|
callButton
|
||||||
|
.disabled(!roomContext.viewState.canJoinCall)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -53,6 +53,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
|||||||
var elementCallBaseURLOverride: URL? { get set }
|
var elementCallBaseURLOverride: URL? { get set }
|
||||||
var fuzzyRoomListSearchEnabled: Bool { get set }
|
var fuzzyRoomListSearchEnabled: Bool { get set }
|
||||||
var pinningEnabled: Bool { get set }
|
var pinningEnabled: Bool { get set }
|
||||||
|
var elementCallPictureInPictureEnabled: Bool { get set }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AppSettings: DeveloperOptionsProtocol { }
|
extension AppSettings: DeveloperOptionsProtocol { }
|
||||||
|
|||||||
+5
@@ -61,6 +61,11 @@ struct DeveloperOptionsScreen: View {
|
|||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.foregroundColor(URL(string: elementCallBaseURLString) == nil ? .red : .primary)
|
.foregroundColor(URL(string: elementCallBaseURLString) == nil ? .red : .primary)
|
||||||
|
|
||||||
|
Toggle(isOn: $context.elementCallPictureInPictureEnabled) {
|
||||||
|
Text("Picture in Picture support")
|
||||||
|
Text("Requires an Element Call deployment with support for signalling PiP availability.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ struct ElementCallConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A string representing the call being configured.
|
/// A string representing the call being configured.
|
||||||
var callID: String {
|
var callRoomID: String {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .genericCallLink(let url):
|
case .genericCallLink(let url):
|
||||||
url.absoluteString
|
url.absoluteString
|
||||||
|
|||||||
@@ -57,9 +57,14 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
|
|
||||||
private var endUnansweredCallTask: Task<Void, Never>?
|
private var endUnansweredCallTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var ongoingCallID: CallID?
|
private var ongoingCallID: CallID? {
|
||||||
|
didSet { ongoingCallRoomIDSubject.send(ongoingCallID?.roomID) }
|
||||||
|
}
|
||||||
|
|
||||||
var ongoingCallRoomID: String? { ongoingCallID?.roomID }
|
let ongoingCallRoomIDSubject = CurrentValueSubject<String?, Never>(nil)
|
||||||
|
var ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never> {
|
||||||
|
ongoingCallRoomIDSubject.asCurrentValuePublisher()
|
||||||
|
}
|
||||||
|
|
||||||
private let actionsSubject: PassthroughSubject<ElementCallServiceAction, Never> = .init()
|
private let actionsSubject: PassthroughSubject<ElementCallServiceAction, Never> = .init()
|
||||||
var actions: AnyPublisher<ElementCallServiceAction, Never> {
|
var actions: AnyPublisher<ElementCallServiceAction, Never> {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ enum ElementCallServiceAction {
|
|||||||
protocol ElementCallServiceProtocol {
|
protocol ElementCallServiceProtocol {
|
||||||
var actions: AnyPublisher<ElementCallServiceAction, Never> { get }
|
var actions: AnyPublisher<ElementCallServiceAction, Never> { get }
|
||||||
|
|
||||||
var ongoingCallRoomID: String? { get }
|
var ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never> { get }
|
||||||
|
|
||||||
func setClientProxy(_ clientProxy: ClientProxyProtocol)
|
func setClientProxy(_ clientProxy: ClientProxyProtocol)
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -272,6 +273,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -289,6 +291,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -306,6 +309,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -326,6 +330,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -346,6 +351,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -366,6 +372,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -387,6 +394,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -407,6 +415,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -426,6 +435,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -459,6 +469,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -479,6 +490,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
@@ -499,6 +511,7 @@ class MockScreen: Identifiable {
|
|||||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
|
|||||||
roomTimelineControllerFactory: timelineControllerFactory,
|
roomTimelineControllerFactory: timelineControllerFactory,
|
||||||
navigationStackCoordinator: navigationStackCoordinator,
|
navigationStackCoordinator: navigationStackCoordinator,
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
analytics: ServiceLocator.shared.analytics,
|
analytics: ServiceLocator.shared.analytics,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher()
|
roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher()
|
||||||
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
analyticsService: ServiceLocator.shared.analytics)
|
analyticsService: ServiceLocator.shared.analytics)
|
||||||
@@ -112,6 +113,7 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher()
|
roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher()
|
||||||
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
|
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
analyticsService: ServiceLocator.shared.analytics)
|
analyticsService: ServiceLocator.shared.analytics)
|
||||||
@@ -140,4 +142,42 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
updateSubject.send(.roomInfoUpdate)
|
updateSubject.send(.roomInfoUpdate)
|
||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCallButtonVisibility() async throws {
|
||||||
|
// Given a room screen with no ongoing call.
|
||||||
|
let ongoingCallRoomIDSubject = CurrentValueSubject<String?, Never>(nil)
|
||||||
|
let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID"))
|
||||||
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
|
mediaProvider: MockMediaProvider(),
|
||||||
|
ongoingCallRoomIDPublisher: ongoingCallRoomIDSubject.asCurrentValuePublisher(),
|
||||||
|
appMediator: AppMediatorMock.default,
|
||||||
|
appSettings: ServiceLocator.shared.settings,
|
||||||
|
analyticsService: ServiceLocator.shared.analytics)
|
||||||
|
self.viewModel = viewModel
|
||||||
|
XCTAssertTrue(viewModel.state.shouldShowCallButton)
|
||||||
|
|
||||||
|
// When a call starts in this room.
|
||||||
|
var deferred = deferFulfillment(viewModel.context.$viewState) { !$0.shouldShowCallButton }
|
||||||
|
ongoingCallRoomIDSubject.send("MyRoomID")
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// Then the call button should be hidden.
|
||||||
|
XCTAssertFalse(viewModel.state.shouldShowCallButton)
|
||||||
|
|
||||||
|
// When a call starts in a different room.
|
||||||
|
deferred = deferFulfillment(viewModel.context.$viewState) { $0.shouldShowCallButton }
|
||||||
|
ongoingCallRoomIDSubject.send("OtherRoomID")
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// Then the call button should be shown again.
|
||||||
|
XCTAssertTrue(viewModel.state.shouldShowCallButton)
|
||||||
|
|
||||||
|
// When the call from the other room finishes.
|
||||||
|
let deferredFailure = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.shouldShowCallButton }
|
||||||
|
ongoingCallRoomIDSubject.send(nil)
|
||||||
|
try await deferredFailure.fulfill()
|
||||||
|
|
||||||
|
// Then the call button should remain visible shown.
|
||||||
|
XCTAssertTrue(viewModel.state.shouldShowCallButton)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user