// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation import Combine import GRDB import DifferenceKit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit class UserListViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() public let state: TableDataState = TableDataState() public let observableState: ObservableTableSourceState = ObservableTableSourceState() private let selectedUsersSubject: CurrentValueSubject>, Never> = CurrentValueSubject([]) public let title: String public let infoBanner: InfoBanner.Info? public let emptyState: String? private let showProfileIcons: Bool private let request: (any FetchRequest) private let footerTitle: String? private let footerAccessibility: Accessibility? private let onTapAction: OnTapAction private let onSubmitAction: OnSubmitAction // MARK: - Initialization init( title: String, infoBanner: InfoBanner.Info? = nil, emptyState: String? = nil, showProfileIcons: Bool, request: (any FetchRequest), footerTitle: String? = nil, footerAccessibility: Accessibility? = nil, onTap: OnTapAction = .radio, onSubmit: OnSubmitAction = .none, using dependencies: Dependencies ) { self.dependencies = dependencies self.title = title self.infoBanner = infoBanner self.emptyState = emptyState self.showProfileIcons = showProfileIcons self.request = request self.footerTitle = footerTitle self.footerAccessibility = footerAccessibility self.onTapAction = onTap self.onSubmitAction = onSubmit } // MARK: - Config public enum Section: SessionTableSection { case users } public enum TableItem: Equatable, Hashable, Differentiable { case user(String) } // MARK: - Content public indirect enum OnTapAction { case none case callback((UserListViewModel?, WithProfile) -> Void) case radio case conditionalAction(action: (WithProfile) -> OnTapAction) case custom(trailingAccessory: (WithProfile) -> SessionCell.Accessory, onTap: (UserListViewModel?, WithProfile) -> Void) } public enum OnSubmitAction { case none case callback((UserListViewModel?, Set>) throws -> Void) case publisher((UserListViewModel?, Set>) -> AnyPublisher) var hasAction: Bool { switch self { case .none: return false default: return true } } } var bannerInfo: AnyPublisher { Just(infoBanner).eraseToAnyPublisher() } var emptyStateTextPublisher: AnyPublisher { Just(emptyState).eraseToAnyPublisher() } lazy var observation: TargetObservation = ObservationBuilder .databaseObservation(self) { [request, dependencies] db -> [WithProfile] in try request.fetchAllWithProfiles(db, using: dependencies) } .map { [weak self, dependencies, showProfileIcons, onTapAction, selectedUsersSubject] (users: [WithProfile]) -> [SectionModel] in let userSessionId: SessionId = dependencies[cache: .general].sessionId return [ SectionModel( model: .users, elements: users .sorted() .map { userInfo -> SessionCell.Info in func finalAction(for action: OnTapAction) -> OnTapAction { switch action { case .conditionalAction(let targetAction): return finalAction(for: targetAction(userInfo)) default: return action } } func generateAccessory(_ action: OnTapAction) -> SessionCell.Accessory? { switch action { case .none, .callback: return nil case .custom(let accessoryGenerator, _): return accessoryGenerator(userInfo) case .conditionalAction(let targetAction): return generateAccessory(targetAction(userInfo)) case .radio: return .radio( isSelected: selectedUsersSubject.value.contains(where: { selectedUserInfo in selectedUserInfo.profileId == userInfo.profileId }) ) } } let finalAction: OnTapAction = finalAction(for: onTapAction) let trailingAccessory: SessionCell.Accessory? = generateAccessory(finalAction) let title: String = { guard userInfo.profileId != userSessionId.hexString else { return "you".localized() } return ( userInfo.profile?.displayName() ?? Profile.truncated(id: userInfo.profileId, truncating: .middle) ) }() return SessionCell.Info( id: .user(userInfo.profileId), leadingAccessory: .profile( id: userInfo.profileId, profile: userInfo.profile, profileIcon: (showProfileIcons ? userInfo.value.profileIcon : .none) ), title: title, subtitle: userInfo.itemDescription(using: dependencies), trailingAccessory: trailingAccessory, styling: SessionCell.StyleInfo( subtitleTintColor: userInfo.itemDescriptionColor(using: dependencies), allowedSeparators: [], customPadding: SessionCell.Padding( top: Values.smallSpacing, bottom: Values.smallSpacing ), backgroundStyle: .noBackgroundEdgeToEdge ), accessibility: Accessibility( identifier: "Contact", label: title ), onTap: { // Trigger any 'onTap' actions switch finalAction { case .none: return case .callback(let callback): callback(self, userInfo) case .custom(_, let callback): callback(self, userInfo) case .radio: break case .conditionalAction(_): return // Shouldn't hit this case } // Only update the selection if the accessory is a 'radio' guard trailingAccessory is SessionCell.AccessoryConfig.Radio else { return } // Toggle the selection if !selectedUsersSubject.value.contains(userInfo) { selectedUsersSubject.send(selectedUsersSubject.value.inserting(userInfo)) } else { selectedUsersSubject.send(selectedUsersSubject.value.removing(userInfo)) } // Force the table data to be refreshed (the database wouldn't have been changed) self?.forceRefresh(type: .postDatabaseQuery) } ) } ) ] } lazy var footerButtonInfo: AnyPublisher = selectedUsersSubject .prepend([]) .map { [weak self, dependencies, footerTitle, footerAccessibility] selectedUsers -> SessionButton.Info? in guard self?.onSubmitAction.hasAction == true, let title: String = footerTitle else { return nil } return SessionButton.Info( style: .bordered, title: title, isEnabled: !selectedUsers.isEmpty, accessibility: footerAccessibility, onTap: { self?.submit(with: selectedUsers) } ) } .eraseToAnyPublisher() // MARK: - Functions private func submit(with selectedUsers: Set>) { switch onSubmitAction { case .none: return case .callback(let submission): do { try submission(self, selectedUsers) selectedUsersSubject.send([]) forceRefresh() // Just in case the filter was impacted } catch { transitionToScreen( ConfirmationModal( info: ConfirmationModal.Info( title: "theError".localized(), body: .text(error.localizedDescription), cancelTitle: "okay".localized(), cancelStyle: .alert_text ) ), transitionType: .present ) } case .publisher(let submission): transitionToScreen( ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] modalActivityIndicator in submission(self, selectedUsers) .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( receiveCompletion: { result in switch result { case .finished: self?.selectedUsersSubject.send([]) self?.forceRefresh() // Just in case the filter was impacted modalActivityIndicator.dismiss(completion: {}) case .failure(let error): modalActivityIndicator.dismiss(completion: { self?.transitionToScreen( ConfirmationModal( info: ConfirmationModal.Info( title: "theError".localized(), body: .text(error.localizedDescription), cancelTitle: "okay".localized(), cancelStyle: .alert_text ) ), transitionType: .present ) }) } } ) }, transitionType: .present ) } } } // MARK: - UserListError public enum UserListError: LocalizedError { case error(String) public var errorDescription: String? { switch self { case .error(let content): return content } } }