Files
letro-ios/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift
Mauro Romito 208e7de3ee hide invite avatars when such flag is on
This affects:
- Invited room preview inviter avatar
- Invited room preview room avatar
- Invited room cell inviter avatar in the room list
- Invited room cell room avatar in the room list
- Push notification for an invite
2025-03-21 15:41:39 +01:00

458 lines
20 KiB
Swift

//
// Copyright 2022-2024 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 analyticsService: AnalyticsService
private let appSettings: AppSettings
private let userIndicatorController: UserIndicatorControllerProtocol
private let roomSummaryProvider: RoomSummaryProviderProtocol?
private var actionsSubject: PassthroughSubject<HomeScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<HomeScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(userSession: UserSessionProtocol,
analyticsService: AnalyticsService,
appSettings: AppSettings,
selectedRoomPublisher: CurrentValuePublisher<String?, Never>,
userIndicatorController: UserIndicatorControllerProtocol) {
self.userSession = userSession
self.analyticsService = analyticsService
self.appSettings = appSettings
self.userIndicatorController = userIndicatorController
roomSummaryProvider = userSession.clientProxy.roomSummaryProvider
super.init(initialViewState: .init(userID: userSession.clientProxy.userID),
mediaProvider: userSession.mediaProvider)
userSession.clientProxy.userAvatarURLPublisher
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.userAvatarURL, on: self)
.store(in: &cancellables)
userSession.clientProxy.userDisplayNamePublisher
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.userDisplayName, 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:
state.requiresExtraAccountSetup = true
if !state.securityBannerMode.isDismissed {
state.securityBannerMode = .show(.setUpRecovery)
}
case .incomplete:
state.requiresExtraAccountSetup = true
state.securityBannerMode = .show(.recoveryOutOfSync)
default:
state.securityBannerMode = .none
state.requiresExtraAccountSetup = false
}
}
.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)
selectedRoomPublisher
.weakAssign(to: \.state.selectedRoomID, on: self)
.store(in: &cancellables)
appSettings.$hideUnreadMessagesBadge
.sink { [weak self] _ in self?.updateRooms() }
.store(in: &cancellables)
appSettings.$seenInvites
.removeDuplicates()
.sink { [weak self] _ in
self?.updateRooms()
}
.store(in: &cancellables)
appSettings.$hideInviteAvatars
.weakAssign(to: \.state.hideInviteAvatars, on: self)
.store(in: &cancellables)
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)
.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()
Task {
await checkSlidingSyncMigration()
}
}
// MARK: - Public
override func process(viewAction: HomeScreenViewAction) {
switch viewAction {
case .selectRoom(let roomIdentifier):
actionsSubject.send(.presentRoom(roomIdentifier: roomIdentifier))
case .showRoomDetails(roomIdentifier: let roomIdentifier):
actionsSubject.send(.presentRoomDetails(roomIdentifier: roomIdentifier))
case .leaveRoom(roomIdentifier: let roomIdentifier):
startLeaveRoomProcess(roomID: roomIdentifier)
case .confirmLeaveRoom(roomIdentifier: let roomIdentifier):
Task { await leaveRoom(roomID: 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 .updateVisibleItemRange(let range):
roomSummaryProvider?.updateVisibleRange(range)
case .startChat:
actionsSubject.send(.presentStartChatScreen)
case .globalSearch:
actionsSubject.send(.presentGlobalSearch)
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: appSettings.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):
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.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 {
roomSummaryProvider?.setFilter(.all(filters: state.bindings.filtersState.activeFilters.set))
}
}
}
private func setupRoomListSubscriptions() {
guard let roomSummaryProvider else {
MXLog.error("Room summary provider unavailable")
return
}
analyticsService.signpost.beginFirstRooms()
roomSummaryProvider.statePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self else { return }
updateRoomListMode(with: state)
}
.store(in: &cancellables)
roomSummaryProvider.roomListPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateRooms()
}
.store(in: &cancellables)
}
private func updateRoomListMode(with roomSummaryProviderState: RoomSummaryProviderState) {
let isLoadingData = !roomSummaryProviderState.isLoaded
let hasNoRooms = roomSummaryProviderState.isLoaded && roomSummaryProviderState.totalNumberOfRooms == 0
var roomListMode = state.roomListMode
if isLoadingData {
roomListMode = .skeletons
} else if hasNoRooms {
roomListMode = .empty
} else {
roomListMode = .rooms
}
guard roomListMode != state.roomListMode else {
return
}
if roomListMode == .rooms, state.roomListMode == .skeletons {
analyticsService.signpost.endFirstRooms()
}
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.loadUserAvatarURL()
await self.userSession.clientProxy.loadUserDisplayName()
}
}
}
private func updateRooms() {
guard let roomSummaryProvider else {
MXLog.error("Room summary provider unavailable")
return
}
var rooms = [HomeScreenRoom]()
let seenInvites = appSettings.seenInvites
for summary in roomSummaryProvider.roomListPublisher.value {
let room = HomeScreenRoom(summary: summary,
hideUnreadMessagesBadge: appSettings.hideUnreadMessagesBadge,
seenInvites: seenInvites)
rooms.append(room)
}
state.rooms = rooms
}
/// Check whether we can inform the user about potential migrations
/// or have him logout as his proxy is no longer available
private func checkSlidingSyncMigration() async {
guard userSession.clientProxy.needsSlidingSyncMigration else {
return
}
// The proxy is no longer supported so a logout is needed.
// Delay setting the alert otherwise it automatically gets dismissed. Same as the crashed last run one
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.state.bindings.alertInfo = AlertInfo(id: UUID(),
title: L10n.bannerMigrateToNativeSlidingSyncAppForceLogoutTitle(InfoPlistReader.main.bundleDisplayName),
primaryButton: .init(title: L10n.bannerMigrateToNativeSlidingSyncAction) { [weak self] in
self?.actionsSubject.send(.logoutWithoutConfirmation)
})
}
}
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 {
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
}
if roomProxy.infoPublisher.value.isPublic {
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.isDirectOneToOneRoom, state: .public)
} else {
state.bindings.leaveRoomAlertItem = if roomProxy.infoPublisher.value.joinedMembersCount > 1 {
LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.isDirectOneToOneRoom, state: .private)
} else {
LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.isDirectOneToOneRoom, state: .empty)
}
}
}
}
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,
iconName: "checkmark"))
actionsSubject.send(.roomLeft(roomIdentifier: roomID))
}
// 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:
actionsSubject.send(.presentRoom(roomIdentifier: roomID))
analyticsService.trackJoinedRoom(isDM: roomProxy.info.isDirect,
isSpace: roomProxy.info.isSpace,
activeMemberCount: UInt(roomProxy.info.activeMembersCount))
appSettings.seenInvites.remove(roomID)
case .failure:
displayError()
}
}
private func showDeclineInviteConfirmationAlert(roomID: String) {
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)
state.bindings.alertInfo = .init(id: UUID(),
title: title,
message: message,
primaryButton: .init(title: L10n.actionDecline, role: .destructive) { Task { await self.declineInvite(roomID: room.id) } },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
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:
appSettings.seenInvites.remove(roomID)
case .failure:
displayError()
}
}
private func displayError() {
state.bindings.alertInfo = .init(id: UUID(),
title: L10n.commonError,
message: L10n.errorUnknown)
}
}