mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
Implement support for ElementCall through Rust side widgets (#1906)
* Implement support for ElementCall through Rust side widgets * Add Element Call feature flag * Fix the unit tests * Address PR comments
This commit is contained in:
@@ -43,6 +43,7 @@ final class AppSettings {
|
||||
case voiceMessageEnabled
|
||||
case mentionsEnabled
|
||||
case appLockFlowEnabled
|
||||
case elementCallEnabled
|
||||
}
|
||||
|
||||
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
|
||||
@@ -203,6 +204,10 @@ final class AppSettings {
|
||||
@UserPreference(key: UserDefaultsKeys.richTextEditorEnabled, defaultValue: true, storageType: .userDefaults(store))
|
||||
var richTextEditorEnabled
|
||||
|
||||
// MARK: - Element Call
|
||||
|
||||
let elementCallBaseURL: URL = "https://call.element.io"
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.enableNotifications, defaultValue: true, storageType: .userDefaults(store))
|
||||
@@ -261,4 +266,7 @@ final class AppSettings {
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.appLockFlowEnabled, defaultValue: false, storageType: .volatile)
|
||||
var appLockFlowEnabled
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.elementCallEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||
var elementCallEnabled
|
||||
}
|
||||
|
||||
@@ -22,6 +22,20 @@ import UserNotifications
|
||||
enum RoomFlowCoordinatorAction: Equatable {
|
||||
case presentedRoom(String)
|
||||
case dismissedRoom
|
||||
case presentCallScreen(roomProxy: RoomProxyProtocol)
|
||||
|
||||
static func == (lhs: RoomFlowCoordinatorAction, rhs: RoomFlowCoordinatorAction) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.presentedRoom(let lhsRoomID), .presentedRoom(let rhsRoomID)):
|
||||
return lhsRoomID == rhsRoomID
|
||||
case (.dismissedRoom, .dismissedRoom):
|
||||
return true
|
||||
case (.presentCallScreen(let lhsRoomProxy), .presentCallScreen(let rhsRoomProxy)):
|
||||
return lhsRoomProxy.id == rhsRoomProxy.id
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
@@ -372,6 +386,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
stateMachine.tryEvent(.presentRoomMemberDetails(member: .init(value: member)))
|
||||
case .presentMessageForwarding(let itemID):
|
||||
stateMachine.tryEvent(.presentMessageForwarding(itemID: itemID))
|
||||
case .presentCallScreen:
|
||||
guard let roomProxy = self.roomProxy else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -85,6 +85,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .dismissedRoom:
|
||||
stateMachine.processEvent(.deselectRoom)
|
||||
analytics.signpost.endRoomFlow()
|
||||
case .presentCallScreen(let roomProxy):
|
||||
presentCallScreen(roomProxy: roomProxy)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -453,4 +455,23 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
self?.stateMachine.processEvent(.closedInvitesScreen)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Calls
|
||||
|
||||
private func presentCallScreen(roomProxy: RoomProxyProtocol) {
|
||||
let callScreenCoordinator = CallScreenCoordinator(parameters: .init(roomProxy: roomProxy,
|
||||
callBaseURL: appSettings.elementCallBaseURL,
|
||||
clientID: InfoPlistReader.main.bundleIdentifier))
|
||||
|
||||
callScreenCoordinator.actions
|
||||
.sink { [weak self] action in
|
||||
switch action {
|
||||
case .dismiss:
|
||||
self?.navigationSplitCoordinator.setSheetCoordinator(nil)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationSplitCoordinator.setSheetCoordinator(callScreenCoordinator, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,61 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol {
|
||||
setSuggestionTriggerClosure?(suggestionTrigger)
|
||||
}
|
||||
}
|
||||
class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol {
|
||||
var messagePublisher: PassthroughSubject<String, Never> {
|
||||
get { return underlyingMessagePublisher }
|
||||
set(value) { underlyingMessagePublisher = value }
|
||||
}
|
||||
var underlyingMessagePublisher: PassthroughSubject<String, Never>!
|
||||
var actions: AnyPublisher<ElementCallWidgetDriverAction, Never> {
|
||||
get { return underlyingActions }
|
||||
set(value) { underlyingActions = value }
|
||||
}
|
||||
var underlyingActions: AnyPublisher<ElementCallWidgetDriverAction, Never>!
|
||||
|
||||
//MARK: - start
|
||||
|
||||
var startBaseURLClientIDCallsCount = 0
|
||||
var startBaseURLClientIDCalled: Bool {
|
||||
return startBaseURLClientIDCallsCount > 0
|
||||
}
|
||||
var startBaseURLClientIDReceivedArguments: (baseURL: URL, clientID: String)?
|
||||
var startBaseURLClientIDReceivedInvocations: [(baseURL: URL, clientID: String)] = []
|
||||
var startBaseURLClientIDReturnValue: Result<URL, ElementCallWidgetDriverError>!
|
||||
var startBaseURLClientIDClosure: ((URL, String) async -> Result<URL, ElementCallWidgetDriverError>)?
|
||||
|
||||
func start(baseURL: URL, clientID: String) async -> Result<URL, ElementCallWidgetDriverError> {
|
||||
startBaseURLClientIDCallsCount += 1
|
||||
startBaseURLClientIDReceivedArguments = (baseURL: baseURL, clientID: clientID)
|
||||
startBaseURLClientIDReceivedInvocations.append((baseURL: baseURL, clientID: clientID))
|
||||
if let startBaseURLClientIDClosure = startBaseURLClientIDClosure {
|
||||
return await startBaseURLClientIDClosure(baseURL, clientID)
|
||||
} else {
|
||||
return startBaseURLClientIDReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - sendMessage
|
||||
|
||||
var sendMessageCallsCount = 0
|
||||
var sendMessageCalled: Bool {
|
||||
return sendMessageCallsCount > 0
|
||||
}
|
||||
var sendMessageReceivedMessage: String?
|
||||
var sendMessageReceivedInvocations: [String] = []
|
||||
var sendMessageReturnValue: Result<Bool, ElementCallWidgetDriverError>!
|
||||
var sendMessageClosure: ((String) async -> Result<Bool, ElementCallWidgetDriverError>)?
|
||||
|
||||
func sendMessage(_ message: String) async -> Result<Bool, ElementCallWidgetDriverError> {
|
||||
sendMessageCallsCount += 1
|
||||
sendMessageReceivedMessage = message
|
||||
sendMessageReceivedInvocations.append(message)
|
||||
if let sendMessageClosure = sendMessageClosure {
|
||||
return await sendMessageClosure(message)
|
||||
} else {
|
||||
return sendMessageReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
class KeychainControllerMock: KeychainControllerProtocol {
|
||||
|
||||
//MARK: - setRestorationToken
|
||||
@@ -1173,6 +1228,11 @@ class RoomProxyMock: RoomProxyProtocol {
|
||||
set(value) { underlyingIsTombstoned = value }
|
||||
}
|
||||
var underlyingIsTombstoned: Bool!
|
||||
var isCallOngoing: Bool {
|
||||
get { return underlyingIsCallOngoing }
|
||||
set(value) { underlyingIsCallOngoing = value }
|
||||
}
|
||||
var underlyingIsCallOngoing: Bool!
|
||||
var canonicalAlias: String?
|
||||
var alternativeAliases: [String] = []
|
||||
var hasUnreadNotifications: Bool {
|
||||
@@ -1944,6 +2004,23 @@ class RoomProxyMock: RoomProxyProtocol {
|
||||
return endPollPollStartIDTextReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - elementCallWidgetDriver
|
||||
|
||||
var elementCallWidgetDriverCallsCount = 0
|
||||
var elementCallWidgetDriverCalled: Bool {
|
||||
return elementCallWidgetDriverCallsCount > 0
|
||||
}
|
||||
var elementCallWidgetDriverReturnValue: ElementCallWidgetDriverProtocol!
|
||||
var elementCallWidgetDriverClosure: (() -> ElementCallWidgetDriverProtocol)?
|
||||
|
||||
func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol {
|
||||
elementCallWidgetDriverCallsCount += 1
|
||||
if let elementCallWidgetDriverClosure = elementCallWidgetDriverClosure {
|
||||
return elementCallWidgetDriverClosure()
|
||||
} else {
|
||||
return elementCallWidgetDriverReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
class RoomTimelineProviderMock: RoomTimelineProviderProtocol {
|
||||
var updatePublisher: AnyPublisher<TimelineProviderUpdate, Never> {
|
||||
|
||||
@@ -28,6 +28,7 @@ struct RoomProxyMockConfiguration {
|
||||
var isPublic = Bool.random()
|
||||
var isEncrypted = Bool.random()
|
||||
var isTombstoned = Bool.random()
|
||||
var isCallOngoing = Bool.random()
|
||||
var canonicalAlias: String?
|
||||
var alternativeAliases: [String] = []
|
||||
var hasUnreadNotifications = Bool.random()
|
||||
@@ -56,6 +57,7 @@ extension RoomProxyMock {
|
||||
isPublic = configuration.isPublic
|
||||
isEncrypted = configuration.isEncrypted
|
||||
isTombstoned = configuration.isTombstoned
|
||||
isCallOngoing = configuration.isCallOngoing
|
||||
canonicalAlias = configuration.canonicalAlias
|
||||
alternativeAliases = configuration.alternativeAliases
|
||||
hasUnreadNotifications = configuration.hasUnreadNotifications
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
struct ElementCallButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.padding(.horizontal, 16.0)
|
||||
.padding(.vertical, 4.0)
|
||||
.foregroundColor(.compound.bgCanvasDefault)
|
||||
.background(Color.compound.iconAccentTertiary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct CallScreenCoordinatorParameters {
|
||||
let roomProxy: RoomProxyProtocol
|
||||
/// Which Element Call instance should be used
|
||||
let callBaseURL: URL
|
||||
/// A way to identify the current client against Element Call
|
||||
let clientID: String
|
||||
}
|
||||
|
||||
enum CallScreenCoordinatorAction {
|
||||
case dismiss
|
||||
}
|
||||
|
||||
final class CallScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: CallScreenCoordinatorParameters
|
||||
private var viewModel: CallScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<CallScreenCoordinatorAction, Never> = .init()
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = .init()
|
||||
var actions: AnyPublisher<CallScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: CallScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = CallScreenViewModel(roomProxy: parameters.roomProxy,
|
||||
callBaseURL: parameters.callBaseURL,
|
||||
clientID: parameters.clientID)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(CallScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum CallScreenViewModelAction {
|
||||
case dismiss
|
||||
}
|
||||
|
||||
struct CallScreenViewState: BindableState {
|
||||
let messageHandler: String
|
||||
let script: String?
|
||||
var url: URL?
|
||||
var bindings = Bindings()
|
||||
}
|
||||
|
||||
struct Bindings {
|
||||
var javaScriptMessageHandler: ((Any) -> Void)?
|
||||
var javaScriptEvaluator: ((String) async throws -> Any)?
|
||||
|
||||
var alertInfo: AlertInfo<UUID>?
|
||||
}
|
||||
|
||||
enum CallScreenViewAction {
|
||||
case urlChanged(URL?)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import CallKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias CallScreenViewModelType = StateStoreViewModel<CallScreenViewState, CallScreenViewAction>
|
||||
|
||||
class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol {
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
private let callBaseURL: URL
|
||||
private let clientID: String
|
||||
|
||||
private let widgetDriver: ElementCallWidgetDriverProtocol
|
||||
|
||||
private let callController = CXCallController()
|
||||
private let callProvider = CXProvider(configuration: .init())
|
||||
private let callID = UUID()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<CallScreenViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<CallScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
deinit {
|
||||
tearDownVoIPSession(callID: callID)
|
||||
}
|
||||
|
||||
/// Designated initialiser
|
||||
/// - Parameters:
|
||||
/// - roomProxy: The room in which the call should be created
|
||||
/// - callBaseURL: Which Element Call instance should be used
|
||||
/// - clientID: Something to identify the current client on the Element Call side
|
||||
init(roomProxy: RoomProxyProtocol, callBaseURL: URL, clientID: String) {
|
||||
self.roomProxy = roomProxy
|
||||
self.callBaseURL = callBaseURL
|
||||
self.clientID = clientID
|
||||
|
||||
widgetDriver = roomProxy.elementCallWidgetDriver()
|
||||
|
||||
super.init(initialViewState: CallScreenViewState(messageHandler: Self.eventHandlerName,
|
||||
script: Self.eventHandlerInjectionScript))
|
||||
|
||||
state.bindings.javaScriptMessageHandler = { [weak self] message in
|
||||
guard let self,
|
||||
let message = message as? String else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await self.widgetDriver.sendMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
widgetDriver.messagePublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] receivedMessage in
|
||||
guard let self else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
let message = "postMessage(\(receivedMessage), '*')"
|
||||
let result = try await self.state.bindings.javaScriptEvaluator?(message)
|
||||
MXLog.debug("Evaluated javascript: \(message) with result: \(String(describing: result))")
|
||||
} catch {
|
||||
MXLog.error("Received javascript evaluation error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
widgetDriver.actions
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .callEnded:
|
||||
actionsSubject.send(.dismiss)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task {
|
||||
switch await widgetDriver.start(baseURL: callBaseURL, clientID: clientID) {
|
||||
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
|
||||
}
|
||||
|
||||
do {
|
||||
try await setupVoIPSession(callID: callID)
|
||||
} catch {
|
||||
MXLog.error("Failed setting up VoIP session with error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func process(viewAction: CallScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .urlChanged(let url):
|
||||
guard let url else { return }
|
||||
MXLog.info("URL changed to: \(url)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CXCallObserverDelegate
|
||||
|
||||
func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) {
|
||||
MXLog.info("Call changed: \(call)")
|
||||
}
|
||||
|
||||
// MARK: - CXProviderDelegate
|
||||
|
||||
func providerDidReset(_ provider: CXProvider) {
|
||||
MXLog.info("Call provider did reset: \(provider)")
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static let eventHandlerName = "elementx"
|
||||
|
||||
private static var eventHandlerInjectionScript: String {
|
||||
"""
|
||||
window.addEventListener(
|
||||
"message",
|
||||
(event) => {
|
||||
let message = {data: event.data, origin: event.origin}
|
||||
if (message.data.response && message.data.api == "toWidget"
|
||||
|| !message.data.response && message.data.api == "fromWidget") {
|
||||
window.webkit.messageHandlers.\(eventHandlerName).postMessage(JSON.stringify(message.data));
|
||||
}else{
|
||||
console.log("-- skipped event handling by the client because it is send from the client itself.");
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
"""
|
||||
}
|
||||
|
||||
private func evaluateJavaScript(_ script: String) async -> String? {
|
||||
guard let evaluator = state.bindings.javaScriptEvaluator else {
|
||||
fatalError("Invalid javaScriptEvaluator")
|
||||
}
|
||||
|
||||
do {
|
||||
return try await evaluator(script) as? String
|
||||
} catch {
|
||||
MXLog.error("Failed evaluating javaScript with error: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setupVoIPSession(callID: UUID) async throws {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .videoChat, options: [])
|
||||
try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)
|
||||
|
||||
let handle = CXHandle(type: .generic, value: roomProxy.roomTitle)
|
||||
let startCallAction = CXStartCallAction(call: callID, handle: handle)
|
||||
startCallAction.isVideo = true
|
||||
|
||||
let transaction = CXTransaction(action: startCallAction)
|
||||
|
||||
try await callController.request(transaction)
|
||||
}
|
||||
|
||||
private nonisolated func tearDownVoIPSession(callID: UUID?) {
|
||||
guard let callID else {
|
||||
return
|
||||
}
|
||||
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
|
||||
let endCallAction = CXEndCallAction(call: callID)
|
||||
let transaction = CXTransaction(action: endCallAction)
|
||||
|
||||
callController.request(transaction) { error in
|
||||
if let error {
|
||||
MXLog.error("Failed transaction with error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol CallScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<CallScreenViewModelAction, Never> { get }
|
||||
var context: CallScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct CallScreen: View {
|
||||
@ObservedObject var context: CallScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
WebView(url: context.viewState.url, viewModelContext: context)
|
||||
.navigationTitle("Call")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.presentationDragIndicator(.visible)
|
||||
.environment(\.colorScheme, .dark)
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WebView: UIViewRepresentable {
|
||||
let url: URL?
|
||||
let viewModelContext: CallScreenViewModel.Context
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
context.coordinator.webView
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(viewModelContext: viewModelContext)
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
if let url {
|
||||
context.coordinator.url = url
|
||||
let request = URLRequest(url: url)
|
||||
webView.load(request)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class Coordinator: NSObject, WKScriptMessageHandler, WKUIDelegate, WKNavigationDelegate {
|
||||
private let viewModelContext: CallScreenViewModel.Context
|
||||
private var webViewURLObservation: NSKeyValueObservation?
|
||||
|
||||
private(set) var webView: WKWebView!
|
||||
|
||||
var url: URL!
|
||||
|
||||
init(viewModelContext: CallScreenViewModel.Context) {
|
||||
self.viewModelContext = viewModelContext
|
||||
|
||||
super.init()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
// Avoid `Publishing changes from within view update warnings`
|
||||
viewModelContext.javaScriptEvaluator = self.evaluateJavaScript(_:)
|
||||
}
|
||||
|
||||
let configuration = WKWebViewConfiguration()
|
||||
|
||||
let userContentController = WKUserContentController()
|
||||
userContentController.add(self, name: viewModelContext.viewState.messageHandler)
|
||||
|
||||
configuration.userContentController = userContentController
|
||||
configuration.allowsInlineMediaPlayback = true
|
||||
configuration.allowsPictureInPictureMediaPlayback = true
|
||||
|
||||
if let script = viewModelContext.viewState.script {
|
||||
let userScript = WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: false)
|
||||
configuration.userContentController.addUserScript(userScript)
|
||||
}
|
||||
|
||||
webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.uiDelegate = self
|
||||
webView.navigationDelegate = self
|
||||
|
||||
// Try matching Element Call colors
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .compound.bgCanvasDefault
|
||||
webView.scrollView.backgroundColor = .compound.bgCanvasDefault
|
||||
}
|
||||
|
||||
func evaluateJavaScript(_ script: String) async throws -> Any? {
|
||||
// After testing different scenarios it seems that when using async/await version of these
|
||||
// methods wkwebView expects JavaScript to return with a value (something other than Void),
|
||||
// if there is no value returning from the JavaScript that you evaluate you will have a crash.
|
||||
try await withCheckedThrowingContinuation { continuaton in
|
||||
webView.evaluateJavaScript(script) { result, error in
|
||||
if let error {
|
||||
continuaton.resume(throwing: error)
|
||||
} else {
|
||||
continuaton.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WKScriptMessageHandler
|
||||
|
||||
nonisolated func userContentController(_ userContentController: WKUserContentController,
|
||||
didReceive message: WKScriptMessage) {
|
||||
Task { @MainActor in
|
||||
viewModelContext.javaScriptMessageHandler?(message.body)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
Task { @MainActor in
|
||||
viewModelContext.send(viewAction: .urlChanged(webView.url))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct CallScreen_Previews: PreviewProvider {
|
||||
static let viewModel = {
|
||||
let roomProxy = RoomProxyMock()
|
||||
|
||||
let widgetDriver = ElementCallWidgetDriverMock()
|
||||
widgetDriver.underlyingMessagePublisher = .init()
|
||||
widgetDriver.underlyingActions = PassthroughSubject<ElementCallWidgetDriverAction, Never>().eraseToAnyPublisher()
|
||||
widgetDriver.startBaseURLClientIDReturnValue = .success(URL.userDirectory)
|
||||
|
||||
roomProxy.elementCallWidgetDriverReturnValue = widgetDriver
|
||||
|
||||
return CallScreenViewModel(roomProxy: roomProxy,
|
||||
callBaseURL: "https://call.element.io",
|
||||
clientID: "io.element.elementx")
|
||||
}()
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
CallScreen(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ enum RoomScreenCoordinatorAction {
|
||||
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case presentRoomMemberDetails(member: RoomMemberProxyProtocol)
|
||||
case presentMessageForwarding(itemID: TimelineItemIdentifier)
|
||||
case presentCallScreen
|
||||
}
|
||||
|
||||
final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
@@ -109,6 +110,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description))
|
||||
case .composer(let action):
|
||||
composerViewModel.process(roomAction: action)
|
||||
case .displayCallScreen:
|
||||
actionsSubject.send(.presentCallScreen)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -34,6 +34,7 @@ enum RoomScreenViewModelAction {
|
||||
case displayMessageForwarding(itemID: TimelineItemIdentifier)
|
||||
case displayLocation(body: String, geoURI: GeoURI, description: String?)
|
||||
case composer(action: RoomScreenComposerAction)
|
||||
case displayCallScreen
|
||||
}
|
||||
|
||||
enum RoomScreenComposerMode: Equatable {
|
||||
@@ -63,7 +64,7 @@ enum RoomScreenViewAction {
|
||||
case paginateBackwards
|
||||
case selectedPollOption(pollStartID: String, optionID: String)
|
||||
case endPoll(pollStartID: String)
|
||||
|
||||
|
||||
case timelineItemMenu(itemID: TimelineItemIdentifier)
|
||||
case timelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
|
||||
|
||||
@@ -73,7 +74,7 @@ enum RoomScreenViewAction {
|
||||
case tappedOnUser(userID: String)
|
||||
|
||||
case reactionSummary(itemID: TimelineItemIdentifier, key: String)
|
||||
|
||||
|
||||
case retrySend(itemID: TimelineItemIdentifier)
|
||||
case cancelSend(itemID: TimelineItemIdentifier)
|
||||
|
||||
@@ -81,8 +82,11 @@ enum RoomScreenViewAction {
|
||||
|
||||
case enableLongPress(itemID: TimelineItemIdentifier)
|
||||
case disableLongPress(itemID: TimelineItemIdentifier)
|
||||
|
||||
case playPauseAudio(itemID: TimelineItemIdentifier)
|
||||
case seekAudio(itemID: TimelineItemIdentifier, progress: Double)
|
||||
|
||||
case presentCall
|
||||
}
|
||||
|
||||
enum RoomScreenComposerAction {
|
||||
@@ -103,9 +107,13 @@ struct RoomScreenViewState: BindableState {
|
||||
var isEncryptedOneToOneRoom = false
|
||||
var timelineViewState = TimelineViewState() // check the doc before changing this
|
||||
var swiftUITimelineEnabled = false
|
||||
|
||||
var longPressDisabledItemID: TimelineItemIdentifier?
|
||||
var ownUserID: String
|
||||
|
||||
|
||||
var showCallButton = false
|
||||
var isCallOngoing = false
|
||||
|
||||
var bindings: RoomScreenViewStateBindings
|
||||
|
||||
/// A closure providing the actions to show when long pressing on an item in the timeline.
|
||||
@@ -127,14 +135,14 @@ struct RoomScreenViewStateBindings {
|
||||
|
||||
/// Information describing the currently displayed alert.
|
||||
var alertInfo: AlertInfo<RoomScreenErrorType>?
|
||||
|
||||
|
||||
/// An alert info for confirmation actions (e.g. ending a poll)
|
||||
var confirmationAlertInfo: AlertInfo<UUID>?
|
||||
|
||||
|
||||
var debugInfo: TimelineItemDebugInfo?
|
||||
|
||||
var actionMenuInfo: TimelineItemActionMenuInfo?
|
||||
|
||||
|
||||
var sendFailedConfirmationDialogInfo: SendFailedConfirmationDialogInfo?
|
||||
|
||||
var reactionSummaryInfo: ReactionSummaryInfo?
|
||||
@@ -144,7 +152,7 @@ struct TimelineItemActionMenuInfo: Equatable, Identifiable {
|
||||
static func == (lhs: TimelineItemActionMenuInfo, rhs: TimelineItemActionMenuInfo) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
|
||||
let item: EventBasedTimelineItemProtocol
|
||||
|
||||
var id: TimelineItemIdentifier {
|
||||
@@ -154,7 +162,7 @@ struct TimelineItemActionMenuInfo: Equatable, Identifiable {
|
||||
|
||||
struct SendFailedConfirmationDialogInfo: ConfirmationDialogProtocol {
|
||||
let title = L10n.screenRoomRetrySendMenuTitle
|
||||
|
||||
|
||||
let itemID: TimelineItemIdentifier
|
||||
}
|
||||
|
||||
@@ -184,19 +192,19 @@ struct RoomMemberState {
|
||||
struct TimelineViewState {
|
||||
var canBackPaginate = true
|
||||
var isBackPaginating = false
|
||||
|
||||
|
||||
// These can be removed when we have full swiftUI and moved as @State values in the view
|
||||
var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
|
||||
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
|
||||
|
||||
var renderedTimelineIDs = [String]()
|
||||
var pendingTimelineIDs = [String]()
|
||||
|
||||
|
||||
var timelineIDs: [String] {
|
||||
itemsDictionary.keys.elements
|
||||
}
|
||||
|
||||
|
||||
var itemViewStates: [RoomTimelineItemViewState] {
|
||||
renderedTimelineIDs.compactMap { itemsDictionary[$0] }
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
readReceiptsEnabled: appSettings.readReceiptsEnabled,
|
||||
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
|
||||
ownUserID: roomProxy.ownUserID,
|
||||
isCallOngoing: roomProxy.isCallOngoing,
|
||||
bindings: .init(reactionsCollapsed: [:])),
|
||||
imageProvider: mediaProvider)
|
||||
|
||||
@@ -163,6 +164,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
message: L10n.commonPollEndConfirmation,
|
||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.actionOk, action: { self.endPoll(pollStartID: pollStartID) }))
|
||||
case .presentCall:
|
||||
actionsSubject.send(.displayCallScreen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +246,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
.weakAssign(to: \.state.readReceiptsEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$elementCallEnabled
|
||||
.weakAssign(to: \.state.showCallButton, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
roomProxy.members
|
||||
.map { members in
|
||||
members.reduce(into: [String: RoomMemberState]()) { dictionary, member in
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
@@ -155,8 +156,33 @@ struct RoomScreen: View {
|
||||
ToolbarItem(placement: .principal) {
|
||||
RoomHeaderView(context: context)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
callButton
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var callButton: some View {
|
||||
if context.viewState.showCallButton {
|
||||
if context.viewState.isCallOngoing {
|
||||
Button {
|
||||
context.send(viewAction: .presentCall)
|
||||
} label: {
|
||||
Label(L10n.actionJoin, icon: \.videoCall)
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(ElementCallButtonStyle())
|
||||
} else {
|
||||
Button {
|
||||
context.send(viewAction: .presentCall)
|
||||
} label: {
|
||||
CompoundIcon(\.videoCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isNavigationBarHidden: Bool {
|
||||
composerToolbarContext.composerActionsEnabled && composerToolbarContext.composerExpanded && UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
@@ -167,11 +193,11 @@ struct RoomScreen: View {
|
||||
struct RoomScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
|
||||
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room", isCallOngoing: true)),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
||||
var voiceMessageEnabled: Bool { get set }
|
||||
var mentionsEnabled: Bool { get set }
|
||||
var appLockFlowEnabled: Bool { get set }
|
||||
var elementCallEnabled: Bool { get set }
|
||||
}
|
||||
|
||||
extension AppSettings: DeveloperOptionsProtocol { }
|
||||
|
||||
+4
@@ -50,6 +50,10 @@ struct DeveloperOptionsScreen: View {
|
||||
Text("Show user mentions")
|
||||
Text("Requires app reboot")
|
||||
}
|
||||
|
||||
Toggle(isOn: $context.elementCallEnabled) {
|
||||
Text("Elemement Call")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Room creation") {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
private struct ElementCallWidgetMessage: Codable {
|
||||
enum Direction: String, Codable {
|
||||
case fromWidget
|
||||
case toWidget
|
||||
}
|
||||
|
||||
enum Action: String, Codable {
|
||||
case hangup = "im.vector.hangup"
|
||||
}
|
||||
|
||||
let direction: Direction
|
||||
let action: Action
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case direction = "api"
|
||||
case action
|
||||
}
|
||||
}
|
||||
|
||||
class ElementCallWidgetDriver: WidgetPermissionsProvider, ElementCallWidgetDriverProtocol {
|
||||
private let room: RoomProtocol
|
||||
private var widgetDriver: WidgetDriverAndHandle?
|
||||
|
||||
let messagePublisher = PassthroughSubject<String, Never>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<ElementCallWidgetDriverAction, Never> = .init()
|
||||
var actions: AnyPublisher<ElementCallWidgetDriverAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(room: RoomProtocol) {
|
||||
self.room = room
|
||||
}
|
||||
|
||||
func start(baseURL: URL, clientID: String) async -> Result<URL, ElementCallWidgetDriverError> {
|
||||
guard let room = room as? Room else {
|
||||
return .failure(.roomInvalid)
|
||||
}
|
||||
|
||||
guard let widgetSettings = try? newVirtualElementCallWidget(props: .init(elementCallUrl: baseURL.absoluteString,
|
||||
widgetId: UUID().uuidString,
|
||||
parentUrl: nil,
|
||||
hideHeader: nil,
|
||||
preload: nil,
|
||||
fontScale: nil,
|
||||
appPrompt: false,
|
||||
skipLobby: true,
|
||||
confineToRoom: true,
|
||||
fonts: nil,
|
||||
analyticsId: nil)) else {
|
||||
return .failure(.failedBuildingWidgetSettings)
|
||||
}
|
||||
|
||||
guard let urlString = try? await generateWebviewUrl(widgetSettings: widgetSettings, room: room,
|
||||
props: .init(clientId: clientID,
|
||||
languageTag: nil,
|
||||
theme: nil)) else {
|
||||
return .failure(.failedBuildingCallURL)
|
||||
}
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
return .failure(.failedParsingCallURL)
|
||||
}
|
||||
|
||||
guard let widgetDriver = try? makeWidgetDriver(settings: widgetSettings) else {
|
||||
return .failure(.failedBuildingWidgetDriver)
|
||||
}
|
||||
|
||||
self.widgetDriver = widgetDriver
|
||||
|
||||
Task.detached { [weak self, widgetDriver, messagePublisher] in
|
||||
MXLog.debug("Started message receiving loop")
|
||||
|
||||
defer {
|
||||
MXLog.debug("Stopped message receiving loop")
|
||||
}
|
||||
|
||||
while true {
|
||||
guard let receivedMessage = await widgetDriver.handle.recv() else {
|
||||
return
|
||||
}
|
||||
|
||||
messagePublisher.send(receivedMessage)
|
||||
MXLog.debug("Received message: \(receivedMessage)")
|
||||
|
||||
self?.handleMessageIfNeeded(receivedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
Task.detached { [widgetDriver] in
|
||||
MXLog.debug("Started widget driver")
|
||||
|
||||
defer {
|
||||
MXLog.debug("Stopped widget driver")
|
||||
}
|
||||
|
||||
await widgetDriver.driver.run(room: room, permissionsProvider: self)
|
||||
}
|
||||
|
||||
return .success(url)
|
||||
}
|
||||
|
||||
func sendMessage(_ message: String) async -> Result<Bool, ElementCallWidgetDriverError> {
|
||||
guard let widgetDriver else {
|
||||
return .failure(.driverNotSetup)
|
||||
}
|
||||
|
||||
let result = await widgetDriver.handle.send(msg: message)
|
||||
MXLog.debug("Sent message: \(message) with result: \(result)")
|
||||
|
||||
handleMessageIfNeeded(message)
|
||||
|
||||
return .success(result)
|
||||
}
|
||||
|
||||
// MARK: - WidgetPermissionsProvider
|
||||
|
||||
func acquirePermissions(permissions: MatrixRustSDK.WidgetPermissions) -> MatrixRustSDK.WidgetPermissions {
|
||||
permissions
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
func handleMessageIfNeeded(_ message: String) {
|
||||
guard let data = message.data(using: .utf8),
|
||||
let widgetMessage = try? JSONDecoder().decode(ElementCallWidgetMessage.self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
if widgetMessage.direction == .fromWidget {
|
||||
switch widgetMessage.action {
|
||||
case .hangup:
|
||||
actionsSubject.send(.callEnded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import Foundation
|
||||
|
||||
enum ElementCallWidgetDriverError: Error {
|
||||
case roomInvalid
|
||||
case failedBuildingCallURL
|
||||
case failedBuildingWidgetSettings
|
||||
case failedBuildingWidgetDriver
|
||||
case failedParsingCallURL
|
||||
case driverNotSetup
|
||||
}
|
||||
|
||||
enum ElementCallWidgetDriverAction {
|
||||
case callEnded
|
||||
case errorReceived(ElementCallWidgetDriverError)
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol ElementCallWidgetDriverProtocol {
|
||||
var messagePublisher: PassthroughSubject<String, Never> { get }
|
||||
var actions: AnyPublisher<ElementCallWidgetDriverAction, Never> { get }
|
||||
|
||||
func start(baseURL: URL, clientID: String) async -> Result<URL, ElementCallWidgetDriverError>
|
||||
|
||||
func sendMessage(_ message: String) async -> Result<Bool, ElementCallWidgetDriverError>
|
||||
}
|
||||
@@ -151,6 +151,10 @@ class RoomProxy: RoomProxyProtocol {
|
||||
room.isTombstoned()
|
||||
}
|
||||
|
||||
var isCallOngoing: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
var canonicalAlias: String? {
|
||||
room.canonicalAlias()
|
||||
}
|
||||
@@ -252,7 +256,7 @@ class RoomProxy: RoomProxyProtocol {
|
||||
return .success(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func sendMessage(_ message: String, html: String?, inReplyTo eventID: String? = nil) async -> Result<Void, RoomProxyError> {
|
||||
sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
|
||||
defer {
|
||||
@@ -430,13 +434,13 @@ class RoomProxy: RoomProxyProtocol {
|
||||
self.room.cancelSend(txnId: transactionID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func editMessage(_ message: String, html: String?, original eventID: String) async -> Result<Void, RoomProxyError> {
|
||||
sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
|
||||
defer {
|
||||
sendMessageBackgroundTask?.stop()
|
||||
}
|
||||
|
||||
|
||||
let messageContent = buildMessageContentFor(message, html: html)
|
||||
|
||||
return await Task.dispatch(on: messageSendingDispatchQueue) {
|
||||
@@ -705,6 +709,12 @@ class RoomProxy: RoomProxyProtocol {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Element Call
|
||||
|
||||
func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol {
|
||||
ElementCallWidgetDriver(room: room)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ protocol RoomProxyProtocol {
|
||||
var isSpace: Bool { get }
|
||||
var isEncrypted: Bool { get }
|
||||
var isTombstoned: Bool { get }
|
||||
var isCallOngoing: Bool { get }
|
||||
var canonicalAlias: String? { get }
|
||||
var alternativeAliases: [String] { get }
|
||||
var hasUnreadNotifications: Bool { get }
|
||||
@@ -176,6 +177,10 @@ protocol RoomProxyProtocol {
|
||||
func sendPollResponse(pollStartID: String, answers: [String]) async -> Result<Void, RoomProxyError>
|
||||
|
||||
func endPoll(pollStartID: String, text: String) async -> Result<Void, RoomProxyError>
|
||||
|
||||
// MARK: - Element Call
|
||||
|
||||
func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol
|
||||
}
|
||||
|
||||
extension RoomProxyProtocol {
|
||||
|
||||
Reference in New Issue
Block a user