WinterGram/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift
isaac 2ac8a490b0 Postbox -> TelegramEngine waves 94-102 (squashed)
Adds AccountManagerResources facade in TelegramCore mirroring
TelegramEngine.Resources, drains the entire accountManager-side
mediaBox refactor across consumer modules, and validates the
Shape-C/D handle-migration pattern (account: Account ->
engine: TelegramEngine) on three classes.

Wave 94 (AccountManagerResources facade + 8 sites): new
AccountManagerResources class in TelegramCore with
storeResourceData, completedResourcePath (resource + id overloads),
status (with approximateSynchronousValue), moveResourceData,
fetch. Migrate EditThemeController (5 storeResourceData + 2
completedResourcePath) and WebBrowserSettingsController (1
storeResourceData).

Wave 95 (10 sites): WallpaperUtils.swift sweep, 5 distinct
storeResourceData patterns, replace_all on the two repeated ones.

Wave 96 (7 sites): theme controller triple — TPC (1 store + 2
moveResourceData), TPCN (1 status + 1 fetch + closure type
MediaResourceStatus -> EngineMediaResource.FetchStatus + rename
account local to engine), TAC (1 completedResourcePath + 1 store).

Wave 97 (9 sites): WallpaperGalleryController.swift, 4
completedResourcePath + 5 storeResourceData via replace_all.

Wave 98 (2 files): WallpaperGallery status-type cascade
Promise<MediaResourceStatus> -> Promise<EngineMediaResource.FetchStatus>
across WallpaperGalleryItem.swift + WallpaperGalleryController.swift.
Drops cosmetic `_asStatus()` unwrap.

Wave 99 (2 facade extensions + 7 sites): adds
AccountManagerResources.data() and approximateSynchronousValue
to engine.resources.status(). Migrates OpenResolvedUrl (data +
storeResourceData with .complete -> .isComplete cascade),
SettingsThemeWallpaperNode (paired engine + accountManager
status), WallpaperUploadManager, OpenChatMessage,
LiveChatReactionStreamView.

Wave 100 (Shape-C/D, free function): legacyWebSearchItem
account: Account -> engine: TelegramEngine. 4 files.

Wave 101 (Shape-C/D, small class): VerticalListContextResults
ChatInputPanelItem stored field migration. 2 files. Drops
`|> map(EngineMediaResource.FetchStatus.init)` redundancy.

Wave 102 (Shape-C/D, medium class with weak-context retention):
SharedMediaPlayer account: Account -> engine: TelegramEngine.
Engine field is intentionally a strong handle since the class
stores context as `weak var`. 2 files.

After this squash the accountManager-side mediaBox refactor is
essentially complete; only Postbox-protocol-blocked
cachedResourceRepresentation triple remains as the foundational
deferred item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:46:44 +04:00

467 lines
22 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import TelegramCore
import Display
import TelegramPresentationData
import TelegramUIPreferences
import MergeLists
import AccountContext
import SwiftSignalKit
import ChatPresentationInterfaceState
import ChatControllerInteraction
import ChatInputContextPanelNode
import ComponentFlow
import ComponentDisplayAdapters
import GlassBackgroundComponent
import EdgeEffect
private enum VerticalChatContextResultsEntryStableId: Hashable {
case action
case result(ChatContextResult)
func hash(into hasher: inout Hasher) {
switch self {
case .action:
hasher.combine(0)
case let .result(result):
hasher.combine(result.id.hashValue)
}
}
static func ==(lhs: VerticalChatContextResultsEntryStableId, rhs: VerticalChatContextResultsEntryStableId) -> Bool {
switch lhs {
case .action:
if case .action = rhs {
return true
} else {
return false
}
case let .result(lhsResult):
if case let .result(rhsResult) = rhs, lhsResult == rhsResult {
return true
} else {
return false
}
}
}
}
private enum VerticalListContextResultsChatInputContextPanelEntry: Comparable, Identifiable {
case action(PresentationTheme, String)
case result(Int, PresentationTheme, ChatContextResult)
func withUpdatedTheme(_ theme: PresentationTheme) -> VerticalListContextResultsChatInputContextPanelEntry {
switch self {
case let .action(_, value):
return .action(theme, value)
case let .result(index, _, result):
return .result(index, theme, result)
}
}
var stableId: VerticalChatContextResultsEntryStableId {
switch self {
case .action:
return .action
case let .result(_, _, result):
return .result(result)
}
}
static func ==(lhs: VerticalListContextResultsChatInputContextPanelEntry, rhs: VerticalListContextResultsChatInputContextPanelEntry) -> Bool {
switch lhs {
case let .action(lhsTheme, lhsTitle):
if case let .action(rhsTheme, rhsTitle) = rhs, lhsTheme === rhsTheme && lhsTitle == rhsTitle {
return true
} else {
return false
}
case let .result(lhsIndex, lhsTheme, lhsResult):
if case let .result(rhsIndex, rhsTheme, rhsResult) = rhs, lhsIndex == rhsIndex, lhsTheme === rhsTheme, lhsResult == rhsResult {
return true
} else {
return false
}
}
}
static func <(lhs: VerticalListContextResultsChatInputContextPanelEntry, rhs: VerticalListContextResultsChatInputContextPanelEntry) -> Bool {
switch lhs {
case .action:
return true
case let .result(index, _, _):
switch rhs {
case .action:
return false
case let .result(rhsIndex, _, _):
return index < rhsIndex
}
}
}
func item(engine: TelegramEngine, actionSelected: @escaping () -> Void, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> ListViewItem {
switch self {
case let .action(theme, title):
return VerticalListContextResultsChatInputPanelButtonItem(theme: theme, title: title, pressed: actionSelected)
case let .result(_, theme, result):
return VerticalListContextResultsChatInputPanelItem(engine: engine, theme: theme, result: result, resultSelected: resultSelected)
}
}
}
private struct VerticalListContextResultsChatInputContextPanelTransition {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
}
private func preparedTransition(from fromEntries: [VerticalListContextResultsChatInputContextPanelEntry], to toEntries: [VerticalListContextResultsChatInputContextPanelEntry], engine: TelegramEngine, actionSelected: @escaping () -> Void, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> VerticalListContextResultsChatInputContextPanelTransition {
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(engine: engine, actionSelected: actionSelected, resultSelected: resultSelected), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(engine: engine, actionSelected: actionSelected, resultSelected: resultSelected), directionHint: nil) }
return VerticalListContextResultsChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates)
}
final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContextPanelNode {
private let backgroundView: GlassBackgroundView
private let listView: ListView
private let listMaskView: UIImageView
private var currentExternalResults: ChatContextResultCollection?
private var currentProcessedResults: ChatContextResultCollection?
private var currentEntries: [VerticalListContextResultsChatInputContextPanelEntry]?
private var enqueuedTransitions: [(VerticalListContextResultsChatInputContextPanelTransition, Bool)] = []
private var validLayout: (CGSize, CGFloat, CGFloat, CGFloat)?
private let loadMoreDisposable = MetaDisposable()
private var isLoadingMore: Bool = false
override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) {
self.backgroundView = GlassBackgroundView()
self.backgroundView.layer.anchorPoint = CGPoint()
self.listView = ListViewImpl()
self.listView.isOpaque = false
self.listView.stackFromBottom = true
self.listView.limitHitTestToNodes = true
self.listView.isHidden = true
self.listView.view.disablesInteractiveTransitionGestureRecognizer = true
self.listView.accessibilityPageScrolledString = { row, count in
return strings.VoiceOver_ScrollStatus(row, count).string
}
self.listMaskView = UIImageView()
super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext)
self.isOpaque = false
self.clipsToBounds = true
self.view.addSubview(self.backgroundView)
self.addSubnode(self.listView)
self.listView.view.mask = self.listMaskView
self.listView.visibleBottomContentOffsetChanged = { [weak self] offset in
guard let strongSelf = self, !strongSelf.isLoadingMore, case let .known(value) = offset, value < 40.0 else {
return
}
strongSelf.loadMore()
}
self.backgroundView.isHidden = true
self.listView.visibleContentOffsetChanged = { [weak self] offset, _ in
guard let self else {
return
}
var topOffset: CGFloat = 0.0
switch offset {
case let .known(offset):
topOffset = max(0.0, -offset + self.listView.insets.top)
case .unknown:
break
case .none:
break
}
self.backgroundView.isHidden = false
self.backgroundView.layer.position = CGPoint(x: 0.0, y: topOffset)
}
}
deinit {
self.loadMoreDisposable.dispose()
}
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 updateInternalResults(_ results: ChatContextResultCollection) {
var entries: [VerticalListContextResultsChatInputContextPanelEntry] = []
var index = 0
var resultIds = Set<VerticalChatContextResultsEntryStableId>()
if let switchPeer = results.switchPeer {
let entry: VerticalListContextResultsChatInputContextPanelEntry = .action(self.theme, switchPeer.text)
entries.append(entry)
resultIds.insert(entry.stableId)
}
if let webView = results.webView {
let entry: VerticalListContextResultsChatInputContextPanelEntry = .action(self.theme, webView.text)
entries.append(entry)
resultIds.insert(entry.stableId)
}
for result in results.results {
let entry: VerticalListContextResultsChatInputContextPanelEntry = .result(index, self.theme, result)
if resultIds.contains(entry.stableId) {
continue
} else {
resultIds.insert(entry.stableId)
}
entries.append(entry)
index += 1
}
prepareTransition(from: self.currentEntries, to: entries, results: results)
}
private func prepareTransition(from: [VerticalListContextResultsChatInputContextPanelEntry]?, to: [VerticalListContextResultsChatInputContextPanelEntry], results: ChatContextResultCollection) {
let firstTime = self.currentEntries == nil
let transition = preparedTransition(from: from ?? [], to: to, engine: self.context.engine, actionSelected: { [weak self] in
if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction {
if let switchPeer = results.switchPeer {
interfaceInteraction.botSwitchChatWithPayload(results.botId, switchPeer.startParam)
} else if let webView = results.webView {
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: results.botId))
|> deliverOnMainQueue).startStandalone(next: { bot in
if let bot {
interfaceInteraction.openWebView(webView.text, webView.url, true, .inline(bot: bot))
}
})
}
}
}, resultSelected: { [weak self] result, node, rect in
if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction {
strongSelf.listView.clearHighlightAnimated(true)
return interfaceInteraction.sendContextResult(results, result, node, rect)
} else {
return false
}
})
self.currentEntries = to
self.enqueueTransition(transition, firstTime: firstTime)
}
private func enqueueTransition(_ transition: VerticalListContextResultsChatInputContextPanelTransition, 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 {
} else {
options.insert(.AnimateTopItemPosition)
options.insert(.AnimateCrossfade)
}
var insets = UIEdgeInsets()
insets.top = topInsetForLayout(size: validLayout.0, bottomInset: validLayout.3, hasSwitchPeer: self.currentExternalResults?.switchPeer != nil || self.currentExternalResults?.webView != nil)
insets.left = validLayout.1
insets.right = validLayout.2
insets.bottom = validLayout.3
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 {
let offset = strongSelf.listView.bounds.size.height - topItemOffset
let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring)
transition.animatePositionAdditive(layer: strongSelf.listView.layer, offset: CGPoint(x: 0.0, y: offset))
transition.animatePositionAdditive(layer: strongSelf.backgroundView.layer, offset: CGPoint(x: 0.0, y: offset))
}
strongSelf.listView.isHidden = false
}
})
}
}
private func topInsetForLayout(size: CGSize, bottomInset: CGFloat, hasSwitchPeer: Bool) -> CGFloat {
var minimumItemHeights: CGFloat = floor(VerticalListContextResultsChatInputPanelItemNode.itemHeight * 3.5)
if hasSwitchPeer {
minimumItemHeights += VerticalListContextResultsChatInputPanelButtonItemNode.itemHeight(style: .regular)
}
return max(size.height - bottomInset - 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)
self.backgroundView.bounds = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height + 32.0))
self.backgroundView.update(
size: self.backgroundView.bounds.size,
cornerRadius: 20.0,
isDark: interfaceState.theme.overallDarkAppearance,
tintColor: .init(kind: .panel),
transition: ComponentTransition(transition)
)
var insets = UIEdgeInsets()
insets.top = self.topInsetForLayout(size: size, bottomInset: bottomInset, hasSwitchPeer: self.currentExternalResults?.switchPeer != nil || self.currentExternalResults?.webView != nil)
insets.left = leftInset
insets.right = rightInset
insets.bottom = bottomInset
transition.updateFrame(node: self.listView, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height))
let listMaskHeight: CGFloat = bottomInset + 1.0
if self.listMaskView.image?.size.height != listMaskHeight {
let baseGradientAlpha: CGFloat = 0.65
let numSteps = 8
let firstStep = 1
let firstLocation = 0.0
let colors: [UIColor] = (0 ..< numSteps).map { i in
if i < firstStep {
return UIColor(white: 0.0, alpha: 0.0)
} else {
let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1)
let value: CGFloat = bezierPoint(0.42, 0.0, 0.58, 1.0, step)
return UIColor(white: 0.0, alpha: 1.0 - baseGradientAlpha * value)
}
}
let locations: [CGFloat] = (0 ..< numSteps).map { i in
if i < firstStep {
return 0.0
} else {
let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1)
return (firstLocation + (1.0 - firstLocation) * step)
}
}
self.listMaskView.image = generateGradientImage(
size: CGSize(width: 8.0, height: listMaskHeight),
colors: colors,
locations: locations
)?.stretchableImage(withLeftCapWidth: 0, topCapHeight: 1)
}
transition.updateFrame(view: self.listMaskView, 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, let currentProcessedResults = self.currentProcessedResults {
self.theme = interfaceState.theme
let new = self.currentEntries?.map({$0.withUpdatedTheme(interfaceState.theme)}) ?? []
prepareTransition(from: self.currentEntries, to: new, results: currentProcessedResults)
}
}
override func animateOut(completion: @escaping () -> Void) {
var topItemOffset: CGFloat?
self.listView.forEachItemNode { itemNode in
if topItemOffset == nil {
topItemOffset = itemNode.frame.minY
}
}
if let topItemOffset {
let offset = (self.listView.bounds.size.height - topItemOffset)
let position = self.listView.layer.position
self.listView.layer.animatePosition(from: position, to: CGPoint(x: position.x, y: position.y + offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
completion()
})
self.backgroundView.layer.animatePosition(from: self.backgroundView.layer.position, to: CGPoint(x: self.backgroundView.layer.position.x, y: self.backgroundView.layer.position.y + offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
} 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)
}
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).startStrict(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)
}))
}
override var topItemFrame: CGRect? {
var topItemFrame: CGRect?
self.listView.forEachItemNode { itemNode in
if topItemFrame == nil {
topItemFrame = itemNode.frame
}
}
return topItemFrame
}
}