Files
Swiftgram/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift
Ilya Laktyushin 6b6ba070c7 Update API
2026-03-26 09:26:15 +01:00

1167 lines
54 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import ItemListUI
import PresentationDataUtils
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import MergeLists
import ChatListSearchItemHeader
import ItemListUI
import SearchUI
import ContextUI
import ListMessageItem
import ComponentFlow
import SearchInputPanelComponent
import ItemListPeerActionItem
import GlassBackgroundComponent
import ActivityIndicator
import SearchBarNode
import PeerMessagesMediaPlaylist
final class AttachmentFileSearchItem: ItemListControllerSearch {
let context: AccountContext
let mode: AttachmentFileControllerMode
let presentationData: PresentationData
let focus: () -> Void
let cancel: () -> Void
let send: (Message) -> Void
let dismissInput: () -> Void
let didPreviewAudio: () -> Void
private var updateActivity: ((Bool) -> Void)?
private var activity: ValuePromise<Bool> = ValuePromise(ignoreRepeated: false)
private let activityDisposable = MetaDisposable()
init(context: AccountContext, mode: AttachmentFileControllerMode, presentationData: PresentationData, focus: @escaping () -> Void, cancel: @escaping () -> Void, send: @escaping (Message) -> Void, dismissInput: @escaping () -> Void, didPreviewAudio: @escaping () -> Void) {
self.context = context
self.mode = mode
self.presentationData = presentationData
self.focus = focus
self.cancel = cancel
self.send = send
self.dismissInput = dismissInput
self.didPreviewAudio = didPreviewAudio
self.activityDisposable.set((activity.get() |> mapToSignal { value -> Signal<Bool, NoError> in
if value {
return .single(value) |> delay(0.2, queue: Queue.mainQueue())
} else {
return .single(value)
}
}).startStrict(next: { [weak self] value in
self?.updateActivity?(value)
}))
}
deinit {
self.activityDisposable.dispose()
}
func isEqual(to: ItemListControllerSearch) -> Bool {
if let to = to as? AttachmentFileSearchItem {
if self.context !== to.context {
return false
}
return true
} else {
return false
}
}
func titleContentNode(current: (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)?) -> (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)? {
switch self.mode {
case .audio:
if let current = current as? AttachmentFileSearchNavigationContentNode {
current.updateTheme(self.presentationData.theme)
return current
} else {
return AttachmentFileSearchNavigationContentNode(theme: self.presentationData.theme, strings: self.presentationData.strings, cancel: self.cancel, focus: self.focus, updateActivity: { _ in
})
}
default:
return nil
}
}
func node(current: ItemListControllerSearchNode?, titleContentNode: (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)?) -> ItemListControllerSearchNode {
return AttachmentFileSearchItemNode(context: self.context, mode: self.mode, presentationData: self.presentationData, focus: self.focus, send: self.send, cancel: self.cancel, updateActivity: { [weak self] value in
self?.activity.set(value)
}, dismissInput: self.dismissInput, didPreviewAudio: self.didPreviewAudio)
}
}
private final class AttachmentFileSearchItemNode: ItemListControllerSearchNode {
private let context: AccountContext
private let mode: AttachmentFileControllerMode
private let presentationData: PresentationData
private let focus: () -> Void
private let cancel: () -> Void
private let containerNode: AttachmentFileSearchContainerNode
private let searchInput = ComponentView<Empty>()
private var validLayout: ContainerViewLayout?
init(context: AccountContext, mode: AttachmentFileControllerMode, presentationData: PresentationData, focus: @escaping () -> Void, send: @escaping (Message) -> Void, cancel: @escaping () -> Void, updateActivity: @escaping(Bool) -> Void, dismissInput: @escaping () -> Void, didPreviewAudio: @escaping () -> Void) {
self.context = context
self.mode = mode
self.presentationData = presentationData
self.focus = focus
self.cancel = cancel
self.containerNode = AttachmentFileSearchContainerNode(context: context, mode: mode, presentationData: presentationData, send: { message in
send(message)
}, updateActivity: updateActivity, didPreviewAudio: didPreviewAudio)
super.init()
self.addedUnderNavigationBar = true
self.addSubnode(self.containerNode)
self.containerNode.cancel = { [weak self] in
dismissInput()
cancel()
self?.deactivateInput()
}
self.containerNode.dismissInput = {
dismissInput()
}
}
override func queryUpdated(_ query: String) {
self.containerNode.searchTextUpdated(text: query)
}
override func scrollToTop() {
self.containerNode.scrollToTop()
}
private func deactivateInput() {
if let layout = self.validLayout, let searchInputView = self.searchInput.view as? SearchInputPanelComponent.View {
let transition = ComponentTransition.spring(duration: 0.4)
transition.setFrame(view: searchInputView, frame: CGRect(origin: CGPoint(x: searchInputView.frame.minX, y: layout.size.height), size: searchInputView.frame.size))
}
}
override func updateLayout(layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.validLayout = layout
transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight)))
self.containerNode.containerLayoutUpdated(layout.withUpdatedSize(CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight)), navigationBarHeight: 0.0, transition: transition)
if case .recent = self.mode {
let searchInputSize = self.searchInput.update(
transition: .immediate,
component: AnyComponent(
SearchInputPanelComponent(
theme: self.presentationData.theme,
strings: self.presentationData.strings,
metrics: layout.metrics,
safeInsets: layout.safeInsets,
placeholder: self.presentationData.strings.Attachment_FilesSearchPlaceholder,
updated: { [weak self] query in
guard let self else {
return
}
self.queryUpdated(query)
},
cancel: { [weak self] in
guard let self else {
return
}
self.cancel()
self.deactivateInput()
}
)
),
environment: {},
containerSize: CGSize(width: layout.size.width, height: layout.size.height)
)
let bottomInset: CGFloat = layout.insets(options: .input).bottom
let searchInputFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - bottomInset - searchInputSize.height), size: searchInputSize)
if let searchInputView = self.searchInput.view as? SearchInputPanelComponent.View {
if searchInputView.superview == nil {
self.view.addSubview(searchInputView)
searchInputView.frame = CGRect(origin: CGPoint(x: searchInputFrame.minX, y: layout.size.height), size: searchInputFrame.size)
self.focus()
searchInputView.activateInput()
}
transition.updateFrame(view: searchInputView, frame: searchInputFrame)
}
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let searchInputView = self.searchInput.view as? SearchInputPanelComponent.View {
if let result = searchInputView.hitTest(self.view.convert(point, to: searchInputView), with: event) {
return result
}
}
if let result = self.containerNode.hitTest(self.view.convert(point, to: self.containerNode.view), with: event) {
return result
}
return super.hitTest(point, with: event)
}
}
private final class AttachmentFileSearchContainerInteraction {
let context: AccountContext
let send: (Message) -> Void
let toggleMediaPlayback: (Message) -> Void
let expandSection: (Int32) -> Void
init(context: AccountContext, send: @escaping (Message) -> Void, toggleMediaPlayback: @escaping (Message) -> Void, expandSection: @escaping (Int32) -> Void) {
self.context = context
self.send = send
self.toggleMediaPlayback = toggleMediaPlayback
self.expandSection = expandSection
}
}
private enum AttachmentFileSearchEntryId: Hashable {
case header(Int32)
case placeholder(Int32, Int32)
case message(Int32, MessageId)
case showMore(Int32)
}
private func areMessagesEqual(_ lhsMessage: Message?, _ rhsMessage: Message?) -> Bool {
guard let lhsMessage = lhsMessage, let rhsMessage = rhsMessage else {
return lhsMessage == nil && rhsMessage == nil
}
if lhsMessage.stableVersion != rhsMessage.stableVersion {
return false
}
if lhsMessage.id != rhsMessage.id || lhsMessage.flags != rhsMessage.flags {
return false
}
return true
}
private enum AttachmentFileSearchEntry: Comparable, Identifiable {
case header(title: String, section: Int32)
case file(index: Int32, message: Message?, section: Int32)
case showMore(text: String, section: Int32)
var section: ItemListSectionId {
switch self {
case let .header(_, section):
return section
case let .file(_, _, section):
return section
case let .showMore(_, section):
return section
}
}
var stableId: AttachmentFileSearchEntryId {
switch self {
case let .header(_, section):
return .header(section)
case let .file(index, message, section):
if let message {
return .message(section, message.id)
} else {
return .placeholder(section, index)
}
case let .showMore(_, section):
return .showMore(section)
}
}
var sortId: Int64 {
switch self {
case let .header(_, section):
return Int64(section) * 100000
case let .file(index, _, section):
return Int64(section) * 100000 + 1 + Int64(index)
case let .showMore(_, section):
return Int64(section + 1) * 100000 - 1
}
}
static func ==(lhs: AttachmentFileSearchEntry, rhs: AttachmentFileSearchEntry) -> Bool {
switch lhs {
case let .header(lhsTitle, lhsSection):
if case let .header(rhsTitle, rhsSection) = rhs, lhsTitle == rhsTitle, lhsSection == rhsSection {
return true
} else {
return false
}
case let .file(lhsIndex, lhsMessage, lhsSection):
if case let .file(rhsIndex, rhsMessage, rhsSection) = rhs, lhsIndex == rhsIndex, areMessagesEqual(lhsMessage, rhsMessage), lhsSection == rhsSection {
return true
} else {
return false
}
case let .showMore(lhsText, lhsSection):
if case let .showMore(rhsText, rhsSection) = rhs, lhsText == rhsText, lhsSection == rhsSection {
return true
} else {
return false
}
}
}
static func <(lhs: AttachmentFileSearchEntry, rhs: AttachmentFileSearchEntry) -> Bool {
return lhs.sortId < rhs.sortId
}
func item(context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, interaction: AttachmentFileSearchContainerInteraction, mode: AttachmentFileControllerMode) -> ListViewItem {
switch self {
case let .header(title, section):
return ItemListSectionHeaderItem(presentationData: ItemListPresentationData(presentationData), text: title, sectionId: section)
case let .file(_, message, section):
let itemInteraction = ListMessageItemInteraction(openMessage: { message, _ in
interaction.send(message)
return false
}, openMessageContextMenu: { _, _, _, _, _ in }, toggleMediaPlayback: { message in
interaction.toggleMediaPlayback(message)
}, toggleMessagesSelection: { _, _ in }, openUrl: { _, _, _, _ in }, openInstantPage: { _, _ in }, longTap: { _, _ in }, getHiddenMedia: { return [:] })
let displayFileInfo = mode.isAudio
let isStoryMusic = mode.isAudio
let isDownloadList = mode.isAudio
return ListMessageItem(presentationData: ChatPresentationData(presentationData: presentationData), systemStyle: .glass, context: interaction.context, chatLocation: .peer(id: PeerId(0)), interaction: itemInteraction, message: message, selection: .none, displayHeader: false, isDownloadList: isDownloadList, isStoryMusic: isStoryMusic, isAttachMusic: true, displayFileInfo: displayFileInfo, displayBackground: true, style: .blocks, sectionId: section)
case let .showMore(text, section):
return ItemListPeerActionItem(presentationData: ItemListPresentationData(presentationData), style: .blocks, systemStyle: .glass, icon: PresentationResourcesItemList.downArrowImage(presentationData.theme), title: text, sectionId: section, editing: false, action: {
interaction.expandSection(section)
})
}
}
}
struct AttachmentFileSearchContainerTransition {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let isSearching: Bool
let isEmpty: Bool
let query: String
let crossfade: Bool
}
private func attachmentFileSearchContainerPreparedRecentTransition(
from fromEntries: [AttachmentFileSearchEntry],
to toEntries: [AttachmentFileSearchEntry],
isSearching: Bool,
isEmpty: Bool,
query: String,
context: AccountContext,
presentationData: PresentationData,
nameSortOrder: PresentationPersonNameOrder,
nameDisplayOrder: PresentationPersonNameOrder,
interaction: AttachmentFileSearchContainerInteraction,
mode: AttachmentFileControllerMode,
crossfade: Bool
) -> AttachmentFileSearchContainerTransition {
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, interaction: interaction, mode: mode), 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, interaction: interaction, mode: mode), directionHint: nil) }
return AttachmentFileSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, isEmpty: isEmpty, query: query, crossfade: crossfade)
}
public final class AttachmentFileSearchContainerNode: SearchDisplayControllerContentNode {
private let context: AccountContext
private let send: (Message) -> Void
private let dimNode: ASDisplayNode
private let backgroundNode: ASDisplayNode
private let listNode: ListView
private let leftOverlayNode: ASDisplayNode
private let rightOverlayNode: ASDisplayNode
private let emptyResultsTitleNode: ImmediateTextNode
private let emptyResultsTextNode: ImmediateTextNode
private var enqueuedTransitions: [(AttachmentFileSearchContainerTransition, Bool)] = []
private var validLayout: (ContainerViewLayout, CGFloat)?
private let searchQuery = Promise<String?>()
private let emptyQueryDisposable = MetaDisposable()
private let searchDisposable = MetaDisposable()
private let savedMusicContext: ProfileSavedMusicContext?
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
private let presentationDataPromise: Promise<PresentationData>
private var expandedSections = Set<Int32>() {
didSet {
self.expandedSectionsPromise.set(self.expandedSections)
}
}
private var expandedSectionsPromise = ValuePromise<Set<Int32>>(Set())
private var _hasDim: Bool = false
override public var hasDim: Bool {
return _hasDim
}
public init(context: AccountContext, mode: AttachmentFileControllerMode, presentationData: PresentationData, send: @escaping (Message) -> Void, updateActivity: @escaping (Bool) -> Void, didPreviewAudio: @escaping () -> Void) {
self.context = context
self.send = send
let savedMusicContext: ProfileSavedMusicContext?
switch mode {
case .audio:
savedMusicContext = ProfileSavedMusicContext(account: context.account, peerId: context.account.peerId)
default:
savedMusicContext = nil
}
self.savedMusicContext = savedMusicContext
self.presentationData = presentationData
self.presentationDataPromise = Promise(self.presentationData)
self.dimNode = ASDisplayNode()
self.dimNode.backgroundColor = .clear
self.backgroundNode = ASDisplayNode()
self.listNode = ListViewImpl()
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
self.leftOverlayNode = ASDisplayNode()
self.leftOverlayNode.isUserInteractionEnabled = false
self.rightOverlayNode = ASDisplayNode()
self.rightOverlayNode.isUserInteractionEnabled = false
self.emptyResultsTitleNode = ImmediateTextNode()
self.emptyResultsTitleNode.displaysAsynchronously = false
self.emptyResultsTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.ChatList_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
super.init()
self.backgroundNode.backgroundColor = self.presentationData.theme.list.blocksBackgroundColor
self.backgroundNode.alpha = 0.0
self.listNode.backgroundColor = self.presentationData.theme.list.blocksBackgroundColor
self.listNode.alpha = 0.0
self.leftOverlayNode.backgroundColor = self.presentationData.theme.list.blocksBackgroundColor
self.rightOverlayNode.backgroundColor = self.presentationData.theme.list.blocksBackgroundColor
self._hasDim = true
self.addSubnode(self.dimNode)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.listNode)
self.addSubnode(self.emptyResultsTitleNode)
self.addSubnode(self.emptyResultsTextNode)
let interaction = AttachmentFileSearchContainerInteraction(
context: context,
send: { [weak self] message in
send(message)
self?.listNode.clearHighlightAnimated(true)
},
toggleMediaPlayback: { message in
didPreviewAudio()
let playlistLocation: PeerMessagesPlaylistLocation = .custom(messages: .single(([message], 0, false)), canReorder: false, at: message.id, loadMore: nil, hidePanel: true)
context.sharedContext.mediaManager.setPlaylist((context, PeerMessagesMediaPlaylist(context: context, location: playlistLocation, chatLocationContextHolder: nil)), type: .music, control: .playback(.togglePlayPause))
},
expandSection: { [weak self] section in
self?.expandedSections.insert(section)
})
let presentationDataPromise = self.presentationDataPromise
let searchQuery = self.searchQuery.get()
|> mapToSignal { query -> Signal<String?, NoError> in
if let query = query, !query.isEmpty {
return (.complete() |> delay(0.6, queue: Queue.mainQueue()))
|> then(.single(query))
} else {
return .single(query)
}
}
let expandedSectionsPromise = self.expandedSectionsPromise
let foundItems = searchQuery
|> mapToSignal { query -> Signal<[AttachmentFileSearchEntry]?, NoError> in
guard let query, !query.isEmpty else {
return .single(nil)
}
let queryTokens = stringTokens(query.lowercased())
let shared: Signal<[Message]?, NoError>
let savedMusic: Signal<[Message]?, NoError>
let globalMusic: Signal<[Message]?, NoError>
switch mode {
case .recent:
shared = .single(nil)
|> then(
context.engine.messages.searchMessages(location: .sentMedia(tags: [.file]), query: query, state: nil)
|> map { result -> [Message]? in
return result.0.messages
}
)
savedMusic = .single(nil)
globalMusic = .single(nil)
case .audio:
shared = .single(nil)
|> then(
context.engine.messages.searchMessages(location: .general(scope: .everywhere, tags: [.music], minDate: nil, maxDate: nil), query: query, state: nil)
|> map { result -> [Message]? in
return result.0.messages
}
)
savedMusic = .single(nil)
|> then(
savedMusicContext!.state
|> map { state in
let peerId = context.account.peerId
var messages: [Message] = []
let peers = SimpleDictionary<PeerId, Peer>()
for file in state.files {
var indexString = ""
for attribute in file.attributes {
if case let .Audio(_, _, title, performer, _) = attribute {
if let title = title?.lowercased() {
indexString += "\(title) "
}
if let performer = performer?.lowercased() {
indexString += "\(performer) "
}
} else if case let .FileName(fileName) = attribute {
indexString += "\(fileName) "
}
}
let tokens = stringTokens(indexString)
guard matchStringTokens(tokens, with: queryTokens) else {
continue
}
let stableId = UInt32(clamping: file.fileId.id % Int64(Int32.max))
messages.append(Message(stableId: stableId, stableVersion: 0, id: MessageId(peerId: peerId, namespace: Namespaces.Message.Local, id: Int32(stableId)), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [.music], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: [file], peers: peers, associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]))
}
return messages
}
)
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
if let data = context.currentAppConfiguration.with({ $0 }).data, let searchBot = data["music_search_username"] as? String, !searchBot.isEmpty, trimmedQuery.count >= 3 {
globalMusic = .single(nil)
|> then(
context.engine.peers.resolvePeerByName(name: searchBot, referrer: nil)
|> mapToSignal { result -> Signal<EnginePeer?, NoError> in
guard case let .result(result) = result else {
return .complete()
}
return .single(result)
}
|> mapToSignal { peer -> Signal<ChatContextResultCollection?, NoError> in
guard let peer = peer else {
return .single(nil)
}
return context.engine.messages.requestChatContextResults(botId: peer.id, peerId: context.account.peerId, query: trimmedQuery, offset: "")
|> map { results -> ChatContextResultCollection? in
return results?.results
}
|> `catch` { error -> Signal<ChatContextResultCollection?, NoError> in
return .single(nil)
}
}
|> map { contextResult in
guard let results = contextResult?.results else {
return []
}
let peerId = context.account.peerId
var messages: [Message] = []
let peers = SimpleDictionary<PeerId, Peer>()
for result in results {
switch result {
case let .internalReference(internalReference):
if let file = internalReference.file {
let stableId = UInt32(clamping: file.fileId.id % Int64(Int32.max))
messages.append(Message(stableId: stableId, stableVersion: 0, id: MessageId(peerId: peerId, namespace: Namespaces.Message.Local, id: Int32(stableId)), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [.music], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: [file], peers: peers, associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]))
}
default:
break
}
}
return messages
}
)
} else {
globalMusic = .single(nil)
}
}
updateActivity(true)
return combineLatest(shared, savedMusic, globalMusic, presentationDataPromise.get(), expandedSectionsPromise.get())
|> mapToSignal { messages, savedMusic, globalMusic, presentationData, expandedSections -> Signal<[AttachmentFileSearchEntry]?, NoError> in
var entries: [AttachmentFileSearchEntry] = []
if let messages {
var section: Int32 = 0
var index: Int32 = 0
if let savedMusic, !savedMusic.isEmpty {
entries.append(.header(title: "PROFILE MUSIC", section: section))
var savedMusic = savedMusic
var hasShowMore = false
if savedMusic.count > 4 && !expandedSections.contains(section) {
savedMusic = Array(savedMusic.prefix(3))
hasShowMore = true
}
for message in savedMusic {
entries.append(.file(index: index, message: message, section: section))
index += 1
}
if hasShowMore {
entries.append(.showMore(text: presentationData.strings.MediaEditor_Audio_ShowMore, section: section))
}
}
index = 0
section += 1
if !messages.isEmpty {
entries.append(.header(title: "YOUR CHATS", section: section))
var messages = messages
var hasShowMore = false
if messages.count > 4 && !expandedSections.contains(section) {
messages = Array(messages.prefix(3))
hasShowMore = true
}
for message in messages {
entries.append(.file(index: index, message: message, section: section))
index += 1
}
if hasShowMore {
entries.append(.showMore(text: presentationData.strings.MediaEditor_Audio_ShowMore, section: section))
}
}
if let globalMusic, !globalMusic.isEmpty {
index = 0
section += 1
entries.append(.header(title: "PUBLIC CHATS", section: section))
for message in globalMusic {
entries.append(.file(index: index, message: message, section: section))
index += 1
}
}
} else {
entries.append(.header(title: " ", section: 0))
var index: Int32 = 0
for _ in 0 ..< 16 {
entries.append(.file(index: index, message: nil, section: 0))
index += 1
}
}
return .single(entries)
}
}
let previousSearchItems = Atomic<[AttachmentFileSearchEntry]?>(value: nil)
let previousHadGlobalItems = Atomic<Bool>(value: false)
self.searchDisposable.set((combineLatest(searchQuery, foundItems, self.presentationDataPromise.get())
|> deliverOnMainQueue).startStrict(next: { [weak self] query, entries, presentationData in
if let strongSelf = self {
let previousEntries = previousSearchItems.swap(entries)
updateActivity(false)
let firstTime = previousEntries == nil
var hasGlobalItems = false
if let entries {
for entry in entries {
if case let .header(_, section) = entry, section == 2 {
hasGlobalItems = true
}
}
}
let hadGlobalItems = previousHadGlobalItems.swap(hasGlobalItems)
let crossfade = hadGlobalItems != hasGlobalItems
let transition = attachmentFileSearchContainerPreparedRecentTransition(from: previousEntries ?? [], to: entries ?? [], isSearching: entries != nil, isEmpty: entries?.isEmpty ?? false, query: query ?? "", context: context, presentationData: presentationData, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, interaction: interaction, mode: mode, crossfade: crossfade)
strongSelf.enqueueTransition(transition, firstTime: firstTime)
}
}))
self.listNode.beganInteractiveDragging = { [weak self] _ in
self?.dismissInput?()
}
self.listNode.itemNodeHitTest = { [weak self] point in
if let strongSelf = self {
return point.x > strongSelf.leftOverlayNode.frame.maxX && point.x < strongSelf.rightOverlayNode.frame.minX
} else {
return true
}
}
}
deinit {
self.searchDisposable.dispose()
self.presentationDataDisposable?.dispose()
}
override public func didLoad() {
super.didLoad()
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
}
private func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) {
self.backgroundNode.backgroundColor = theme.list.blocksBackgroundColor
self.listNode.backgroundColor = theme.list.blocksBackgroundColor
self.leftOverlayNode.backgroundColor = theme.list.blocksBackgroundColor
self.rightOverlayNode.backgroundColor = theme.list.blocksBackgroundColor
}
override public func searchTextUpdated(text: String) {
self.searchQuery.set(.single(!text.isEmpty ? text : nil))
}
private func enqueueTransition(_ transition: AttachmentFileSearchContainerTransition, firstTime: Bool) {
self.enqueuedTransitions.append((transition, firstTime))
if let _ = self.validLayout {
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)
//options.insert(.AnimateInsertion)
if transition.crossfade {
options.insert(.AnimateCrossfade)
}
let isSearching = transition.isSearching
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
}
let containerTransition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut)
containerTransition.updateAlpha(node: strongSelf.backgroundNode, alpha: isSearching ? 1.0 : 0.0)
containerTransition.updateAlpha(node: strongSelf.listNode, alpha: isSearching ? 1.0 : 0.0)
strongSelf.dimNode.isHidden = transition.isSearching
strongSelf.emptyResultsTextNode.attributedText = NSAttributedString(string: strongSelf.presentationData.strings.ChatList_Search_NoResultsQueryDescription(transition.query).string, font: Font.regular(15.0), textColor: strongSelf.presentationData.theme.list.freeTextColor)
let emptyResults = transition.isSearching && transition.isEmpty
strongSelf.emptyResultsTitleNode.isHidden = !emptyResults
strongSelf.emptyResultsTextNode.isHidden = !emptyResults
if let (layout, navigationBarHeight) = strongSelf.validLayout {
strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
})
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
let hadValidLayout = self.validLayout == nil
self.validLayout = (layout, navigationBarHeight)
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
var insets = layout.insets(options: [.input])
insets.top += navigationBarHeight
insets.bottom += 60.0
let inset = max(16.0, floor((layout.size.width - 674.0) / 2.0))
if layout.size.width >= 375.0 {
insets.left += inset
insets.right += inset
}
if self.rightOverlayNode.supernode == nil {
self.insertSubnode(self.rightOverlayNode, aboveSubnode: self.listNode)
}
if self.leftOverlayNode.supernode == nil {
self.insertSubnode(self.leftOverlayNode, aboveSubnode: self.listNode)
}
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.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -66.0), size: CGSize(width: layout.size.width, height: 66.0))
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: insets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
self.leftOverlayNode.frame = CGRect(x: 0.0, y: 0.0, width: insets.left, height: layout.size.height)
self.rightOverlayNode.frame = CGRect(x: layout.size.width - insets.right, y: 0.0, width: insets.right, height: layout.size.height)
let padding: CGFloat = 16.0
let emptyTitleSize = self.emptyResultsTitleNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
let emptyTextSize = self.emptyResultsTextNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
let emptyTextSpacing: CGFloat = 8.0
let emptyTotalHeight = emptyTitleSize.height + emptyTextSize.height + emptyTextSpacing
let emptyTitleY = navigationBarHeight + floorToScreenPixels((layout.size.height - navigationBarHeight - max(insets.bottom, layout.intrinsicInsets.bottom) - emptyTotalHeight) / 2.0)
transition.updateFrame(node: self.emptyResultsTitleNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + padding + (layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0 - emptyTitleSize.width) / 2.0, y: emptyTitleY), size: emptyTitleSize))
transition.updateFrame(node: self.emptyResultsTextNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + padding + (layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0 - emptyTextSize.width) / 2.0, y: emptyTitleY + emptyTitleSize.height + emptyTextSpacing), size: emptyTextSize))
if !hadValidLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
override public func scrollToTop() {
if self.listNode.alpha > 0.0 {
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 })
}
}
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.cancel?()
}
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let result = self.view.hitTest(point, with: event) else {
return nil
}
if result === self.view {
return nil
}
return result
}
}
private func stringTokens(_ string: String) -> [ValueBoxKey] {
let nsString = string.folding(options: .diacriticInsensitive, locale: .current).lowercased() as NSString
let flag = UInt(kCFStringTokenizerUnitWord)
let tokenizer = CFStringTokenizerCreate(kCFAllocatorDefault, nsString, CFRangeMake(0, nsString.length), flag, CFLocaleCopyCurrent())
var tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
var tokens: [ValueBoxKey] = []
var addedTokens = Set<ValueBoxKey>()
while tokenType != [] {
let currentTokenRange = CFStringTokenizerGetCurrentTokenRange(tokenizer)
if currentTokenRange.location >= 0 && currentTokenRange.length != 0 {
let token = ValueBoxKey(length: currentTokenRange.length * 2)
nsString.getCharacters(token.memory.assumingMemoryBound(to: unichar.self), range: NSMakeRange(currentTokenRange.location, currentTokenRange.length))
if !addedTokens.contains(token) {
tokens.append(token)
addedTokens.insert(token)
}
}
tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
}
return tokens
}
private func matchStringTokens(_ tokens: [ValueBoxKey], with other: [ValueBoxKey]) -> Bool {
if other.isEmpty {
return false
} else if other.count == 1 {
let otherToken = other[0]
for token in tokens {
if otherToken.isPrefix(to: token) {
return true
}
}
} else {
for otherToken in other {
var found = false
for token in tokens {
if otherToken.isPrefix(to: token) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
return false
}
private let searchBarFont = Font.regular(17.0)
private final class AttachmentFileSearchNavigationContentNode: NavigationBarContentNode, ItemListControllerSearchNavigationContentNode {
private struct Params: Equatable {
let size: CGSize
let leftInset: CGFloat
let rightInset: CGFloat
init(size: CGSize, leftInset: CGFloat, rightInset: CGFloat) {
self.size = size
self.leftInset = leftInset
self.rightInset = rightInset
}
}
private var theme: PresentationTheme
private let strings: PresentationStrings
private let cancel: () -> Void
private let backgroundContainer: GlassBackgroundContainerView
private let backgroundView: GlassBackgroundView
private let iconView: UIImageView
private var activityIndicator: ActivityIndicator?
private let searchBar: SearchBarNode
private let close: (background: GlassBackgroundView, icon: UIImageView)
private var params: Params?
private var queryUpdated: ((String) -> Void)?
var activity: Bool = false {
didSet {
if self.activity != oldValue {
if let params = self.params {
let _ = self.updateLayout(size: params.size, leftInset: params.leftInset, rightInset: params.rightInset, transition: .immediate)
}
}
}
}
init(theme: PresentationTheme, strings: PresentationStrings, cancel: @escaping () -> Void, focus: @escaping () -> Void, updateActivity: @escaping( @escaping (Bool) -> Void) -> Void) {
self.theme = theme
self.strings = strings
self.cancel = cancel
self.backgroundContainer = GlassBackgroundContainerView()
self.backgroundView = GlassBackgroundView()
self.backgroundContainer.contentView.addSubview(self.backgroundView)
self.iconView = UIImageView()
self.backgroundView.contentView.addSubview(self.iconView)
self.close = (GlassBackgroundView(), UIImageView())
self.close.background.contentView.addSubview(self.close.icon)
self.searchBar = SearchBarNode(
theme: SearchBarNodeTheme(
background: .clear,
separator: .clear,
inputFill: .clear,
primaryText: theme.chat.inputPanel.panelControlColor,
placeholder: theme.chat.inputPanel.inputPlaceholderColor,
inputIcon: theme.chat.inputPanel.inputControlColor,
inputClear: theme.chat.inputPanel.panelControlColor,
accent: theme.chat.inputPanel.panelControlAccentColor,
keyboard: theme.rootController.keyboardColor
),
presentationTheme: theme,
strings: strings,
fieldStyle: .inlineNavigation,
forceSeparator: false,
displayBackground: false,
cancelText: nil
)
super.init()
self.view.addSubview(self.backgroundContainer)
self.backgroundView.contentView.addSubview(self.searchBar.view)
self.backgroundContainer.contentView.addSubview(self.close.background)
self.close.background.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onCloseTapGesture(_:))))
self.searchBar.cancel = { [weak self] in
self?.searchBar.deactivate(clear: false)
self?.cancel()
}
self.searchBar.textUpdated = { [weak self] query, _ in
self?.queryUpdated?(query)
}
self.searchBar.focusUpdated = { hasFocus in
if hasFocus {
focus()
}
}
updateActivity({ [weak self] value in
self?.activity = value
})
self.updatePlaceholder()
}
@objc private func onCloseTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.searchBar.cancel?()
}
}
func setQueryUpdated(_ f: @escaping (String) -> Void) {
self.queryUpdated = f
}
func updateTheme(_ theme: PresentationTheme) {
self.theme = theme
let searchBarTheme = SearchBarNodeTheme(
background: .clear,
separator: .clear,
inputFill: .clear,
primaryText: theme.chat.inputPanel.panelControlColor,
placeholder: theme.chat.inputPanel.inputPlaceholderColor,
inputIcon: theme.chat.inputPanel.inputControlColor,
inputClear: theme.chat.inputPanel.panelControlColor,
accent: theme.chat.inputPanel.panelControlAccentColor,
keyboard: theme.rootController.keyboardColor
)
self.searchBar.updateThemeAndStrings(theme: searchBarTheme, presentationTheme: theme, strings: self.strings)
if let params = self.params {
let _ = self.updateLayout(size: params.size, leftInset: params.leftInset, rightInset: params.rightInset, transition: .immediate)
}
self.updatePlaceholder()
}
func updatePlaceholder() {
let placeholderText: String
placeholderText = self.strings.Common_Search
self.searchBar.placeholderString = NSAttributedString(string: placeholderText, font: searchBarFont, textColor: self.theme.rootController.navigationSearchBar.inputPlaceholderTextColor)
}
override var nominalHeight: CGFloat {
return 68.0
}
override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGSize {
self.params = Params(size: size, leftInset: leftInset, rightInset: rightInset)
let transition = ComponentTransition(transition)
let backgroundFrame = CGRect(origin: CGPoint(x: leftInset + 16.0, y: 6.0), size: CGSize(width: size.width - 16.0 * 2.0 - leftInset - rightInset - 44.0 - 8.0, height: 44.0))
let closeFrame = CGRect(origin: CGPoint(x: size.width - 16.0 - rightInset - 44.0, y: backgroundFrame.minY), size: CGSize(width: 44.0, height: 44.0))
transition.setFrame(view: self.backgroundContainer, frame: CGRect(origin: CGPoint(), size: size))
self.backgroundContainer.update(size: size, isDark: self.theme.overallDarkAppearance, transition: transition)
transition.setFrame(view: self.backgroundView, frame: backgroundFrame)
self.backgroundView.update(size: backgroundFrame.size, cornerRadius: backgroundFrame.height * 0.5, isDark: self.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: true, transition: transition)
if self.iconView.image == nil {
self.iconView.image = UIImage(bundleImageName: "Navigation/Search")?.withRenderingMode(.alwaysTemplate)
}
transition.setTintColor(view: self.iconView, color: self.theme.rootController.navigationSearchBar.inputIconColor)
if let image = self.iconView.image {
let imageSize: CGSize
let iconFrame: CGRect
let iconFraction: CGFloat = 0.8
imageSize = CGSize(width: image.size.width * iconFraction, height: image.size.height * iconFraction)
iconFrame = CGRect(origin: CGPoint(x: 12.0, y: floor((backgroundFrame.height - imageSize.height) * 0.5)), size: imageSize)
transition.setPosition(view: self.iconView, position: iconFrame.center)
transition.setBounds(view: self.iconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size))
}
if self.activity {
let activityIndicator: ActivityIndicator
if let current = self.activityIndicator {
activityIndicator = current
} else {
activityIndicator = ActivityIndicator(type: .custom(self.theme.chat.inputPanel.inputControlColor, 14.0, 14.0, false))
self.activityIndicator = activityIndicator
self.backgroundView.contentView.addSubview(activityIndicator.view)
}
let indicatorSize = activityIndicator.measure(CGSize(width: 32.0, height: 32.0))
let indicatorFrame = CGRect(origin: CGPoint(x: 15.0, y: floorToScreenPixels((backgroundFrame.height - indicatorSize.height) * 0.5)), size: indicatorSize)
transition.setPosition(view: activityIndicator.view, position: indicatorFrame.center)
transition.setBounds(view: activityIndicator.view, bounds: CGRect(origin: CGPoint(), size: indicatorFrame.size))
} else if let activityIndicator = self.activityIndicator {
self.activityIndicator = nil
activityIndicator.view.removeFromSuperview()
}
self.iconView.isHidden = self.activity
let searchBarFrame = CGRect(origin: CGPoint(x: 36.0, y: 0.0), size: CGSize(width: backgroundFrame.width - 36.0 - 4.0, height: 44.0))
transition.setFrame(view: self.searchBar.view, frame: searchBarFrame)
self.searchBar.updateLayout(boundingSize: searchBarFrame.size, leftInset: 0.0, rightInset: 0.0, transition: transition.containedViewLayoutTransition)
if self.close.icon.image == nil {
self.close.icon.image = generateImage(CGSize(width: 40.0, height: 40.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setLineWidth(2.0)
context.setLineCap(.round)
context.setStrokeColor(UIColor.white.cgColor)
context.beginPath()
context.move(to: CGPoint(x: 12.0, y: 12.0))
context.addLine(to: CGPoint(x: size.width - 12.0, y: size.height - 12.0))
context.move(to: CGPoint(x: size.width - 12.0, y: 12.0))
context.addLine(to: CGPoint(x: 12.0, y: size.height - 12.0))
context.strokePath()
})?.withRenderingMode(.alwaysTemplate)
}
if let image = close.icon.image {
self.close.icon.frame = image.size.centered(in: CGRect(origin: CGPoint(), size: closeFrame.size))
}
self.close.icon.tintColor = self.theme.chat.inputPanel.panelControlColor
transition.setFrame(view: self.close.background, frame: closeFrame)
self.close.background.update(size: closeFrame.size, cornerRadius: closeFrame.height * 0.5, isDark: self.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: true, transition: transition)
return size
}
func activate() {
self.searchBar.activate()
}
func deactivate() {
self.searchBar.deactivate(clear: false)
}
}