Files
Telegram-iOS/submodules/TelegramUI/Sources/MentionChatInputPanelItem.swift
T
isaacisaacClaude Opus 4.7
d1aa0db537 Postbox -> TelegramEngine waves 46-93 (squashed)
Squash of 63 commits spanning waves 46-93 (plus interspersed docs commits)
of the gradual Postbox->TelegramEngine consumer-side migration.

Scope: 139 files changed, 2123 insertions(+), 452 deletions(-).

## Themes by wave-block

**Waves 46-58 — Peer field migrations + facade additions**
Foundational EnginePeer convenience init additions (PeerReference, RenderedPeer,
SelectivePrivacyPeer). Multiple `peer: Peer` field migrations across PeerInfo,
ChatList, and SettingsUI components.

**Waves 59-73 — peer field cascade + EnginePeer wrap drops**
Series of single- to two-file peer-field migrations; consumer-side wrap
removal (`EnginePeer(peer)` -> direct EnginePeer use); `as? TelegramUser`
cast conversion to `case let .user(...)` enum match. Wave 64: RenderedPeer
convenience init. Wave 68: SelectivePrivacyPeer convenience init.

**Waves 74-83 — controller-Node bridge cleanup + small migrations**
Wave-71 shadow-pattern cleanup at controller->Node bridges. Migrations of
ChatRecentActionsController.peer (74), PeerInfoMember (75), MentionChatInputPanelItem
(76), PassportUI SecureIdAuthController (77), AccountWithInfo + ShareController
(78), peerInputActivitiesPromise (79), InactiveChannel (80), BlockedPeers (81),
openHashtag resolveSignal (82), NotificationExceptionsList (83).

**Waves 84-90 — TelegramEngine.Resources facade migrations**
Per-method Shape-A/B sweeps converting `<ctx>.account.postbox.mediaBox.X(...)`
to `<ctx>.engine.resources.X(...)`. Wave 90 was a single-commit big sweep:
40 fetchedMediaResource sites in 25 files migrated to engine.resources.fetch
facade in one atomic pass with first-pass-clean build.

Methods covered: storeResourceData, completedResourcePath, cancelInteractiveResourceFetch,
resourceRangesStatus, resourceStatus, fetch (fetchedMediaResource).

**Waves 91-92 — additional type migrations**
Wave 91: ItemListWebsiteItem.peer + RecentSessionsController enum-case payload
+ openWebSession callback Peer? -> EnginePeer?.
Wave 92: ChatListController StateHolder.EntryContext status type
MediaResourceStatus -> EngineMediaResource.FetchStatus.

**Wave 93 — speculative `import Postbox` drop sweep**
Drop import from 7 wave-touched files where it became unused; restore in 5
files where bare PeerId/Message/MediaId/StoryId references escaped the
pre-flight regex. Includes one MediaId(...) -> EngineMedia.Id(...) swap in
InAppPurchaseManager to unlock its import drop.

## Build state

Final state at squash: clean Telegram/Telegram build at debug_sim_arm64.

## Persistent-state notes

- Pre-existing WIP unchanged across the squashed range:
  - build-system/bazel-rules/sourcekit-bazel-bsp submodule marker
  - Untracked: build-system/tulsi/, submodules/TgVoip/, third-party/libx264/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:48:15 +04:00

467 lines
21 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import Postbox
import TelegramPresentationData
import TelegramUIPreferences
import AvatarNode
import AccountContext
import ItemListUI
final class MentionChatInputPanelItem: ListViewItem {
fileprivate let context: AccountContext
fileprivate let presentationData: ItemListPresentationData
fileprivate let revealed: Bool
fileprivate let inverted: Bool
fileprivate let peer: EnginePeer
private let peerSelected: (EnginePeer) -> Void
fileprivate let setPeerIdRevealed: (EnginePeer.Id?) -> Void
fileprivate let removeRequested: (EnginePeer.Id) -> Void
let selectable: Bool = true
public init(context: AccountContext, presentationData: ItemListPresentationData, inverted: Bool, peer: EnginePeer, revealed: Bool, setPeerIdRevealed: @escaping (PeerId?) -> Void, peerSelected: @escaping (EnginePeer) -> Void, removeRequested: @escaping (PeerId) -> Void) {
self.context = context
self.presentationData = presentationData
self.inverted = inverted
self.peer = peer
self.revealed = revealed
self.setPeerIdRevealed = setPeerIdRevealed
self.peerSelected = peerSelected
self.removeRequested = removeRequested
}
public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
let configure = { () -> Void in
let node = MentionChatInputPanelItemNode()
let nodeLayout = node.asyncLayout()
let (top, bottom) = (previousItem != nil, nextItem != nil)
let (layout, apply) = nodeLayout(self, params, top, bottom)
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply(.None) })
})
}
}
if Thread.isMainThread {
async {
configure()
}
} else {
configure()
}
}
public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? MentionChatInputPanelItemNode {
let nodeLayout = nodeValue.asyncLayout()
async {
let (top, bottom) = (previousItem != nil, nextItem != nil)
let (layout, apply) = nodeLayout(self, params, top, bottom)
Queue.mainQueue().async {
completion(layout, { _ in
apply(animation)
})
}
}
} else {
assertionFailure()
}
}
}
func selected(listView: ListView) {
if self.revealed {
self.setPeerIdRevealed(nil)
} else {
self.peerSelected(self.peer)
}
}
}
private let avatarFont = avatarPlaceholderFont(size: 16.0)
final class MentionChatInputPanelItemNode: ListViewItemNode {
static let itemHeight: CGFloat = 42.0
private let avatarNode: AvatarNode
private let textNode: TextNode
private let separatorNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private var revealNode: ItemListRevealOptionsNode?
private var revealOptions: [ItemListRevealOption] = []
private var initialRevealOffset: CGFloat = 0.0
public private(set) var revealOffset: CGFloat = 0.0
private var recognizer: ItemListRevealOptionsGestureRecognizer?
private var hapticFeedback: HapticFeedback?
private let activateAreaNode: AccessibilityAreaNode
private var item: MentionChatInputPanelItem?
private var validLayout: (CGSize, CGFloat, CGFloat)?
init() {
self.avatarNode = AvatarNode(font: avatarFont)
self.textNode = TextNode()
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.activateAreaNode = AccessibilityAreaNode()
self.activateAreaNode.accessibilityTraits = [.button]
super.init(layerBacked: false)
self.addSubnode(self.separatorNode)
self.addSubnode(self.avatarNode)
self.addSubnode(self.textNode)
self.addSubnode(self.activateAreaNode)
}
override func didLoad() {
super.didLoad()
let recognizer = ItemListRevealOptionsGestureRecognizer(target: self, action: #selector(self.revealGesture(_:)))
self.recognizer = recognizer
recognizer.allowAnyDirection = false
self.view.addGestureRecognizer(recognizer)
}
override public func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
if let item = item as? MentionChatInputPanelItem {
let doLayout = self.asyncLayout()
let merged = (top: previousItem != nil, bottom: nextItem != nil)
let (layout, apply) = doLayout(item, params, merged.top, merged.bottom)
self.contentSize = layout.contentSize
self.insets = layout.insets
apply(.None)
}
}
func asyncLayout() -> (_ item: MentionChatInputPanelItem, _ params: ListViewItemLayoutParams, _ mergedTop: Bool, _ mergedBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) {
let makeTextLayout = TextNode.asyncLayout(self.textNode)
let previousItem = self.item
return { [weak self] item, params, mergedTop, mergedBottom in
let primaryFont = Font.medium(floor(item.presentationData.fontSize.baseDisplaySize * 14.0 / 17.0))
let secondaryFont = Font.regular(floor(item.presentationData.fontSize.baseDisplaySize * 14.0 / 17.0))
let leftInset: CGFloat = 55.0 + params.leftInset
let rightInset: CGFloat = 10.0 + params.rightInset
var updatedInverted: Bool?
if previousItem?.inverted != item.inverted {
updatedInverted = item.inverted
}
let title = item.peer.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
var username: String?
let string = NSMutableAttributedString()
string.append(NSAttributedString(string: title, font: primaryFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor))
if let addressName = item.peer.addressName, !addressName.isEmpty {
string.append(NSAttributedString(string: " @\(addressName)", font: secondaryFont, textColor: item.presentationData.theme.list.itemSecondaryTextColor))
username = "@\(addressName)"
}
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: string, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: HashtagChatInputPanelItemNode.itemHeight), insets: UIEdgeInsets())
return (nodeLayout, { animation in
if let strongSelf = self {
strongSelf.item = item
strongSelf.validLayout = (nodeLayout.contentSize, params.leftInset, params.rightInset)
if let updatedInverted = updatedInverted {
if updatedInverted {
strongSelf.transform = CATransform3DMakeRotation(CGFloat(Double.pi), 0.0, 0.0, 1.0)
} else {
strongSelf.transform = CATransform3DIdentity
}
}
strongSelf.separatorNode.backgroundColor = item.presentationData.theme.list.itemPlainSeparatorColor
strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor
strongSelf.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: item.peer, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor)
let _ = textApply()
strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: params.leftInset + 12.0, y: floor((nodeLayout.contentSize.height - 30.0) / 2.0)), size: CGSize(width: 30.0, height: 30.0))
strongSelf.textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((nodeLayout.contentSize.height - textLayout.size.height) / 2.0)), size: textLayout.size)
strongSelf.separatorNode.isHidden = !mergedBottom
strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: !item.inverted ? (nodeLayout.contentSize.height - UIScreenPixel) : 0.0), size: CGSize(width: params.width - leftInset, height: UIScreenPixel))
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: params.width, height: nodeLayout.size.height + UIScreenPixel))
strongSelf.activateAreaNode.accessibilityLabel = title
strongSelf.activateAreaNode.accessibilityValue = username
strongSelf.activateAreaNode.frame = CGRect(origin: .zero, size: nodeLayout.size)
if case let .user(peer) = item.peer, let _ = peer.botInfo {
strongSelf.setRevealOptions([ItemListRevealOption(key: 0, title: item.presentationData.strings.Common_Delete, icon: .none, color: item.presentationData.theme.list.itemDisclosureActions.destructive.fillColor, textColor: item.presentationData.theme.list.itemDisclosureActions.destructive.foregroundColor)])
strongSelf.setRevealOptionsOpened(item.revealed, animated: animation.isAnimated)
} else {
strongSelf.setRevealOptions([])
strongSelf.setRevealOptionsOpened(false, animated: animation.isAnimated)
}
}
})
}
}
func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) {
if let (_, leftInset, _) = self.validLayout {
transition.updateFrameAdditive(node: self.avatarNode, frame: CGRect(origin: CGPoint(x: min(offset, 0.0) + 12.0 + leftInset, y: self.avatarNode.frame.minY), size: self.avatarNode.frame.size))
transition.updateFrameAdditive(node: self.textNode, frame: CGRect(origin: CGPoint(x: min(offset, 0.0) + 55.0 + leftInset, y: self.textNode.frame.minY), size: self.textNode.frame.size))
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if let _ = self.revealNode, self.revealOffset != 0 {
return
}
if highlighted {
self.highlightedBackgroundNode.alpha = 1.0
if self.highlightedBackgroundNode.supernode == nil {
//self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode)
}
} else {
if self.highlightedBackgroundNode.supernode != nil {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
if let strongSelf = self {
if completed {
strongSelf.highlightedBackgroundNode.removeFromSupernode()
}
}
})
self.highlightedBackgroundNode.alpha = 0.0
} else {
self.highlightedBackgroundNode.removeFromSupernode()
}
}
}
}
func setRevealOptions(_ options: [ItemListRevealOption]) {
if self.revealOptions == options {
return
}
let wasEmpty = self.revealOptions.isEmpty
self.revealOptions = options
let isEmpty = options.isEmpty
if options.isEmpty {
if let _ = self.revealNode {
self.recognizer?.becomeCancelled()
self.updateRevealOffsetInternal(offset: 0.0, transition: .animated(duration: 0.3, curve: .spring))
}
}
if wasEmpty != isEmpty {
self.recognizer?.isEnabled = !isEmpty
}
}
private func setRevealOptionsOpened(_ value: Bool, animated: Bool) {
if value != !self.revealOffset.isZero {
if !self.revealOffset.isZero {
self.recognizer?.becomeCancelled()
}
let transition: ContainedViewLayoutTransition
if animated {
transition = .animated(duration: 0.3, curve: .spring)
} else {
transition = .immediate
}
if value {
if self.revealNode == nil {
self.setupAndAddRevealNode()
if let revealNode = self.revealNode, revealNode.isNodeLoaded, let _ = self.validLayout {
revealNode.layout()
let revealSize = revealNode.bounds.size
self.updateRevealOffsetInternal(offset: -revealSize.width, transition: transition)
}
}
} else if !self.revealOffset.isZero {
self.updateRevealOffsetInternal(offset: 0.0, transition: transition)
}
}
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if let recognizer = self.recognizer, otherGestureRecognizer == recognizer {
return true
} else {
return false
}
}
@objc func revealGesture(_ recognizer: ItemListRevealOptionsGestureRecognizer) {
guard let (size, _, _) = self.validLayout else {
return
}
switch recognizer.state {
case .began:
if let revealNode = self.revealNode {
let revealSize = revealNode.bounds.size
let location = recognizer.location(in: self.view)
if location.x > size.width - revealSize.width {
recognizer.becomeCancelled()
} else {
self.initialRevealOffset = self.revealOffset
}
} else {
if self.revealOptions.isEmpty {
recognizer.becomeCancelled()
}
self.initialRevealOffset = self.revealOffset
}
case .changed:
var translation = recognizer.translation(in: self.view)
translation.x += self.initialRevealOffset
if self.revealNode == nil && translation.x.isLess(than: 0.0) {
self.setupAndAddRevealNode()
self.revealOptionsInteractivelyOpened()
}
self.updateRevealOffsetInternal(offset: translation.x, transition: .immediate)
if self.revealNode == nil {
self.revealOptionsInteractivelyClosed()
}
case .ended, .cancelled:
guard let recognizer = self.recognizer else {
break
}
if let revealNode = self.revealNode {
let velocity = recognizer.velocity(in: self.view)
let revealSize = revealNode.bounds.size
var reveal = false
if abs(velocity.x) < 100.0 {
if self.initialRevealOffset.isZero && self.revealOffset < 0.0 {
reveal = true
} else if self.revealOffset < -revealSize.width {
reveal = true
} else {
reveal = false
}
} else {
if velocity.x < 0.0 {
reveal = true
} else {
reveal = false
}
}
self.updateRevealOffsetInternal(offset: reveal ? -revealSize.width : 0.0, transition: .animated(duration: 0.3, curve: .spring))
if !reveal {
self.revealOptionsInteractivelyClosed()
}
}
default:
break
}
}
private func revealOptionSelected(_ option: ItemListRevealOption, animated: Bool) {
guard let item = self.item else {
return
}
item.removeRequested(item.peer.id)
}
private func setupAndAddRevealNode() {
if !self.revealOptions.isEmpty {
let revealNode = ItemListRevealOptionsNode(optionSelected: { [weak self] option in
self?.revealOptionSelected(option, animated: false)
}, tapticAction: { [weak self] in
self?.hapticImpact()
})
revealNode.setOptions(self.revealOptions, isLeft: false, enableAnimations: true)
self.revealNode = revealNode
if let (size, _, rightInset) = self.validLayout {
var revealSize = revealNode.measure(CGSize(width: CGFloat.greatestFiniteMagnitude, height: size.height))
revealSize.width += rightInset
revealNode.frame = CGRect(origin: CGPoint(x: size.width + max(self.revealOffset, -revealSize.width), y: 0.0), size: revealSize)
revealNode.updateRevealOffset(offset: 0.0, sideInset: -rightInset, transition: .immediate)
}
self.addSubnode(revealNode)
}
}
private func updateRevealOffsetInternal(offset: CGFloat, transition: ContainedViewLayoutTransition) {
self.revealOffset = offset
guard let (size, _, rightInset) = self.validLayout else {
return
}
if let revealNode = self.revealNode {
let revealSize = revealNode.bounds.size
let revealFrame = CGRect(origin: CGPoint(x: size.width + max(self.revealOffset, -revealSize.width), y: 0.0), size: revealSize)
let revealNodeOffset = -max(self.revealOffset, -revealSize.width)
revealNode.updateRevealOffset(offset: revealNodeOffset, sideInset: -rightInset, transition: transition)
if CGFloat(0.0).isLessThanOrEqualTo(offset) {
self.revealNode = nil
transition.updateFrame(node: revealNode, frame: revealFrame, completion: { [weak revealNode] _ in
revealNode?.removeFromSupernode()
})
} else {
transition.updateFrame(node: revealNode, frame: revealFrame)
}
}
self.updateRevealOffset(offset: offset, transition: transition)
}
func revealOptionsInteractivelyOpened() {
if let item = self.item {
item.setPeerIdRevealed(item.peer.id)
}
}
func revealOptionsInteractivelyClosed() {
if let item = self.item {
item.setPeerIdRevealed(nil)
}
}
private func hapticImpact() {
if self.hapticFeedback == nil {
self.hapticFeedback = HapticFeedback()
}
self.hapticFeedback?.impact(.medium)
}
}