Telegram-iOS/submodules/ShareController/Sources/SharePeersContainerNode.swift
isaac 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

781 lines
38 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import Display
import TelegramPresentationData
import TelegramUIPreferences
import MergeLists
import AvatarNode
import AccountContext
import PeerPresenceStatusManager
import AppBundle
import SegmentedControlNode
import ContextUI
private let subtitleFont = Font.regular(12.0)
extension CGPoint {
func angle(to other: CGPoint) -> CGFloat {
let originX = other.x - self.x
let originY = other.y - self.y
let bearingRadians = atan2f(Float(originY), Float(originX))
return CGFloat(bearingRadians)
}
func distance(to other: CGPoint) -> CGFloat {
return sqrt((self.x - other.x) * (self.x - other.x) + (self.y - other.y) * (self.y - other.y))
}
func offsetBy(distance: CGFloat, inDirection radians: CGFloat) -> CGPoint {
let vertical = sin(radians) * distance
let horizontal = cos(radians) * distance
return self.offsetBy(dx: horizontal, dy: vertical)
}
}
private struct SharePeerEntry: Comparable, Identifiable {
let index: Int32
let item: ShareControllerPeerGridItem.ShareItem
let theme: PresentationTheme
let strings: PresentationStrings
var stableId: Int64 {
switch self.item {
case let .peer(peer, _, _, _, _, _):
return peer.peerId.toInt64()
case .story:
return 0
}
}
static func ==(lhs: SharePeerEntry, rhs: SharePeerEntry) -> Bool {
if lhs.index != rhs.index {
return false
}
if lhs.item != rhs.item {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
static func <(lhs: SharePeerEntry, rhs: SharePeerEntry) -> Bool {
return lhs.index < rhs.index
}
func item(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, interfaceInteraction: ShareControllerInteraction) -> GridItem {
return ShareControllerPeerGridItem(environment: environment, context: context, theme: self.theme, strings: self.strings, item: self.item, controllerInteraction: interfaceInteraction, search: false)
}
}
private struct ShareGridTransaction {
let deletions: [Int]
let insertions: [GridNodeInsertItem]
let updates: [GridNodeUpdateItem]
let animated: Bool
}
private let avatarFont = avatarPlaceholderFont(size: 17.0)
private func preparedGridEntryTransition(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, from fromEntries: [SharePeerEntry], to toEntries: [SharePeerEntry], interfaceInteraction: ShareControllerInteraction) -> ShareGridTransaction {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices
let insertions = indicesAndItems.map { GridNodeInsertItem(index: $0.0, item: $0.1.item(environment: environment, context: context, interfaceInteraction: interfaceInteraction), previousIndex: $0.2) }
let updates = updateIndices.map { GridNodeUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(environment: environment, context: context, interfaceInteraction: interfaceInteraction)) }
return ShareGridTransaction(deletions: deletions, insertions: insertions, updates: updates, animated: false)
}
final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode {
private let environment: ShareControllerEnvironment
private let context: ShareControllerAccountContext
private var theme: PresentationTheme
private let themePromise: Promise<PresentationTheme>
private let strings: PresentationStrings
private let nameDisplayOrder: PresentationPersonNameOrder
private let controllerInteraction: ShareControllerInteraction
private let switchToAnotherAccount: () -> Void
private let debugAction: () -> Void
private let extendedInitialReveal: Bool
let accountPeer: EnginePeer
private let foundPeers = Promise<[(peer: EngineRenderedPeer, requiresPremiumForMessaging: Bool)]>([])
private let disposable = MetaDisposable()
private var entries: [SharePeerEntry] = []
private var enqueuedTransitions: [(ShareGridTransaction, Bool)] = []
let contentGridNode: GridNode
private let headerNode: ASDisplayNode
private let contentTitleNode: ASTextNode
private let contentSubtitleNode: ImmediateTextNode
private let contentTitleAccountNode: AvatarNode
private let contentSeparatorNode: ASDisplayNode
private let searchButtonNode: HighlightableButtonNode
private let shareButtonNode: HighlightableButtonNode
private let shareReferenceNode: ContextReferenceContentNode
private let shareContainerNode: ContextControllerSourceNode
private let segmentedNode: SegmentedControlNode
private let segmentedValues: [ShareControllerSegmentedValue]?
private var contentDidBeginDragging: (() -> Void)?
private var contentOffsetUpdated: ((CGFloat, ContainedViewLayoutTransition) -> Void)?
var openSearch: (() -> Void)?
var openShare: ((ASDisplayNode, ContextGesture?) -> Void)?
var segmentedSelectedIndexUpdated: ((Int) -> Void)?
private var ensurePeerVisibleOnLayout: EnginePeer.Id?
private var validLayout: (CGSize, CGFloat)?
private var overrideGridOffsetTransition: ContainedViewLayoutTransition?
let peersValue = Promise<[(peer: EngineRenderedPeer, presence: EnginePeer.Presence?, requiresPremiumForMessaging: Bool, requiresStars: Int64?)]>()
private var _tick: Int = 0 {
didSet {
self.tick.set(self._tick)
}
}
private let tick = ValuePromise<Int>(0)
init(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, switchableAccounts: [ShareControllerSwitchableAccount], theme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, peers: [(peer: EngineRenderedPeer, presence: EnginePeer.Presence?, requiresPremiumForMessaging: Bool, requiresStars: Int64?)], accountPeer: EnginePeer, controllerInteraction: ShareControllerInteraction, externalShare: Bool, isMainApp: Bool, switchToAnotherAccount: @escaping () -> Void, debugAction: @escaping () -> Void, extendedInitialReveal: Bool, segmentedValues: [ShareControllerSegmentedValue]?, fromPublicChannel: Bool) {
self.environment = environment
self.context = context
self.theme = theme
self.themePromise = Promise()
self.themePromise.set(.single(theme))
self.strings = strings
self.nameDisplayOrder = nameDisplayOrder
self.controllerInteraction = controllerInteraction
self.accountPeer = accountPeer
self.switchToAnotherAccount = switchToAnotherAccount
self.debugAction = debugAction
self.extendedInitialReveal = extendedInitialReveal
self.segmentedValues = segmentedValues
self.peersValue.set(.single(peers))
let canShareStory = controllerInteraction.shareStory != nil
let items: Signal<[SharePeerEntry], NoError> = combineLatest(self.peersValue.get(), self.foundPeers.get(), self.tick.get(), self.themePromise.get())
|> map { [weak controllerInteraction] initialPeers, foundPeers, _, theme -> [SharePeerEntry] in
var entries: [SharePeerEntry] = []
var index: Int32 = 0
if canShareStory {
let storyMode: ShareControllerPeerGridItem.ShareItem.StoryMode
if fromPublicChannel {
storyMode = .repostMessage
} else {
if !isMainApp {
storyMode = .createStory
} else {
storyMode = .repostStory
}
}
entries.append(SharePeerEntry(index: index, item: .story(mode: storyMode), theme: theme, strings: strings))
index += 1
}
var existingPeerIds: Set<EnginePeer.Id> = Set()
entries.append(SharePeerEntry(index: index, item: .peer(peer: EngineRenderedPeer(peer: accountPeer), presence: nil, topicId: nil, threadData: nil, requiresPremiumForMessaging: false, requiresStars: nil), theme: theme, strings: strings))
existingPeerIds.insert(accountPeer.id)
index += 1
for (peer, requiresPremiumForMessaging) in foundPeers.reversed() {
if !existingPeerIds.contains(peer.peerId) {
entries.append(SharePeerEntry(index: index, item: .peer(peer: peer, presence: nil, topicId: nil, threadData: nil, requiresPremiumForMessaging: requiresPremiumForMessaging, requiresStars: nil), theme: theme, strings: strings))
existingPeerIds.insert(peer.peerId)
index += 1
}
}
for (peer, presence, requiresPremiumForMessaging, requiresStars) in initialPeers {
if !existingPeerIds.contains(peer.peerId) {
let thread = controllerInteraction?.selectedTopics[peer.peerId]
entries.append(SharePeerEntry(index: index, item: .peer(peer: peer, presence: presence, topicId: thread?.0, threadData: thread?.1, requiresPremiumForMessaging: requiresPremiumForMessaging, requiresStars: requiresStars), theme: theme, strings: strings))
existingPeerIds.insert(peer.peerId)
index += 1
}
}
return entries
}
self.contentGridNode = GridNode()
self.headerNode = ASDisplayNode()
self.contentTitleNode = ASTextNode()
self.contentTitleNode.attributedText = NSAttributedString(string: strings.ShareMenu_ShareTo, font: Font.medium(20.0), textColor: self.theme.actionSheet.primaryTextColor)
self.contentSubtitleNode = ImmediateTextNode()
self.contentSubtitleNode.maximumNumberOfLines = 1
self.contentSubtitleNode.isUserInteractionEnabled = false
self.contentSubtitleNode.displaysAsynchronously = false
self.contentSubtitleNode.truncationMode = .byTruncatingTail
self.contentSubtitleNode.attributedText = NSAttributedString(string: strings.ShareMenu_SelectChats, font: subtitleFont, textColor: self.theme.actionSheet.secondaryTextColor)
self.contentTitleAccountNode = AvatarNode(font: avatarFont)
var hasOtherAccounts = false
if switchableAccounts.count > 1, let info = switchableAccounts.first(where: { $0.account.accountId == context.accountId }) {
hasOtherAccounts = true
self.contentTitleAccountNode.setPeer(
accountPeerId: context.accountPeerId,
postbox: context.stateManager.postbox,
network: context.stateManager.network,
contentSettings: context.contentSettings,
theme: theme,
peer: info.peer,
emptyColor: nil,
synchronousLoad: false
)
} else {
self.contentTitleAccountNode.isHidden = true
}
self.searchButtonNode = HighlightableButtonNode()
self.searchButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Share/SearchIcon"), color: self.theme.actionSheet.controlAccentColor), for: [])
self.shareButtonNode = HighlightableButtonNode()
self.shareButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Share/ShareIcon"), color: self.theme.actionSheet.controlAccentColor), for: [])
self.shareReferenceNode = ContextReferenceContentNode()
self.shareContainerNode = ContextControllerSourceNode()
self.shareContainerNode.animateScale = false
let segmentedItems: [SegmentedControlItem]
if let segmentedValues = segmentedValues {
segmentedItems = segmentedValues.map { SegmentedControlItem(title: $0.title) }
} else {
segmentedItems = []
}
self.segmentedNode = SegmentedControlNode(theme: SegmentedControlTheme(theme: theme), items: segmentedItems, selectedIndex: 0)
self.segmentedNode.isHidden = segmentedValues == nil
self.contentTitleNode.isHidden = self.segmentedValues != nil
self.contentSubtitleNode.isHidden = self.segmentedValues != nil
self.contentSeparatorNode = ASDisplayNode()
self.contentSeparatorNode.isLayerBacked = true
self.contentSeparatorNode.displaysAsynchronously = false
self.contentSeparatorNode.backgroundColor = self.theme.actionSheet.opaqueItemSeparatorColor
if !externalShare || hasOtherAccounts {
self.shareButtonNode.isHidden = true
}
super.init()
self.addSubnode(self.contentGridNode)
self.addSubnode(self.headerNode)
self.headerNode.addSubnode(self.contentTitleNode)
self.headerNode.addSubnode(self.contentSubtitleNode)
self.headerNode.addSubnode(self.contentTitleAccountNode)
self.headerNode.addSubnode(self.segmentedNode)
self.headerNode.addSubnode(self.searchButtonNode)
self.shareContainerNode.addSubnode(self.shareReferenceNode)
self.shareButtonNode.addSubnode(self.shareContainerNode)
self.headerNode.addSubnode(self.shareButtonNode)
self.addSubnode(self.contentSeparatorNode)
self.shareContainerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self else {
return
}
strongSelf.openShare?(strongSelf.shareReferenceNode, gesture)
}
let previousItems = Atomic<[SharePeerEntry]?>(value: [])
self.disposable.set((items
|> deliverOnMainQueue).start(next: { [weak self] entries in
if let strongSelf = self {
let previousEntries = previousItems.swap(entries)
strongSelf.entries = entries
let firstTime = previousEntries == nil
let transition = preparedGridEntryTransition(environment: environment, context: context, from: previousEntries ?? [], to: entries, interfaceInteraction: controllerInteraction)
strongSelf.enqueueTransition(transition, firstTime: firstTime)
}
}))
self.contentGridNode.scrollingInitiated = { [weak self] in
self?.contentDidBeginDragging?()
}
self.contentGridNode.presentationLayoutUpdated = { [weak self] presentationLayout, transition in
self?.gridPresentationLayoutUpdated(presentationLayout, transition: transition)
}
self.searchButtonNode.addTarget(self, action: #selector(self.searchPressed), forControlEvents: .touchUpInside)
self.shareButtonNode.addTarget(self, action: #selector(self.sharePressed), forControlEvents: .touchUpInside)
self.contentTitleAccountNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.accountTapGesture(_:))))
self.segmentedNode.selectedIndexChanged = { [weak self] index in
self?.segmentedSelectedIndexUpdated?(index)
}
self.contentTitleNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.debugTapGesture(_:))))
}
deinit {
self.disposable.dispose()
}
func updateTheme(_ theme: PresentationTheme) {
self.theme = theme
self.themePromise.set(.single(theme))
self.contentTitleNode.attributedText = NSAttributedString(string: self.strings.ShareMenu_ShareTo, font: Font.medium(20.0), textColor: self.theme.actionSheet.primaryTextColor)
self.updateSelectedPeers(animated: false)
}
private func enqueueTransition(_ transition: ShareGridTransaction, firstTime: Bool) {
self.enqueuedTransitions.append((transition, firstTime))
if self.validLayout != nil {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
if let (transition, _) = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)
var itemTransition: ContainedViewLayoutTransition = .immediate
if transition.animated {
itemTransition = .animated(duration: 0.3, curve: .spring)
}
self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in })
}
}
func setEnsurePeerVisibleOnLayout(_ peerId: EnginePeer.Id?) {
self.ensurePeerVisibleOnLayout = peerId
}
func setDidBeginDragging(_ f: (() -> Void)?) {
self.contentDidBeginDragging = f
}
func setContentOffsetUpdated(_ f: ((CGFloat, ContainedViewLayoutTransition) -> Void)?) {
self.contentOffsetUpdated = f
}
private func calculateMetrics(size: CGSize, additionalBottomInset: CGFloat, isEmbedded: Bool) -> (topInset: CGFloat, itemWidth: CGFloat) {
let itemCount = self.entries.count
let itemInsets = UIEdgeInsets(top: 0.0, left: 12.0, bottom: 0.0, right: 12.0)
let minimalItemWidth: CGFloat = size.width > 301.0 ? 70.0 : 60.0
let effectiveWidth = size.width - itemInsets.left - itemInsets.right
let itemsPerRow = Int(effectiveWidth / minimalItemWidth)
let itemWidth = floor(effectiveWidth / CGFloat(itemsPerRow))
var rowCount = itemCount / itemsPerRow + (itemCount % itemsPerRow != 0 ? 1 : 0)
rowCount = max(rowCount, 4)
let minimallyRevealedRowCount: CGFloat
if self.extendedInitialReveal {
minimallyRevealedRowCount = 4.6
} else {
minimallyRevealedRowCount = 3.7
}
let initiallyRevealedRowCount = min(minimallyRevealedRowCount, CGFloat(rowCount))
let gridTopInset = isEmbedded ? 136.0 : max(0.0, size.height - floor(initiallyRevealedRowCount * itemWidth) - 14.0 - additionalBottomInset)
return (gridTopInset, itemWidth)
}
func activate() {
}
func deactivate() {
}
func frameForPeerId(_ peerId: EnginePeer.Id) -> CGRect? {
var node: ASDisplayNode?
self.contentGridNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId {
node = itemNode
}
}
if let node = node {
return node.frame.offsetBy(dx: 0.0, dy: -10.0)
} else {
return nil
}
}
func prepareForAnimateIn() {
self.searchButtonNode.alpha = 0.0
self.shareButtonNode.alpha = 0.0
self.contentTitleNode.alpha = 0.0
self.contentSubtitleNode.alpha = 0.0
self.contentGridNode.alpha = 0.0
}
func animateIn(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? {
self.headerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.searchButtonNode.alpha = 1.0
self.searchButtonNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.searchButtonNode.layer.animatePosition(from: CGPoint(x: -20.0, y: 0.0), to: .zero, duration: 0.2, additive: true)
self.shareButtonNode.alpha = 1.0
self.shareButtonNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.shareButtonNode.layer.animatePosition(from: CGPoint(x: 0.0, y: 0.0), to: .zero, duration: 0.2, additive: true)
self.contentTitleNode.alpha = 1.0
self.contentTitleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.contentTitleNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -10.0), to: .zero, duration: 0.2, additive: true)
self.contentTitleNode.layer.animateScale(from: 0.85, to: 1.0, duration: 0.2)
self.contentSubtitleNode.alpha = 1.0
self.contentSubtitleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.contentSubtitleNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -10.0), to: .zero, duration: 0.2, additive: true)
self.contentSubtitleNode.layer.animateScale(from: 0.85, to: 1.0, duration: 0.2)
self.contentGridNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
if let targetFrame = self.frameForPeerId(peerId), let (size, bottomInset) = self.validLayout {
let clippedNode = ASDisplayNode()
clippedNode.clipsToBounds = true
clippedNode.cornerRadius = 16.0
clippedNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.headerNode.frame.minY - 15.0), size: CGSize(width: size.width, height: size.height - bottomInset + 15.0))
self.contentGridNode.view.superview?.insertSubview(clippedNode.view, aboveSubview: self.contentGridNode.view)
clippedNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
let maskView = UIView()
maskView.frame = clippedNode.bounds
let maskImageView = UIImageView()
maskImageView.image = generatePeersMaskImage()
maskImageView.frame = maskView.bounds.offsetBy(dx: 0.0, dy: 36.0)
maskView.addSubview(maskImageView)
clippedNode.view.mask = maskView
self.contentGridNode.alpha = 1.0
self.contentGridNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId {
itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
itemNode.layer.animateScale(from: 1.35, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak clippedNode] _ in
clippedNode?.view.removeFromSuperview()
})
} else if let snapshotView = itemNode.view.snapshotView(afterScreenUpdates: false) {
snapshotView.frame = itemNode.view.convert(itemNode.bounds, to: clippedNode.view)
clippedNode.view.addSubview(snapshotView)
itemNode.alpha = 0.0
let angle = targetFrame.center.angle(to: itemNode.position)
let distance = targetFrame.center.distance(to: itemNode.position)
let newDistance = distance * 2.8
let newPosition = snapshotView.center.offsetBy(distance: newDistance, inDirection: angle)
snapshotView.layer.animatePosition(from: newPosition, to: snapshotView.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
snapshotView.layer.animateScale(from: 1.35, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak itemNode] _ in
itemNode?.alpha = 1.0
})
snapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, removeOnCompletion: false)
}
}
return targetFrame
} else {
return nil
}
}
func animateOut(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? {
self.headerNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.searchButtonNode.alpha = 0.0
self.searchButtonNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.searchButtonNode.layer.animatePosition(from: .zero, to: CGPoint(x: -20.0, y: 0.0), duration: 0.2, additive: true)
self.shareButtonNode.alpha = 0.0
self.shareButtonNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.shareButtonNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 0.0), duration: 0.2, additive: true)
self.contentTitleNode.alpha = 0.0
self.contentTitleNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.contentTitleNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -10.0), duration: 0.2, additive: true)
self.contentTitleNode.layer.animateScale(from: 1.0, to: 0.85, duration: 0.3)
self.contentSubtitleNode.alpha = 0.0
self.contentSubtitleNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.contentSubtitleNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -10.0), duration: 0.2, additive: true)
self.contentSubtitleNode.layer.animateScale(from: 1.0, to: 0.85, duration: 0.3)
self.contentGridNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
if let sourceFrame = self.frameForPeerId(peerId), let (size, bottomInset) = self.validLayout {
let clippedNode = ASDisplayNode()
clippedNode.clipsToBounds = true
clippedNode.cornerRadius = 16.0
clippedNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.headerNode.frame.minY - 15.0), size: CGSize(width: size.width, height: size.height - bottomInset + 15.0))
self.contentGridNode.view.superview?.insertSubview(clippedNode.view, aboveSubview: self.contentGridNode.view)
clippedNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
let maskView = UIView()
maskView.frame = clippedNode.bounds
let maskImageView = UIImageView()
maskImageView.image = generatePeersMaskImage()
maskImageView.frame = maskView.bounds.offsetBy(dx: 0.0, dy: 36.0)
maskView.addSubview(maskImageView)
clippedNode.view.mask = maskView
self.contentGridNode.forEachItemNode { itemNode in
if let snapshotView = itemNode.view.snapshotView(afterScreenUpdates: false) {
snapshotView.frame = itemNode.view.convert(itemNode.bounds, to: clippedNode.view)
clippedNode.view.addSubview(snapshotView)
if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId {
} else {
let angle = sourceFrame.center.angle(to: itemNode.position)
let distance = sourceFrame.center.distance(to: itemNode.position)
let newDistance = distance * 2.8
let newPosition = snapshotView.center.offsetBy(distance: newDistance, inDirection: angle)
snapshotView.layer.animatePosition(from: snapshotView.center, to: newPosition, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring)
}
snapshotView.layer.animateScale(from: 1.0, to: 1.35, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
}
}
clippedNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak clippedNode] _ in
clippedNode?.view.removeFromSuperview()
})
self.contentGridNode.alpha = 0.0
return sourceFrame
} else {
return nil
}
}
var isEmbedded = false
func updateLayout(size: CGSize, isLandscape: Bool, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) {
let firstLayout = self.validLayout == nil
self.validLayout = (size, bottomInset)
self.contentTitleNode.isHidden = self.isEmbedded
self.contentSubtitleNode.isHidden = self.isEmbedded
self.searchButtonNode.isHidden = self.isEmbedded
let gridLayoutTransition: ContainedViewLayoutTransition
if firstLayout {
gridLayoutTransition = .immediate
self.overrideGridOffsetTransition = transition
} else {
gridLayoutTransition = transition
self.overrideGridOffsetTransition = nil
}
let (gridTopInset, itemWidth) = self.calculateMetrics(size: size, additionalBottomInset: bottomInset, isEmbedded: self.isEmbedded)
var scrollToItem: GridNodeScrollToItem?
if let ensurePeerVisibleOnLayout = self.ensurePeerVisibleOnLayout {
self.ensurePeerVisibleOnLayout = nil
if let index = self.entries.firstIndex(where: { $0.item.peerId == ensurePeerVisibleOnLayout }) {
scrollToItem = GridNodeScrollToItem(index: index, position: .visible, transition: transition, directionHint: .up, adjustForSection: false)
}
}
let gridSize = CGSize(width: size.width - 10.0, height: size.height)
self.contentGridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: scrollToItem, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: gridSize, insets: UIEdgeInsets(top: gridTopInset, left: 0.0, bottom: bottomInset, right: 0.0), preloadSize: 80.0, type: .fixed(itemSize: CGSize(width: itemWidth, height: itemWidth + 25.0), fillWidth: nil, lineSpacing: 0.0, itemSpacing: nil)), transition: gridLayoutTransition), itemTransition: .immediate, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in })
gridLayoutTransition.updateFrame(node: self.contentGridNode, frame: CGRect(origin: CGPoint(x: floor((size.width - gridSize.width) / 2.0), y: 0.0), size: gridSize))
if firstLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func gridPresentationLayoutUpdated(_ presentationLayout: GridNodeCurrentPresentationLayout, transition: ContainedViewLayoutTransition) {
guard let (size, _) = self.validLayout else {
return
}
let actualTransition = self.overrideGridOffsetTransition ?? transition
self.overrideGridOffsetTransition = nil
let titleAreaHeight: CGFloat = 64.0
let rawTitleOffset = -titleAreaHeight - presentationLayout.contentOffset.y
let titleOffset = max(-titleAreaHeight, rawTitleOffset)
let headerFrame = CGRect(origin: CGPoint(x: 0.0, y: titleOffset), size: CGSize(width: size.width, height: 64.0))
transition.updateFrame(node: self.headerNode, frame: headerFrame)
let titleSize = self.contentTitleNode.measure(size)
let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: 15.0), size: titleSize)
transition.updateFrame(node: self.contentTitleNode, frame: titleFrame)
let subtitleSize = self.contentSubtitleNode.updateLayout(CGSize(width: size.width - 44.0 * 2.0 - 8.0 * 2.0, height: titleAreaHeight))
let subtitleFrame = CGRect(origin: CGPoint(x: floor((size.width - subtitleSize.width) / 2.0), y: 40.0), size: subtitleSize)
var originalSubtitleFrame = self.contentSubtitleNode.frame
originalSubtitleFrame.origin.x = subtitleFrame.origin.x
originalSubtitleFrame.size = subtitleFrame.size
self.contentSubtitleNode.frame = originalSubtitleFrame
transition.updateFrame(node: self.contentSubtitleNode, frame: subtitleFrame)
let titleButtonSize = CGSize(width: 44.0, height: 44.0)
let searchButtonFrame = CGRect(origin: CGPoint(x: 12.0, y: 12.0), size: titleButtonSize)
transition.updateFrame(node: self.searchButtonNode, frame: searchButtonFrame)
let shareButtonFrame = CGRect(origin: CGPoint(x: size.width - titleButtonSize.width - 12.0, y: 12.0), size: titleButtonSize)
transition.updateFrame(node: self.shareButtonNode, frame: shareButtonFrame)
transition.updateFrame(node: self.shareContainerNode, frame: CGRect(origin: CGPoint(), size: titleButtonSize))
transition.updateFrame(node: self.shareReferenceNode, frame: CGRect(origin: CGPoint(), size: titleButtonSize))
let segmentedSize = self.segmentedNode.updateLayout(.sizeToFit(maximumWidth: size.width - titleButtonSize.width * 2.0, minimumWidth: 160.0, height: 32.0), transition: transition)
transition.updateFrame(node: self.segmentedNode, frame: CGRect(origin: CGPoint(x: floor((size.width - segmentedSize.width) / 2.0), y: 18.0), size: segmentedSize))
let avatarButtonSize = CGSize(width: 36.0, height: 36.0)
let avatarButtonFrame = CGRect(origin: CGPoint(x: size.width - avatarButtonSize.width - 20.0, y: 15.0), size: avatarButtonSize)
transition.updateFrame(node: self.contentTitleAccountNode, frame: avatarButtonFrame)
transition.updateFrame(node: self.contentSeparatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: titleOffset + titleAreaHeight), size: CGSize(width: size.width, height: UIScreenPixel)))
if rawTitleOffset.isLess(than: -titleAreaHeight) {
self.contentSeparatorNode.alpha = 1.0
} else {
self.contentSeparatorNode.alpha = 0.0
}
self.contentOffsetUpdated?(presentationLayout.contentOffset.y, actualTransition)
}
func updateVisibleItemsSelection(animated: Bool) {
self.contentGridNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ShareControllerPeerGridItemNode {
itemNode.updateSelection(animated: animated)
}
}
}
func updateFoundPeers() {
self.foundPeers.set(.single(self.controllerInteraction.foundPeers))
}
func update() {
self._tick += 1
}
func updateSelectedPeers(animated: Bool = true) {
if self.segmentedValues != nil {
self.contentTitleNode.isHidden = true
self.contentSubtitleNode.isHidden = true
} else {
self.contentTitleNode.isHidden = self.isEmbedded
self.contentSubtitleNode.isHidden = self.isEmbedded
var subtitleText = self.strings.ShareMenu_SelectChats
if !self.controllerInteraction.selectedPeers.isEmpty {
subtitleText = self.controllerInteraction.selectedPeers.reduce("", { string, peer in
let text: String
if peer.peerId == self.accountPeer.id {
text = self.strings.DialogList_SavedMessages
} else {
text = peer.chatMainPeer?.displayTitle(strings: self.strings, displayOrder: self.nameDisplayOrder) ?? ""
}
if !string.isEmpty {
return string + ", " + text
} else {
return string + text
}
})
}
self.contentSubtitleNode.attributedText = NSAttributedString(string: subtitleText, font: subtitleFont, textColor: self.theme.actionSheet.secondaryTextColor)
}
self.contentGridNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ShareControllerPeerGridItemNode {
itemNode.updateSelection(animated: animated)
}
}
}
@objc func searchPressed() {
self.openSearch?()
}
@objc func sharePressed() {
self.openShare?(self.shareReferenceNode, nil)
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let nodes: [ASDisplayNode] = [self.searchButtonNode, self.shareButtonNode, self.contentTitleAccountNode]
for node in nodes {
let nodeFrame = node.frame
if node.isHidden {
continue
}
if let result = node.hitTest(point.offsetBy(dx: -self.headerNode.frame.minX, dy: -self.headerNode.frame.minY).offsetBy(dx: -nodeFrame.minX, dy: -nodeFrame.minY), with: event) {
return result
}
}
return super.hitTest(point, with: event)
}
@objc private func accountTapGesture(_ recognizer: UITapGestureRecognizer) {
self.switchToAnotherAccount()
}
private var debugTapCounter: (Double, Int) = (0.0, 0)
@objc private func debugTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
let timestamp = CACurrentMediaTime()
if self.debugTapCounter.0 < timestamp - 0.4 {
self.debugTapCounter.0 = timestamp
self.debugTapCounter.1 = 0
}
if self.debugTapCounter.0 >= timestamp - 0.4 {
self.debugTapCounter.0 = timestamp
self.debugTapCounter.1 += 1
}
if self.debugTapCounter.1 >= 10 {
self.debugTapCounter.1 = 0
self.debugAction()
}
}
}
}
func generatePeersMaskImage() -> UIImage? {
return generateImage(CGSize(width: 100.0, height: 100.0), contextGenerator: { size, context in
context.clear(CGRect(origin: .zero, size: size))
let path = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size).insetBy(dx: 16.0, dy: 16.0), cornerRadius: 16.0)
context.setFillColor(UIColor.white.cgColor)
context.setShadow(offset: .zero, blur: 40.0, color: UIColor.white.cgColor)
for _ in 0 ..< 10 {
context.addPath(path.cgPath)
context.fillPath()
}
})?.stretchableImage(withLeftCapWidth: 49, topCapHeight: 49)
}