mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
277 lines
13 KiB
Swift
277 lines
13 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Postbox
|
|
import SwiftSignalKit
|
|
import TelegramCore
|
|
import Display
|
|
import TelegramPresentationData
|
|
import TelegramUIPreferences
|
|
import MergeLists
|
|
import AccountContext
|
|
import ChatPresentationInterfaceState
|
|
import ChatControllerInteraction
|
|
import ChatContextQuery
|
|
import ChatInputContextPanelNode
|
|
|
|
private struct CommandMenuChatInputContextPanelEntryStableId: Hashable {
|
|
let command: PeerCommand
|
|
}
|
|
|
|
private struct CommandMenuChatInputContextPanelEntry: Comparable, Identifiable {
|
|
let index: Int
|
|
let command: PeerCommand
|
|
let theme: PresentationTheme
|
|
|
|
var stableId: CommandMenuChatInputContextPanelEntryStableId {
|
|
return CommandMenuChatInputContextPanelEntryStableId(command: self.command)
|
|
}
|
|
|
|
func withUpdatedTheme(_ theme: PresentationTheme) -> CommandMenuChatInputContextPanelEntry {
|
|
return CommandMenuChatInputContextPanelEntry(index: self.index, command: self.command, theme: theme)
|
|
}
|
|
|
|
static func ==(lhs: CommandMenuChatInputContextPanelEntry, rhs: CommandMenuChatInputContextPanelEntry) -> Bool {
|
|
return lhs.index == rhs.index && lhs.command == rhs.command && lhs.theme === rhs.theme
|
|
}
|
|
|
|
static func <(lhs: CommandMenuChatInputContextPanelEntry, rhs: CommandMenuChatInputContextPanelEntry) -> Bool {
|
|
return lhs.index < rhs.index
|
|
}
|
|
|
|
func item(context: AccountContext, fontSize: PresentationFontSize, commandSelected: @escaping (PeerCommand, Bool) -> Void) -> ListViewItem {
|
|
return CommandMenuChatInputPanelItem(context: context, theme: self.theme, fontSize: fontSize, command: self.command, commandSelected: commandSelected)
|
|
}
|
|
}
|
|
|
|
private struct CommandMenuChatInputContextPanelTransition {
|
|
let deletions: [ListViewDeleteItem]
|
|
let insertions: [ListViewInsertItem]
|
|
let updates: [ListViewUpdateItem]
|
|
}
|
|
|
|
private func preparedTransition(from fromEntries: [CommandMenuChatInputContextPanelEntry], to toEntries: [CommandMenuChatInputContextPanelEntry], context: AccountContext, fontSize: PresentationFontSize, commandSelected: @escaping (PeerCommand, Bool) -> Void) -> CommandMenuChatInputContextPanelTransition {
|
|
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, fontSize: fontSize, commandSelected: commandSelected), directionHint: nil) }
|
|
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, fontSize: fontSize, commandSelected: commandSelected), directionHint: nil) }
|
|
|
|
return CommandMenuChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates)
|
|
}
|
|
|
|
final class CommandMenuChatInputContextPanelNode: ChatInputContextPanelNode {
|
|
private let listView: ListView
|
|
private var currentEntries: [CommandMenuChatInputContextPanelEntry]?
|
|
|
|
private var enqueuedTransitions: [(CommandMenuChatInputContextPanelTransition, Bool)] = []
|
|
private var validLayout: (CGSize, CGFloat, CGFloat, CGFloat)?
|
|
|
|
private let disposable = MetaDisposable()
|
|
|
|
init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, peerId: PeerId, chatPresentationContext: ChatPresentationContext) {
|
|
self.listView = ListView()
|
|
self.listView.clipsToBounds = false
|
|
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)
|
|
|
|
self.disposable.set((context.engine.peers.peerCommands(id: peerId)
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] results in
|
|
if let strongSelf = self {
|
|
strongSelf.updateResults(results.commands)
|
|
}
|
|
}))
|
|
}
|
|
|
|
deinit {
|
|
self.disposable.dispose()
|
|
}
|
|
|
|
func updateResults(_ results: [PeerCommand]) {
|
|
var entries: [CommandMenuChatInputContextPanelEntry] = []
|
|
var index = 0
|
|
var stableIds = Set<CommandMenuChatInputContextPanelEntryStableId>()
|
|
for command in results {
|
|
let entry = CommandMenuChatInputContextPanelEntry(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: [CommandMenuChatInputContextPanelEntry]? , to: [CommandMenuChatInputContextPanelEntry]) {
|
|
let firstTime = self.currentEntries == nil
|
|
let transition = preparedTransition(from: from ?? [], to: to, context: self.context, fontSize: self.fontSize, 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.updateShowCommands { _ in return false }
|
|
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)
|
|
} else {
|
|
let inputText = NSMutableAttributedString(string: "/" + command.command.text + " ")
|
|
let selectionPosition = (inputText.string as NSString).length + 1
|
|
return (ChatTextInputState(inputText: inputText, selectionRange: selectionPosition ..< selectionPosition), inputMode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
self.currentEntries = to
|
|
self.enqueueTransition(transition, firstTime: firstTime)
|
|
}
|
|
|
|
private func enqueueTransition(_ transition: CommandMenuChatInputContextPanelTransition, 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 * 4.7)
|
|
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)}) ?? []
|
|
self.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
|
|
}
|
|
}
|