// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import Combine import GRDB import DifferenceKit import SessionUIKit import SignalUtilitiesKit import SessionUtilitiesKit public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource, PagedObservationSource { public static let pageSize: Int = 30 public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() public let state: TableDataState = TableDataState() public let observableState: ObservableTableSourceState = ObservableTableSourceState() private let selectedContactIdsSubject: CurrentValueSubject, Never> = CurrentValueSubject([]) public private(set) var pagedDataObserver: PagedDatabaseObserver? // MARK: - Initialization init(using dependencies: Dependencies = Dependencies()) { self.dependencies = dependencies self.pagedDataObserver = nil // Note: Since this references self we need to finish initializing before setting it, we // also want to skip the initial query and trigger it async so that the push animation // doesn't stutter (it should load basically immediately but without this there is a // distinct stutter) self.pagedDataObserver = PagedDatabaseObserver( pagedTable: Contact.self, pageSize: BlockedContactsViewModel.pageSize, idColumn: .id, observedChanges: [ PagedData.ObservedChanges( table: Contact.self, columns: [.id, .isBlocked] ), PagedData.ObservedChanges( table: Profile.self, columns: [ .id, .name, .nickname, .profilePictureFileName ], joinToPagedType: { let contact: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(contact[.id])") }() ) ], /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query joinSQL: TableItem.optimisedJoinSQL, filterSQL: TableItem.filterSQL, orderSQL: TableItem.orderSQL, dataQuery: TableItem.query( filterSQL: TableItem.filterSQL, orderSQL: TableItem.orderSQL ), onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in PagedData.processAndTriggerUpdates( updatedData: self?.process(data: updatedData, for: updatedPageInfo) .mapToSessionTableViewData(for: self), currentDataRetriever: { self?.tableData }, valueSubject: self?.pendingTableDataSubject ) } ) // Run the initial query on a background thread so we don't block the push transition DispatchQueue.global(qos: .userInitiated).async { [weak self] in // The `.pageBefore` will query from a `0` offset loading the first page self?.pagedDataObserver?.load(.pageBefore) } } // MARK: - Section public enum Section: SessionTableSection { case contacts case loadMore public var style: SessionTableSectionStyle { switch self { case .contacts: return .none case .loadMore: return .loadMore } } } // MARK: - Content let title: String = "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized() let emptyStateTextPublisher: AnyPublisher = Just("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".localized()) .eraseToAnyPublisher() lazy var footerButtonInfo: AnyPublisher = selectedContactIdsSubject .prepend([]) .map { selectedContactIds in SessionButton.Info( style: .destructive, title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK".localized(), isEnabled: !selectedContactIds.isEmpty, onTap: { [weak self] in self?.unblockTapped() } ) } .eraseToAnyPublisher() // MARK: - Functions private func process( data: [TableItem], for pageInfo: PagedData.PageInfo ) -> [SectionModel] { return [ [ SectionModel( section: .contacts, elements: data .sorted { lhs, rhs -> Bool in let lhsValue: String = (lhs.profile?.displayName() ?? lhs.id) let rhsValue: String = (rhs.profile?.displayName() ?? rhs.id) return (lhsValue < rhsValue) } .map { [weak self] model -> SessionCell.Info in SessionCell.Info( id: model, leftAccessory: .profile(id: model.id, profile: model.profile), title: ( model.profile?.displayName() ?? Profile.truncated(id: model.id, truncating: .middle) ), rightAccessory: .radio( isSelected: { self?.selectedContactIdsSubject.value.contains(model.id) == true } ), onTap: { var updatedSelectedIds: Set = (self?.selectedContactIdsSubject.value ?? []) if !updatedSelectedIds.contains(model.id) { updatedSelectedIds.insert(model.id) } else { updatedSelectedIds.remove(model.id) } self?.selectedContactIdsSubject.send(updatedSelectedIds) } ) } ) ], (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? [SectionModel(section: .loadMore)] : [] ) ].flatMap { $0 } } private func unblockTapped() { guard !selectedContactIdsSubject.value.isEmpty else { return } let contactIds: Set = selectedContactIdsSubject.value let contactNames: [String] = contactIds .compactMap { contactId in guard let section: BlockedContactsViewModel.SectionModel = self.tableData .first(where: { section in section.model == .contacts }), let info: SessionCell.Info = section.elements .first(where: { info in info.id.id == contactId }) else { return contactId } return info.title?.text } let confirmationTitle: String = { guard contactNames.count > 1 else { // Show a single users name return String( format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_SINGLE".localized(), ( contactNames.first ?? "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_FALLBACK".localized() ) ) } guard contactNames.count > 3 else { // Show up to three users names let initialNames: [String] = Array(contactNames.prefix(upTo: (contactNames.count - 1))) let lastName: String = contactNames[contactNames.count - 1] return [ String( format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(), initialNames.joined(separator: ", ") ), String( format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_2_SINGLE".localized(), lastName ) ] .reversed(if: CurrentAppContext().isRTL) .joined(separator: " ") } // If we have exactly 4 users, show the first two names followed by 'and X others', for // more than 4 users, show the first 3 names followed by 'and X others' let numNamesToShow: Int = (contactNames.count == 4 ? 2 : 3) let initialNames: [String] = Array(contactNames.prefix(upTo: numNamesToShow)) return [ String( format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(), initialNames.joined(separator: ", ") ), String( format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_3".localized(), (contactNames.count - numNamesToShow) ) ] .reversed(if: CurrentAppContext().isRTL) .joined(separator: " ") }() let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: confirmationTitle, confirmTitle: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON".localized(), confirmStyle: .danger, cancelStyle: .alert_text ) { [weak self] _ in // Unblock the contacts Storage.shared.write { db in _ = try Contact .filter(ids: contactIds) .updateAllAndConfig(db, Contact.Columns.isBlocked.set(to: false)) } self?.selectedContactIdsSubject.send([]) } ) self.transitionToScreen(confirmationModal, transitionType: .present) } // MARK: - TableItem public struct TableItem: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case rowId case id case profile } public var differenceIdentifier: String { id } public let rowId: Int64 public let id: String public let profile: Profile? static func query( filterSQL: SQL, orderSQL: SQL ) -> (([Int64]) -> any FetchRequest) { return { rowIds -> any FetchRequest in let contact: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() /// **Note:** The `numColumnsBeforeProfile` value **MUST** match the number of fields before /// the `TableItem.profileKey` entry below otherwise the query will fail to /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfile: Int = 2 let request: SQLRequest = """ SELECT \(contact[.rowId]) AS \(TableItem.Columns.rowId), \(contact[.id]), \(profile.allColumns) FROM \(Contact.self) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(contact[.id]) WHERE \(contact[.rowId]) IN \(rowIds) ORDER BY \(orderSQL) """ return request.adapted { db in let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeProfile, Profile.numberOfSelectedColumns(db) ]) return ScopeAdapter.with(TableItem.self, [ .profile: adapters[1] ]) } } } static var optimisedJoinSQL: SQL = { let contact: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(contact[.id])") }() static var filterSQL: SQL = { let contact: TypedTableAlias = TypedTableAlias() return SQL("\(contact[.isBlocked]) = true") }() static let orderSQL: SQL = { let contact: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() return SQL("IFNULL(IFNULL(\(profile[.nickname]), \(profile[.name])), \(contact[.id])) ASC") }() } }