Files
element-x-ios/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift
T
Valere Fedronic fb49f5b83b Add experimental native calls behind a developer flag (#6159)
Add the element-call-ios dependency and a developer option for native calls

The package carries the call UI and the matrix-rust-rtc media stack, pinned by
exact version the way the SDK is, with the local path kept commented out for
working against a checkout. Its media xcframework has no Intel simulator slice
and a package cannot say so for the projects that consume it, hence the
excluded architecture at the project level.

The developer option is off by default and nothing reads it yet. It is placed
next to the Element Call URL override, which only applies to the web view that
native calls replace.
2026-09-17 19:42:18 +03:00

484 lines
21 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2024-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Foundation
import MatrixRustSDK
import OrderedCollections
import SwiftUI
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, RoomScreenViewAction>
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private let clientProxy: ClientProxyProtocol
private let roomProxy: JoinedRoomProxyProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsServiceProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private var initialSelectedPinnedEventID: String?
private let pinnedEventStringBuilder: RoomEventStringBuilder
private var identityPinningViolations = [String: RoomMemberProxyProtocol]()
private var identityVerificationViolations = [String: RoomMemberProxyProtocol]()
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var pinnedEventsTimelineItemProvider: TimelineItemProviderProtocol? {
didSet {
guard let pinnedEventsTimelineItemProvider else {
return
}
buildPinnedEventContents(timelineItems: pinnedEventsTimelineItemProvider.itemProxies)
pinnedEventsTimelineItemProvider.updatePublisher
// When pinning or unpinning an item, the timeline might return empty for a short while, so we need to debounce it to prevent weird UI behaviours like the banner disappearing
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink { [weak self] updatedItems, _ in
guard let self else { return }
buildPinnedEventContents(timelineItems: updatedItems)
}
.store(in: &cancellables)
}
}
init(userSession: UserSessionProtocol,
roomProxy: JoinedRoomProxyProtocol,
initialSelectedPinnedEventID: String?,
ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>,
appSettings: AppSettings,
appHooks: AppHooks,
analyticsService: AnalyticsServiceProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
clientProxy = userSession.clientProxy
self.roomProxy = roomProxy
self.appSettings = appSettings
self.analyticsService = analyticsService
self.userIndicatorController = userIndicatorController
self.initialSelectedPinnedEventID = initialSelectedPinnedEventID
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)
let viewState = RoomScreenViewState(roomTitle: roomProxy.infoPublisher.value.displayNameOrID,
roomAvatar: roomProxy.infoPublisher.value.avatar,
hasOngoingCall: roomProxy.infoPublisher.value.hasRoomCall,
isDM: roomProxy.infoPublisher.value.isDM,
hasSuccessor: roomProxy.infoPublisher.value.successor != nil,
roomHistorySharingState: roomProxy.infoPublisher.value.historySharingState)
super.init(initialViewState: appHooks.roomScreenHook.update(viewState),
mediaProvider: userSession.mediaProvider)
updateRoomInfo(roomProxy.infoPublisher.value)
setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher)
Task {
await updateVerificationBadge()
}
}
override func process(viewAction: RoomScreenViewAction) {
switch viewAction {
case .tappedPinnedEventsBanner:
handleTappedPinnedEventsBanner()
case .viewAllPins:
analyticsService.trackInteraction(name: .PinnedMessageBannerViewAllButton)
actionsSubject.send(.displayPinnedEventsTimeline)
case .displayRoomDetails:
actionsSubject.send(.displayRoomDetails)
case .displayCall(let isVoiceCall):
actionsSubject.send(.displayCall(isVoiceCall: isVoiceCall))
actionsSubject.send(.removeComposerFocus)
analyticsService.trackInteraction(name: .MobileRoomCallButton)
case .footerViewAction(let action):
switch action {
case .resolvePinViolation(let userID):
Task { await resolveIdentityPinningViolation(userID) }
case .resolveVerificationViolation(let userID):
Task { await resolveIdentityVerificationViolation(userID) }
}
case .acceptKnock(let eventID):
Task { await acceptKnock(eventID: eventID) }
case .dismissKnockRequests:
Task { await markAllKnocksAsSeen() }
case .viewKnockRequests:
actionsSubject.send(.displayKnockRequests)
case .displaySuccessorRoom:
guard let successorID = roomProxy.infoPublisher.value.successor?.roomId else { return }
let serverNames = roomProxy.knownServerNames(maxCount: 50) // Limit to the same number used by ClientProxy.resolveRoomAlias(_:)
actionsSubject.send(.displayRoom(roomID: successorID, via: Array(serverNames)))
case .displayThreadList:
actionsSubject.send(.displayThreadList)
case .tappedStopLiveLocation:
actionsSubject.send(.stopLiveLocationSharing)
case .tappedOpenLiveLocation:
actionsSubject.send(.displayLiveLocation)
}
}
func stop() {
Task {
// When navigating away from the room, we need to mark the room as both read
// and fully read for Synapse to clear this room from the app's badge count.
_ = await roomProxy.markAsRead(receiptType: appSettings.sharePresence ? .read : .readPrivate)
_ = await roomProxy.markAsRead(receiptType: .fullyRead)
}
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewViewModel = nil
}
func timelineHasScrolled(direction: ScrollDirection) {
state.lastScrollDirection = direction
}
func setSelectedPinnedEventID(_ eventID: String) {
state.pinnedEventsBannerState.setSelectedPinnedEventID(eventID)
}
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) {
mediaPreviewViewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
state.bindings.mediaPreviewViewModel = nil
case .displayMessageForwarding(let forwardingItem):
state.bindings.mediaPreviewViewModel = nil
// We need a small delay because we need to wait for the media preview to be fully dismissed.
DispatchQueue.main.asyncAfter(deadline: .now() + TimelineMediaPreviewViewModel.displayMessageForwardingDelay) {
self.actionsSubject.send(.displayMessageForwarding(forwardingItem))
}
case .viewInRoomTimeline:
fatalError("\(action) should not be visible on a room preview.")
}
}
.store(in: &cancellables)
state.bindings.mediaPreviewViewModel = mediaPreviewViewModel
}
// MARK: - Private
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {
appSettings.threadsEnabledPublisher
.weakAssign(to: \.state.roomThreadListEnabled, on: self)
.store(in: &cancellables)
appSettings.liveLocationSharingSessionsByRoomIDPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] sessionsByRoomID in
guard let self else { return }
state.isSharingLiveLocation = sessionsByRoomID.keys.contains(roomProxy.id)
}
.store(in: &cancellables)
roomProxy.infoPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] roomInfo in
self?.updateRoomInfo(roomInfo)
}
.store(in: &cancellables)
let identityStatusChangesPublisher = roomProxy.identityStatusChangesPublisher.receive(on: DispatchQueue.main)
Task { [weak self] in
for await changes in identityStatusChangesPublisher.values {
guard !Task.isCancelled else {
return
}
await self?.processIdentityStatusChanges(changes)
await self?.updateVerificationBadge()
}
}
.store(in: &cancellables)
clientProxy.homeserverReachabilityPublisher
.filter { $0 == .reachable }
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.setupPinnedEventsTimelineItemProviderIfNeeded()
}
.store(in: &cancellables)
ongoingCallRoomIDPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] ongoingCallRoomID in
guard let self else { return }
state.isParticipatingInOngoingCall = ongoingCallRoomID == roomProxy.id
}
.store(in: &cancellables)
roomProxy.knockRequestsStatePublisher
// We only care about unseen requests
.map { knockRequestsState in
guard case let .loaded(requests) = knockRequestsState else {
return []
}
return requests
.filter { !$0.isSeen }
.map(KnockRequestInfo.init)
}
// If the requests have the same event ids we can discard the output
.removeDuplicates { Set($0.map(\.eventID)) == Set($1.map(\.eventID)) }
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
.weakAssign(to: \.state.unseenKnockRequests, on: self)
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { [weak self] _ in
self?.roomProxy.timeline.retryDecryption(sessionIDs: nil)
}
.store(in: &cancellables)
}
private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async {
for change in changes {
switch change.changedTo {
case .pinViolation:
guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else {
MXLog.error("Failed retrieving room member for identity status change: \(change)")
continue
}
identityPinningViolations[change.userId] = member
case .verificationViolation:
guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else {
MXLog.error("Failed retrieving room member for identity status change: \(change)")
continue
}
identityVerificationViolations[change.userId] = member
default:
identityVerificationViolations[change.userId] = nil
identityPinningViolations[change.userId] = nil
}
}
if let member = identityVerificationViolations.values.first {
state.footerDetails = .verificationViolation(member: member,
learnMoreURL: appSettings.identityPinningViolationDetailsURL)
} else if let member = identityPinningViolations.values.first {
state.footerDetails = .pinViolation(member: member,
learnMoreURL: appSettings.identityPinningViolationDetailsURL)
} else {
state.footerDetails = nil
}
}
private func updateVerificationBadge() async {
guard roomProxy.infoPublisher.value.isDM,
let dmRecipient = roomProxy.membersPublisher.value.first(where: { $0.userID != roomProxy.ownUserID }),
case let .success(userIdentity) = await clientProxy.userIdentity(for: dmRecipient.userID, fallBackToServer: true) else {
state.dmRecipientDetails.verification = .notVerified
return
}
guard let userIdentity else {
MXLog.failure("User identity should be known at this point")
state.dmRecipientDetails.verification = .notVerified
return
}
state.dmRecipientDetails.verification = userIdentity.verificationState
}
private func resolveIdentityPinningViolation(_ userID: String) async {
defer {
hideLoadingIndicator()
}
showLoadingIndicator()
if case .failure = await clientProxy.pinUserIdentity(userID) {
state.bindings.alertInfo = .init(id: .unknown, title: L10n.commonError)
}
}
private func resolveIdentityVerificationViolation(_ userID: String) async {
defer {
hideLoadingIndicator()
}
showLoadingIndicator()
if case .failure = await clientProxy.withdrawUserIdentityVerification(userID) {
state.bindings.alertInfo = .init(id: .unknown, title: L10n.commonError)
}
}
private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) {
var pinnedEventContents = OrderedDictionary<String, AttributedString>()
for item in timelineItems {
// Only remote events are pinned
if case let .event(event) = item,
let eventID = event.id.eventID {
pinnedEventContents.updateValue(pinnedEventStringBuilder.buildAttributedString(for: event) ?? AttributedString(L10n.commonUnsupportedEvent),
forKey: eventID)
}
}
state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents)
// If it's the first time we are setting the pinned events, we should select the initial event if available.
if let initialSelectedPinnedEventID {
state.pinnedEventsBannerState.setSelectedPinnedEventID(initialSelectedPinnedEventID)
self.initialSelectedPinnedEventID = nil
}
}
private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
state.roomTitle = roomInfo.displayNameOrID
state.roomAvatar = roomInfo.avatar
state.dmRecipientDetails.statusEmoji = roomInfo.statusEmoji
state.hasOngoingCall = roomInfo.hasRoomCall
state.activeRoomCallIntent = roomInfo.activeRoomCallIntent
state.hasSuccessor = roomInfo.successor != nil
state.isDM = roomInfo.isDM
let pinnedEventIDs = roomInfo.pinnedEventIDs
// Only update the loading state of the banner
if state.pinnedEventsBannerState.isLoading {
state.pinnedEventsBannerState = .loading(numbersOfEvents: pinnedEventIDs.count)
}
switch (roomInfo.isDM, roomInfo.joinRule) {
case (false, .knock), (false, .knockRestricted):
state.isKnockableRoom = true
default:
state.isKnockableRoom = false
}
if let powerLevels = roomInfo.powerLevels {
state.canSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage)
state.canJoinCall = powerLevels.canOwnUserJoinCall()
state.canAcceptKnocks = powerLevels.canOwnUserInvite()
state.canDeclineKnocks = powerLevels.canOwnUserKick()
state.canBan = powerLevels.canOwnUserBan()
}
state.roomHistorySharingState = roomInfo.historySharingState
}
private func setupPinnedEventsTimelineItemProviderIfNeeded() {
guard pinnedEventsTimelineItemProvider == nil else {
return
}
Task {
guard case let .success(pinnedEventsTimeline) = await roomProxy.pinnedEventsTimeline() else {
return
}
if pinnedEventsTimelineItemProvider == nil {
pinnedEventsTimelineItemProvider = pinnedEventsTimeline.timelineItemProvider
}
}
}
private func acceptKnock(eventID: String) async {
guard case let .loaded(requests) = roomProxy.knockRequestsStatePublisher.value,
let request = requests.first(where: { $0.eventID == eventID }) else {
return
}
state.handledEventIDs.insert(eventID)
switch await request.accept() {
case .success:
break
case .failure:
userIndicatorController.submitIndicator(.init(id: Self.errorIndicatorIdentifier, type: .toast, title: L10n.errorUnknown))
state.handledEventIDs.remove(eventID)
}
}
private func markAllKnocksAsSeen() async {
guard case let .loaded(requests) = roomProxy.knockRequestsStatePublisher.value else {
return
}
state.handledEventIDs.formUnion(Set(requests.map(\.eventID)))
let failedIDs = await withTaskGroup(of: (String, Result<Void, KnockRequestProxyError>).self) { group in
for request in requests {
group.addTask {
await (request.eventID, request.markAsSeen())
}
}
var failedIDs = [String]()
for await result in group where result.1.isFailure {
failedIDs.append(result.0)
}
return failedIDs
}
state.handledEventIDs.subtract(failedIDs)
}
private func handleTappedPinnedEventsBanner() {
analyticsService.trackInteraction(name: .PinnedMessageBannerClick)
if let eventID = state.pinnedEventsBannerState.selectedPinnedEventID {
Task {
switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
case .success(let event):
if appSettings.threadsEnabled,
let threadRootEventID = event.threadRootEventId() {
actionsSubject.send(.focusEvent(eventID: threadRootEventID))
actionsSubject.send(.displayThread(threadRootEventID: threadRootEventID, focussedEventID: eventID))
} else {
actionsSubject.send(.focusEvent(eventID: eventID))
}
case .failure:
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
}
state.pinnedEventsBannerState.previousPin()
}
// MARK: Loading indicators
private static let loadingIndicatorIdentifier = "\(RoomScreenViewModel.self)-Loading"
private static let errorIndicatorIdentifier = "\(RoomScreenViewModel.self)-Error"
private func showLoadingIndicator() {
userIndicatorController.submitIndicator(.init(id: Self.loadingIndicatorIdentifier, type: .toast, title: L10n.commonLoading))
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}
extension RoomScreenViewModel {
static func mock(roomProxyMock: JoinedRoomProxyMock,
clientProxyMock: ClientProxyMock = ClientProxyMock(.init()),
appHooks: AppHooks = AppHooks()) -> RoomScreenViewModel {
RoomScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxyMock)),
roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
ongoingCallRoomIDPublisher: .init(.init(nil)),
appSettings: .volatile(),
appHooks: appHooks,
analyticsService: AnalyticsServiceMock(.init()),
userIndicatorController: UserIndicatorControllerMock())
}
}
private extension KnockRequestInfo {
init(from proxy: KnockRequestProxyProtocol) {
self.init(displayName: proxy.displayName,
avatarURL: proxy.avatarURL,
userID: proxy.userID,
reason: proxy.reason,
eventID: proxy.eventID)
}
}