// // Copyright 2022 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // import AnalyticsEvents import Combine import SwiftUI typealias HomeScreenViewModelType = StateStoreViewModel 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 migrationCancellable: AnyCancellable? private var visibleItemRangeObservationToken: AnyCancellable? private let visibleItemRangePublisher = CurrentValueSubject<(range: Range, isScrolling: Bool), Never>((0..<0, false)) private var actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } init(userSession: UserSessionProtocol, analyticsService: AnalyticsService, appSettings: AppSettings, selectedRoomPublisher: CurrentValuePublisher, 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.verificationState, securityState.recoveryState) { case (.verified, .disabled): state.requiresExtraAccountSetup = true state.securityBannerMode = .none case (.verified, .incomplete): state.requiresExtraAccountSetup = true if state.securityBannerMode != .dismissed { state.securityBannerMode = .recoveryKeyConfirmation } 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.$publicSearchEnabled .weakAssign(to: \.state.isRoomDirectorySearchEnabled, 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() } // 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 .confirmRecoveryKey: actionsSubject.send(.presentSecureBackupSettings) case .skipRecoveryKeyConfirmation: state.securityBannerMode = .dismissed case .updateVisibleItemRange(let range, let isScrolling): visibleItemRangePublisher.send((range, isScrolling)) 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 .selectRoomDirectorySearch: actionsSubject.send(.presentRoomDirectorySearch) case .acceptInvite(let roomIdentifier): Task { await acceptInvite(roomID: roomIdentifier) } case .declineInvite(let roomIdentifier): showDeclineInviteConfirmationAlert(roomID: roomIdentifier) } } // perphery: ignore - used in release mode func presentCrashedLastRunAlert() { 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() let hasUserBeenMigrated = appSettings.migratedAccounts[userSession.clientProxy.userID] == true if !hasUserBeenMigrated { state.roomListMode = .migration MXLog.info("Account not migrated, setting view room list mode to \"\(state.roomListMode)\"") migrationCancellable = userSession.clientProxy.actionsPublisher .receive(on: DispatchQueue.main) .sink { [weak self] callback in guard let self, case .receivedSyncUpdate = callback else { return } migrationCancellable = nil appSettings.migratedAccounts[userSession.clientProxy.userID] = true MXLog.info("Received first sync response, updating room list mode") updateRoomListMode(with: roomSummaryProvider.statePublisher.value) } } roomSummaryProvider.statePublisher .receive(on: DispatchQueue.main) .sink { [weak self] state in guard let self else { return } updateRoomListMode(with: state) } .store(in: &cancellables) roomSummaryProvider.roomListPublisher .dropFirst(1) // We don't care about its initial value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateRooms() // Wait for the all rooms view to receive its first update before installing // dynamic timeline modifiers self?.installListRangeModifiers() } .store(in: &cancellables) } private func updateRoomListMode(with roomSummaryProviderState: RoomSummaryProviderState) { guard appSettings.migratedAccounts[userSession.clientProxy.userID] == true else { // Ignore room summary provider updates while "migrating" return } 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 installListRangeModifiers() { guard visibleItemRangeObservationToken == nil else { return } visibleItemRangeObservationToken = visibleItemRangePublisher .filter { $0.isScrolling == false } .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true) .removeDuplicates(by: { $0.isScrolling == $1.isScrolling && $0.range == $1.range }) .sink { [weak self] value in guard let self else { return } // Ignore scrolling while filtering rooms guard self.state.bindings.searchQuery.isEmpty else { return } self.updateVisibleRange(value.range) } } private func updateRooms() { guard let roomSummaryProvider else { MXLog.error("Room summary provider unavailable") return } var rooms = [HomeScreenRoom]() for summary in roomSummaryProvider.roomListPublisher.value { let room = HomeScreenRoom(summary: summary, hideUnreadMessagesBadge: appSettings.hideUnreadMessagesBadge) rooms.append(room) } state.rooms = rooms } private func updateVisibleRange(_ range: Range) { guard !range.isEmpty else { return } guard let roomSummaryProvider else { MXLog.error("Visible rooms summary provider unavailable") return } roomSummaryProvider.updateVisibleRange(range) } 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.isPublic { state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.isEncryptedOneToOneRoom, state: .public) } else { state.bindings.leaveRoomAlertItem = if roomProxy.joinedMembersCount > 1 { LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.isEncryptedOneToOneRoom, state: .private) } else { LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.isEncryptedOneToOneRoom, 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 roomProxy.acceptInvitation() { case .success: actionsSubject.send(.presentRoom(roomIdentifier: roomID)) analyticsService.trackJoinedRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace, activeMemberCount: UInt(roomProxy.activeMembersCount)) 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.actionCancel, role: .cancel, action: nil), secondaryButton: .init(title: L10n.actionDecline, role: .destructive, action: { Task { await self.declineInvite(roomID: room.id) } })) } 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() if case .failure = result { displayError() } } private func displayError() { state.bindings.alertInfo = .init(id: UUID(), title: L10n.commonError, message: L10n.errorUnknown) } }