Files
element-x-ios/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift
Mauro 268d6c8ead Notify every signed-in account and turn notifications on and off per account (#6337)
* Store the notifications switch per account

Adds AccountSettings.enableNotifications and moves the app-wide value into the signed in accounts before they're restored: the first account takes it out of the app settings and the others get the same switch, so someone who turned notifications off keeps them off.

* Add a notification service to every user session

Each account's NotificationService sets its pusher when its notifications are on and deletes it when they're off (appending with multiple accounts), keeps the account's unread count and decides which of its delivered notifications to clear. It asks the NotificationManager, the only owner of the notification center, through actions: remove the notifications matching a rule, request authorization and the unread count. The UserSessionManager connects every live session's service to the manager, and flows and screens use the session's service instead of the manager, which leaves the flow parameters.

* Route notifications by account

Taps only open rooms of the active account for now, a tap made while restoring only opens once its account is the active one, replies are sent from the account they were made for, and signing out only clears that account's notifications unless it was the last one. The notification extension hides pushes for an account that signed out while other accounts are still signed in.

* Turn notifications on and off for each account

The notification settings switch is now saved per account, and each account's NotificationService sets or deletes its pusher when it changes, asking for permission when it's turned on.

* Apply review suggestions to multi-account notifications

Migration
- Move the notifications switch with a single per-account migration, like 26.10.1 and 26.10.2, and drop its dedicated test.

Notification manager and service
- Follow each account's badge count publisher directly, removing the badge count action and the stored counts.
- Only reset the badge and unregister from APNs when the last account signs out, as each account already clears its own notifications.
- Set up the service's subscriptions in a single setupSubscriptions().
- Rename switchChanged(to:) to enableNotificationsChanged(to:) and pusherConfiguration(identifiers:) to pusherConfiguration(for:).
- Build the service without hopping to the main actor, it already runs there.

Notification service extension
- Check the account's switch in NotificationHandler using the session's settings, and log an error when a push arrives for an account that's turned off.
- Always show pushes that match no signed-in account and log an error, so a broken setup stays visible.

Other
- Store a pending inline reply in a PendingInlineReplyDetails struct.
- Assign the chats tab badge with weakAssign.
- Make PusherConfiguration Equatable so the pusher test checks it in one expectation.
- Clarify the comment about notifications without a receiver.

* Print instead of logging before the app hooks run in the NSE

A push that matches no signed-in account goes back to printing, as MXLog shouldn't be used before the app hooks have run. The push is still shown so a broken setup stays visible.
2026-10-15 13:10:26 +02:00

615 lines
29 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 availableSessionsPublisher: CurrentValuePublisher<[UserSessionDetails], Never>
private let spaceFilterSubject: CurrentValueSubject<SpaceServiceFilter?, Never>
private let analyticsService: AnalyticsServiceProtocol
private let bugReportService: BugReportServiceProtocol
private let userSettings: UserSettings
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,
availableSessionsPublisher: CurrentValuePublisher<[UserSessionDetails], Never>,
selectedRoomPublisher: CurrentValuePublisher<String?, Never>,
analyticsService: AnalyticsServiceProtocol,
bugReportService: BugReportServiceProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.userSession = userSession
self.availableSessionsPublisher = availableSessionsPublisher
self.analyticsService = analyticsService
self.bugReportService = bugReportService
userSettings = userSession.userSettings
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: userSession.userSettings))),
mediaProvider: userSession.mediaProvider)
if userSettings.app.globalSearchEnabled {
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.account.showAllRoomListActivityPublisher
.removeDuplicates()
.sink { [weak self] _ in
self?.updateRooms()
}
.store(in: &cancellables)
userSettings.app.seenInvitesPublisher
.removeDuplicates()
.sink { [weak self] _ in
self?.updateRooms()
}
.store(in: &cancellables)
userSettings.app.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.app.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.app.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.account.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.app.seenInvites
for summary in roomSummaryProvider.roomListPublisher.value {
let room = HomeScreenRoom(summary: summary,
showAllActivity: userSettings.account.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() {
// Never announce it to someone who already uses several accounts, even once they've removed some.
if availableSessionsPublisher.value.count > 1, !userSettings.app.hasSeenMultiAccountAnnouncement {
MXLog.info("Several accounts are signed in, marking the multi-account announcement as seen.")
userSettings.app.hasSeenMultiAccountAnnouncement = true
}
// An unverified session gets the identity confirmation cover first, the room list appears again once it's dismissed.
guard userSettings.app.multiAccountEnabled,
!userSettings.app.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.app.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:
userSession.notificationService.removeDeliveredMessageNotifications(for: roomID) // Normally handled by the room flow, but that's never presented in this case.
userSettings.app.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)
}
}