Files
element-x-ios/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift
T
Mauro f2eaa8e80d Show the multi-account announcement on the HomeScreen (#6265)
* Present the multi-account announcement when nothing else is showing

The user session flow owns onboarding, verification and settings, so it presents the announcement in the tab sheet slot with the lowest priority: once the verification state is known, and whenever onboarding, settings or a verification sheet is dismissed. Routes and sign-out dismiss settings without triggering it, and it is marked as seen when presented.

* Show the multi-account announcement on the home screen

Following review, the announcement is a best-effort sheet on the home
screen instead of a UserSessionFlowCoordinator state. It's presented when
the room list appears with the flag on, the announcement armed and the
session verified, and counts as seen once its content appears.

- Restore UserSessionFlowCoordinator to develop.
- HomeScreenViewModel presents the announcement through a binding and
  waits for a known verification state, so it never competes with the
  identity confirmation onboarding.
- Add account only closes the sheet until the add account flow exists.
- Rename MultiAccountAnnouncementScreen to MultiAccountAnnouncementView
  and drop its unused coordinator and view model protocol.
- Size the sheet from the measured navigation bar height and match the
  Figma spacing; the hard-coded 44pt pushed the content under the button.

* Present the multi-account announcement from the home screen view model

The announcement no longer needs its own view model: the view takes the
home screen's context and presents through a boolean binding, like the
home screen banners.

- Drop MultiAccountAnnouncementViewModel and its models. The view sends
  home screen actions to close or add an account and builds its own
  attributed subtitle.
- Check the verification state when the room list appears instead of
  waiting for it. Restored sessions already know it by then, unverified
  ones get the identity confirmation cover first and the room list
  appears again once it's dismissed. Nothing is left subscribed, so the
  sheet can no longer turn up later wherever the user is.
2026-10-07 12:29:51 +02:00

610 lines
28 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-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 AnalyticsEvents
import Combine
import MatrixRustSDK
import SwiftUI
typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState, HomeScreenViewAction>
class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol {
private let userSession: UserSessionProtocol
private let spaceFilterSubject: CurrentValueSubject<SpaceServiceFilter?, Never>
private let analyticsService: AnalyticsServiceProtocol
private let bugReportService: BugReportServiceProtocol
private let userSettings: UserSettings
private let notificationManager: NotificationManagerProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let roomSummaryProvider: RoomSummaryProviderProtocol?
private var actionsSubject: PassthroughSubject<HomeScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<HomeScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
// swiftlint:disable:next function_body_length
init(userSession: UserSessionProtocol,
selectedRoomPublisher: CurrentValuePublisher<String?, Never>,
userSettings: UserSettings,
analyticsService: AnalyticsServiceProtocol,
bugReportService: BugReportServiceProtocol,
notificationManager: NotificationManagerProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.userSession = userSession
self.analyticsService = analyticsService
self.bugReportService = bugReportService
self.userSettings = userSettings
self.notificationManager = notificationManager
self.userIndicatorController = userIndicatorController
spaceFilterSubject = CurrentValueSubject<SpaceServiceFilter?, Never>(nil)
roomSummaryProvider = userSession.clientProxy.roomSummaryProvider
super.init(initialViewState: .init(userProfile: userSession.clientProxy.userProfilePublisher.value,
bindings: .init(filtersState: .init(userSettings: userSettings))),
mediaProvider: userSession.mediaProvider)
if userSettings.globalSearchEnabled, #available(iOS 26.0, *) {
state.isRoomListSearchEnabled = false
}
userSession.clientProxy.userProfilePublisher
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.userProfile, on: self)
.store(in: &cancellables)
userSession.sessionSecurityStatePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] securityState in
guard let self else { return }
switch securityState.recoveryState {
case .disabled:
if !state.securityBannerMode.isDismissed {
state.securityBannerMode = .show(.setUpRecovery)
}
case .incomplete:
state.securityBannerMode = .show(.recoveryOutOfSync)
default:
state.securityBannerMode = .none
}
}
.store(in: &cancellables)
userSession.sessionSecurityStatePublisher
.receive(on: DispatchQueue.main)
.filter { state in
state.verificationState != .unknown
&& state.recoveryState != .settingUp
&& state.recoveryState != .unknown
}
.sink { [weak self] state in
guard let self else { return }
self.analyticsService.updateUserProperties(AnalyticsEvent.newVerificationStateUserProperty(verificationState: state.verificationState, recoveryState: state.recoveryState))
self.analyticsService.trackSessionSecurityState(state)
}
.store(in: &cancellables)
userSession.clientProxy.spaceService.spaceFilterPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] filters in
guard let self else { return }
state.shouldShowSpaceFilters = !filters.isEmpty
if let selectedSpaceFilter = spaceFilterSubject.value,
!filters.contains(selectedSpaceFilter) {
// Clear the spaces filter if the space has been left.
spaceFilterSubject.send(nil)
}
}
.store(in: &cancellables)
selectedRoomPublisher
.weakAssign(to: \.state.selectedRoomID, on: self)
.store(in: &cancellables)
userSettings.showAllRoomListActivityPublisher
.removeDuplicates()
.sink { [weak self] _ in
self?.updateRooms()
}
.store(in: &cancellables)
userSettings.seenInvitesPublisher
.removeDuplicates()
.sink { [weak self] _ in
self?.updateRooms()
}
.store(in: &cancellables)
userSettings.hasSeenNewSoundBannerPublisher
.sink { [weak self] hasSeenNewSoundBanner in
self?.state.shouldShowNewSoundBanner = !hasSeenNewSoundBanner
}
.store(in: &cancellables)
userSession.clientProxy.hideInviteAvatarsPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.hideInviteAvatars, on: self)
.store(in: &cancellables)
spaceFilterSubject
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.selectedSpaceFilter, on: self)
.store(in: &cancellables)
bugReportService.lastCrashEventIDSubject
.compactMap { $0 }
.first()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.presentCrashedLastRunAlert()
}
.store(in: &cancellables)
Task {
state.reportRoomEnabled = await userSession.clientProxy.isReportRoomSupported
}
let isSearchFieldFocused = context.$viewState.map(\.bindings.isSearchFieldFocused)
let searchQuery = context.$viewState.map(\.bindings.searchQuery)
let activeFilters = context.$viewState.map(\.bindings.filtersState.activeFilters)
isSearchFieldFocused
.combineLatest(searchQuery, activeFilters, spaceFilterSubject)
.removeDuplicates { $0 == $1 }
.sink { [weak self] isSearchFieldFocused, _, _, _ in
guard let self else { return }
// isSearchFieldFocused` is sometimes turning to true after cancelling the search. So to be extra sure we are updating the values correctly we read them directly in the next run loop, and we add a small delay if the value has changed
let delay = isSearchFieldFocused == self.context.viewState.bindings.isSearchFieldFocused ? 0.0 : 0.05
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.updateFilter()
}
}
.store(in: &cancellables)
setupRoomListSubscriptions()
updateRooms()
if let roomSummaryProvider {
updateRoomListMode(with: roomSummaryProvider.statePublisher.value,
hasRooms: !roomSummaryProvider.roomListPublisher.value.isEmpty)
}
// Fall back to the skeletons if the cached rooms take too long to publish.
Task { [weak self] in
try? await Task.sleep(for: .milliseconds(500))
guard let self, state.roomListMode == .awaitingCachedRooms else { return }
state.roomListMode = .skeletons
}
}
// MARK: - Public
override func process(viewAction: HomeScreenViewAction) {
switch viewAction {
case .selectRoom(let roomIdentifier):
actionsSubject.send(.presentRoom(roomIdentifier: roomIdentifier))
case .detachRoom(let roomIdentifier):
actionsSubject.send(.detachRoom(roomIdentifier: roomIdentifier))
case .showRoomDetails(let roomIdentifier):
actionsSubject.send(.presentRoomDetails(roomIdentifier: roomIdentifier))
case .leaveRoom(let roomIdentifier):
startLeaveRoomProcess(roomID: roomIdentifier)
case .confirmLeaveRoom(let roomIdentifier):
Task { await leaveRoom(roomID: roomIdentifier) }
case .reportRoom(let roomIdentifier):
actionsSubject.send(.presentReportRoom(roomIdentifier: roomIdentifier))
case .showSettings:
actionsSubject.send(.presentSettingsScreen)
case .setupRecovery:
actionsSubject.send(.presentSecureBackupSettings)
case .confirmRecoveryKey:
actionsSubject.send(.presentRecoveryKeyScreen)
case .resetEncryption:
actionsSubject.send(.presentEncryptionResetScreen)
case .skipRecoveryKeyConfirmation:
state.securityBannerMode = .dismissed
case .dismissNewSoundBanner:
userSettings.hasSeenNewSoundBanner = true
case .screenAppeared:
// Use a task otherwise the presentation isn't animated.
Task { presentMultiAccountAnnouncementIfNeeded() }
case .multiAccountAnnouncementAppeared:
MXLog.info("The multi-account announcement has been seen.")
userSettings.hasSeenMultiAccountAnnouncement = true
case .dismissMultiAccountAnnouncement, .addAccount: // There is no add account flow to start yet.
state.bindings.isPresentingMultiAccountAnnouncement = false
case .updateVisibleItemRange(let range):
roomSummaryProvider?.updateVisibleRange(range)
case .startChat:
actionsSubject.send(.presentStartChatScreen)
case .spaceFilters:
if spaceFilterSubject.value != nil {
spaceFilterSubject.send(nil)
} else {
state.bindings.spaceFiltersViewModel = ChatsSpaceFiltersScreenViewModel(spaceService: userSession.clientProxy.spaceService,
mediaProvider: userSession.mediaProvider)
state.bindings.spaceFiltersViewModel?.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .confirm(let spaceServiceFilter):
spaceFilterSubject.send(spaceServiceFilter)
state.bindings.spaceFiltersViewModel = nil
case .cancel:
state.bindings.spaceFiltersViewModel = nil
}
}
.store(in: &cancellables)
}
case .markRoomAsUnread(let roomIdentifier):
Task {
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
MXLog.error("Failed retrieving room for identifier: \(roomIdentifier)")
return
}
switch await roomProxy.flagAsUnread(true) {
case .success:
analyticsService.trackInteraction(name: .MobileRoomListRoomContextMenuUnreadToggle)
case .failure(let error):
MXLog.error("Failed marking room \(roomIdentifier) as unread with error: \(error)")
}
}
case .markRoomAsRead(let roomIdentifier):
Task {
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
MXLog.error("Failed retrieving room for identifier: \(roomIdentifier)")
return
}
switch await roomProxy.flagAsUnread(false) {
case .success:
analyticsService.trackInteraction(name: .MobileRoomListRoomContextMenuUnreadToggle)
if case .failure(let error) = await roomProxy.markAsRead(receiptType: userSettings.sharePresence ? .read : .readPrivate) {
MXLog.error("Failed marking room \(roomIdentifier) as read with error: \(error)")
}
case .failure(let error):
MXLog.error("Failed flagging room \(roomIdentifier) as read with error: \(error)")
}
}
case .markRoomAsFavourite(let roomIdentifier, let isFavourite):
Task {
await markRoomAsFavourite(roomIdentifier, isFavourite: isFavourite)
}
case .acceptInvite(let roomIdentifier):
Task {
await acceptInvite(roomID: roomIdentifier)
}
case .declineInvite(let roomIdentifier):
Task { await showDeclineInviteConfirmationAlert(roomID: roomIdentifier) }
}
}
// perphery: ignore - used in release mode
func presentCrashedLastRunAlert() {
// Delay setting the alert otherwise it automatically gets dismissed. Same as the force logout one.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in
state.bindings.alertInfo = AlertInfo(id: UUID(),
title: L10n.crashDetectionDialogContent(InfoPlistReader.main.bundleDisplayName),
primaryButton: .init(title: L10n.actionNo, action: nil),
secondaryButton: .init(title: L10n.actionYes) { [weak self] in
self?.actionsSubject.send(.presentFeedbackScreen)
})
}
}
// MARK: - Private
private func updateFilter() {
if state.shouldHideRoomList {
roomSummaryProvider?.setFilter(.excludeAll)
} else {
if state.bindings.isSearchFieldFocused {
roomSummaryProvider?.setFilter(.search(query: state.bindings.searchQuery))
} else {
if let spaceFilter = spaceFilterSubject.value {
roomSummaryProvider?.setFilter(.rooms(roomsIDs: spaceFilter.descendants,
filters: state.bindings.filtersState.activeFilters.set))
} else {
roomSummaryProvider?.setFilter(.all(filters: state.bindings.filtersState.activeFilters.set))
}
}
}
}
private func setupRoomListSubscriptions() {
guard let roomSummaryProvider else {
MXLog.error("Room summary provider unavailable")
return
}
// Combined so that the mode and the rooms are always updated from the same pair of
// values: the state can report loaded before the first summaries have published and
// flipping to .rooms then would flash an empty list.
roomSummaryProvider.statePublisher
.combineLatest(roomSummaryProvider.roomListPublisher)
.receive(on: DispatchQueue.main)
.sink { [weak self] state, rooms in
guard let self else { return }
updateRooms()
updateRoomListMode(with: state, hasRooms: !rooms.isEmpty)
}
.store(in: &cancellables)
}
private func updateRoomListMode(with roomSummaryProviderState: RoomSummaryProviderState, hasRooms: Bool) {
let isFiltering = state.bindings.filtersState.isFiltering || state.selectedSpaceFilter != nil || state.bindings.isSearchFieldFocused
let roomListMode: HomeScreenRoomListMode = if !roomSummaryProviderState.isLoaded {
.skeletons // Still loading.
} else if roomSummaryProviderState.totalNumberOfRooms == 0 {
.empty // Loaded, there are no rooms at all.
} else if hasRooms || isFiltering {
.rooms // Loaded and the summaries have published (or filtered down to nothing).
} else if state.roomListMode == .skeletons || state.roomListMode == .awaitingCachedRooms {
state.roomListMode // Loaded but nothing published yet, flipping to .rooms would flash an empty list.
} else {
.rooms
}
guard roomListMode != state.roomListMode else {
return
}
if roomListMode == .rooms, state.roomListMode == .skeletons || state.roomListMode == .awaitingCachedRooms {
analyticsService.signpost.finishTransaction(.cachedRoomList)
}
state.roomListMode = roomListMode
MXLog.info("Received room summary provider update, setting view room list mode to \"\(state.roomListMode)\"")
// Delay user profile detail loading until after the initial room list loads
if roomListMode == .rooms {
Task {
await self.userSession.clientProxy.loadUserProfileIfNeeded()
}
}
}
private func updateRooms() {
guard let roomSummaryProvider else {
MXLog.error("Room summary provider unavailable")
return
}
var rooms = [HomeScreenRoom]()
let seenInvites = userSettings.seenInvites
for summary in roomSummaryProvider.roomListPublisher.value {
let room = HomeScreenRoom(summary: summary,
showAllActivity: userSettings.showAllRoomListActivity,
seenInvites: seenInvites)
rooms.append(room)
}
state.rooms = rooms
}
private func markRoomAsFavourite(_ roomID: String, isFavourite: Bool) async {
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Failed retrieving room for identifier: \(roomID)")
return
}
switch await roomProxy.flagAsFavourite(isFavourite) {
case .success:
analyticsService.trackInteraction(name: .MobileRoomListRoomContextMenuFavouriteToggle)
case .failure(let error):
MXLog.error("Failed marking room \(roomID) as favourite: \(isFavourite) with error: \(error)")
}
}
private static let leaveRoomLoadingID = "LeaveRoomLoading"
private func startLeaveRoomProcess(roomID: String) {
Task { [self] in
defer {
userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID)
}
userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLoading, persistent: true))
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else {
state.bindings.alertInfo = AlertInfo(id: UUID(), title: L10n.errorUnknown)
return
}
guard roomProxy.infoPublisher.value.joinedMembersCount > 1 else {
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomID,
isDM: roomProxy.infoPublisher.value.isDM,
state: roomProxy.infoPublisher.value.isPrivate ?? true ? .empty : .public)
return
}
if !roomProxy.infoPublisher.value.isDM {
if case let .success(ownMember) = await roomProxy.getMember(userID: roomProxy.ownUserID),
ownMember.role.isOwner {
await roomProxy.updateMembers()
var isLastOwner = true
for member in roomProxy.membersPublisher.value where member.userID != roomProxy.ownUserID && member.membership == .join {
if member.role.isOwner {
isLastOwner = false
break
}
}
if isLastOwner {
state.bindings.alertInfo = .init(id: UUID(),
title: L10n.leaveRoomAlertSelectNewOwnerTitle,
message: L10n.leaveRoomAlertSelectNewOwnerSubtitle,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.leaveRoomAlertSelectNewOwnerAction, role: .destructive) { [weak self] in
self?.actionsSubject.send(.transferOwnership(roomIdentifier: roomID))
})
return
}
}
}
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.infoPublisher.value.isDM, state: roomProxy.infoPublisher.value.isPrivate ?? true ? .private : .public)
}
}
private func leaveRoom(roomID: String) async {
defer {
userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID)
}
userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLeavingRoom, persistent: true))
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID),
case .success = await roomProxy.leaveRoom() else {
state.bindings.alertInfo = AlertInfo(id: UUID(), title: L10n.errorUnknown)
return
}
userIndicatorController.submitIndicator(UserIndicator(id: UUID().uuidString,
type: .toast,
title: L10n.commonCurrentUserLeftRoom,
icon: \.check))
actionsSubject.send(.roomLeft(roomIdentifier: roomID))
}
// MARK: Multi-account announcement
private func presentMultiAccountAnnouncementIfNeeded() {
// An unverified session gets the identity confirmation cover first, the room list appears again once it's dismissed.
guard userSettings.multiAccountEnabled,
!userSettings.hasSeenMultiAccountAnnouncement,
userSession.sessionSecurityStatePublisher.value.verificationState == .verified,
!state.bindings.isPresentingMultiAccountAnnouncement else {
return
}
MXLog.info("Presenting the multi-account announcement.")
state.bindings.isPresentingMultiAccountAnnouncement = true
}
// MARK: Invites
private func acceptInvite(roomID: String) async {
defer {
userIndicatorController.retractIndicatorWithId(roomID)
}
userIndicatorController.submitIndicator(UserIndicator(id: roomID, type: .modal, title: L10n.commonLoading, persistent: true))
guard case let .invited(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else {
displayError()
return
}
switch await userSession.clientProxy.joinRoom(roomID, via: []) {
case .success:
await finishAcceptInvite(roomProxy: roomProxy)
case .failure(let error):
switch error {
case .invalidInvite:
displayError(title: L10n.dialogTitleError, message: L10n.errorInvalidInvite)
default:
displayError()
}
}
}
private func finishAcceptInvite(roomProxy: InvitedRoomProxyProtocol) async {
if roomProxy.info.isSpace {
let spaceService = userSession.clientProxy.spaceService
switch await spaceService.spaceRoomList(spaceID: roomProxy.id) {
case .success(let spaceRoomListProxy):
actionsSubject.send(.presentSpace(spaceRoomListProxy))
case .failure(let error):
MXLog.error("Failed to get the space room list after accepting invite: \(error)")
displayError()
return
}
} else {
actionsSubject.send(.presentRoom(roomIdentifier: roomProxy.id))
}
analyticsService.trackJoinedRoom(isDM: roomProxy.info.isDirect,
isSpace: roomProxy.info.isSpace,
activeMemberCount: UInt(roomProxy.info.activeMembersCount))
userSettings.seenInvites.remove(roomProxy.id)
}
private func showDeclineInviteConfirmationAlert(roomID: String) async {
guard let room = state.rooms.first(where: { $0.id == roomID }) else {
displayError()
return
}
let roomPlaceholder = room.isDirect ? (room.inviter?.displayName ?? room.name) : room.name
let title = room.isDirect ? L10n.screenInvitesDeclineDirectChatTitle : L10n.screenInvitesDeclineChatTitle
let message = room.isDirect ? L10n.screenInvitesDeclineDirectChatMessage(roomPlaceholder) : L10n.screenInvitesDeclineChatMessage(roomPlaceholder)
if await userSession.clientProxy.isReportRoomSupported, let userID = room.inviter?.id {
state.bindings.alertInfo = .init(id: UUID(),
title: title,
message: message,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionDeclineAndBlock, role: .destructive) { [weak self] in self?.declineAndBlockInvite(userID: userID, roomID: roomID) },
verticalButtons: [.init(title: L10n.actionDecline) { [weak self] in Task { await self?.declineInvite(roomID: room.id) } }])
} else {
state.bindings.alertInfo = .init(id: UUID(),
title: title,
message: message,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionDecline, role: .destructive) { [weak self] in Task { await self?.declineInvite(roomID: room.id) } })
}
}
private func declineAndBlockInvite(userID: String, roomID: String) {
actionsSubject.send(.presentDeclineAndBlock(userID: userID, roomID: roomID))
}
private func declineInvite(roomID: String) async {
defer {
userIndicatorController.retractIndicatorWithId(roomID)
}
userIndicatorController.submitIndicator(UserIndicator(id: roomID, type: .modal, title: L10n.commonLoading, persistent: true))
guard case let .invited(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else {
displayError()
return
}
let result = await roomProxy.rejectInvitation()
switch result {
case .success:
await notificationManager.removeDeliveredMessageNotifications(for: roomID) // Normally handled by the room flow, but that's never presented in this case.
userSettings.seenInvites.remove(roomID)
case .failure:
displayError()
}
}
private func displayError(title: String? = nil, message: String? = nil) {
state.bindings.alertInfo = .init(id: UUID(),
title: title ?? L10n.commonError,
message: message ?? L10n.errorUnknown)
}
}