import Foundation import UIKit import Display import AsyncDisplayKit import SwiftSignalKit import TelegramCore import TelegramPresentationData import TelegramUIPreferences import ItemListUI import PresentationDataUtils import AvatarNode import TelegramStringFormatting import ContextUI import AccountContext import LegacyComponents import AudioBlob import PeerInfoAvatarListNode import ComponentFlow import EmojiStatusComponent import VoiceChatActionButton final class VoiceChatParticipantItem: ListViewItem { enum ParticipantText: Equatable { struct TextIcon: OptionSet { public var rawValue: Int32 public init(rawValue: Int32) { self.rawValue = rawValue } public init() { self.rawValue = 0 } public static let volume = TextIcon(rawValue: 1 << 0) public static let video = TextIcon(rawValue: 1 << 1) public static let screen = TextIcon(rawValue: 1 << 2) } enum TextColor { case generic case accent case constructive case destructive } case text(String, TextIcon, TextColor) case none } enum Icon { case none case microphone(Bool, UIColor) case invite(Bool) case wantsToSpeak } let presentationData: ItemListPresentationData let dateTimeFormat: PresentationDateTimeFormat let nameDisplayOrder: PresentationPersonNameOrder let context: AccountContext let peer: EnginePeer let text: ParticipantText let expandedText: ParticipantText? let icon: Icon let getAudioLevel: (() -> Signal)? let action: ((ASDisplayNode?) -> Void)? let contextAction: ((ASDisplayNode, ContextGesture?) -> Void)? let getIsExpanded: () -> Bool let getUpdatingAvatar: () -> Signal<(TelegramMediaImageRepresentation, Float)?, NoError> public let selectable: Bool = true public init(presentationData: ItemListPresentationData, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, context: AccountContext, peer: EnginePeer, text: ParticipantText, expandedText: ParticipantText?, icon: Icon, getAudioLevel: (() -> Signal)?, action: ((ASDisplayNode?) -> Void)?, contextAction: ((ASDisplayNode, ContextGesture?) -> Void)? = nil, getIsExpanded: @escaping () -> Bool, getUpdatingAvatar: @escaping () -> Signal<(TelegramMediaImageRepresentation, Float)?, NoError>) { self.presentationData = presentationData self.dateTimeFormat = dateTimeFormat self.nameDisplayOrder = nameDisplayOrder self.context = context self.peer = peer self.text = text self.expandedText = expandedText self.icon = icon self.getAudioLevel = getAudioLevel self.action = action self.contextAction = contextAction self.getIsExpanded = getIsExpanded self.getUpdatingAvatar = getUpdatingAvatar } public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { async { let node = VoiceChatParticipantItemNode() let (layout, apply) = node.asyncLayout()(self, params, previousItem == nil || previousItem is VoiceChatTilesGridItem, nextItem == nil) node.contentSize = layout.contentSize node.insets = layout.insets Queue.mainQueue().async { completion(node, { return (node.avatarNode.ready, { _ in apply(synchronousLoads, false) }) }) } } } 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? VoiceChatParticipantItemNode { let makeLayout = nodeValue.asyncLayout() var animated = true if case .None = animation { animated = false } async { let (layout, apply) = makeLayout(self, params, previousItem == nil || previousItem is VoiceChatTilesGridItem, nextItem == nil) Queue.mainQueue().async { completion(layout, { _ in apply(false, animated) }) } } } } } public func selected(listView: ListView) { listView.clearHighlightAnimated(true) } } private let avatarFont = avatarPlaceholderFont(size: floor(40.0 * 16.0 / 37.0)) private let tileSize = CGSize(width: 84.0, height: 84.0) private let backgroundCornerRadius: CGFloat = 14.0 private let avatarSize: CGFloat = 40.0 private let accentColor: UIColor = UIColor(rgb: 0x007aff) private let constructiveColor: UIColor = UIColor(rgb: 0x34c759) private let destructiveColor: UIColor = UIColor(rgb: 0xff3b30) class VoiceChatParticipantStatusNode: ASDisplayNode { private var iconNodes: [ASImageNode] private let textNode: TextNode private var currentParams: (CGSize, VoiceChatParticipantItem.ParticipantText)? override init() { self.iconNodes = [] self.textNode = TextNode() self.textNode.isUserInteractionEnabled = false self.textNode.contentMode = .left self.textNode.contentsScale = UIScreen.main.scale super.init() self.addSubnode(self.textNode) } func asyncLayout() -> (_ size: CGSize, _ text: VoiceChatParticipantItem.ParticipantText, _ expanded: Bool) -> (CGSize, () -> Void) { let makeTextLayout = TextNode.asyncLayout(self.textNode) return { size, text, expanded in let statusFont = Font.regular(14.0) var attributedString: NSAttributedString? var color: UIColor = .white var hasVolume = false var hasVideo = false var hasScreen = false switch text { case let .text(text, textIcon, textColor): hasVolume = textIcon.contains(.volume) hasVideo = textIcon.contains(.video) hasScreen = textIcon.contains(.screen) var textColorValue: UIColor switch textColor { case .generic: textColorValue = UIColor(rgb: 0x98989e) case .accent: textColorValue = accentColor case .constructive: textColorValue = constructiveColor case .destructive: textColorValue = destructiveColor } color = textColorValue attributedString = NSAttributedString(string: text, font: statusFont, textColor: textColorValue) default: break } let iconSize = CGSize(width: 16.0, height: 16.0) let spacing: CGFloat = 3.0 var icons: [UIImage] = [] if hasVolume, let image = generateTintedImage(image: UIImage(bundleImageName: "Call/StatusVolume"), color: color) { icons.append(image) } if hasVideo, let image = generateTintedImage(image: UIImage(bundleImageName: "Call/StatusVideo"), color: color) { icons.append(image) } if hasScreen, let image = generateTintedImage(image: UIImage(bundleImageName: "Call/StatusScreen"), color: color) { icons.append(image) } let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedString, backgroundColor: nil, maximumNumberOfLines: expanded ? 4 : 1, truncationType: .end, constrainedSize: CGSize(width: size.width - (iconSize.width + spacing) * CGFloat(icons.count), height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) var contentSize = textLayout.size contentSize.width += (iconSize.width + spacing) * CGFloat(icons.count) return (contentSize, { [weak self] in guard let strongSelf = self else { return } strongSelf.currentParams = (size, text) for i in 0 ..< icons.count { let iconNode: ASImageNode if strongSelf.iconNodes.count >= i + 1 { iconNode = strongSelf.iconNodes[i] } else { iconNode = ASImageNode() strongSelf.addSubnode(iconNode) strongSelf.iconNodes.append(iconNode) } iconNode.frame = CGRect(origin: CGPoint(x: (iconSize.width + spacing) * CGFloat(i), y: 1.0), size: iconSize) iconNode.image = icons[i] } if strongSelf.iconNodes.count > icons.count { for i in icons.count ..< strongSelf.iconNodes.count { strongSelf.iconNodes[i].image = nil } } let _ = textApply() strongSelf.textNode.frame = CGRect(origin: CGPoint(x: (iconSize.width + spacing) * CGFloat(icons.count), y: 0.0), size: textLayout.size) }) } } } class VoiceChatParticipantItemNode: ItemListRevealOptionsItemNode { private let topStripeNode: ASDisplayNode private let bottomStripeNode: ASDisplayNode private let highlightContainerNode: ASDisplayNode private let highlightedBackgroundNode: ASDisplayNode let contextSourceNode: ContextExtractedContentContainingNode private let containerNode: ContextControllerSourceNode private let backgroundImageNode: ASImageNode private let extractedBackgroundImageNode: ASImageNode private let offsetContainerNode: ASDisplayNode private var extractedRect: CGRect? private var nonExtractedRect: CGRect? private var extractedVerticalOffset: CGFloat? let avatarNode: AvatarNode private let contentWrapperNode: ASDisplayNode private let titleNode: TextNode private let statusNode: VoiceChatParticipantStatusNode private let expandedStatusNode: VoiceChatParticipantStatusNode private var credibilityIconView: ComponentHostView? private var avatarTransitionNode: ASImageNode? private var avatarListContainerNode: ASDisplayNode? private var avatarListWrapperNode: PinchSourceContainerNode? private var avatarListNode: PeerInfoAvatarListContainerNode? private let actionContainerNode: ASDisplayNode private var animationNode: VoiceChatMicrophoneNode? private var iconNode: ASImageNode? private var raiseHandNode: VoiceChatRaiseHandNode? private var actionButtonNode: HighlightableButtonNode var audioLevelView: VoiceBlobView? private let audioLevelDisposable = MetaDisposable() private var didSetupAudioLevel = false private var absoluteLocation: (CGRect, CGSize)? private var layoutParams: (VoiceChatParticipantItem, ListViewItemLayoutParams, Bool, Bool)? private var isExtracted = false private var animatingExtraction = false private var wavesColor: UIColor? private var raiseHandTimer: SwiftSignalKit.Timer? private var silenceTimer: SwiftSignalKit.Timer? var item: VoiceChatParticipantItem? { return self.layoutParams?.0 } private var currentTitle: String? init() { self.topStripeNode = ASDisplayNode() self.topStripeNode.isLayerBacked = true self.bottomStripeNode = ASDisplayNode() self.bottomStripeNode.isLayerBacked = true self.contextSourceNode = ContextExtractedContentContainingNode() self.containerNode = ContextControllerSourceNode() self.backgroundImageNode = ASImageNode() self.backgroundImageNode.clipsToBounds = true self.backgroundImageNode.displaysAsynchronously = false self.backgroundImageNode.alpha = 0.0 self.extractedBackgroundImageNode = ASImageNode() self.extractedBackgroundImageNode.clipsToBounds = true self.extractedBackgroundImageNode.displaysAsynchronously = false self.extractedBackgroundImageNode.alpha = 0.0 self.offsetContainerNode = ASDisplayNode() self.avatarNode = AvatarNode(font: avatarFont) self.avatarNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 40.0, height: 40.0)) self.contentWrapperNode = ASDisplayNode() self.titleNode = TextNode() self.titleNode.isUserInteractionEnabled = false self.titleNode.contentMode = .left self.titleNode.contentsScale = UIScreen.main.scale self.statusNode = VoiceChatParticipantStatusNode() self.statusNode.isUserInteractionEnabled = false self.expandedStatusNode = VoiceChatParticipantStatusNode() self.expandedStatusNode.isUserInteractionEnabled = false self.expandedStatusNode.alpha = 0.0 self.actionContainerNode = ASDisplayNode() self.actionButtonNode = HighlightableButtonNode() self.highlightContainerNode = ASDisplayNode() self.highlightContainerNode.clipsToBounds = true self.highlightedBackgroundNode = ASDisplayNode() super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false) self.isAccessibilityElement = true self.highlightContainerNode.addSubnode(self.highlightedBackgroundNode) self.containerNode.addSubnode(self.contextSourceNode) self.containerNode.targetNodeForActivationProgress = self.contextSourceNode.contentNode self.addSubnode(self.containerNode) self.contextSourceNode.contentNode.addSubnode(self.backgroundImageNode) self.backgroundImageNode.addSubnode(self.extractedBackgroundImageNode) self.contextSourceNode.contentNode.addSubnode(self.offsetContainerNode) self.offsetContainerNode.addSubnode(self.contentWrapperNode) self.contentWrapperNode.addSubnode(self.titleNode) self.contentWrapperNode.addSubnode(self.statusNode) self.contentWrapperNode.addSubnode(self.expandedStatusNode) self.contentWrapperNode.addSubnode(self.actionContainerNode) self.actionContainerNode.addSubnode(self.actionButtonNode) self.offsetContainerNode.addSubnode(self.avatarNode) self.containerNode.targetNodeForActivationProgress = self.contextSourceNode.contentNode self.actionButtonNode.addTarget(self, action: #selector(self.actionButtonPressed), forControlEvents: .touchUpInside) self.containerNode.shouldBegin = { [weak self] location in guard let strongSelf = self else { return false } if strongSelf.actionButtonNode.frame.contains(location) { return false } return true } self.containerNode.activated = { [weak self] gesture, _ in guard let strongSelf = self, let item = strongSelf.layoutParams?.0, let contextAction = item.contextAction else { gesture.cancel() return } contextAction(strongSelf.contextSourceNode, gesture) } self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in guard let strongSelf = self, let item = strongSelf.layoutParams?.0 else { return } strongSelf.isExtracted = isExtracted let inset: CGFloat = 0.0 if isExtracted { strongSelf.contextSourceNode.contentNode.customHitTest = { [weak self] point in if let strongSelf = self { if let avatarListWrapperNode = strongSelf.avatarListWrapperNode, avatarListWrapperNode.frame.contains(point) { return strongSelf.avatarListNode?.view } } return nil } } else { strongSelf.contextSourceNode.contentNode.customHitTest = nil } let extractedVerticalOffset = strongSelf.extractedVerticalOffset ?? 0.0 if let extractedRect = strongSelf.extractedRect, let nonExtractedRect = strongSelf.nonExtractedRect { let rect: CGRect if isExtracted { if extractedVerticalOffset > 0.0 { rect = CGRect(x: extractedRect.minX, y: extractedRect.minY + extractedVerticalOffset, width: extractedRect.width, height: extractedRect.height - extractedVerticalOffset) } else { rect = extractedRect } } else { rect = nonExtractedRect } let springDuration: Double = isExtracted ? 0.42 : 0.3 let springDamping: CGFloat = isExtracted ? 124.0 : 1000.0 let itemBackgroundColor: UIColor = item.getIsExpanded() ? UIColor(rgb: 0x1c1c1e) : UIColor(rgb: 0x2c2c2e) if !extractedVerticalOffset.isZero { let radiusTransition = ContainedViewLayoutTransition.animated(duration: 0.15, curve: .easeInOut) if isExtracted { strongSelf.backgroundImageNode.image = generateImage(CGSize(width: backgroundCornerRadius * 2.0, height: backgroundCornerRadius * 2.0), rotatedContext: { (size, context) in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) context.setFillColor(itemBackgroundColor.cgColor) context.fillEllipse(in: bounds) context.fill(CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height / 2.0)) })?.stretchableImage(withLeftCapWidth: Int(backgroundCornerRadius), topCapHeight: Int(backgroundCornerRadius)) strongSelf.extractedBackgroundImageNode.image = generateImage(CGSize(width: backgroundCornerRadius * 2.0, height: backgroundCornerRadius * 2.0), rotatedContext: { (size, context) in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) context.setFillColor(item.presentationData.theme.list.itemBlocksBackgroundColor.cgColor) context.fillEllipse(in: bounds) context.fill(CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height / 2.0)) })?.stretchableImage(withLeftCapWidth: Int(backgroundCornerRadius), topCapHeight: Int(backgroundCornerRadius)) strongSelf.backgroundImageNode.cornerRadius = backgroundCornerRadius strongSelf.avatarNode.transform = CATransform3DIdentity var avatarInitialRect = strongSelf.avatarNode.view.convert(strongSelf.avatarNode.bounds, to: strongSelf.offsetContainerNode.supernode?.view) if strongSelf.avatarTransitionNode == nil { transition.updateCornerRadius(node: strongSelf.backgroundImageNode, cornerRadius: 0.0) let targetRect = CGRect(x: extractedRect.minX, y: extractedRect.minY, width: extractedRect.width, height: extractedRect.width) let initialScale = avatarInitialRect.width / targetRect.width avatarInitialRect.origin.y += backgroundCornerRadius / 2.0 * initialScale let avatarListWrapperNode = PinchSourceContainerNode() avatarListWrapperNode.clipsToBounds = true avatarListWrapperNode.cornerRadius = backgroundCornerRadius avatarListWrapperNode.activate = { [weak self] sourceNode in guard let strongSelf = self else { return } strongSelf.avatarListNode?.controlsContainerNode.alpha = 0.0 let pinchController = PinchController(sourceNode: sourceNode, getContentAreaInScreenSpace: { return UIScreen.main.bounds }) item.context.sharedContext.mainWindow?.presentInGlobalOverlay(pinchController) } avatarListWrapperNode.deactivated = { [weak self] in guard let strongSelf = self else { return } strongSelf.avatarListWrapperNode?.contentNode.layer.animate(from: 0.0 as NSNumber, to: backgroundCornerRadius as NSNumber, keyPath: "cornerRadius", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.3, completion: { _ in }) } avatarListWrapperNode.update(size: targetRect.size, transition: .immediate) avatarListWrapperNode.frame = CGRect(x: targetRect.minX, y: targetRect.minY, width: targetRect.width, height: targetRect.height + backgroundCornerRadius) avatarListWrapperNode.animatedOut = { [weak self] in guard let strongSelf = self else { return } strongSelf.avatarListNode?.controlsContainerNode.alpha = 1.0 strongSelf.avatarListNode?.controlsContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } let transitionNode = ASImageNode() transitionNode.clipsToBounds = true transitionNode.displaysAsynchronously = false transitionNode.displayWithoutProcessing = true transitionNode.image = strongSelf.avatarNode.unroundedImage transitionNode.frame = CGRect(origin: CGPoint(), size: targetRect.size) transitionNode.cornerRadius = targetRect.width / 2.0 radiusTransition.updateCornerRadius(node: transitionNode, cornerRadius: 0.0) strongSelf.avatarNode.isHidden = true avatarListWrapperNode.contentNode.addSubnode(transitionNode) strongSelf.avatarTransitionNode = transitionNode let avatarListContainerNode = ASDisplayNode() avatarListContainerNode.clipsToBounds = true avatarListContainerNode.frame = CGRect(origin: CGPoint(), size: targetRect.size) avatarListContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) avatarListContainerNode.cornerRadius = targetRect.width / 2.0 avatarListWrapperNode.layer.animateSpring(from: initialScale as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, initialVelocity: 0.0, damping: springDamping) avatarListWrapperNode.layer.animateSpring(from: NSValue(cgPoint: avatarInitialRect.center), to: NSValue(cgPoint: avatarListWrapperNode.position), keyPath: "position", duration: springDuration, initialVelocity: 0.0, damping: springDamping) radiusTransition.updateCornerRadius(node: avatarListContainerNode, cornerRadius: 0.0) let avatarListNode = PeerInfoAvatarListContainerNode(context: item.context) avatarListWrapperNode.contentNode.clipsToBounds = true avatarListNode.backgroundColor = .clear avatarListNode.peer = item.peer avatarListNode.firstFullSizeOnly = true avatarListNode.offsetLocation = true avatarListNode.customCenterTapAction = { [weak self] in self?.contextSourceNode.requestDismiss?() } avatarListNode.frame = CGRect(x: targetRect.width / 2.0, y: targetRect.height / 2.0, width: targetRect.width, height: targetRect.height) avatarListNode.controlsClippingNode.frame = CGRect(x: -targetRect.width / 2.0, y: -targetRect.height / 2.0, width: targetRect.width, height: targetRect.height) avatarListNode.controlsClippingOffsetNode.frame = CGRect(origin: CGPoint(x: targetRect.width / 2.0, y: targetRect.height / 2.0), size: CGSize()) avatarListNode.stripContainerNode.frame = CGRect(x: 0.0, y: 13.0, width: targetRect.width, height: 2.0) avatarListNode.topShadowNode.frame = CGRect(x: 0.0, y: 0.0, width: targetRect.width, height: 44.0) avatarListContainerNode.addSubnode(avatarListNode) avatarListContainerNode.addSubnode(avatarListNode.controlsClippingOffsetNode) avatarListWrapperNode.contentNode.addSubnode(avatarListContainerNode) avatarListNode.update(size: targetRect.size, peer: item.peer, customNode: nil, additionalEntry: item.getUpdatingAvatar(), isExpanded: true, transition: .immediate) strongSelf.offsetContainerNode.supernode?.addSubnode(avatarListWrapperNode) strongSelf.audioLevelView?.alpha = 0.0 strongSelf.avatarListWrapperNode = avatarListWrapperNode strongSelf.avatarListContainerNode = avatarListContainerNode strongSelf.avatarListNode = avatarListNode } } else if let transitionNode = strongSelf.avatarTransitionNode, let avatarListWrapperNode = strongSelf.avatarListWrapperNode, let avatarListContainerNode = strongSelf.avatarListContainerNode { strongSelf.animatingExtraction = true transition.updateCornerRadius(node: strongSelf.backgroundImageNode, cornerRadius: backgroundCornerRadius) var avatarInitialRect = CGRect(origin: strongSelf.avatarNode.frame.origin, size: strongSelf.avatarNode.frame.size) let targetScale = avatarInitialRect.width / avatarListContainerNode.frame.width avatarInitialRect.origin.y += backgroundCornerRadius / 2.0 * targetScale strongSelf.avatarTransitionNode = nil strongSelf.avatarListWrapperNode = nil strongSelf.avatarListContainerNode = nil strongSelf.avatarListNode = nil avatarListContainerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak avatarListContainerNode] _ in avatarListContainerNode?.removeFromSupernode() }) avatarListWrapperNode.layer.animate(from: 1.0 as NSNumber, to: targetScale as NSNumber, keyPath: "transform.scale", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2, removeOnCompletion: false) avatarListWrapperNode.layer.animate(from: NSValue(cgPoint: avatarListWrapperNode.position), to: NSValue(cgPoint: avatarInitialRect.center), keyPath: "position", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2, removeOnCompletion: false, completion: { [weak transitionNode, weak self] _ in transitionNode?.removeFromSupernode() self?.avatarNode.isHidden = false self?.audioLevelView?.alpha = 1.0 self?.audioLevelView?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) if let strongSelf = self { strongSelf.animatingExtraction = false } }) radiusTransition.updateCornerRadius(node: avatarListContainerNode, cornerRadius: avatarListContainerNode.frame.width / 2.0) radiusTransition.updateCornerRadius(node: transitionNode, cornerRadius: avatarListContainerNode.frame.width / 2.0) } let alphaTransition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) alphaTransition.updateAlpha(node: strongSelf.statusNode, alpha: isExtracted ? 0.0 : 1.0) alphaTransition.updateAlpha(node: strongSelf.expandedStatusNode, alpha: isExtracted ? 1.0 : 0.0) alphaTransition.updateAlpha(node: strongSelf.actionContainerNode, alpha: isExtracted ? 0.0 : 1.0, delay: isExtracted ? 0.0 : 0.1) let offsetInitialSublayerTransform = strongSelf.offsetContainerNode.layer.sublayerTransform strongSelf.offsetContainerNode.layer.sublayerTransform = CATransform3DMakeTranslation(isExtracted ? -43 : 0.0, isExtracted ? extractedVerticalOffset : 0.0, 0.0) let actionInitialSublayerTransform = strongSelf.actionContainerNode.layer.sublayerTransform strongSelf.actionContainerNode.layer.sublayerTransform = CATransform3DMakeTranslation(isExtracted ? 43.0 : 0.0, 0.0, 0.0) let initialBackgroundPosition = strongSelf.backgroundImageNode.position strongSelf.backgroundImageNode.layer.position = rect.center let initialBackgroundBounds = strongSelf.backgroundImageNode.bounds strongSelf.backgroundImageNode.layer.bounds = CGRect(origin: CGPoint(), size: rect.size) let initialExtractedBackgroundPosition = strongSelf.extractedBackgroundImageNode.position strongSelf.extractedBackgroundImageNode.layer.position = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0) let initialExtractedBackgroundBounds = strongSelf.extractedBackgroundImageNode.bounds strongSelf.extractedBackgroundImageNode.layer.bounds = strongSelf.backgroundImageNode.layer.bounds if isExtracted { strongSelf.offsetContainerNode.layer.animateSpring(from: NSValue(caTransform3D: offsetInitialSublayerTransform), to: NSValue(caTransform3D: strongSelf.offsetContainerNode.layer.sublayerTransform), keyPath: "sublayerTransform", duration: springDuration, delay: 0.0, initialVelocity: 0.0, damping: springDamping) strongSelf.actionContainerNode.layer.animateSpring(from: NSValue(caTransform3D: actionInitialSublayerTransform), to: NSValue(caTransform3D: strongSelf.actionContainerNode.layer.sublayerTransform), keyPath: "sublayerTransform", duration: springDuration, delay: 0.0, initialVelocity: 0.0, damping: springDamping) strongSelf.backgroundImageNode.layer.animateSpring(from: NSValue(cgPoint: initialBackgroundPosition), to: NSValue(cgPoint: strongSelf.backgroundImageNode.position), keyPath: "position", duration: springDuration, delay: 0.0, initialVelocity: 0.0, damping: springDamping) strongSelf.backgroundImageNode.layer.animateSpring(from: NSValue(cgRect: initialBackgroundBounds), to: NSValue(cgRect: strongSelf.backgroundImageNode.bounds), keyPath: "bounds", duration: springDuration, initialVelocity: 0.0, damping: springDamping) strongSelf.extractedBackgroundImageNode.layer.animateSpring(from: NSValue(cgPoint: initialExtractedBackgroundPosition), to: NSValue(cgPoint: strongSelf.extractedBackgroundImageNode.position), keyPath: "position", duration: springDuration, delay: 0.0, initialVelocity: 0.0, damping: springDamping) strongSelf.extractedBackgroundImageNode.layer.animateSpring(from: NSValue(cgRect: initialExtractedBackgroundBounds), to: NSValue(cgRect: strongSelf.extractedBackgroundImageNode.bounds), keyPath: "bounds", duration: springDuration, initialVelocity: 0.0, damping: springDamping) } else { strongSelf.offsetContainerNode.layer.animate(from: NSValue(caTransform3D: offsetInitialSublayerTransform), to: NSValue(caTransform3D: strongSelf.offsetContainerNode.layer.sublayerTransform), keyPath: "sublayerTransform", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) strongSelf.actionContainerNode.layer.animate(from: NSValue(caTransform3D: actionInitialSublayerTransform), to: NSValue(caTransform3D: strongSelf.actionContainerNode.layer.sublayerTransform), keyPath: "sublayerTransform", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) strongSelf.backgroundImageNode.layer.animate(from: NSValue(cgPoint: initialBackgroundPosition), to: NSValue(cgPoint: strongSelf.backgroundImageNode.position), keyPath: "position", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) strongSelf.backgroundImageNode.layer.animate(from: NSValue(cgRect: initialBackgroundBounds), to: NSValue(cgRect: strongSelf.backgroundImageNode.bounds), keyPath: "bounds", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) strongSelf.extractedBackgroundImageNode.layer.animate(from: NSValue(cgPoint: initialExtractedBackgroundPosition), to: NSValue(cgPoint: strongSelf.extractedBackgroundImageNode.position), keyPath: "position", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) strongSelf.extractedBackgroundImageNode.layer.animate(from: NSValue(cgRect: initialExtractedBackgroundBounds), to: NSValue(cgRect: strongSelf.extractedBackgroundImageNode.bounds), keyPath: "bounds", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) } if isExtracted { strongSelf.backgroundImageNode.alpha = 1.0 strongSelf.extractedBackgroundImageNode.alpha = 1.0 strongSelf.extractedBackgroundImageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, delay: 0.1, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) } else { strongSelf.extractedBackgroundImageNode.alpha = 0.0 strongSelf.extractedBackgroundImageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, delay: 0.0, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in if let strongSelf = self { strongSelf.backgroundImageNode.image = nil strongSelf.extractedBackgroundImageNode.image = nil strongSelf.extractedBackgroundImageNode.layer.removeAllAnimations() } }) } } else { if isExtracted { strongSelf.backgroundImageNode.alpha = 0.0 strongSelf.extractedBackgroundImageNode.alpha = 1.0 strongSelf.backgroundImageNode.image = generateStretchableFilledCircleImage(diameter: backgroundCornerRadius * 2.0, color: itemBackgroundColor) strongSelf.extractedBackgroundImageNode.image = generateStretchableFilledCircleImage(diameter: backgroundCornerRadius * 2.0, color: item.presentationData.theme.list.itemBlocksBackgroundColor) } transition.updateFrame(node: strongSelf.backgroundImageNode, frame: rect) transition.updateFrame(node: strongSelf.extractedBackgroundImageNode, frame: CGRect(origin: CGPoint(), size: rect.size)) transition.updateAlpha(node: strongSelf.statusNode, alpha: isExtracted ? 0.0 : 1.0) transition.updateAlpha(node: strongSelf.expandedStatusNode, alpha: isExtracted ? 1.0 : 0.0) transition.updateAlpha(node: strongSelf.actionContainerNode, alpha: isExtracted ? 0.0 : 1.0) transition.updateSublayerTransformOffset(layer: strongSelf.offsetContainerNode.layer, offset: CGPoint(x: isExtracted ? inset : 0.0, y: isExtracted ? extractedVerticalOffset : 0.0)) transition.updateSublayerTransformOffset(layer: strongSelf.actionContainerNode.layer, offset: CGPoint(x: isExtracted ? -inset * 2.0 : 0.0, y: 0.0)) transition.updateAlpha(node: strongSelf.backgroundImageNode, alpha: isExtracted ? 1.0 : 0.0, completion: { _ in if !isExtracted { self?.backgroundImageNode.image = nil self?.extractedBackgroundImageNode.image = nil } }) } } } } deinit { self.audioLevelDisposable.dispose() self.raiseHandTimer?.invalidate() self.silenceTimer?.invalidate() } override func didLoad() { super.didLoad() if #available(iOS 13.0, *) { self.highlightContainerNode.layer.cornerCurve = .continuous } } override func selected() { super.selected() self.layoutParams?.0.action?(self.contextSourceNode) } func animateTransitionIn(from sourceNode: ASDisplayNode, containerNode: ASDisplayNode, transition: ContainedViewLayoutTransition) { guard let _ = self.item, let sourceNode = sourceNode as? VoiceChatFullscreenParticipantItemNode, let _ = sourceNode.item else { return } var duration: Double = 0.2 var timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue if case let .animated(transitionDuration, curve) = transition { duration = transitionDuration + 0.08 timingFunction = curve.timingFunction } let startContainerAvatarPosition = sourceNode.avatarNode.view.convert(sourceNode.avatarNode.bounds, to: containerNode.view).center var animate = true if containerNode.frame.width > containerNode.frame.height { if startContainerAvatarPosition.y < -tileSize.height * 2.0 || startContainerAvatarPosition.y > containerNode.frame.height + tileSize.height * 2.0 { animate = false } } else { if startContainerAvatarPosition.x < -tileSize.width * 4.0 || startContainerAvatarPosition.x > containerNode.frame.width + tileSize.width * 4.0 { animate = false } } if animate { sourceNode.avatarNode.alpha = 0.0 sourceNode.audioLevelView?.alpha = 0.0 let initialAvatarPosition = self.avatarNode.position let initialBackgroundPosition = sourceNode.backgroundImageNode.position let initialContentPosition = sourceNode.contentWrapperNode.position let startContainerBackgroundPosition = sourceNode.backgroundImageNode.view.convert(sourceNode.backgroundImageNode.bounds, to: containerNode.view).center let startContainerContentPosition = sourceNode.contentWrapperNode.view.convert(sourceNode.contentWrapperNode.bounds, to: containerNode.view).center let targetContainerAvatarPosition = self.avatarNode.view.convert(self.avatarNode.bounds, to: containerNode.view).center sourceNode.backgroundImageNode.position = targetContainerAvatarPosition sourceNode.contentWrapperNode.position = targetContainerAvatarPosition containerNode.addSubnode(sourceNode.backgroundImageNode) containerNode.addSubnode(sourceNode.contentWrapperNode) sourceNode.highlightNode.alpha = 0.0 sourceNode.backgroundImageNode.layer.animatePosition(from: startContainerBackgroundPosition, to: targetContainerAvatarPosition, duration: duration, timingFunction: timingFunction, completion: { [weak sourceNode] _ in if let sourceNode = sourceNode { Queue.mainQueue().after(0.1, { sourceNode.backgroundImageNode.layer.removeAllAnimations() sourceNode.contentWrapperNode.layer.removeAllAnimations() }) sourceNode.backgroundImageNode.alpha = 1.0 sourceNode.highlightNode.alpha = 1.0 sourceNode.backgroundImageNode.position = initialBackgroundPosition sourceNode.contextSourceNode.contentNode.insertSubnode(sourceNode.backgroundImageNode, at: 0) } }) sourceNode.contentWrapperNode.layer.animatePosition(from: startContainerContentPosition, to: targetContainerAvatarPosition, duration: duration, timingFunction: timingFunction, completion: { [weak sourceNode] _ in if let sourceNode = sourceNode { sourceNode.avatarNode.alpha = 1.0 sourceNode.audioLevelView?.alpha = 1.0 sourceNode.contentWrapperNode.position = initialContentPosition sourceNode.offsetContainerNode.insertSubnode(sourceNode.contentWrapperNode, aboveSubnode: sourceNode.videoContainerNode) } }) if let audioLevelView = self.audioLevelView { audioLevelView.center = targetContainerAvatarPosition containerNode.view.addSubview(audioLevelView) audioLevelView.layer.animateScale(from: 1.25, to: 1.0, duration: duration, timingFunction: timingFunction) audioLevelView.layer.animatePosition(from: startContainerAvatarPosition, to: targetContainerAvatarPosition, duration: duration, timingFunction: timingFunction, removeOnCompletion: false) } self.avatarNode.position = targetContainerAvatarPosition containerNode.addSubnode(self.avatarNode) self.avatarNode.layer.animateScale(from: 1.25, to: 1.0, duration: duration, timingFunction: timingFunction) self.avatarNode.layer.animatePosition(from: startContainerAvatarPosition, to: targetContainerAvatarPosition, duration: duration, timingFunction: timingFunction, completion: { [weak self] _ in if let strongSelf = self { strongSelf.avatarNode.position = initialAvatarPosition strongSelf.offsetContainerNode.addSubnode(strongSelf.avatarNode) if let audioLevelView = strongSelf.audioLevelView { audioLevelView.layer.removeAllAnimations() audioLevelView.center = initialAvatarPosition strongSelf.offsetContainerNode.view.insertSubview(audioLevelView, at: 0) } } }) sourceNode.backgroundImageNode.layer.animateScale(from: 1.0, to: 0.001, duration: duration, timingFunction: timingFunction) sourceNode.backgroundImageNode.layer.animateAlpha(from: sourceNode.backgroundImageNode.alpha, to: 0.0, duration: duration, timingFunction: timingFunction, removeOnCompletion: false) sourceNode.contentWrapperNode.layer.animateScale(from: 1.0, to: 0.001, duration: duration, timingFunction: timingFunction) sourceNode.contentWrapperNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, timingFunction: timingFunction, removeOnCompletion: false) } } func asyncLayout() -> (_ item: VoiceChatParticipantItem, _ params: ListViewItemLayoutParams, _ first: Bool, _ last: Bool) -> (ListViewItemNodeLayout, (Bool, Bool) -> Void) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let makeStatusLayout = self.statusNode.asyncLayout() let makeExpandedStatusLayout = self.expandedStatusNode.asyncLayout() let currentItem = self.layoutParams?.0 let currentTitle = self.currentTitle return { item, params, first, last in var updatedTheme: PresentationTheme? if currentItem?.presentationData.theme !== item.presentationData.theme { updatedTheme = item.presentationData.theme } let titleFont = Font.regular(17.0) var titleAttributedString: NSAttributedString? let titleColor = item.presentationData.theme.list.itemPrimaryTextColor let rightInset: CGFloat = params.rightInset var updatedTitle = false if case let .user(user) = item.peer { if let firstName = user.firstName, let lastName = user.lastName, !firstName.isEmpty, !lastName.isEmpty { let string = NSMutableAttributedString() switch item.nameDisplayOrder { case .firstLast: string.append(NSAttributedString(string: firstName, font: titleFont, textColor: titleColor)) string.append(NSAttributedString(string: " ", font: titleFont, textColor: titleColor)) string.append(NSAttributedString(string: lastName, font: titleFont, textColor: titleColor)) case .lastFirst: string.append(NSAttributedString(string: lastName, font: titleFont, textColor: titleColor)) string.append(NSAttributedString(string: " ", font: titleFont, textColor: titleColor)) string.append(NSAttributedString(string: firstName, font: titleFont, textColor: titleColor)) } titleAttributedString = string } else if let firstName = user.firstName, !firstName.isEmpty { titleAttributedString = NSAttributedString(string: firstName, font: titleFont, textColor: titleColor) } else if let lastName = user.lastName, !lastName.isEmpty { titleAttributedString = NSAttributedString(string: lastName, font: titleFont, textColor: titleColor) } else { titleAttributedString = NSAttributedString(string: item.presentationData.strings.User_DeletedAccount, font: titleFont, textColor: titleColor) } } else if case let .legacyGroup(group) = item.peer { titleAttributedString = NSAttributedString(string: group.title, font: titleFont, textColor: titleColor) } else if case let .channel(channel) = item.peer { titleAttributedString = NSAttributedString(string: channel.title, font: titleFont, textColor: titleColor) } if let currentTitle = currentTitle, currentTitle != titleAttributedString?.string { updatedTitle = true } var wavesColor = UIColor(rgb: 0x34c759) if case let .text(_, _, textColor) = item.text { switch textColor { case .accent: wavesColor = accentColor case .destructive: wavesColor = destructiveColor default: break } } let leftInset: CGFloat = 58.0 + params.leftInset let verticalInset: CGFloat = 8.0 let verticalOffset: CGFloat = 0.0 let premiumConfiguration = PremiumConfiguration.with(appConfiguration: item.context.currentAppConfiguration.with { $0 }) var titleIconsWidth: CGFloat = 0.0 var credibilityIcon: EmojiStatusComponent.Content? if item.peer.isScam { credibilityIcon = .text(color: item.presentationData.theme.chat.message.incoming.scamColor, string: item.presentationData.strings.Message_ScamAccount.uppercased()) } else if item.peer.isFake { credibilityIcon = .text(color: item.presentationData.theme.chat.message.incoming.scamColor, string: item.presentationData.strings.Message_FakeAccount.uppercased()) } else if let emojiStatus = item.peer.emojiStatus { credibilityIcon = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 20.0, height: 20.0), placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor, themeColor: item.presentationData.theme.list.itemAccentColor, loopMode: .count(2)) } else if item.peer.isVerified { credibilityIcon = .verified(fillColor: item.presentationData.theme.list.itemCheckColors.fillColor, foregroundColor: item.presentationData.theme.list.itemCheckColors.foregroundColor, sizeType: .compact) } else if item.peer.isPremium && !premiumConfiguration.isPremiumDisabled { credibilityIcon = .premium(color: item.presentationData.theme.list.itemAccentColor) } if let credibilityIcon = credibilityIcon { titleIconsWidth += 4.0 switch credibilityIcon { case let .text(_, string): let textString = NSAttributedString(string: string, font: Font.bold(10.0), textColor: .black, paragraphAlignment: .center) let stringRect = textString.boundingRect(with: CGSize(width: 100.0, height: 16.0), options: .usesLineFragmentOrigin, context: nil) titleIconsWidth += floor(stringRect.width) + 11.0 default: titleIconsWidth += 16.0 } } var expandedRightInset: CGFloat = 30.0 if item.peer.smallProfileImage != nil { expandedRightInset = 0.0 } let constrainedWidth = params.width - leftInset - 12.0 - rightInset - 30.0 - titleIconsWidth let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) let expandedWidth = min(params.width - leftInset - rightInset, params.availableHeight - 30.0) let (statusLayout, statusApply) = makeStatusLayout(CGSize(width: params.width - leftInset - 8.0 - rightInset - 30.0, height: CGFloat.greatestFiniteMagnitude), item.text, false) let (expandedStatusLayout, expandedStatusApply) = makeExpandedStatusLayout(CGSize(width: expandedWidth - 8.0 - expandedRightInset, height: CGFloat.greatestFiniteMagnitude), item.expandedText ?? item.text, params.availableHeight > params.width) let titleSpacing: CGFloat = statusLayout.height == 0.0 ? 0.0 : 1.0 let minHeight: CGFloat = titleLayout.size.height + verticalInset * 2.0 let rawHeight: CGFloat = verticalInset * 2.0 + titleLayout.size.height + titleSpacing + statusLayout.height let contentSize = CGSize(width: params.width, height: max(minHeight, rawHeight)) let insets = UIEdgeInsets() let separatorHeight = UIScreenPixel let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) let layoutSize = layout.size var animateStatusTransitionFromUp: Bool? if let currentItem = currentItem { if case let .text(_, _, currentColor) = currentItem.text, case let .text(_, _, newColor) = item.text, currentColor != newColor { animateStatusTransitionFromUp = newColor == .constructive } } return (layout, { [weak self] synchronousLoad, animated in if let strongSelf = self { let hadItem = strongSelf.layoutParams?.0 != nil strongSelf.layoutParams = (item, params, first, last) strongSelf.currentTitle = titleAttributedString?.string strongSelf.wavesColor = wavesColor let nonExtractedRect: CGRect let avatarFrame: CGRect let titleFrame: CGRect let animationSize: CGSize let animationFrame: CGRect let animationScale: CGFloat nonExtractedRect = CGRect(origin: CGPoint(x: 16.0, y: 0.0), size: CGSize(width: layout.contentSize.width - 32.0, height: layout.contentSize.height)) avatarFrame = CGRect(origin: CGPoint(x: params.leftInset + 8.0, y: floorToScreenPixels((layout.contentSize.height - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) animationSize = CGSize(width: 36.0, height: 36.0) animationScale = 1.0 animationFrame = CGRect(x: params.width - animationSize.width - 6.0 - params.rightInset, y: floor((layout.contentSize.height - animationSize.height) / 2.0) + 1.0, width: animationSize.width, height: animationSize.height) titleFrame = CGRect(origin: CGPoint(x: leftInset, y: verticalInset + verticalOffset), size: titleLayout.size) var extractedRect = CGRect(origin: CGPoint(), size: layout.contentSize).insetBy(dx: params.leftInset, dy: 0.0) var extractedHeight = extractedRect.height + expandedStatusLayout.height - statusLayout.height var extractedVerticalOffset: CGFloat = 0.0 if item.peer.smallProfileImage != nil { extractedRect.size.width = min(extractedRect.width, params.availableHeight - 20.0) extractedVerticalOffset = extractedRect.width extractedHeight += extractedVerticalOffset } extractedRect.size.height = extractedHeight strongSelf.extractedVerticalOffset = extractedVerticalOffset strongSelf.extractedRect = extractedRect strongSelf.nonExtractedRect = nonExtractedRect if strongSelf.isExtracted { var extractedRect = extractedRect if !extractedVerticalOffset.isZero { extractedRect = CGRect(x: extractedRect.minX, y: extractedRect.minY + extractedVerticalOffset, width: extractedRect.width, height: extractedRect.height - extractedVerticalOffset) } strongSelf.backgroundImageNode.frame = extractedRect } else { strongSelf.backgroundImageNode.frame = nonExtractedRect } strongSelf.extractedBackgroundImageNode.frame = strongSelf.backgroundImageNode.bounds strongSelf.contextSourceNode.contentRect = extractedRect let contentBounds = CGRect(origin: CGPoint(), size: layout.contentSize) strongSelf.containerNode.frame = contentBounds strongSelf.contextSourceNode.frame = contentBounds strongSelf.contentWrapperNode.frame = contentBounds strongSelf.offsetContainerNode.frame = contentBounds strongSelf.contextSourceNode.contentNode.frame = contentBounds strongSelf.actionContainerNode.frame = contentBounds strongSelf.containerNode.isGestureEnabled = item.contextAction != nil strongSelf.accessibilityLabel = titleAttributedString?.string let combinedValueString = "" // if let statusString = statusAttributedString?.string, !statusString.isEmpty { // combinedValueString.append(statusString) // } strongSelf.accessibilityValue = combinedValueString if let _ = updatedTheme { strongSelf.topStripeNode.backgroundColor = UIColor(rgb: 0xffffff, alpha: 0.08) strongSelf.bottomStripeNode.backgroundColor = UIColor(rgb: 0xffffff, alpha: 0.08) strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor } let transition: ContainedViewLayoutTransition if animated && hadItem { transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) } else { transition = .immediate } if updatedTitle, let snapshotView = strongSelf.titleNode.view.snapshotContentTree() { strongSelf.titleNode.view.superview?.insertSubview(snapshotView, aboveSubview: strongSelf.titleNode.view) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) strongSelf.titleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } if let animateStatusTransitionFromUp = animateStatusTransitionFromUp, !strongSelf.contextSourceNode.isExtractedToContextPreview { let offset: CGFloat = animateStatusTransitionFromUp ? -7.0 : 7.0 if let snapshotView = strongSelf.statusNode.view.snapshotContentTree() { strongSelf.statusNode.view.superview?.insertSubview(snapshotView, belowSubview: strongSelf.statusNode.view) snapshotView.frame = strongSelf.statusNode.frame snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) snapshotView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -offset), duration: 0.2, removeOnCompletion: false, additive: true) strongSelf.statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) strongSelf.statusNode.layer.animatePosition(from: CGPoint(x: 0.0, y: offset), to: CGPoint(), duration: 0.2, additive: true) } } let _ = titleApply() let _ = statusApply() let _ = expandedStatusApply() if strongSelf.topStripeNode.supernode == nil { strongSelf.insertSubnode(strongSelf.topStripeNode, at: 0) } if strongSelf.bottomStripeNode.supernode == nil { strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 1) } strongSelf.topStripeNode.isHidden = first strongSelf.bottomStripeNode.isHidden = last transition.updateFrame(node: strongSelf.topStripeNode, frame: CGRect(origin: CGPoint(x: leftInset, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))) transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: leftInset, y: contentSize.height + -separatorHeight), size: CGSize(width: layoutSize.width - leftInset, height: separatorHeight))) transition.updateFrame(node: strongSelf.titleNode, frame: titleFrame) transition.updateFrame(node: strongSelf.statusNode, frame: CGRect(origin: CGPoint(x: leftInset, y: strongSelf.titleNode.frame.maxY + titleSpacing), size: statusLayout)) transition.updateFrame(node: strongSelf.expandedStatusNode, frame: CGRect(origin: CGPoint(x: leftInset, y: strongSelf.titleNode.frame.maxY + titleSpacing), size: expandedStatusLayout)) if let credibilityIcon = credibilityIcon { let animationCache = item.context.animationCache let animationRenderer = item.context.animationRenderer let credibilityIconView: ComponentHostView if let current = strongSelf.credibilityIconView { credibilityIconView = current } else { credibilityIconView = ComponentHostView() strongSelf.offsetContainerNode.view.addSubview(credibilityIconView) strongSelf.credibilityIconView = credibilityIconView } let iconSize = credibilityIconView.update( transition: .immediate, component: AnyComponent(EmojiStatusComponent( context: item.context, animationCache: animationCache, animationRenderer: animationRenderer, content: credibilityIcon, isVisibleForAnimations: true, action: nil, emojiFileUpdated: nil )), environment: {}, containerSize: CGSize(width: 20.0, height: 20.0) ) transition.updateFrame(view: credibilityIconView, frame: CGRect(origin: CGPoint(x: leftInset + titleLayout.size.width + 3.0, y: verticalInset + floor((titleFrame.height - iconSize.height) / 2.0)), size: iconSize)) } else if let credibilityIconView = strongSelf.credibilityIconView { strongSelf.credibilityIconView = nil credibilityIconView.removeFromSuperview() } transition.updateFrameAsPositionAndBounds(node: strongSelf.avatarNode, frame: avatarFrame) let blobFrame = avatarFrame.insetBy(dx: -14.0, dy: -14.0) if let getAudioLevel = item.getAudioLevel { if !strongSelf.didSetupAudioLevel || currentItem?.peer.id != item.peer.id { strongSelf.audioLevelView?.frame = blobFrame strongSelf.didSetupAudioLevel = true strongSelf.audioLevelDisposable.set((getAudioLevel() |> deliverOnMainQueue).start(next: { value in guard let strongSelf = self else { return } if strongSelf.audioLevelView == nil, value > 0.0, item.context.sharedContext.energyUsageSettings.fullTranslucency { let audioLevelView = VoiceBlobView( frame: blobFrame, maxLevel: 1.5, smallBlobRange: (0, 0), mediumBlobRange: (0.69, 0.87), bigBlobRange: (0.71, 1.0) ) let maskRect = CGRect(origin: .zero, size: blobFrame.size) let playbackMaskLayer = CAShapeLayer() playbackMaskLayer.frame = maskRect playbackMaskLayer.fillRule = .evenOdd let maskPath = UIBezierPath() maskPath.append(UIBezierPath(roundedRect: maskRect.insetBy(dx: 14, dy: 14), cornerRadius: 22)) maskPath.append(UIBezierPath(rect: maskRect)) playbackMaskLayer.path = maskPath.cgPath audioLevelView.layer.mask = playbackMaskLayer audioLevelView.setColor(wavesColor) audioLevelView.alpha = strongSelf.isExtracted ? 0.0 : 1.0 strongSelf.audioLevelView = audioLevelView strongSelf.offsetContainerNode.view.insertSubview(audioLevelView, at: 0) } let level = min(1.0, max(0.0, CGFloat(value))) if let audioLevelView = strongSelf.audioLevelView { audioLevelView.updateLevel(CGFloat(value)) let avatarScale: CGFloat if value > 0.02 { audioLevelView.startAnimating() avatarScale = 1.03 + level * 0.13 if let wavesColor = strongSelf.wavesColor { audioLevelView.setColor(wavesColor, animated: true) } if let silenceTimer = strongSelf.silenceTimer { silenceTimer.invalidate() strongSelf.silenceTimer = nil } } else { avatarScale = 1.0 if strongSelf.silenceTimer == nil { let silenceTimer = SwiftSignalKit.Timer(timeout: 1.0, repeat: false, completion: { [weak self] in self?.audioLevelView?.stopAnimating(duration: 0.75) self?.silenceTimer = nil }, queue: Queue.mainQueue()) strongSelf.silenceTimer = silenceTimer silenceTimer.start() } } let transition: ContainedViewLayoutTransition = .animated(duration: 0.15, curve: .easeInOut) transition.updateTransformScale(node: strongSelf.avatarNode, scale: strongSelf.isExtracted ? 1.0 : avatarScale, beginWithCurrentState: true) } })) } } else if let audioLevelView = strongSelf.audioLevelView { strongSelf.audioLevelView = nil audioLevelView.removeFromSuperview() strongSelf.audioLevelDisposable.set(nil) } var overrideImage: AvatarNodeImageOverride? if item.peer.isDeleted { overrideImage = .deletedIcon } strongSelf.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: item.peer, overrideImage: overrideImage, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, synchronousLoad: synchronousLoad, storeUnrounded: true) strongSelf.highlightContainerNode.frame = CGRect(origin: CGPoint(x: params.leftInset, y: -UIScreenPixel), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: layout.contentSize.height + UIScreenPixel + UIScreenPixel + 11.0)) strongSelf.highlightContainerNode.cornerRadius = first ? 11.0 : 0.0 strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: params.width, height: layout.contentSize.height + UIScreenPixel + UIScreenPixel)) var hadMicrophoneNode = false var hadRaiseHandNode = false var hadIconNode = false var nodeToAnimateIn: ASDisplayNode? if case let .microphone(muted, color) = item.icon { let animationNode: VoiceChatMicrophoneNode if let current = strongSelf.animationNode { animationNode = current } else { animationNode = VoiceChatMicrophoneNode() strongSelf.animationNode = animationNode strongSelf.actionButtonNode.addSubnode(animationNode) nodeToAnimateIn = animationNode } animationNode.alpha = 1.0 animationNode.update(state: VoiceChatMicrophoneNode.State(muted: muted, filled: false, color: color), animated: true) strongSelf.actionButtonNode.isUserInteractionEnabled = false } else if let animationNode = strongSelf.animationNode { hadMicrophoneNode = true strongSelf.animationNode = nil animationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) animationNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false, completion: { [weak animationNode] _ in animationNode?.removeFromSupernode() }) } if case .wantsToSpeak = item.icon { let raiseHandNode: VoiceChatRaiseHandNode if let current = strongSelf.raiseHandNode { raiseHandNode = current } else { raiseHandNode = VoiceChatRaiseHandNode(color: item.presentationData.theme.list.itemAccentColor) raiseHandNode.contentMode = .center strongSelf.raiseHandNode = raiseHandNode strongSelf.actionButtonNode.addSubnode(raiseHandNode) nodeToAnimateIn = raiseHandNode raiseHandNode.playRandomAnimation() strongSelf.raiseHandTimer = SwiftSignalKit.Timer(timeout: Double.random(in: 8.0 ... 10.5), repeat: true, completion: { self?.raiseHandNode?.playRandomAnimation() }, queue: Queue.mainQueue()) strongSelf.raiseHandTimer?.start() } strongSelf.actionButtonNode.isUserInteractionEnabled = false } else if let raiseHandNode = strongSelf.raiseHandNode { hadRaiseHandNode = true strongSelf.raiseHandNode = nil if let raiseHandTimer = strongSelf.raiseHandTimer { strongSelf.raiseHandTimer = nil raiseHandTimer.invalidate() } raiseHandNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) raiseHandNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false, completion: { [weak raiseHandNode] _ in raiseHandNode?.removeFromSupernode() }) } if case let .invite(invited) = item.icon { let iconNode: ASImageNode if let current = strongSelf.iconNode { iconNode = current } else { iconNode = ASImageNode() iconNode.contentMode = .center strongSelf.iconNode = iconNode strongSelf.actionButtonNode.addSubnode(iconNode) nodeToAnimateIn = iconNode } if invited { iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Invited"), color: UIColor(rgb: 0x979797)) } else { iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddUser"), color: item.presentationData.theme.list.itemAccentColor) } strongSelf.actionButtonNode.isUserInteractionEnabled = false } else if let iconNode = strongSelf.iconNode { hadIconNode = true strongSelf.iconNode = nil iconNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) iconNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false, completion: { [weak iconNode] _ in iconNode?.removeFromSupernode() }) } if let node = nodeToAnimateIn, hadMicrophoneNode || hadRaiseHandNode || hadIconNode { node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) node.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2) } strongSelf.avatarNode.isHidden = strongSelf.isExtracted strongSelf.iconNode?.frame = CGRect(origin: CGPoint(), size: animationSize) strongSelf.animationNode?.frame = CGRect(origin: CGPoint(), size: animationSize) strongSelf.raiseHandNode?.frame = CGRect(origin: CGPoint(), size: animationSize).insetBy(dx: -6.0, dy: -6.0).offsetBy(dx: -2.0, dy: 0.0) strongSelf.actionButtonNode.transform = CATransform3DMakeScale(animationScale, animationScale, 1.0) transition.updateFrame(node: strongSelf.actionButtonNode, frame: animationFrame) strongSelf.updateIsHighlighted(transition: transition) } }) } } var isHighlighted = false func updateIsHighlighted(transition: ContainedViewLayoutTransition) { if self.isHighlighted { self.highlightContainerNode.alpha = 1.0 if self.highlightContainerNode.supernode == nil { var anchorNode: ASDisplayNode? if self.bottomStripeNode.supernode != nil { anchorNode = self.bottomStripeNode } else if self.topStripeNode.supernode != nil { anchorNode = self.topStripeNode } if let anchorNode = anchorNode { self.insertSubnode(self.highlightContainerNode, aboveSubnode: anchorNode) } else { self.addSubnode(self.highlightContainerNode) } } } else { if self.highlightContainerNode.supernode != nil { if transition.isAnimated { self.highlightContainerNode.layer.animateAlpha(from: self.highlightContainerNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in if let strongSelf = self { if completed { strongSelf.highlightContainerNode.removeFromSupernode() } } }) self.highlightContainerNode.alpha = 0.0 } else { self.highlightContainerNode.removeFromSupernode() } } } } override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { super.setHighlighted(highlighted, at: point, animated: animated) self.isHighlighted = highlighted self.updateIsHighlighted(transition: (animated && !highlighted) ? .animated(duration: 0.3, curve: .easeInOut) : .immediate) } override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) } override func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } override func headers() -> [ListViewItemHeader]? { return nil } override func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { var rect = rect rect.origin.y += self.insets.top self.absoluteLocation = (rect, containerSize) } @objc private func actionButtonPressed() { if let item = self.layoutParams?.0, let contextAction = item.contextAction { contextAction(self.contextSourceNode, nil) } } }