import UIKit import ComponentFlow import Display import AsyncDisplayKit import TelegramCore import Postbox import AccountContext import ChatListUI import MergeLists import ComponentDisplayAdapters import TelegramPresentationData import SwiftSignalKit import TelegramUIPreferences import UIKitRuntimeUtils import ChatPresentationInterfaceState import ContactsPeerItem import ItemListUI import ChatListSearchItemHeader public final class ChatInlineSearchResultsListComponent: Component { public struct Presentation: Equatable { public var theme: PresentationTheme public var strings: PresentationStrings public var chatListFontSize: PresentationFontSize public var dateTimeFormat: PresentationDateTimeFormat public var nameSortOrder: PresentationPersonNameOrder public var nameDisplayOrder: PresentationPersonNameOrder public init( theme: PresentationTheme, strings: PresentationStrings, chatListFontSize: PresentationFontSize, dateTimeFormat: PresentationDateTimeFormat, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder ) { self.theme = theme self.strings = strings self.chatListFontSize = chatListFontSize self.dateTimeFormat = dateTimeFormat self.nameSortOrder = nameSortOrder self.nameDisplayOrder = nameDisplayOrder } public static func ==(lhs: Presentation, rhs: Presentation) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.strings != rhs.strings { return false } if lhs.chatListFontSize != rhs.chatListFontSize { return false } if lhs.dateTimeFormat != rhs.dateTimeFormat { return false } if lhs.nameSortOrder != rhs.nameSortOrder { return false } if lhs.nameDisplayOrder != rhs.nameDisplayOrder { return false } return true } } public enum Contents: Equatable { case empty case tag(MemoryBuffer) case search(query: String, includeSavedPeers: Bool) } public let context: AccountContext public let presentation: Presentation public let peerId: EnginePeer.Id public let contents: Contents public let insets: UIEdgeInsets public let messageSelected: (EngineMessage) -> Void public let peerSelected: (EnginePeer) -> Void public let loadTagMessages: (MemoryBuffer, MessageIndex?) -> Signal? public let getSearchResult: () -> Signal? public let getSavedPeers: (String) -> Signal<[(EnginePeer, MessageIndex?)], NoError>? public let loadMoreSearchResults: () -> Void public init( context: AccountContext, presentation: Presentation, peerId: EnginePeer.Id, contents: Contents, insets: UIEdgeInsets, messageSelected: @escaping (EngineMessage) -> Void, peerSelected: @escaping (EnginePeer) -> Void, loadTagMessages: @escaping (MemoryBuffer, MessageIndex?) -> Signal?, getSearchResult: @escaping () -> Signal?, getSavedPeers: @escaping (String) -> Signal<[(EnginePeer, MessageIndex?)], NoError>?, loadMoreSearchResults: @escaping () -> Void ) { self.context = context self.presentation = presentation self.peerId = peerId self.contents = contents self.insets = insets self.messageSelected = messageSelected self.peerSelected = peerSelected self.loadTagMessages = loadTagMessages self.getSearchResult = getSearchResult self.getSavedPeers = getSavedPeers self.loadMoreSearchResults = loadMoreSearchResults } public static func ==(lhs: ChatInlineSearchResultsListComponent, rhs: ChatInlineSearchResultsListComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.presentation != rhs.presentation { return false } if lhs.peerId != rhs.peerId { return false } if lhs.contents != rhs.contents { return false } if lhs.insets != rhs.insets { return false } return true } private enum Entry: Equatable, Comparable { enum Id: Hashable { case peer(EnginePeer.Id) case message(EngineMessage.Id) } case peer(EnginePeer) case message(EngineMessage) var id: Id { switch self { case let .peer(peer): return .peer(peer.id) case let .message(message): return .message(message.id) } } static func ==(lhs: Entry, rhs: Entry) -> Bool { switch lhs { case let .peer(peer): if case .peer(peer) = rhs { return true } else { return false } case let .message(message): if case .message(message) = rhs { return true } else { return false } } } static func <(lhs: Entry, rhs: Entry) -> Bool { switch lhs { case let .peer(lhsPeer): switch rhs { case let .peer(rhsPeer): if lhsPeer.debugDisplayTitle != rhsPeer.debugDisplayTitle { return lhsPeer.debugDisplayTitle < rhsPeer.debugDisplayTitle } return lhsPeer.id < rhsPeer.id case .message: return true } case let .message(lhsMessage): switch rhs { case .peer: return false case let .message(rhsMessage): return lhsMessage.index > rhsMessage.index } } } } private struct ContentsState: Equatable { enum ContentId: Equatable { case empty case tag(MemoryBuffer) case search(String) } var id: Int var contentId: ContentId var entries: [Entry] var messages: [EngineMessage] var hasEarlier: Bool var hasLater: Bool init(id: Int, contentId: ContentId, entries: [Entry], messages: [EngineMessage], hasEarlier: Bool, hasLater: Bool) { self.id = id self.contentId = contentId self.entries = entries self.messages = messages self.hasEarlier = hasEarlier self.hasLater = hasLater } } public final class View: UIView { private var component: ChatInlineSearchResultsListComponent? private weak var state: EmptyComponentState? private var isUpdating: Bool = false private let listNode: ListView private var tagContents: (index: MessageIndex?, disposable: Disposable?)? private var searchContents: (index: MessageIndex?, disposable: Disposable?)? private var nextContentsId: Int = 0 private var contentsState: ContentsState? private var appliedContentsState: ContentsState? private var currentChatListPresentationData: (Presentation, ChatListPresentationData)? private var chatListNodeInteraction: ChatListNodeInteraction? private let isReadyPromise = Promise() private var didSetReady: Bool = false public var isReady: Signal { return self.isReadyPromise.get() } override public init(frame: CGRect) { self.listNode = ListView() super.init(frame: frame) self.addSubnode(self.listNode) self.listNode.beganInteractiveDragging = { [weak self] _ in guard let self else { return } self.window?.endEditing(true) } } required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.tagContents?.disposable?.dispose() self.searchContents?.disposable?.dispose() } public func animateIn() { self.listNode.layer.animateSublayerScale(from: 0.95, to: 1.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) if let blurFilter = makeBlurFilter() { blurFilter.setValue(0.0 as NSNumber, forKey: "inputRadius") self.listNode.layer.filters = [blurFilter] self.listNode.layer.animate(from: 30.0 as NSNumber, to: 0.0 as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2, removeOnCompletion: false, completion: { [weak self] completed in guard let self, completed else { return } self.listNode.layer.filters = [] }) } } public func animateOut() { self.listNode.layer.animateSublayerScale(from: 1.0, to: 0.95, duration: 0.3, removeOnCompletion: false) if let blurFilter = makeBlurFilter() { blurFilter.setValue(30.0 as NSNumber, forKey: "inputRadius") self.listNode.layer.filters = [blurFilter] self.listNode.layer.animate(from: 0.0 as NSNumber, to: 30.0 as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.3, removeOnCompletion: false) } } func update(component: ChatInlineSearchResultsListComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } let previousComponent = self.component self.component = component self.state = state switch component.contents { case .empty: self.backgroundColor = nil default: break } self.listNode.frame = CGRect(origin: CGPoint(), size: availableSize) let (listDuration, listCurve) = listViewAnimationDurationAndCurve(transition: transition.containedViewLayoutTransition) self.listNode.transaction( deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency, .PreferSynchronousDrawing, .PreferSynchronousResourceLoading], updateSizeAndInsets: ListViewUpdateSizeAndInsets( size: availableSize, insets: component.insets, duration: listDuration, curve: listCurve ), updateOpaqueState: nil ) self.listNode.displayedItemRangeChanged = { [weak self] displayedRange, opaqueTransactionState in guard let self else { return } guard let stateId = opaqueTransactionState as? Int else { return } guard let contentsState = self.contentsState, contentsState.id == stateId else { return } guard let visibleRange = displayedRange.visibleRange else { return } var loadAroundIndex: MessageIndex? if visibleRange.firstIndex <= 5 { if contentsState.hasLater { loadAroundIndex = contentsState.messages.first?.index } } else if visibleRange.lastIndex >= contentsState.messages.count - 5 { if contentsState.hasEarlier { loadAroundIndex = contentsState.messages.last?.index } } if let (currentIndex, disposable) = self.tagContents { if let loadAroundIndex, loadAroundIndex != currentIndex { switch component.contents { case .empty: break case let .tag(tag): disposable?.dispose() let updatedDisposable = MetaDisposable() self.tagContents = (loadAroundIndex, updatedDisposable) if let historySignal = component.loadTagMessages(tag, self.tagContents?.index) { updatedDisposable.set((historySignal |> deliverOnMainQueue).startStrict(next: { [weak self] view in guard let self else { return } let messages = view.entries.reversed().map { entry in return EngineMessage(entry.message) } let contentsId = self.nextContentsId self.nextContentsId += 1 self.contentsState = ContentsState( id: contentsId, contentId: .tag(tag), entries: messages.map { message in return .message(message) }, messages: messages, hasEarlier: view.earlierId != nil, hasLater: view.laterId != nil ) if !self.isUpdating { self.state?.updated(transition: .immediate) } if !self.didSetReady { self.didSetReady = true self.isReadyPromise.set(.single(true)) } })) } case .search: break } } } else if let (currentIndex, disposable) = self.searchContents { if let loadAroundIndex, loadAroundIndex != currentIndex { switch component.contents { case .empty: break case .tag: break case .search: self.searchContents = (loadAroundIndex, disposable) component.loadMoreSearchResults() } } } } switch component.contents { case .empty: if previousComponent?.contents != component.contents { self.tagContents?.disposable?.dispose() self.tagContents = nil self.searchContents?.disposable?.dispose() self.searchContents = nil let contentsId = self.nextContentsId self.nextContentsId += 1 self.contentsState = ContentsState( id: contentsId, contentId: .empty, entries: [], messages: [], hasEarlier: false, hasLater: false ) if !self.isUpdating { self.state?.updated(transition: .immediate) } if !self.didSetReady { self.didSetReady = true self.isReadyPromise.set(.single(true)) } } case let .tag(tag): if previousComponent?.contents != component.contents { self.tagContents?.disposable?.dispose() self.tagContents = nil self.searchContents?.disposable?.dispose() self.searchContents = nil let disposable = MetaDisposable() self.tagContents = (nil, disposable) if let historySignal = component.loadTagMessages(tag, self.tagContents?.index) { disposable.set((historySignal |> deliverOnMainQueue).startStrict(next: { [weak self] view in guard let self else { return } let messages = view.entries.reversed().map { entry in return EngineMessage(entry.message) } let contentsId = self.nextContentsId self.nextContentsId += 1 self.contentsState = ContentsState( id: contentsId, contentId: .tag(tag), entries: messages.map { message in return .message(message) }, messages: messages, hasEarlier: view.earlierId != nil, hasLater: view.laterId != nil ) if !self.isUpdating { self.state?.updated(transition: .immediate) } if !self.didSetReady { self.didSetReady = true self.isReadyPromise.set(.single(true)) } })) } } case let .search(query, includeSavedPeers): if previousComponent?.contents != component.contents { self.tagContents?.disposable?.dispose() self.tagContents = nil self.searchContents?.disposable?.dispose() self.searchContents = nil let disposable = MetaDisposable() self.searchContents = (nil, disposable) let savedPeers: Signal<[(EnginePeer, MessageIndex?)], NoError> if includeSavedPeers, !query.isEmpty, let savedPeersSignal = component.getSavedPeers(query) { savedPeers = savedPeersSignal } else { savedPeers = .single([]) } if let historySignal = component.getSearchResult() { disposable.set((savedPeers |> mapToSignal { savedPeers -> Signal<([(EnginePeer, MessageIndex?)], SearchMessagesResult?), NoError> in if savedPeers.isEmpty { return historySignal |> map { result in return ([], result) } } else { return (.single(nil) |> then(historySignal)) |> map { result in return (savedPeers, result) } } } |> deliverOnMainQueue).startStrict(next: { [weak self] savedPeers, result in guard let self else { return } let messages: [EngineMessage] = result?.messages.map { entry in return EngineMessage(entry) } ?? [] var entries: [Entry] = [] for (peer, _) in savedPeers { entries.append(.peer(peer)) } for message in messages { entries.append(.message(message)) } entries.sort() let contentsId = self.nextContentsId self.nextContentsId += 1 self.contentsState = ContentsState( id: contentsId, contentId: .search(query), entries: entries, messages: messages, hasEarlier: !(result?.completed ?? true), hasLater: false ) if !self.isUpdating { self.state?.updated(transition: .immediate) } if !self.didSetReady { self.didSetReady = true self.isReadyPromise.set(.single(true)) } })) } } } if let contentsState = self.contentsState, self.contentsState != self.appliedContentsState { let previousContentsState = self.appliedContentsState self.appliedContentsState = self.contentsState let chatListNodeInteraction: ChatListNodeInteraction if let current = self.chatListNodeInteraction { chatListNodeInteraction = current } else { chatListNodeInteraction = ChatListNodeInteraction( context: component.context, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, activateSearch: { }, peerSelected: { _, _, _, _ in }, disabledPeerSelected: { _, _, _ in }, togglePeerSelected: { _, _ in }, togglePeersSelection: { _, _ in }, additionalCategorySelected: { _ in }, messageSelected: { [weak self] _, _, message, _ in guard let self else { return } self.listNode.clearHighlightAnimated(true) self.component?.messageSelected(message) }, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, setPeerThreadHidden: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in }, toggleArchivedFolderHiddenByDefault: { }, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { item, _, node, gesture, _ in gesture?.cancel() }, present: { _ in }, openForumThread: { _, _ in }, openStorageManagement: { }, openPasswordSetup: { }, openPremiumIntro: { }, openPremiumGift: { _ in }, openPremiumManagement: { }, openActiveSessions: { }, openBirthdaySetup: { }, performActiveSessionAction: { _, _ in }, openChatFolderUpdates: { }, hideChatFolderUpdates: { }, openStories: { _, _ in }, dismissNotice: { _ in }, editPeer: { _ in } ) self.chatListNodeInteraction = chatListNodeInteraction } var searchTextHighightState: String? if case let .search(query, _) = component.contents, !query.isEmpty { searchTextHighightState = query.lowercased() } var allUpdated = false if chatListNodeInteraction.searchTextHighightState != searchTextHighightState { chatListNodeInteraction.searchTextHighightState = searchTextHighightState allUpdated = true } let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates( leftList: previousContentsState?.entries ?? [], rightList: contentsState.entries, isLess: { lhs, rhs in return lhs < rhs }, isEqual: { lhs, rhs in return lhs == rhs }, getId: { entry in return entry.id }, allUpdated: allUpdated ) let displayMessagesHeader = contentsState.entries.count != contentsState.messages.count let chatListPresentationData: ChatListPresentationData if let current = self.currentChatListPresentationData, current.0 == component.presentation { chatListPresentationData = current.1 } else { chatListPresentationData = ChatListPresentationData( theme: component.presentation.theme, fontSize: component.presentation.chatListFontSize, strings: component.presentation.strings, dateTimeFormat: component.presentation.dateTimeFormat, nameSortOrder: component.presentation.nameSortOrder, nameDisplayOrder: component.presentation.nameDisplayOrder, disableAnimations: false ) self.currentChatListPresentationData = (component.presentation, chatListPresentationData) } let listPresentationData = ItemListPresentationData(component.context.sharedContext.currentPresentationData.with({ $0 })) let peerSelected = component.peerSelected let entryToItem: (Entry) -> ListViewItem = { entry -> ListViewItem in switch entry { case let .peer(peer): return ContactsPeerItem( presentationData: listPresentationData, sortOrder: component.presentation.nameSortOrder, displayOrder: component.presentation.nameDisplayOrder, context: component.context, peerMode: .generalSearch(isSavedMessages: true), peer: .peer(peer: peer, chatPeer: peer), status: .none, badge: nil, requiresPremiumForMessaging: false, enabled: true, selection: .none, editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), index: nil, header: displayMessagesHeader ? ChatListSearchItemHeader(type: .chats, theme: listPresentationData.theme, strings: listPresentationData.strings) : nil, action: { [weak self] peer in self?.listNode.clearHighlightAnimated(true) if case let .peer(peer?, _) = peer { peerSelected(peer) } }, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer ) case let .message(message): var effectiveAuthor: EnginePeer? if let forwardInfo = message.forwardInfo { effectiveAuthor = forwardInfo.author.flatMap(EnginePeer.init) if effectiveAuthor == nil, let authorSignature = forwardInfo.authorSignature { effectiveAuthor = EnginePeer(TelegramUser(id: PeerId(namespace: Namespaces.Peer.Empty, id: PeerId.Id._internalFromInt64Value(Int64(authorSignature.persistentHashValue % 32))), accessHash: nil, firstName: authorSignature, lastName: nil, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil)) } } if let sourceAuthorInfo = message._asMessage().sourceAuthorInfo { if let originalAuthor = sourceAuthorInfo.originalAuthor, let peer = message.peers[originalAuthor] { effectiveAuthor = EnginePeer(peer) } else if let authorSignature = sourceAuthorInfo.originalAuthorName { effectiveAuthor = EnginePeer(TelegramUser(id: PeerId(namespace: Namespaces.Peer.Empty, id: PeerId.Id._internalFromInt64Value(Int64(authorSignature.persistentHashValue % 32))), accessHash: nil, firstName: authorSignature, lastName: nil, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil)) } } if effectiveAuthor == nil { effectiveAuthor = message.author } let renderedPeer: EngineRenderedPeer if let effectiveAuthor { renderedPeer = EngineRenderedPeer(peer: effectiveAuthor) } else { renderedPeer = EngineRenderedPeer(peerId: message.id.peerId, peers: [:], associatedMedia: [:]) } return ChatListItem( presentationData: chatListPresentationData, context: component.context, chatListLocation: component.peerId == component.context.account.peerId ? .savedMessagesChats : .chatList(groupId: .root), filterData: nil, index: .forum( pinnedIndex: .none, timestamp: message.timestamp, threadId: message.threadId ?? component.context.account.peerId.toInt64(), namespace: message.id.namespace, id: message.id.id ), content: .peer(ChatListItemContent.PeerData( messages: [message], peer: renderedPeer, threadInfo: nil, combinedReadState: nil, isRemovedFromTotalUnreadCount: false, presence: nil, hasUnseenMentions: false, hasUnseenReactions: false, draftState: nil, mediaDraftContentType: nil, inputActivities: nil, promoInfo: nil, ignoreUnreadBadge: false, displayAsMessage: component.peerId != component.context.account.peerId, hasFailedMessages: false, forumTopicData: nil, topForumTopicItems: [], autoremoveTimeout: nil, storyState: nil, requiresPremiumForMessaging: false, displayAsTopicList: false, tags: [] )), editing: false, hasActiveRevealControls: false, selected: false, header: displayMessagesHeader ? ChatListSearchItemHeader(type: .messages(location: nil), theme: listPresentationData.theme, strings: listPresentationData.strings) : nil, enableContextActions: false, hiddenOffset: false, interaction: chatListNodeInteraction ) } } var scrollToItem: ListViewScrollToItem? if previousContentsState?.contentId != contentsState.contentId && !contentsState.entries.isEmpty { scrollToItem = ListViewScrollToItem( index: 0, position: .top(0.0), animated: false, curve: .Default(duration: nil), directionHint: .Up ) } self.listNode.transaction( deleteIndices: deleteIndices.map { index in return ListViewDeleteItem(index: index, directionHint: nil) }, insertIndicesAndItems: indicesAndItems.map { index, item, previousIndex in return ListViewInsertItem( index: index, previousIndex: previousIndex, item: entryToItem(item), directionHint: nil, forceAnimateInsertion: false ) }, updateIndicesAndItems: updateIndices.map { index, item, previousIndex in return ListViewUpdateItem( index: index, previousIndex: previousIndex, item: entryToItem(item), directionHint: nil ) }, options: [.Synchronous, .LowLatency, .PreferSynchronousDrawing, .PreferSynchronousResourceLoading], scrollToItem: scrollToItem, updateSizeAndInsets: nil, updateOpaqueState: contentsState.id ) switch component.contents { case .empty: self.backgroundColor = nil default: self.backgroundColor = component.presentation.theme.list.plainBackgroundColor } } return availableSize } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }