mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
425 lines
24 KiB
Swift
425 lines
24 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Postbox
|
|
import TelegramCore
|
|
import Display
|
|
import SwiftSignalKit
|
|
import TelegramPresentationData
|
|
import TelegramUIPreferences
|
|
import MergeLists
|
|
import AccountContext
|
|
import StickerPackPreviewUI
|
|
import StickerPeekUI
|
|
import ContextUI
|
|
import ChatPresentationInterfaceState
|
|
import UndoUI
|
|
import PremiumUI
|
|
import ChatControllerInteraction
|
|
|
|
private struct ChatContextResultStableId: Hashable {
|
|
let result: ChatContextResult
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(result.id.hashValue)
|
|
}
|
|
|
|
static func ==(lhs: ChatContextResultStableId, rhs: ChatContextResultStableId) -> Bool {
|
|
return lhs.result == rhs.result
|
|
}
|
|
}
|
|
|
|
private struct HorizontalListContextResultsChatInputContextPanelEntry: Comparable, Identifiable {
|
|
let index: Int
|
|
let theme: PresentationTheme
|
|
let result: ChatContextResult
|
|
|
|
var stableId: ChatContextResultStableId {
|
|
return ChatContextResultStableId(result: self.result)
|
|
}
|
|
|
|
static func ==(lhs: HorizontalListContextResultsChatInputContextPanelEntry, rhs: HorizontalListContextResultsChatInputContextPanelEntry) -> Bool {
|
|
return lhs.index == rhs.index && lhs.theme === rhs.theme && lhs.result == rhs.result
|
|
}
|
|
|
|
static func <(lhs: HorizontalListContextResultsChatInputContextPanelEntry, rhs: HorizontalListContextResultsChatInputContextPanelEntry) -> Bool {
|
|
return lhs.index < rhs.index
|
|
}
|
|
|
|
func item(context: AccountContext, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> ListViewItem {
|
|
return HorizontalListContextResultsChatInputPanelItem(context: context, theme: self.theme, result: self.result, resultSelected: resultSelected)
|
|
}
|
|
}
|
|
|
|
private struct HorizontalListContextResultsChatInputContextPanelTransition {
|
|
let deletions: [ListViewDeleteItem]
|
|
let insertions: [ListViewInsertItem]
|
|
let updates: [ListViewUpdateItem]
|
|
let entryCount: Int
|
|
let hasMore: Bool
|
|
}
|
|
|
|
private final class HorizontalListContextResultsOpaqueState {
|
|
let entryCount: Int
|
|
let hasMore: Bool
|
|
|
|
init(entryCount: Int, hasMore: Bool) {
|
|
self.entryCount = entryCount
|
|
self.hasMore = hasMore
|
|
}
|
|
}
|
|
|
|
private func preparedTransition(from fromEntries: [HorizontalListContextResultsChatInputContextPanelEntry], to toEntries: [HorizontalListContextResultsChatInputContextPanelEntry], hasMore: Bool, context: AccountContext, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> HorizontalListContextResultsChatInputContextPanelTransition {
|
|
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, resultSelected: resultSelected), directionHint: nil) }
|
|
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, resultSelected: resultSelected), directionHint: nil) }
|
|
|
|
return HorizontalListContextResultsChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates, entryCount: toEntries.count, hasMore: hasMore)
|
|
}
|
|
|
|
final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputContextPanelNode {
|
|
private var strings: PresentationStrings
|
|
|
|
private let listView: ListView
|
|
private let separatorNode: ASDisplayNode
|
|
private var currentExternalResults: ChatContextResultCollection?
|
|
private var currentProcessedResults: ChatContextResultCollection?
|
|
private var currentEntries: [HorizontalListContextResultsChatInputContextPanelEntry]?
|
|
private var isLoadingMore = false
|
|
private let loadMoreDisposable = MetaDisposable()
|
|
|
|
private var enqueuedTransitions: [(HorizontalListContextResultsChatInputContextPanelTransition, Bool)] = []
|
|
private var hasValidLayout = false
|
|
|
|
override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) {
|
|
self.strings = strings
|
|
|
|
self.separatorNode = ASDisplayNode()
|
|
self.separatorNode.isLayerBacked = true
|
|
self.separatorNode.backgroundColor = theme.list.itemPlainSeparatorColor
|
|
self.separatorNode.isHidden = true
|
|
|
|
self.listView = ListView()
|
|
self.listView.isOpaque = true
|
|
self.listView.backgroundColor = theme.list.plainBackgroundColor
|
|
self.listView.transform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0)
|
|
self.listView.isHidden = 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.addSubnode(self.separatorNode)
|
|
|
|
self.listView.displayedItemRangeChanged = { [weak self] displayedRange, opaqueTransactionState in
|
|
if let strongSelf = self, let state = opaqueTransactionState as? HorizontalListContextResultsOpaqueState {
|
|
if let visible = displayedRange.visibleRange {
|
|
if state.hasMore && visible.lastIndex >= state.entryCount - 10 {
|
|
strongSelf.loadMore()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
self.loadMoreDisposable.dispose()
|
|
}
|
|
|
|
override func didLoad() {
|
|
super.didLoad()
|
|
|
|
self.listView.view.disablesInteractiveTransitionGestureRecognizer = true
|
|
self.listView.view.disablesInteractiveKeyboardGestureRecognizer = true
|
|
self.view.addGestureRecognizer(PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point in
|
|
if let strongSelf = self {
|
|
let convertedPoint = strongSelf.listView.view.convert(point, from: strongSelf.view)
|
|
|
|
if !strongSelf.listView.bounds.contains(convertedPoint) {
|
|
return nil
|
|
}
|
|
|
|
var selectedItemNodeAndContent: (UIView, CGRect, PeekControllerContent)?
|
|
selectedItemNodeAndContent = nil
|
|
strongSelf.listView.forEachItemNode { itemNode in
|
|
if itemNode.frame.contains(convertedPoint), let itemNode = itemNode as? HorizontalListContextResultsChatInputPanelItemNode, let item = itemNode.item {
|
|
if case let .internalReference(internalReference) = item.result, let file = internalReference.file, file.isSticker {
|
|
var menuItems: [ContextMenuItem] = []
|
|
menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.StickerPack_Send, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Resend"), color: theme.contextMenu.primaryColor) }, action: { _, f in
|
|
f(.default)
|
|
|
|
let _ = item.resultSelected(item.result, itemNode, itemNode.bounds)
|
|
})))
|
|
for case let .Sticker(_, packReference, _) in file.attributes {
|
|
guard let packReference = packReference else {
|
|
continue
|
|
}
|
|
menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.StickerPack_ViewPack, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Sticker"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
|
|
f(.default)
|
|
|
|
if let strongSelf = self {
|
|
let controller = StickerPackScreen(context: strongSelf.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: strongSelf.interfaceInteraction?.getNavigationController(), sendSticker: { file, sourceView, sourceRect in
|
|
if let strongSelf = self {
|
|
return strongSelf.interfaceInteraction?.sendSticker(file, false, sourceView, sourceRect, nil, []) ?? false
|
|
} else {
|
|
return false
|
|
}
|
|
})
|
|
|
|
strongSelf.interfaceInteraction?.getNavigationController()?.view.window?.endEditing(true)
|
|
strongSelf.interfaceInteraction?.presentController(controller, nil)
|
|
}
|
|
})))
|
|
}
|
|
selectedItemNodeAndContent = (itemNode.view, itemNode.bounds, StickerPreviewPeekContent(context: item.context, theme: strongSelf.theme, strings: strongSelf.strings, item: .found(FoundStickerItem(file: file, stringRepresentations: [])), menu: menuItems, openPremiumIntro: { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
let controller = PremiumIntroScreen(context: strongSelf.context, source: .stickers)
|
|
strongSelf.interfaceInteraction?.getNavigationController()?.pushViewController(controller)
|
|
}))
|
|
} else {
|
|
var menuItems: [ContextMenuItem] = []
|
|
if case let .internalReference(internalReference) = item.result, let file = internalReference.file, file.isAnimated {
|
|
menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.Preview_SaveGif, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.actionSheet.primaryTextColor)
|
|
}, action: { _, f in
|
|
f(.dismissWithoutContent)
|
|
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
|
|
let context = strongSelf.context
|
|
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
|
let interfaceInteraction = strongSelf.interfaceInteraction
|
|
let _ = (toggleGifSaved(account: context.account, fileReference: .standalone(media: file), saved: true)
|
|
|> deliverOnMainQueue).start(next: { result in
|
|
switch result {
|
|
case .generic:
|
|
interfaceInteraction?.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: nil, text: presentationData.strings.Gallery_GifSaved, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil)
|
|
case let .limitExceeded(limit, premiumLimit):
|
|
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
|
|
let text: String
|
|
if limit == premiumLimit || premiumConfiguration.isPremiumDisabled {
|
|
text = presentationData.strings.Premium_MaxSavedGifsFinalText
|
|
} else {
|
|
text = presentationData.strings.Premium_MaxSavedGifsText("\(premiumLimit)").string
|
|
}
|
|
interfaceInteraction?.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: presentationData.strings.Premium_MaxSavedGifsTitle("\(limit)").string, text: text, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { action in
|
|
if case .info = action {
|
|
let controller = PremiumIntroScreen(context: context, source: .savedGifs)
|
|
interfaceInteraction?.getNavigationController()?.pushViewController(controller)
|
|
return true
|
|
}
|
|
return false
|
|
}), nil)
|
|
}
|
|
})
|
|
})))
|
|
}
|
|
menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.ShareMenu_Send, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Resend"), color: theme.actionSheet.primaryTextColor)
|
|
}, action: { _, f in
|
|
f(.default)
|
|
let _ = item.resultSelected(item.result, itemNode, itemNode.bounds)
|
|
})))
|
|
selectedItemNodeAndContent = (itemNode.view, itemNode.bounds, ChatContextResultPeekContent(account: item.context.account, contextResult: item.result, menu: menuItems))
|
|
}
|
|
}
|
|
}
|
|
return .single(selectedItemNodeAndContent)
|
|
}
|
|
return nil
|
|
}, present: { [weak self] content, sourceView, sourceRect in
|
|
if let strongSelf = self {
|
|
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
|
|
let controller = PeekController(presentationData: presentationData, content: content, sourceView: {
|
|
return (sourceView, sourceRect)
|
|
})
|
|
strongSelf.interfaceInteraction?.presentGlobalOverlayController(controller, nil)
|
|
return controller
|
|
}
|
|
return nil
|
|
}))
|
|
}
|
|
|
|
func updateResults(_ results: ChatContextResultCollection) {
|
|
if self.currentExternalResults == results {
|
|
return
|
|
}
|
|
self.currentExternalResults = results
|
|
self.currentProcessedResults = results
|
|
|
|
self.isLoadingMore = false
|
|
self.loadMoreDisposable.set(nil)
|
|
self.updateInternalResults(results)
|
|
}
|
|
|
|
private func loadMore() {
|
|
guard !self.isLoadingMore, let currentProcessedResults = self.currentProcessedResults, let nextOffset = currentProcessedResults.nextOffset else {
|
|
return
|
|
}
|
|
self.isLoadingMore = true
|
|
let geoPoint = currentProcessedResults.geoPoint.flatMap { geoPoint -> (Double, Double) in
|
|
return (geoPoint.latitude, geoPoint.longitude)
|
|
}
|
|
self.loadMoreDisposable.set((self.context.engine.messages.requestChatContextResults(botId: currentProcessedResults.botId, peerId: currentProcessedResults.peerId, query: currentProcessedResults.query, location: .single(geoPoint), offset: nextOffset)
|
|
|> map { results -> ChatContextResultCollection? in
|
|
return results?.results
|
|
}
|
|
|> deliverOnMainQueue).start(next: { [weak self] nextResults in
|
|
guard let strongSelf = self, let nextResults = nextResults else {
|
|
return
|
|
}
|
|
strongSelf.isLoadingMore = false
|
|
var results: [ChatContextResult] = []
|
|
var existingIds = Set<String>()
|
|
for result in currentProcessedResults.results {
|
|
results.append(result)
|
|
existingIds.insert(result.id)
|
|
}
|
|
for result in nextResults.results {
|
|
if !existingIds.contains(result.id) {
|
|
results.append(result)
|
|
existingIds.insert(result.id)
|
|
}
|
|
}
|
|
let mergedResults = ChatContextResultCollection(botId: currentProcessedResults.botId, peerId: currentProcessedResults.peerId, query: currentProcessedResults.query, geoPoint: currentProcessedResults.geoPoint, queryId: nextResults.queryId, nextOffset: nextResults.nextOffset, presentation: currentProcessedResults.presentation, switchPeer: currentProcessedResults.switchPeer, webView: currentProcessedResults.webView, results: results, cacheTimeout: currentProcessedResults.cacheTimeout)
|
|
strongSelf.currentProcessedResults = mergedResults
|
|
strongSelf.updateInternalResults(mergedResults)
|
|
}))
|
|
}
|
|
|
|
private func updateInternalResults(_ results: ChatContextResultCollection) {
|
|
var entries: [HorizontalListContextResultsChatInputContextPanelEntry] = []
|
|
var index = 0
|
|
var resultIds = Set<ChatContextResultStableId>()
|
|
for result in results.results {
|
|
let entry = HorizontalListContextResultsChatInputContextPanelEntry(index: index, theme: self.theme, result: result)
|
|
if resultIds.contains(entry.stableId) {
|
|
continue
|
|
} else {
|
|
resultIds.insert(entry.stableId)
|
|
}
|
|
entries.append(entry)
|
|
index += 1
|
|
}
|
|
|
|
let firstTime = self.currentEntries == nil
|
|
let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, hasMore: results.nextOffset != nil, context: self.context, resultSelected: { [weak self] result, node, rect in
|
|
if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction {
|
|
return interfaceInteraction.sendContextResult(results, result, node, rect)
|
|
} else {
|
|
return false
|
|
}
|
|
})
|
|
self.currentEntries = entries
|
|
self.enqueueTransition(transition, firstTime: firstTime)
|
|
}
|
|
|
|
private func enqueueTransition(_ transition: HorizontalListContextResultsChatInputContextPanelTransition, firstTime: Bool) {
|
|
enqueuedTransitions.append((transition, firstTime))
|
|
|
|
if self.hasValidLayout {
|
|
while !self.enqueuedTransitions.isEmpty {
|
|
self.dequeueTransition()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func dequeueTransition() {
|
|
if let (transition, firstTime) = self.enqueuedTransitions.first {
|
|
self.enqueuedTransitions.remove(at: 0)
|
|
|
|
var options = ListViewDeleteAndInsertOptions()
|
|
options.insert(.Synchronous)
|
|
options.insert(.LowLatency)
|
|
options.insert(.PreferSynchronousResourceLoading)
|
|
if firstTime {
|
|
//options.insert(.Synchronous)
|
|
//options.insert(.LowLatency)
|
|
} else {
|
|
//options.insert(.AnimateTopItemPosition)
|
|
//options.insert(.AnimateCrossfade)
|
|
}
|
|
|
|
self.listView.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: HorizontalListContextResultsOpaqueState(entryCount: transition.entryCount, hasMore: transition.hasMore), completion: { [weak self] _ in
|
|
if let strongSelf = self, firstTime {
|
|
let position = strongSelf.listView.position
|
|
let separatorPosition = strongSelf.separatorNode.layer.position
|
|
|
|
strongSelf.listView.isHidden = false
|
|
strongSelf.separatorNode.isHidden = false
|
|
|
|
strongSelf.listView.position = CGPoint(x: position.x, y: position.y + strongSelf.listView.bounds.size.width)
|
|
strongSelf.separatorNode.position = CGPoint(x: separatorPosition.x, y: separatorPosition.y + strongSelf.listView.bounds.size.width)
|
|
ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring).animateView {
|
|
strongSelf.listView.position = position
|
|
strongSelf.separatorNode.position = separatorPosition
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) {
|
|
let listHeight: CGFloat = 105.0
|
|
|
|
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - listHeight), size: CGSize(width: size.width, height: UIScreenPixel)))
|
|
self.listView.bounds = CGRect(x: 0.0, y: 0.0, width: listHeight, height: size.width)
|
|
|
|
//transition.updateFrame(node: self.listView, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height))
|
|
|
|
transition.updatePosition(node: self.listView, position: CGPoint(x: size.width / 2.0, y: size.height - listHeight / 2.0))
|
|
|
|
var insets = UIEdgeInsets()
|
|
insets.top = leftInset
|
|
insets.bottom = rightInset
|
|
|
|
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
|
|
let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: CGSize(width: listHeight, height: size.width), 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 !hasValidLayout {
|
|
hasValidLayout = true
|
|
while !self.enqueuedTransitions.isEmpty {
|
|
self.dequeueTransition()
|
|
}
|
|
}
|
|
|
|
if self.theme !== interfaceState.theme {
|
|
self.theme = interfaceState.theme
|
|
self.separatorNode.backgroundColor = theme.list.itemPlainSeparatorColor
|
|
self.listView.backgroundColor = theme.list.plainBackgroundColor
|
|
}
|
|
}
|
|
|
|
override func animateOut(completion: @escaping () -> Void) {
|
|
let position = self.listView.layer.position
|
|
self.listView.layer.animatePosition(from: position, to: CGPoint(x: position.x, y: position.y + self.listView.bounds.size.width), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
|
|
completion()
|
|
})
|
|
let separatorPosition = self.separatorNode.layer.position
|
|
self.separatorNode.layer.animatePosition(from: separatorPosition, to: CGPoint(x: separatorPosition.x, y: separatorPosition.y + listView.bounds.size.width), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
let listViewBounds = self.listView.bounds
|
|
let listViewPosition = self.listView.position
|
|
let listViewFrame = CGRect(origin: CGPoint(x: listViewPosition.x - listViewBounds.height / 2.0, y: listViewPosition.y - listViewBounds.width / 2.0), size: CGSize(width: listViewBounds.height, height: listViewBounds.width))
|
|
if !listViewFrame.contains(point) {
|
|
return nil
|
|
}
|
|
return super.hitTest(point, with: event)
|
|
}
|
|
}
|