// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import DifferenceKit import SignalUtilitiesKit public class BlockedContactsViewModel { public typealias SectionModel = ArraySection> // MARK: - Section public enum Section: Differentiable { case contacts case loadMore } // MARK: - Variables public static let pageSize: Int = 30 // MARK: - Initialization init() { 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: Profile.self, pageSize: BlockedContactsViewModel.pageSize, idColumn: .id, observedChanges: [ PagedData.ObservedChanges( table: Profile.self, columns: [ .id, .name, .nickname, .profilePictureFileName ] ), PagedData.ObservedChanges( table: Contact.self, columns: [.isBlocked], joinToPagedType: { let profile: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(profile[.id])") }() ) ], /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query joinSQL: DataModel.optimisedJoinSQL, filterSQL: DataModel.filterSQL, orderSQL: DataModel.orderSQL, dataQuery: DataModel.query( filterSQL: DataModel.filterSQL, orderSQL: DataModel.orderSQL ), onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in PagedData.processAndTriggerUpdates( updatedData: self?.process(data: updatedData, for: updatedPageInfo), currentDataRetriever: { self?.contactData }, onDataChange: self?.onContactChange, onUnobservedDataChange: { updatedData, changeset in self?.unobservedContactDataChanges = (updatedData, changeset) } ) } ) // 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: - Contact Data public private(set) var selectedContactIds: Set = [] public private(set) var unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)? public private(set) var contactData: [SectionModel] = [] public private(set) var pagedDataObserver: PagedDatabaseObserver? public var onContactChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? { didSet { // When starting to observe interaction changes we want to trigger a UI update just in case the // data was changed while we weren't observing if let unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedContactDataChanges { onContactChange?(unobservedContactDataChanges.0 , unobservedContactDataChanges.1) self.unobservedContactDataChanges = nil } } } private func process(data: [DataModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { // Update the 'selectedContactIds' to only include selected contacts which are within the // data (ie. handle profile deletions) let profileIds: Set = data.map { $0.id }.asSet() selectedContactIds = selectedContactIds.intersection(profileIds) return [ [ SectionModel( section: .contacts, elements: data .sorted { lhs, rhs -> Bool in lhs.profile.displayName() > rhs.profile.displayName() } .map { model -> SessionCell.Info in SessionCell.Info( id: model.profile, leftAccessory: .profile(model.profile.id, model.profile), title: model.profile.displayName(), rightAccessory: .radio( isSelected: { [weak self] in self?.selectedContactIds.contains(model.profile.id) == true } ), onTap: { [weak self] in guard self?.selectedContactIds.contains(model.profile.id) == true else { self?.selectedContactIds.insert(model.profile.id) return } self?.selectedContactIds.remove(model.profile.id) } ) } ) ], (!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 ) { _ in // Unblock the contacts Storage.shared.write { db in _ = try Contact .filter(ids: contactIds) .updateAll(db, Contact.Columns.isBlocked.set(to: false)) // Force a config sync try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete() } } ) self.transitionToScreen(confirmationModal, transitionType: .present) } // MARK: - DataModel public struct DataModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) public static let profileString: String = CodingKeys.profile.stringValue public var differenceIdentifier: String { profile.id } public var id: String { profile.id } public let rowId: Int64 public let profile: Profile static func query( filterSQL: SQL, orderSQL: SQL ) -> (([Int64]) -> AdaptedFetchRequest>) { return { rowIds -> AdaptedFetchRequest> in let profile: TypedTableAlias = TypedTableAlias() /// **Note:** The `numColumnsBeforeProfile` value **MUST** match the number of fields before /// the `DataModel.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 = 1 let request: SQLRequest = """ SELECT \(profile.alias[Column.rowID]) AS \(DataModel.rowIdKey), \(DataModel.profileKey).* FROM \(Profile.self) WHERE \(profile.alias[Column.rowID]) IN \(rowIds) ORDER BY \(orderSQL) """ return request.adapted { db in let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeProfile, Profile.numberOfSelectedColumns(db) ]) return ScopeAdapter([ DataModel.profileString: adapters[1] ]) } } } static var optimisedJoinSQL: SQL = { let profile: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(profile[.id])") }() static var filterSQL: SQL = { let contact: TypedTableAlias = TypedTableAlias() return SQL("\(contact[.isBlocked]) = true") }() static let orderSQL: SQL = { let profile: TypedTableAlias = TypedTableAlias() return SQL("IFNULL(IFNULL(\(profile[.nickname]), \(profile[.name])), \(profile[.id])) ASC") }() } }