Swiftgram/submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift
2025-04-11 12:56:35 +04:00

734 lines
40 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import MergeLists
import AccountContext
import SearchUI
import ChatListSearchItemHeader
import ContactsPeerItem
import ContextUI
import PhoneNumberFormat
import ItemListUI
import AnimatedStickerNode
import TelegramAnimatedStickerNode
private enum ContactListSearchGroup {
case contacts
case global
case deviceContacts
}
private enum ContactListSearchEntryId: Hashable {
case addContact
case peerId(ContactListPeerId)
static func <(lhs: ContactListSearchEntryId, rhs: ContactListSearchEntryId) -> Bool {
return lhs.hashValue < rhs.hashValue
}
static func ==(lhs: ContactListSearchEntryId, rhs: ContactListSearchEntryId) -> Bool {
switch lhs {
case .addContact:
switch rhs {
case .addContact:
return true
default:
return false
}
case let .peerId(lhsId):
switch rhs {
case let .peerId(rhsId):
return lhsId == rhsId
default:
return false
}
}
}
}
private enum ContactListSearchEntry: Comparable, Identifiable {
case addContact(theme: PresentationTheme, strings: PresentationStrings, phoneNumber: String)
case peer(index: Int, theme: PresentationTheme, strings: PresentationStrings, peer: ContactListPeer, presence: EnginePeer.Presence?, group: ContactListSearchGroup, enabled: Bool, requiresPremiumForMessaging: Bool, displayCallIcons: Bool)
var stableId: ContactListSearchEntryId {
switch self {
case .addContact:
return .addContact
case let .peer(_, _, _, peer, _, _, _, _, _):
return .peerId(peer.id)
}
}
static func ==(lhs: ContactListSearchEntry, rhs: ContactListSearchEntry) -> Bool {
switch lhs {
case let .addContact(lhsTheme, lhsStrings, lhsPhoneNumber):
if case let .addContact(rhsTheme, rhsStrings, rhsPhoneNumber) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsPhoneNumber == rhsPhoneNumber {
return true
} else {
return false
}
case let .peer(lhsIndex, lhsTheme, lhsStrings, lhsPeer, lhsPresence, lhsGroup, lhsEnabled, lhsRequiresPremiumForMessaging, lhsDisplayCallIcons):
switch rhs {
case let .peer(rhsIndex, rhsTheme, rhsStrings, rhsPeer, rhsPresence, rhsGroup, rhsEnabled, rhsRequiresPremiumForMessaging, rhsDisplayCallIcons):
if lhsIndex != rhsIndex {
return false
}
if lhsTheme !== rhsTheme {
return false
}
if lhsStrings !== rhsStrings {
return false
}
if lhsPeer != rhsPeer {
return false
}
if let lhsPresence = lhsPresence, let rhsPresence = rhsPresence {
if lhsPresence != rhsPresence {
return false
}
} else if (lhsPresence != nil) != (rhsPresence != nil) {
return false
}
if lhsGroup != rhsGroup {
return false
}
if lhsEnabled != rhsEnabled {
return false
}
if lhsRequiresPremiumForMessaging != rhsRequiresPremiumForMessaging {
return false
}
if lhsDisplayCallIcons != rhsDisplayCallIcons {
return false
}
return true
default:
return false
}
}
}
static func <(lhs: ContactListSearchEntry, rhs: ContactListSearchEntry) -> Bool {
switch lhs {
case .addContact:
return true
case let .peer(lhsIndex, _, _, _, _, _, _, _, _):
switch rhs {
case .addContact:
return false
case let .peer(rhsIndex, _, _, _, _, _, _, _, _):
return lhsIndex < rhsIndex
}
}
}
func item(context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, timeFormat: PresentationDateTimeFormat, isPeerEnabled: @escaping (ContactListPeer) -> Bool, addContact: ((String) -> Void)?, openPeer: @escaping (ContactListPeer, ContactsSearchContainerNode.OpenPeerAction) -> Void, openDisabledPeer: @escaping (EnginePeer, ChatListDisabledPeerReason) -> Void, contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?) -> ListViewItem {
switch self {
case let .addContact(theme, strings, phoneNumber):
return ContactsAddItem(context: context, theme: theme, strings: strings, phoneNumber: phoneNumber, header: ChatListSearchItemHeader(type: .phoneNumber, theme: theme, strings: strings, actionTitle: nil, action: nil), action: {
addContact?(phoneNumber)
})
case let .peer(_, theme, strings, peer, presence, group, enabled, requiresPremiumForMessaging, displayCallIcons):
let header: ListViewItemHeader
let status: ContactsPeerItemStatus
switch group {
case .contacts:
header = ChatListSearchItemHeader(type: .contacts, theme: theme, strings: strings, actionTitle: nil, action: nil)
if let presence = presence {
status = .presence(presence, timeFormat)
} else {
status = .none
}
case .global:
header = ChatListSearchItemHeader(type: .globalPeers, theme: theme, strings: strings, actionTitle: nil, action: nil)
if case let .peer(peer, _, _) = peer, let _ = peer.addressName {
status = .addressName("")
} else {
status = .none
}
case .deviceContacts:
header = ChatListSearchItemHeader(type: .deviceContacts, theme: theme, strings: strings, actionTitle: nil, action: nil)
status = .none
}
var nativePeer: EnginePeer?
let peerItem: ContactsPeerItemPeer
switch peer {
case let .peer(peer, _, _):
peerItem = .peer(peer: EnginePeer(peer), chatPeer: EnginePeer(peer))
nativePeer = EnginePeer(peer)
case let .deviceContact(stableId, contact):
peerItem = .deviceContact(stableId: stableId, contact: contact)
}
var additionalActions: [ContactsPeerItemAction] = []
if displayCallIcons {
additionalActions = [ContactsPeerItemAction(icon: .voiceCall, action: { _, sourceNode, gesture in
openPeer(peer, .voiceCall)
}), ContactsPeerItemAction(icon: .videoCall, action: { _, sourceNode, gesture in
openPeer(peer, .videoCall)
})]
}
return ContactsPeerItem(presentationData: ItemListPresentationData(presentationData), sortOrder: nameSortOrder, displayOrder: nameDisplayOrder, context: context, peerMode: .peer, peer: peerItem, status: status, requiresPremiumForMessaging: requiresPremiumForMessaging, enabled: enabled && isPeerEnabled(peer), selection: .none, editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), additionalActions: additionalActions, index: nil, header: header, action: { _ in
openPeer(peer, .generic)
}, disabledAction: { _ in
if case let .peer(peer, _, _) = peer {
openDisabledPeer(EnginePeer(peer), requiresPremiumForMessaging ? .premiumRequired : .generic)
}
}, contextAction: contextAction.flatMap { contextAction in
return nativePeer.flatMap { nativePeer in
return { node, gesture, location in
contextAction(nativePeer, node, gesture, location)
}
}
})
}
}
}
struct ContactListSearchContainerTransition {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let isSearching: Bool
let emptyResults: Bool
let query: String
}
private func contactListSearchContainerPreparedRecentTransition(from fromEntries: [ContactListSearchEntry], to toEntries: [ContactListSearchEntry], isSearching: Bool, emptyResults: Bool, query: String, context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, timeFormat: PresentationDateTimeFormat, isPeerEnabled: @escaping (ContactListPeer) -> Bool, addContact: ((String) -> Void)?, openPeer: @escaping (ContactListPeer, ContactsSearchContainerNode.OpenPeerAction) -> Void, openDisabledPeer: @escaping (EnginePeer, ChatListDisabledPeerReason) -> Void, contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?) -> ContactListSearchContainerTransition {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, timeFormat: timeFormat, isPeerEnabled: isPeerEnabled, addContact: addContact, openPeer: openPeer, openDisabledPeer: openDisabledPeer, contextAction: contextAction), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, timeFormat: timeFormat, isPeerEnabled: isPeerEnabled, addContact: addContact, openPeer: openPeer, openDisabledPeer: openDisabledPeer, contextAction: contextAction), directionHint: nil) }
return ContactListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, emptyResults: emptyResults, query: query)
}
public struct ContactsSearchCategories: OptionSet {
public var rawValue: Int32
public init(rawValue: Int32) {
self.rawValue = rawValue
}
public static let cloudContacts = ContactsSearchCategories(rawValue: 1 << 0)
public static let global = ContactsSearchCategories(rawValue: 1 << 1)
public static let deviceContacts = ContactsSearchCategories(rawValue: 1 << 2)
public static let channels = ContactsSearchCategories(rawValue: 1 << 3)
}
public final class ContactsSearchContainerNode: SearchDisplayControllerContentNode {
public enum OpenPeerAction {
case generic
case voiceCall
case videoCall
}
private let context: AccountContext
private let isPeerEnabled: (ContactListPeer) -> Bool
private let addContact: ((String) -> Void)?
private let openPeer: (ContactListPeer, ContactsSearchContainerNode.OpenPeerAction) -> Void
private let openDisabledPeer: (EnginePeer, ChatListDisabledPeerReason) -> Void
private let contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?
private let dimNode: ASDisplayNode
public let listNode: ListView
private let emptyResultsTitleNode: ImmediateTextNode
private let emptyResultsTextNode: ImmediateTextNode
private let emptyResultsAnimationNode: AnimatedStickerNode
private var emptyResultsAnimationSize: CGSize = CGSize()
private let searchQuery = Promise<String?>()
private let searchDisposable = MetaDisposable()
private var presentationData: PresentationData
private let themeAndStringsPromise: Promise<(PresentationTheme, PresentationStrings)>
private var containerViewLayout: (ContainerViewLayout, CGFloat)?
private var enqueuedTransitions: [ContactListSearchContainerTransition] = []
public override var hasDim: Bool {
return true
}
public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, onlyWriteable: Bool, categories: ContactsSearchCategories, filters: [ContactListFilter] = [.excludeSelf], displayCallIcons: Bool = false, isPeerEnabled: @escaping (ContactListPeer) -> Bool = { _ in true }, addContact: ((String) -> Void)?, openPeer: @escaping (ContactListPeer, ContactsSearchContainerNode.OpenPeerAction) -> Void, openDisabledPeer: @escaping (EnginePeer, ChatListDisabledPeerReason) -> Void, contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?) {
self.context = context
self.isPeerEnabled = isPeerEnabled
self.addContact = addContact
self.openPeer = openPeer
self.openDisabledPeer = openDisabledPeer
self.contextAction = contextAction
let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 }
self.presentationData = presentationData
self.themeAndStringsPromise = Promise((self.presentationData.theme, self.presentationData.strings))
self.dimNode = ASDisplayNode()
self.dimNode.backgroundColor = UIColor.black.withAlphaComponent(0.5)
self.listNode = ListView()
self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.listNode.isHidden = true
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
self.emptyResultsTitleNode = ImmediateTextNode()
self.emptyResultsTitleNode.displaysAsynchronously = false
self.emptyResultsTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.Contacts_Search_NoResults, font: Font.semibold(17.0), textColor: self.presentationData.theme.list.freeTextColor)
self.emptyResultsTitleNode.textAlignment = .center
self.emptyResultsTitleNode.isHidden = true
self.emptyResultsTextNode = ImmediateTextNode()
self.emptyResultsTextNode.displaysAsynchronously = false
self.emptyResultsTextNode.maximumNumberOfLines = 0
self.emptyResultsTextNode.textAlignment = .center
self.emptyResultsTextNode.isHidden = true
self.emptyResultsAnimationNode = DefaultAnimatedStickerNodeImpl()
self.emptyResultsAnimationNode.isHidden = true
super.init()
self.emptyResultsAnimationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: "ChatListNoResults"), width: 256, height: 256, playbackMode: .once, mode: .direct(cachePathPrefix: nil))
self.emptyResultsAnimationSize = CGSize(width: 148.0, height: 148.0)
self.backgroundColor = nil
self.isOpaque = false
self.addSubnode(self.dimNode)
self.addSubnode(self.listNode)
self.addSubnode(self.emptyResultsAnimationNode)
self.addSubnode(self.emptyResultsTitleNode)
self.addSubnode(self.emptyResultsTextNode)
self.listNode.isHidden = true
let themeAndStringsPromise = self.themeAndStringsPromise
let previousFoundRemoteContacts = Atomic<([FoundPeer], [FoundPeer])?>(value: nil)
let searchItems = self.searchQuery.get()
|> mapToSignal { query -> Signal<([ContactListSearchEntry]?, String), NoError> in
if let query = query, !query.isEmpty {
let foundLocalContacts: Signal<([EnginePeer], [EnginePeer.Id: EnginePeer.Presence]), NoError>
if categories.contains(.cloudContacts) {
foundLocalContacts = context.engine.contacts.searchContacts(query: query.lowercased())
} else {
foundLocalContacts = .single(([], [:]))
}
let foundRemoteContacts: Signal<([FoundPeer], [FoundPeer])?, NoError>
if categories.contains(.global) {
foundRemoteContacts = .single(previousFoundRemoteContacts.with({ $0 }))
|> then(
context.engine.contacts.searchRemotePeers(query: query)
|> map { ($0.0, $0.1) }
|> delay(0.2, queue: Queue.concurrentDefaultQueue())
)
} else {
foundRemoteContacts = .single(([], []))
}
let searchDeviceContacts = categories.contains(.deviceContacts)
let foundDeviceContacts: Signal<[DeviceContactStableId: (DeviceContactBasicData, EnginePeer.Id?)]?, NoError>
if searchDeviceContacts, let contactDataManager = context.sharedContext.contactDataManager {
foundDeviceContacts = contactDataManager.search(query: query)
|> map(Optional.init)
} else {
foundDeviceContacts = .single([:])
}
struct FoundPeers {
var foundLocalContacts: ([EnginePeer], [EnginePeer.Id: EnginePeer.Presence])
var foundRemoteContacts: ([FoundPeer], [FoundPeer])?
}
let foundPeers = Promise<FoundPeers>()
foundPeers.set(combineLatest(
foundLocalContacts,
foundRemoteContacts
)
|> map { foundLocalContacts, foundRemoteContacts -> FoundPeers in
return FoundPeers(
foundLocalContacts: foundLocalContacts,
foundRemoteContacts: foundRemoteContacts
)
})
let peerRequiresPremiumForMessaging: Signal<[EnginePeer.Id: Bool], NoError>
if onlyWriteable {
peerRequiresPremiumForMessaging = foundPeers.get()
|> map { foundPeers -> Set<EnginePeer.Id> in
var result = Set<EnginePeer.Id>()
for peer in foundPeers.foundLocalContacts.0 {
if case let .user(user) = peer, user.flags.contains(.requirePremium) {
result.insert(user.id)
}
}
if let foundRemoteContacts = foundPeers.foundRemoteContacts {
for peer in foundRemoteContacts.0 {
if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium) {
result.insert(user.id)
}
}
for peer in foundRemoteContacts.1 {
if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium) {
result.insert(user.id)
}
}
}
return result
}
|> distinctUntilChanged
|> mapToSignal { peerIds -> Signal<[EnginePeer.Id: Bool], NoError> in
return context.engine.data.subscribe(
EngineDataMap(
peerIds.map(TelegramEngine.EngineData.Item.Peer.IsPremiumRequiredForMessaging.init(id:))
)
)
}
} else {
peerRequiresPremiumForMessaging = .single([:])
}
return combineLatest(foundPeers.get(), peerRequiresPremiumForMessaging, foundDeviceContacts, themeAndStringsPromise.get())
|> delay(0.1, queue: Queue.concurrentDefaultQueue())
|> map { foundPeers, peerRequiresPremiumForMessaging, deviceContacts, themeAndStrings -> ([ContactListSearchEntry], String) in
let localPeersAndPresences = foundPeers.foundLocalContacts
let remotePeers = foundPeers.foundRemoteContacts
if !peerRequiresPremiumForMessaging.isEmpty {
context.account.viewTracker.refreshCanSendMessagesForPeerIds(peerIds: Array(peerRequiresPremiumForMessaging.keys))
}
let _ = previousFoundRemoteContacts.swap(remotePeers)
var entries: [ContactListSearchEntry] = []
var existingPeerIds = Set<EnginePeer.Id>()
var disabledPeerIds = Set<EnginePeer.Id>()
var requirePhoneNumbers = false
var excludeBots = false
for filter in filters {
switch filter {
case .excludeSelf:
existingPeerIds.insert(context.account.peerId)
case let .exclude(peerIds):
existingPeerIds = existingPeerIds.union(peerIds)
case let .disable(peerIds):
disabledPeerIds = disabledPeerIds.union(peerIds)
case .excludeWithoutPhoneNumbers:
requirePhoneNumbers = true
case .excludeBots:
excludeBots = true
}
}
var existingNormalizedPhoneNumbers = Set<DeviceContactNormalizedPhoneNumber>()
var index = 0
for peer in localPeersAndPresences.0 {
if existingPeerIds.contains(peer.id) {
continue
}
if case let .user(user) = peer {
if requirePhoneNumbers {
let phone = user.phone ?? ""
if phone.isEmpty {
continue
}
}
if excludeBots {
if user.botInfo != nil {
continue
}
}
}
existingPeerIds.insert(peer.id)
var enabled = true
var requiresPremiumForMessaging = false
if onlyWriteable {
enabled = canSendMessagesToPeer(peer._asPeer())
if let value = peerRequiresPremiumForMessaging[peer.id], value {
requiresPremiumForMessaging = true
enabled = false
}
}
entries.append(.peer(index: index, theme: themeAndStrings.0, strings: themeAndStrings.1, peer: .peer(peer: peer._asPeer(), isGlobal: false, participantCount: nil), presence: localPeersAndPresences.1[peer.id], group: .contacts, enabled: enabled, requiresPremiumForMessaging: requiresPremiumForMessaging, displayCallIcons: displayCallIcons))
if searchDeviceContacts, case let .user(user) = peer, let phone = user.phone {
existingNormalizedPhoneNumbers.insert(DeviceContactNormalizedPhoneNumber(rawValue: formatPhoneNumber(phone)))
}
index += 1
}
if let remotePeers = remotePeers {
for peer in remotePeers.0 {
if !(peer.peer is TelegramUser) {
if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info, categories.contains(.channels) {
} else {
continue
}
}
if let user = peer.peer as? TelegramUser {
if requirePhoneNumbers {
let phone = user.phone ?? ""
if phone.isEmpty {
continue
}
}
if excludeBots {
if user.botInfo != nil {
continue
}
}
}
if !existingPeerIds.contains(peer.peer.id) {
existingPeerIds.insert(peer.peer.id)
var enabled = true
var requiresPremiumForMessaging = false
if onlyWriteable {
enabled = canSendMessagesToPeer(peer.peer)
if let value = peerRequiresPremiumForMessaging[peer.peer.id], value {
requiresPremiumForMessaging = true
enabled = false
}
}
entries.append(.peer(index: index, theme: themeAndStrings.0, strings: themeAndStrings.1, peer: .peer(peer: peer.peer, isGlobal: true, participantCount: peer.subscribers), presence: nil, group: .global, enabled: enabled, requiresPremiumForMessaging: requiresPremiumForMessaging, displayCallIcons: displayCallIcons))
if searchDeviceContacts, let user = peer.peer as? TelegramUser, let phone = user.phone {
existingNormalizedPhoneNumbers.insert(DeviceContactNormalizedPhoneNumber(rawValue: formatPhoneNumber(phone)))
}
index += 1
}
}
for peer in remotePeers.1 {
if !(peer.peer is TelegramUser) {
if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info, categories.contains(.channels) {
} else {
continue
}
}
if let user = peer.peer as? TelegramUser, requirePhoneNumbers {
let phone = user.phone ?? ""
if phone.isEmpty {
continue
}
}
if !existingPeerIds.contains(peer.peer.id) {
existingPeerIds.insert(peer.peer.id)
var enabled = true
var requiresPremiumForMessaging = false
if onlyWriteable {
enabled = canSendMessagesToPeer(peer.peer)
if let value = peerRequiresPremiumForMessaging[peer.peer.id], value {
requiresPremiumForMessaging = true
enabled = false
}
}
entries.append(.peer(index: index, theme: themeAndStrings.0, strings: themeAndStrings.1, peer: .peer(peer: peer.peer, isGlobal: true, participantCount: peer.subscribers), presence: nil, group: .global, enabled: enabled, requiresPremiumForMessaging: requiresPremiumForMessaging, displayCallIcons: displayCallIcons))
if searchDeviceContacts, let user = peer.peer as? TelegramUser, let phone = user.phone {
existingNormalizedPhoneNumbers.insert(DeviceContactNormalizedPhoneNumber(rawValue: formatPhoneNumber(phone)))
}
index += 1
}
}
}
if let _ = remotePeers, let deviceContacts = deviceContacts {
outer: for (stableId, contact) in deviceContacts {
inner: for phoneNumber in contact.0.phoneNumbers {
let normalizedNumber = DeviceContactNormalizedPhoneNumber(rawValue: formatPhoneNumber(phoneNumber.value))
if existingNormalizedPhoneNumbers.contains(normalizedNumber) {
continue outer
}
}
if let peerId = contact.1 {
if existingPeerIds.contains(peerId) {
continue outer
}
}
entries.append(.peer(index: index, theme: themeAndStrings.0, strings: themeAndStrings.1, peer: .deviceContact(stableId, contact.0), presence: nil, group: .deviceContacts, enabled: true, requiresPremiumForMessaging: false, displayCallIcons: displayCallIcons))
index += 1
}
}
if let _ = addContact, isViablePhoneNumber(query) {
entries.append(.addContact(theme: themeAndStrings.0, strings: themeAndStrings.1, phoneNumber: query))
}
return (entries, query)
}
} else {
let _ = previousFoundRemoteContacts.swap(nil)
return .single((nil, ""))
}
}
let previousSearchItems = Atomic<[ContactListSearchEntry]>(value: [])
self.searchDisposable.set((searchItems
|> deliverOnMainQueue).start(next: { [weak self] items, query in
if let strongSelf = self {
let previousItems = previousSearchItems.swap(items ?? [])
var addContact: ((String) -> Void)?
if let originalAddContact = strongSelf.addContact {
addContact = { [weak self] phoneNumber in
self?.listNode.clearHighlightAnimated(true)
originalAddContact(phoneNumber)
}
}
let transition = contactListSearchContainerPreparedRecentTransition(from: previousItems, to: items ?? [], isSearching: items != nil, emptyResults: items?.isEmpty ?? false, query: query, context: context, presentationData: strongSelf.presentationData, nameSortOrder: strongSelf.presentationData.nameSortOrder, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder, timeFormat: strongSelf.presentationData.dateTimeFormat, isPeerEnabled: strongSelf.isPeerEnabled, addContact: addContact, openPeer: { peer, action in
self?.listNode.clearHighlightAnimated(true)
self?.openPeer(peer, action)
}, openDisabledPeer: { peer, reason in
guard let self else {
return
}
self.listNode.clearHighlightAnimated(true)
self.openDisabledPeer(peer, reason)
}, contextAction: strongSelf.contextAction)
strongSelf.enqueueTransition(transition)
}
}))
self.listNode.beganInteractiveDragging = { [weak self] _ in
self?.dismissInput?()
}
}
deinit {
self.searchDisposable.dispose()
}
override public func scrollToTop() {
if !self.listNode.isHidden {
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
}
}
override public func didLoad() {
super.didLoad()
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
}
override public func updatePresentationData(_ presentationData: PresentationData) {
super.updatePresentationData(presentationData)
self.presentationData = presentationData
self.themeAndStringsPromise.set(.single((presentationData.theme, presentationData.strings)))
self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
}
override public func searchTextUpdated(text: String) {
if text.isEmpty {
self.searchQuery.set(.single(nil))
} else {
self.searchQuery.set(.single(text))
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
let hadValidLayout = self.containerViewLayout != nil
self.containerViewLayout = (layout, navigationBarHeight)
let topInset = navigationBarHeight
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: layout.size.width, height: layout.size.height - topInset)))
self.listNode.frame = CGRect(origin: CGPoint(), size: layout.size)
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: topInset, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right), duration: 0.0, curve: .Default(duration: nil)), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
let size = layout.size
let sideInset = layout.safeInsets.left
let visibleHeight = layout.size.height
let bottomInset = layout.insets(options: .input).bottom
let padding: CGFloat = 16.0
let emptyTitleSize = self.emptyResultsTitleNode.updateLayout(CGSize(width: size.width - sideInset * 2.0 - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
let emptyTextSize = self.emptyResultsTextNode.updateLayout(CGSize(width: size.width - sideInset * 2.0 - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
let emptyAnimationHeight = self.emptyResultsAnimationSize.height
let emptyAnimationSpacing: CGFloat = 8.0
let emptyTextSpacing: CGFloat = 8.0
let emptyTotalHeight = emptyAnimationHeight + emptyAnimationSpacing + emptyTitleSize.height + emptyTextSize.height + emptyTextSpacing
let emptyAnimationY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0)
let textTransition = ContainedViewLayoutTransition.immediate
textTransition.updateFrame(node: self.emptyResultsAnimationNode, frame: CGRect(origin: CGPoint(x: sideInset + padding + (size.width - sideInset * 2.0 - padding * 2.0 - self.emptyResultsAnimationSize.width) / 2.0, y: emptyAnimationY), size: self.emptyResultsAnimationSize))
textTransition.updateFrame(node: self.emptyResultsTitleNode, frame: CGRect(origin: CGPoint(x: sideInset + padding + (size.width - sideInset * 2.0 - padding * 2.0 - emptyTitleSize.width) / 2.0, y: emptyAnimationY + emptyAnimationHeight + emptyAnimationSpacing), size: emptyTitleSize))
textTransition.updateFrame(node: self.emptyResultsTextNode, frame: CGRect(origin: CGPoint(x: sideInset + padding + (size.width - sideInset * 2.0 - padding * 2.0 - emptyTextSize.width) / 2.0, y: emptyAnimationY + emptyAnimationHeight + emptyAnimationSpacing + emptyTitleSize.height + emptyTextSpacing), size: emptyTextSize))
self.emptyResultsAnimationNode.updateLayout(size: self.emptyResultsAnimationSize)
if !hadValidLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func enqueueTransition(_ transition: ContactListSearchContainerTransition) {
self.enqueuedTransitions.append(transition)
if self.containerViewLayout != nil {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
if let transition = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
options.insert(.PreferSynchronousDrawing)
options.insert(.PreferSynchronousResourceLoading)
let isSearching = transition.isSearching
let emptyResults = transition.emptyResults
let query = transition.query
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.emptyResultsTextNode.attributedText = NSAttributedString(string: strongSelf.presentationData.strings.Contacts_Search_NoResultsQueryDescription(query).string, font: Font.regular(15.0), textColor: strongSelf.presentationData.theme.list.freeTextColor)
if let (layout, navigationBarHeight) = strongSelf.containerViewLayout {
strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
strongSelf.listNode.isHidden = !isSearching
strongSelf.dimNode.isHidden = isSearching
strongSelf.emptyResultsAnimationNode.isHidden = !emptyResults
strongSelf.emptyResultsTitleNode.isHidden = !emptyResults
strongSelf.emptyResultsTextNode.isHidden = !emptyResults
strongSelf.emptyResultsAnimationNode.visibility = emptyResults
})
}
}
@objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.cancel?()
}
}
}