Swiftgram/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift
2023-06-20 18:05:20 +04:00

260 lines
12 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Postbox
import TelegramCore
import Display
import TelegramPresentationData
import TelegramUIPreferences
import MergeLists
import AccountContext
import ChatPresentationInterfaceState
import ChatControllerInteraction
import ItemListUI
import ChatContextQuery
private struct CommandChatInputContextPanelEntryStableId: Hashable {
let command: PeerCommand
}
private struct CommandChatInputContextPanelEntry: Comparable, Identifiable {
let index: Int
let command: PeerCommand
let theme: PresentationTheme
var stableId: CommandChatInputContextPanelEntryStableId {
return CommandChatInputContextPanelEntryStableId(command: self.command)
}
func withUpdatedTheme(_ theme: PresentationTheme) -> CommandChatInputContextPanelEntry {
return CommandChatInputContextPanelEntry(index: self.index, command: self.command, theme: theme)
}
static func ==(lhs: CommandChatInputContextPanelEntry, rhs: CommandChatInputContextPanelEntry) -> Bool {
return lhs.index == rhs.index && lhs.command == rhs.command && lhs.theme === rhs.theme
}
static func <(lhs: CommandChatInputContextPanelEntry, rhs: CommandChatInputContextPanelEntry) -> Bool {
return lhs.index < rhs.index
}
func item(context: AccountContext, presentationData: PresentationData, commandSelected: @escaping (PeerCommand, Bool) -> Void) -> ListViewItem {
return CommandChatInputPanelItem(context: context, presentationData: ItemListPresentationData(presentationData), command: self.command, commandSelected: commandSelected)
}
}
private struct CommandChatInputContextPanelTransition {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
}
private func preparedTransition(from fromEntries: [CommandChatInputContextPanelEntry], to toEntries: [CommandChatInputContextPanelEntry], context: AccountContext, presentationData: PresentationData, commandSelected: @escaping (PeerCommand, Bool) -> Void) -> CommandChatInputContextPanelTransition {
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, commandSelected: commandSelected), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, commandSelected: commandSelected), directionHint: nil) }
return CommandChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates)
}
final class CommandChatInputContextPanelNode: ChatInputContextPanelNode {
private let listView: ListView
private var currentEntries: [CommandChatInputContextPanelEntry]?
private var enqueuedTransitions: [(CommandChatInputContextPanelTransition, Bool)] = []
private var validLayout: (CGSize, CGFloat, CGFloat, CGFloat)?
override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) {
self.listView = ListView()
self.listView.isOpaque = false
self.listView.stackFromBottom = true
self.listView.keepBottomItemOverscrollBackground = theme.list.plainBackgroundColor
self.listView.limitHitTestToNodes = true
self.listView.view.disablesInteractiveTransitionGestureRecognizer = true
self.listView.accessibilityPageScrolledString = { row, count in
return strings.VoiceOver_ScrollStatus(row, count).string
}
super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext)
self.isOpaque = false
self.clipsToBounds = true
self.addSubnode(self.listView)
}
func updateResults(_ results: [PeerCommand]) {
var entries: [CommandChatInputContextPanelEntry] = []
var index = 0
var stableIds = Set<CommandChatInputContextPanelEntryStableId>()
for command in results {
let entry = CommandChatInputContextPanelEntry(index: index, command: command, theme: self.theme)
if stableIds.contains(entry.stableId) {
continue
}
stableIds.insert(entry.stableId)
entries.append(entry)
index += 1
}
self.prepareTransition(from: self.currentEntries ?? [], to: entries)
}
private func prepareTransition(from: [CommandChatInputContextPanelEntry]? , to: [CommandChatInputContextPanelEntry]) {
let firstTime = self.currentEntries == nil
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let transition = preparedTransition(from: from ?? [], to: to, context: self.context, presentationData: presentationData, commandSelected: { [weak self] command, sendImmediately in
if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction {
if sendImmediately {
interfaceInteraction.sendBotCommand(command.peer, "/" + command.command.text)
} else {
interfaceInteraction.updateTextInputStateAndMode { textInputState, inputMode in
var commandQueryRange: NSRange?
inner: for (range, type, _) in textInputStateContextQueryRangeAndType(textInputState) {
if type == [.command] {
commandQueryRange = range
break inner
}
}
if let range = commandQueryRange {
let inputText = NSMutableAttributedString(attributedString: textInputState.inputText)
let replacementText = command.command.text + " "
inputText.replaceCharacters(in: range, with: replacementText)
let selectionPosition = range.lowerBound + (replacementText as NSString).length
return (ChatTextInputState(inputText: inputText, selectionRange: selectionPosition ..< selectionPosition), inputMode)
}
return (textInputState, inputMode)
}
}
}
})
self.currentEntries = to
self.enqueueTransition(transition, firstTime: firstTime)
}
private func enqueueTransition(_ transition: CommandChatInputContextPanelTransition, firstTime: Bool) {
enqueuedTransitions.append((transition, firstTime))
if self.validLayout != nil {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
if let validLayout = self.validLayout, let (transition, firstTime) = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
if firstTime {
//options.insert(.Synchronous)
//options.insert(.LowLatency)
} else {
options.insert(.AnimateTopItemPosition)
options.insert(.AnimateCrossfade)
}
var insets = UIEdgeInsets()
insets.top = topInsetForLayout(size: validLayout.0)
insets.left = validLayout.1
insets.right = validLayout.2
let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: self.listView.bounds.size, insets: insets, duration: 0.0, curve: .Default(duration: nil))
self.listView.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: updateSizeAndInsets, updateOpaqueState: nil, completion: { [weak self] _ in
if let strongSelf = self, firstTime {
var topItemOffset: CGFloat?
strongSelf.listView.forEachItemNode { itemNode in
if topItemOffset == nil {
topItemOffset = itemNode.frame.minY
}
}
if let topItemOffset = topItemOffset {
let position = strongSelf.listView.layer.position
strongSelf.listView.position = CGPoint(x: position.x, y: position.y + (strongSelf.listView.bounds.size.height - topItemOffset))
ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring).animateView {
strongSelf.listView.position = position
}
}
}
})
}
}
private func topInsetForLayout(size: CGSize) -> CGFloat {
let minimumItemHeights: CGFloat = floor(MentionChatInputPanelItemNode.itemHeight * 3.5)
return max(size.height - minimumItemHeights, 0.0)
}
override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) {
let hadValidLayout = self.validLayout != nil
self.validLayout = (size, leftInset, rightInset, bottomInset)
var insets = UIEdgeInsets()
insets.top = self.topInsetForLayout(size: size)
insets.left = leftInset
insets.right = rightInset
transition.updateFrame(node: self.listView, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height))
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: size, insets: insets, duration: duration, curve: curve)
self.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
if !hadValidLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
if self.theme !== interfaceState.theme {
self.theme = interfaceState.theme
self.listView.keepBottomItemOverscrollBackground = self.theme.list.plainBackgroundColor
let new = self.currentEntries?.map({$0.withUpdatedTheme(interfaceState.theme)}) ?? []
prepareTransition(from: self.currentEntries, to: new)
}
}
override func animateOut(completion: @escaping () -> Void) {
var topItemOffset: CGFloat?
self.listView.forEachItemNode { itemNode in
if topItemOffset == nil {
topItemOffset = itemNode.frame.minY
}
}
if let topItemOffset = topItemOffset {
let position = self.listView.layer.position
self.listView.layer.animatePosition(from: position, to: CGPoint(x: position.x, y: position.y + (self.listView.bounds.size.height - topItemOffset)), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
completion()
})
} else {
completion()
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let listViewFrame = self.listView.frame
return self.listView.hitTest(CGPoint(x: point.x - listViewFrame.minX, y: point.y - listViewFrame.minY), with: event)
}
override var topItemFrame: CGRect? {
var topItemFrame: CGRect?
self.listView.forEachItemNode { itemNode in
if topItemFrame == nil {
topItemFrame = itemNode.frame
}
}
return topItemFrame
}
}