mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
268d6c8ead
* 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.
615 lines
29 KiB
Swift
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)
|
|
}
|
|
}
|