import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import SyncCore import TelegramCore import AvatarNode import AccountContext import SwiftSignalKit import TelegramPresentationData import PhotoResources import PeerAvatarGalleryUI import TelegramStringFormatting import PhoneNumberFormat import ActivityIndicator import TelegramUniversalVideoContent import GalleryUI import UniversalMediaPlayer import RadialStatusNode import TelegramUIPreferences enum PeerInfoHeaderButtonKey: Hashable { case message case discussion case call case videoCall case voiceChat case mute case more case addMember case search case leave } enum PeerInfoHeaderButtonIcon { case message case call case videoCall case voiceChat case mute case unmute case more case addMember case search case leave } final class PeerInfoHeaderButtonNode: HighlightableButtonNode { let key: PeerInfoHeaderButtonKey private let action: (PeerInfoHeaderButtonNode) -> Void let containerNode: ASDisplayNode private let backgroundNode: ASImageNode private let textNode: ImmediateTextNode private var theme: PresentationTheme? private var icon: PeerInfoHeaderButtonIcon? init(key: PeerInfoHeaderButtonKey, action: @escaping (PeerInfoHeaderButtonNode) -> Void) { self.key = key self.action = action self.containerNode = ASDisplayNode() self.backgroundNode = ASImageNode() self.backgroundNode.displaysAsynchronously = false self.backgroundNode.displayWithoutProcessing = true self.textNode = ImmediateTextNode() self.textNode.displaysAsynchronously = false super.init() self.accessibilityTraits = .button self.addSubnode(self.containerNode) self.containerNode.addSubnode(self.backgroundNode) self.containerNode.addSubnode(self.textNode) self.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { strongSelf.layer.removeAnimation(forKey: "opacity") strongSelf.alpha = 0.4 } else { strongSelf.alpha = 1.0 strongSelf.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } } self.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) } @objc private func buttonPressed() { self.action(self) } func update(size: CGSize, text: String, icon: PeerInfoHeaderButtonIcon, isExpanded: Bool, presentationData: PresentationData, transition: ContainedViewLayoutTransition) { if self.theme != presentationData.theme || self.icon != icon { self.theme = presentationData.theme self.icon = icon self.backgroundNode.image = generateImage(CGSize(width: 40.0, height: 40.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(presentationData.theme.list.itemAccentColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) context.setBlendMode(.normal) context.setFillColor(presentationData.theme.list.itemCheckColors.foregroundColor.cgColor) let imageName: String switch icon { case .message: imageName = "Peer Info/ButtonMessage" case .call: imageName = "Peer Info/ButtonCall" case .videoCall: imageName = "Peer Info/ButtonVideo" case .voiceChat: imageName = "Peer Info/ButtonVoiceChat" case .mute: imageName = "Peer Info/ButtonMute" case .unmute: imageName = "Peer Info/ButtonUnmute" case .more: imageName = "Peer Info/ButtonMore" case .addMember: imageName = "Peer Info/ButtonAddMember" case .search: imageName = "Peer Info/ButtonSearch" case .leave: imageName = "Peer Info/ButtonLeave" } if let image = generateTintedImage(image: UIImage(bundleImageName: imageName), color: .white) { let imageRect = CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size) context.clip(to: imageRect, mask: image.cgImage!) context.fill(imageRect) } }) } self.textNode.attributedText = NSAttributedString(string: text, font: Font.regular(12.0), textColor: presentationData.theme.list.itemAccentColor) self.accessibilityLabel = text let titleSize = self.textNode.updateLayout(CGSize(width: 120.0, height: .greatestFiniteMagnitude)) transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(), size: size)) transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size)) transition.updateFrameAdditiveToCenter(node: self.textNode, frame: CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: size.height + 6.0), size: titleSize)) transition.updateAlpha(node: self.textNode, alpha: isExpanded ? 0.0 : 1.0) } } final class PeerInfoHeaderNavigationTransition { let sourceNavigationBar: NavigationBar let sourceTitleView: ChatTitleView let sourceTitleFrame: CGRect let sourceSubtitleFrame: CGRect let fraction: CGFloat init(sourceNavigationBar: NavigationBar, sourceTitleView: ChatTitleView, sourceTitleFrame: CGRect, sourceSubtitleFrame: CGRect, fraction: CGFloat) { self.sourceNavigationBar = sourceNavigationBar self.sourceTitleView = sourceTitleView self.sourceTitleFrame = sourceTitleFrame self.sourceSubtitleFrame = sourceSubtitleFrame self.fraction = fraction } } enum PeerInfoAvatarListItem: Equatable { case topImage([ImageRepresentationWithReference], [VideoRepresentationWithReference], Data?) case image(TelegramMediaImageReference?, [ImageRepresentationWithReference], [VideoRepresentationWithReference], Data?) var id: WrappedMediaResourceId { switch self { case let .topImage(representations, _, _): let representation = largestImageRepresentation(representations.map { $0.representation }) ?? representations[representations.count - 1].representation return WrappedMediaResourceId(representation.resource.id) case let .image(_, representations, _, _): let representation = largestImageRepresentation(representations.map { $0.representation }) ?? representations[representations.count - 1].representation return WrappedMediaResourceId(representation.resource.id) } } var videoRepresentations: [VideoRepresentationWithReference] { switch self { case let .topImage(_, videoRepresentations, _): return videoRepresentations case let .image(_, _, videoRepresentations, _): return videoRepresentations } } init(entry: AvatarGalleryEntry) { switch entry { case let .topImage(representations, videoRepresentations, _, _, immediateThumbnailData, _): self = .topImage(representations, videoRepresentations, immediateThumbnailData) case let .image(_, reference, representations, videoRepresentations, _, _, _, _, immediateThumbnailData, _): self = .image(reference, representations, videoRepresentations, immediateThumbnailData) } } } final class PeerInfoAvatarListItemNode: ASDisplayNode { private let context: AccountContext private let peer: Peer let imageNode: TransformImageNode private var videoNode: UniversalVideoNode? private var videoContent: NativeVideoContent? private var videoStartTimestamp: Double? private let playbackStartDisposable = MetaDisposable() private let statusDisposable = MetaDisposable() private let preloadDisposable = MetaDisposable() private let statusNode: RadialStatusNode private var playerStatus: MediaPlayerStatus? private var isLoading = ValuePromise(false) private var loadingProgress = ValuePromise(nil) private var loadingProgressDisposable = MetaDisposable() private var hasProgress = false let isReady = Promise() private var didSetReady: Bool = false var item: PeerInfoAvatarListItem? private var statusPromise = Promise<(MediaPlayerStatus?, Double?)?>() var mediaStatus: Signal<(MediaPlayerStatus?, Double?)?, NoError> { get { return self.statusPromise.get() } } var delayCentralityLose = false var isCentral: Bool? = nil { didSet { guard self.isCentral != oldValue, let isCentral = self.isCentral else { return } if isCentral { self.setupVideoPlayback() self.preloadDisposable.set(nil) } else { if let videoNode = self.videoNode { self.playbackStartDisposable.set(nil) self.statusPromise.set(.single(nil)) self.videoNode = nil if self.delayCentralityLose { Queue.mainQueue().after(0.5) { videoNode.removeFromSupernode() } } else { videoNode.removeFromSupernode() } } if let videoContent = self.videoContent { let duration: Double = (self.videoStartTimestamp ?? 0.0) + 4.0 self.preloadDisposable.set(preloadVideoResource(postbox: self.context.account.postbox, resourceReference: videoContent.fileReference.resourceReference(videoContent.fileReference.media.resource), duration: duration).start()) } } } } init(context: AccountContext, peer: Peer) { self.context = context self.peer = peer self.imageNode = TransformImageNode() self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.3)) self.statusNode.isUserInteractionEnabled = false super.init() self.clipsToBounds = true self.imageNode.contentAnimations = [.firstUpdate, .subsequentUpdates] self.addSubnode(self.imageNode) self.addSubnode(self.statusNode) self.loadingProgressDisposable.set((combineLatest(self.isLoading.get() |> mapToSignal { value -> Signal in if value { return .single(value) |> delay(0.5, queue: Queue.mainQueue()) } else { return .single(value) } } |> distinctUntilChanged, self.loadingProgress.get() |> distinctUntilChanged)).start(next: { [weak self] isLoading, progress in guard let strongSelf = self else { return } if isLoading, let progress = progress { strongSelf.hasProgress = true strongSelf.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: CGFloat(max(0.027, progress)), cancelEnabled: false, animateRotation: true), completion: {}) } else if strongSelf.hasProgress { strongSelf.hasProgress = false strongSelf.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: 1.0, cancelEnabled: false, animateRotation: true), completion: { [weak self] in guard let strongSelf = self else { return } if !strongSelf.hasProgress { Queue.mainQueue().after(0.3) { strongSelf.statusNode.transitionToState(.none, completion: {}) } } }) } })) } deinit { self.statusDisposable.dispose() self.playbackStartDisposable.dispose() self.preloadDisposable.dispose() } private func updateStatus() { guard let videoContent = self.videoContent else { return } var bufferingProgress: Float? if isMediaStreamable(resource: videoContent.fileReference.media.resource) { if let playerStatus = self.playerStatus { if case let .buffering(_, _, progress, _) = playerStatus.status { bufferingProgress = progress } else if case .playing = playerStatus.status { bufferingProgress = nil } } else { bufferingProgress = nil } } self.loadingProgress.set(bufferingProgress) self.isLoading.set(bufferingProgress != nil) } func updateTransitionFraction(_ fraction: CGFloat, transition: ContainedViewLayoutTransition) { if let videoNode = self.videoNode { if case .immediate = transition, fraction == 1.0 { return } transition.updateAlpha(node: videoNode, alpha: 1.0 - fraction) } } private func setupVideoPlayback() { guard let videoContent = self.videoContent, let isCentral = self.isCentral, isCentral, self.videoNode == nil else { return } let mediaManager = self.context.sharedContext.mediaManager let videoNode = UniversalVideoNode(postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay) videoNode.isUserInteractionEnabled = false videoNode.canAttachContent = true videoNode.isHidden = true if let _ = self.videoStartTimestamp { self.playbackStartDisposable.set((videoNode.status |> map { status -> Bool in if let status = status, case .playing = status.status { return true } else { return false } } |> filter { playing in return playing } |> take(1) |> deliverOnMainQueue).start(completed: { [weak self] in if let strongSelf = self { Queue.mainQueue().after(0.1) { strongSelf.videoNode?.isHidden = false } } })) } else { self.playbackStartDisposable.set(nil) videoNode.isHidden = false } videoNode.play() self.videoNode = videoNode let videoStartTimestamp = self.videoStartTimestamp self.statusPromise.set(videoNode.status |> map { ($0, videoStartTimestamp) }) self.statusDisposable.set((self.mediaStatus |> deliverOnMainQueue).start(next: { [weak self] mediaStatus in if let strongSelf = self { if let mediaStatusAndStartTimestamp = mediaStatus { strongSelf.playerStatus = mediaStatusAndStartTimestamp.0 } strongSelf.updateStatus() } })) self.insertSubnode(videoNode, belowSubnode: self.statusNode) self.isReady.set(videoNode.ready |> map { return true }) } func setup(item: PeerInfoAvatarListItem, synchronous: Bool) { self.item = item let representations: [ImageRepresentationWithReference] let videoRepresentations: [VideoRepresentationWithReference] let immediateThumbnailData: Data? var id: Int64 switch item { case let .topImage(topRepresentations, videoRepresentationsValue, immediateThumbnail): representations = topRepresentations videoRepresentations = videoRepresentationsValue immediateThumbnailData = immediateThumbnail id = Int64(self.peer.id.id) if let resource = videoRepresentations.first?.representation.resource as? CloudPhotoSizeMediaResource { id = id &+ resource.photoId } case let .image(reference, imageRepresentations, videoRepresentationsValue, immediateThumbnail): representations = imageRepresentations videoRepresentations = videoRepresentationsValue immediateThumbnailData = immediateThumbnail if case let .cloud(imageId, _, _) = reference { id = imageId } else { id = Int64(self.peer.id.id) } } self.imageNode.setSignal(chatAvatarGalleryPhoto(account: self.context.account, representations: representations, immediateThumbnailData: immediateThumbnailData, autoFetchFullSize: true, attemptSynchronously: synchronous), attemptSynchronously: synchronous, dispatchOnDisplayLink: false) if let video = videoRepresentations.last, let peerReference = PeerReference(self.peer) { let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [])])) let videoContent = NativeVideoContent(id: .profileVideo(id, nil), fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.representation.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, startTimestamp: video.representation.startTimestamp, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear) if videoContent.id != self.videoContent?.id { self.videoContent = videoContent self.videoStartTimestamp = video.representation.startTimestamp self.setupVideoPlayback() } } else { if let videoNode = self.videoNode { self.videoContent = nil self.videoStartTimestamp = nil self.videoNode = nil videoNode.removeFromSupernode() } self.statusPromise.set(.single(nil)) self.statusDisposable.set(nil) self.imageNode.imageUpdated = { [weak self] _ in guard let strongSelf = self else { return } if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf.isReady.set(.single(true)) } } } } func update(size: CGSize, transition: ContainedViewLayoutTransition) { let imageSize = CGSize(width: min(size.width, size.height), height: min(size.width, size.height)) let makeLayout = self.imageNode.asyncLayout() let applyLayout = makeLayout(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets())) let _ = applyLayout() let imageFrame = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: floor((size.height - imageSize.height) / 2.0)), size: imageSize) transition.updateFrame(node: self.imageNode, frame: imageFrame) transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(x: floor((size.width - 50.0) / 2.0), y: floor((size.height - 50.0) / 2.0)), size: CGSize(width: 50.0, height: 50.0))) if let videoNode = self.videoNode { videoNode.updateLayout(size: imageSize, transition: .immediate) videoNode.frame = imageFrame } } } private class PeerInfoAvatarListLoadingStripNode: ASImageNode { private var currentInHierarchy = false let imageNode = ASImageNode() override init() { super.init() self.addSubnode(self.imageNode) } override public var isHidden: Bool { didSet { self.updateAnimation() } } private var isAnimating = false { didSet { if self.isAnimating != oldValue { if self.isAnimating { let basicAnimation = CABasicAnimation(keyPath: "opacity") basicAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) basicAnimation.duration = 0.45 basicAnimation.fromValue = 0.1 basicAnimation.toValue = 0.75 basicAnimation.repeatCount = Float.infinity basicAnimation.autoreverses = true self.imageNode.layer.add(basicAnimation, forKey: "loading") } else { self.imageNode.layer.removeAnimation(forKey: "loading") } } } } private func updateAnimation() { self.isAnimating = !self.isHidden && self.currentInHierarchy } override public func willEnterHierarchy() { super.willEnterHierarchy() self.currentInHierarchy = true self.updateAnimation() } override public func didExitHierarchy() { super.didExitHierarchy() self.currentInHierarchy = false self.updateAnimation() } override func layout() { super.layout() self.imageNode.frame = self.bounds } } final class PeerInfoAvatarListContainerNode: ASDisplayNode { private let context: AccountContext var peer: Peer? let controlsContainerNode: ASDisplayNode let controlsClippingNode: ASDisplayNode let controlsClippingOffsetNode: ASDisplayNode let shadowNode: ASImageNode let contentNode: ASDisplayNode let leftHighlightNode: ASImageNode let rightHighlightNode: ASImageNode var highlightedSide: Bool? let stripContainerNode: ASDisplayNode let highlightContainerNode: ASDisplayNode private(set) var galleryEntries: [AvatarGalleryEntry] = [] private var items: [PeerInfoAvatarListItem] = [] private var itemNodes: [WrappedMediaResourceId: PeerInfoAvatarListItemNode] = [:] private var stripNodes: [ASImageNode] = [] private var activeStripNode: ASImageNode private var loadingStripNode: PeerInfoAvatarListLoadingStripNode private let activeStripImage: UIImage private var appliedStripNodeCurrentIndex: Int? var currentIndex: Int = 0 private var transitionFraction: CGFloat = 0.0 private var validLayout: CGSize? var isCollapsing = false private var isExpanded = false private let disposable = MetaDisposable() private let positionDisposable = MetaDisposable() private var initializedList = false private var ignoreNextProfilePhotoUpdate = false var itemsUpdated: (([PeerInfoAvatarListItem]) -> Void)? var currentIndexUpdated: (() -> Void)? let isReady = Promise() private var didSetReady = false var currentItemNode: PeerInfoAvatarListItemNode? { if self.currentIndex >= 0 && self.currentIndex < self.items.count { return self.itemNodes[self.items[self.currentIndex].id] } else { return nil } } var currentEntry: AvatarGalleryEntry? { if self.currentIndex >= 0 && self.currentIndex < self.galleryEntries.count { return self.galleryEntries[self.currentIndex] } else { return nil } } private var playerUpdateTimer: SwiftSignalKit.Timer? private var playerStatus: (MediaPlayerStatus?, Double?)? { didSet { if self.playerStatus?.0 != oldValue?.0 || self.playerStatus?.1 != oldValue?.1 { if let (playerStatus, _) = self.playerStatus, let status = playerStatus, case .playing = status.status { self.ensureHasTimer() } else { self.stopTimer() } self.updateStatus() } } } private func ensureHasTimer() { if self.playerUpdateTimer == nil { let timer = SwiftSignalKit.Timer(timeout: 0.016, repeat: true, completion: { [weak self] in self?.updateStatus() }, queue: Queue.mainQueue()) self.playerUpdateTimer = timer timer.start() } } private var playbackProgress: CGFloat? private var loading: Bool = false private func updateStatus() { var position: CGFloat = 1.0 var loading = false if let (status, videoStartTimestamp) = self.playerStatus, let playerStatus = status { var playerPosition: Double if case .buffering = playerStatus.status { loading = true } if !playerStatus.generationTimestamp.isZero, case .playing = playerStatus.status { playerPosition = playerStatus.timestamp + (CACurrentMediaTime() - playerStatus.generationTimestamp) } else { playerPosition = playerStatus.timestamp } if let videoStartTimestamp = videoStartTimestamp, false { playerPosition -= videoStartTimestamp if playerPosition < 0.0 { playerPosition = playerStatus.duration + playerPosition } } if playerStatus.duration.isZero { position = 0.0 } else { position = CGFloat(playerPosition / playerStatus.duration) } } else { self.playbackProgress = nil } if let size = self.validLayout { self.playbackProgress = position self.loading = loading self.updateStrips(size: size, itemsAdded: false, stripTransition: .animated(duration: 0.3, curve: .spring)) } } private func stopTimer() { self.playerUpdateTimer?.invalidate() self.playerUpdateTimer = nil } init(context: AccountContext) { self.context = context self.contentNode = ASDisplayNode() self.leftHighlightNode = ASImageNode() self.leftHighlightNode.displaysAsynchronously = false self.leftHighlightNode.displayWithoutProcessing = true self.leftHighlightNode.contentMode = .scaleToFill self.leftHighlightNode.image = generateImage(CGSize(width: 88.0, height: 1.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) let topColor = UIColor(rgb: 0x000000, alpha: 0.1) let bottomColor = UIColor(rgb: 0x000000, alpha: 0.0) var locations: [CGFloat] = [0.0, 1.0] let colors: [CGColor] = [topColor.cgColor, bottomColor.cgColor] let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]) }) self.leftHighlightNode.alpha = 0.0 self.rightHighlightNode = ASImageNode() self.rightHighlightNode.displaysAsynchronously = false self.rightHighlightNode.displayWithoutProcessing = true self.rightHighlightNode.contentMode = .scaleToFill self.rightHighlightNode.image = generateImage(CGSize(width: 88.0, height: 1.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) let topColor = UIColor(rgb: 0x000000, alpha: 0.1) let bottomColor = UIColor(rgb: 0x000000, alpha: 0.0) var locations: [CGFloat] = [0.0, 1.0] let colors: [CGColor] = [topColor.cgColor, bottomColor.cgColor] let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! context.drawLinearGradient(gradient, start: CGPoint(x: size.width, y: 0.0), end: CGPoint(x: 0.0, y: 0.0), options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]) }) self.rightHighlightNode.alpha = 0.0 self.stripContainerNode = ASDisplayNode() self.contentNode.addSubnode(self.stripContainerNode) self.activeStripImage = generateSmallHorizontalStretchableFilledCircleImage(diameter: 2.0, color: .white)! self.activeStripNode = ASImageNode() self.activeStripNode.image = self.activeStripImage self.loadingStripNode = PeerInfoAvatarListLoadingStripNode() self.loadingStripNode.imageNode.image = self.activeStripImage self.highlightContainerNode = ASDisplayNode() self.highlightContainerNode.addSubnode(self.leftHighlightNode) self.highlightContainerNode.addSubnode(self.rightHighlightNode) self.controlsContainerNode = ASDisplayNode() self.controlsContainerNode.isUserInteractionEnabled = false self.controlsClippingOffsetNode = ASDisplayNode() self.controlsClippingNode = ASDisplayNode() self.controlsClippingNode.isUserInteractionEnabled = false self.controlsClippingNode.clipsToBounds = true self.shadowNode = ASImageNode() self.shadowNode.displaysAsynchronously = false self.shadowNode.displayWithoutProcessing = true self.shadowNode.contentMode = .scaleToFill do { let size = CGSize(width: 88.0, height: 88.0) UIGraphicsBeginImageContextWithOptions(size, false, 0.0) if let context = UIGraphicsGetCurrentContext() { context.clip(to: CGRect(origin: CGPoint(), size: size)) let topColor = UIColor(rgb: 0x000000, alpha: 0.4) let bottomColor = UIColor(rgb: 0x000000, alpha: 0.0) var locations: [CGFloat] = [0.0, 1.0] let colors: [CGColor] = [topColor.cgColor, bottomColor.cgColor] let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]) let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() if let image = image { self.shadowNode.image = generateImage(image.size, contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.translateBy(x: size.width / 2.0, y: size.height / 2.0) context.rotate(by: -CGFloat.pi / 2.0) context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) context.draw(image.cgImage!, in: CGRect(origin: CGPoint(), size: size)) }) } } } super.init() self.backgroundColor = .black self.addSubnode(self.contentNode) self.controlsContainerNode.addSubnode(self.highlightContainerNode) self.controlsContainerNode.addSubnode(self.shadowNode) self.controlsContainerNode.addSubnode(self.stripContainerNode) self.controlsClippingNode.addSubnode(self.controlsContainerNode) self.controlsClippingOffsetNode.addSubnode(self.controlsClippingNode) self.view.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in guard let strongSelf = self else { return false } return strongSelf.currentIndex != 0 } self.view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))) let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) recognizer.tapActionAtPoint = { _ in return .keepWithSingleTap } recognizer.highlight = { [weak self] point in guard let strongSelf = self, let size = strongSelf.validLayout else { return } var highlightedSide: Bool? if let point = point { if point.x < size.width * 1.0 / 5.0 { if strongSelf.items.count > 1 { highlightedSide = false } } else { if strongSelf.items.count > 1 { highlightedSide = true } } } if strongSelf.highlightedSide != highlightedSide { strongSelf.highlightedSide = highlightedSide let leftAlpha: CGFloat let rightAlpha: CGFloat if let highlightedSide = highlightedSide { leftAlpha = highlightedSide ? 0.0 : 1.0 rightAlpha = highlightedSide ? 1.0 : 0.0 } else { leftAlpha = 0.0 rightAlpha = 0.0 } if strongSelf.leftHighlightNode.alpha != leftAlpha { strongSelf.leftHighlightNode.alpha = leftAlpha if leftAlpha.isZero { strongSelf.leftHighlightNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.16, timingFunction: kCAMediaTimingFunctionSpring) } else { strongSelf.leftHighlightNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.08) } } if strongSelf.rightHighlightNode.alpha != rightAlpha { strongSelf.rightHighlightNode.alpha = rightAlpha if rightAlpha.isZero { strongSelf.rightHighlightNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.16, timingFunction: kCAMediaTimingFunctionSpring) } else { strongSelf.rightHighlightNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.08) } } } } self.view.addGestureRecognizer(recognizer) } deinit { self.disposable.dispose() self.positionDisposable.dispose() } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return super.hitTest(point, with: event) } func selectFirstItem() { let previousIndex = self.currentIndex self.currentIndex = 0 if self.currentIndex != previousIndex { self.currentIndexUpdated?() } if let size = self.validLayout { self.updateItems(size: size, transition: .immediate, stripTransition: .immediate) } } func updateEntryIsHidden(entry: AvatarGalleryEntry?) { if let entry = entry, let index = self.galleryEntries.firstIndex(of: entry) { self.currentItemNode?.isHidden = index == self.currentIndex } else { self.currentItemNode?.isHidden = false } } @objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { switch recognizer.state { case .ended: if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { if let size = self.validLayout, case .tap = gesture { if location.x < size.width * 1.0 / 5.0 { if self.currentIndex != 0 { let previousIndex = self.currentIndex self.currentIndex -= 1 if self.currentIndex != previousIndex { self.currentIndexUpdated?() } self.updateItems(size: size, transition: .immediate, stripTransition: .animated(duration: 0.3, curve: .spring), synchronous: true) } else if self.items.count > 1 { let previousIndex = self.currentIndex self.currentIndex = self.items.count - 1 if self.currentIndex != previousIndex { self.currentIndexUpdated?() } self.updateItems(size: size, transition: .immediate, stripTransition: .animated(duration: 0.3, curve: .spring), synchronous: true) } } else { if self.currentIndex < self.items.count - 1 { let previousIndex = self.currentIndex self.currentIndex += 1 if self.currentIndex != previousIndex { self.currentIndexUpdated?() } self.updateItems(size: size, transition: .immediate, stripTransition: .animated(duration: 0.3, curve: .spring), synchronous: true) } else if self.items.count > 1 { let previousIndex = self.currentIndex self.currentIndex = 0 if self.currentIndex != previousIndex { self.currentIndexUpdated?() } self.updateItems(size: size, transition: .immediate, stripTransition: .animated(duration: 0.3, curve: .spring), synchronous: true) } } } } default: break } } private var pageChangedByPan = false @objc private func panGesture(_ recognizer: UIPanGestureRecognizer) { switch recognizer.state { case .changed: let translation = recognizer.translation(in: self.view) var transitionFraction = translation.x / self.bounds.width if self.currentIndex <= 0 { transitionFraction = min(0.0, transitionFraction) } if self.currentIndex >= self.items.count - 1 { transitionFraction = max(0.0, transitionFraction) } self.transitionFraction = transitionFraction if let size = self.validLayout { self.updateItems(size: size, transition: .animated(duration: 0.3, curve: .spring), stripTransition: .animated(duration: 0.3, curve: .spring)) } case .cancelled, .ended: let translation = recognizer.translation(in: self.view) let velocity = recognizer.velocity(in: self.view) var directionIsToRight: Bool? if abs(velocity.x) > 10.0 { directionIsToRight = velocity.x < 0.0 } else if abs(transitionFraction) > 0.5 { directionIsToRight = transitionFraction < 0.0 } var updatedIndex = self.currentIndex if let directionIsToRight = directionIsToRight { if directionIsToRight { updatedIndex = min(updatedIndex + 1, self.items.count - 1) } else { updatedIndex = max(updatedIndex - 1, 0) } } let previousIndex = self.currentIndex self.currentIndex = updatedIndex if self.currentIndex != previousIndex { self.pageChangedByPan = true self.currentIndexUpdated?() } self.transitionFraction = 0.0 if let size = self.validLayout { self.updateItems(size: size, transition: .animated(duration: 0.3, curve: .spring), stripTransition: .animated(duration: 0.3, curve: .spring)) self.pageChangedByPan = false } default: break } } func setMainItem(_ item: PeerInfoAvatarListItem) { guard case let .image(image) = item else { return } var items: [PeerInfoAvatarListItem] = [] var entries: [AvatarGalleryEntry] = [] for entry in self.galleryEntries { switch entry { case let .topImage(representations, videoRepresentations, _, _, immediateThumbnailData, _): entries.append(entry) items.append(.topImage(representations, videoRepresentations, immediateThumbnailData)) case let .image(_, reference, representations, videoRepresentations, _, _, _, _, immediateThumbnailData, _): if image.0 == reference { entries.insert(entry, at: 0) items.insert(.image(reference, representations, videoRepresentations, immediateThumbnailData), at: 0) } else { entries.append(entry) items.append(.image(reference, representations, videoRepresentations, immediateThumbnailData)) } } } self.galleryEntries = normalizeEntries(entries) self.items = items self.itemsUpdated?(items) let previousIndex = self.currentIndex self.currentIndex = 0 if self.currentIndex != previousIndex { self.currentIndexUpdated?() } self.ignoreNextProfilePhotoUpdate = true if let size = self.validLayout { self.updateItems(size: size, update: true, transition: .immediate, stripTransition: .immediate, synchronous: true) } } func deleteItem(_ item: PeerInfoAvatarListItem) -> Bool { guard case let .image(image) = item else { return false } var items: [PeerInfoAvatarListItem] = [] var entries: [AvatarGalleryEntry] = [] let previousIndex = self.currentIndex var index = 0 var deletedIndex: Int? for entry in self.galleryEntries { switch entry { case let .topImage(representations, videoRepresentations, _, _, immediateThumbnailData, _): entries.append(entry) items.append(.topImage(representations, videoRepresentations, immediateThumbnailData)) case let .image(_, reference, representations, videoRepresentations, _, _, _, _, immediateThumbnailData, _): if image.0 != reference { entries.append(entry) items.append(.image(reference, representations, videoRepresentations, immediateThumbnailData)) } else { deletedIndex = index } } index += 1 } if let peer = self.peer, peer is TelegramGroup || peer is TelegramChannel, deletedIndex == 0 { self.galleryEntries = [] self.items = [] self.itemsUpdated?([]) self.currentIndex = 0 if let size = self.validLayout { self.updateItems(size: size, update: true, transition: .immediate, stripTransition: .immediate, synchronous: true) } return true } self.galleryEntries = normalizeEntries(entries) self.items = items self.itemsUpdated?(items) self.currentIndex = max(0, previousIndex - 1) if self.currentIndex != previousIndex { self.currentIndexUpdated?() } self.ignoreNextProfilePhotoUpdate = true if let size = self.validLayout { self.updateItems(size: size, update: true, transition: .immediate, stripTransition: .immediate, synchronous: true) } return items.count == 0 } func update(size: CGSize, peer: Peer?, isExpanded: Bool, transition: ContainedViewLayoutTransition) { self.validLayout = size let previousExpanded = self.isExpanded self.isExpanded = isExpanded if !isExpanded && previousExpanded { self.isCollapsing = true } self.leftHighlightNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: floor(size.width * 1.0 / 5.0), height: size.height)) self.rightHighlightNode.frame = CGRect(origin: CGPoint(x: size.width - floor(size.width * 1.0 / 5.0), y: 0.0), size: CGSize(width: floor(size.width * 1.0 / 5.0), height: size.height)) if let peer = peer, !self.initializedList { self.initializedList = true self.disposable.set((peerInfoProfilePhotosWithCache(context: self.context, peerId: peer.id) |> deliverOnMainQueue).start(next: { [weak self] (complete, entries) in guard let strongSelf = self else { return } if strongSelf.galleryEntries.count > 1, entries.count == 1 && !complete { return } var entries = entries var synchronous = false if !strongSelf.galleryEntries.isEmpty, let updated = entries.first, case let .image(image) = updated, !image.3.isEmpty, let previous = strongSelf.galleryEntries.first, case let .topImage(topImage) = previous { let firstEntry = AvatarGalleryEntry.image(image.0, image.1, topImage.0, image.3, image.4, image.5, image.6, image.7, image.8, image.9) entries.remove(at: 0) entries.insert(firstEntry, at: 0) synchronous = true } if strongSelf.ignoreNextProfilePhotoUpdate { if entries.count == 1, let first = entries.first, case .topImage = first { return } else { strongSelf.ignoreNextProfilePhotoUpdate = false synchronous = true } } var items: [PeerInfoAvatarListItem] = [] for entry in entries { items.append(PeerInfoAvatarListItem(entry: entry)) } strongSelf.galleryEntries = entries strongSelf.items = items strongSelf.itemsUpdated?(items) if let size = strongSelf.validLayout { strongSelf.updateItems(size: size, update: true, transition: .immediate, stripTransition: .immediate, synchronous: synchronous) } if items.isEmpty { if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf.isReady.set(.single(true)) } } })) } self.updateItems(size: size, transition: transition, stripTransition: transition) } private func updateStrips(size: CGSize, itemsAdded: Bool, stripTransition: ContainedViewLayoutTransition) { let hadOneStripNode = self.stripNodes.count == 1 if self.stripNodes.count != self.items.count { if self.stripNodes.count < self.items.count { for _ in 0 ..< self.items.count - self.stripNodes.count { let stripNode = ASImageNode() stripNode.displaysAsynchronously = false stripNode.displayWithoutProcessing = true stripNode.image = self.activeStripImage stripNode.alpha = 0.2 self.stripNodes.append(stripNode) self.stripContainerNode.addSubnode(stripNode) } } else { for i in (self.items.count ..< self.stripNodes.count).reversed() { self.stripNodes[i].removeFromSupernode() self.stripNodes.remove(at: i) } } self.stripContainerNode.addSubnode(self.activeStripNode) self.stripContainerNode.addSubnode(self.loadingStripNode) } if self.appliedStripNodeCurrentIndex != self.currentIndex || itemsAdded { if !self.itemNodes.isEmpty { self.appliedStripNodeCurrentIndex = self.currentIndex } if let currentItemNode = self.currentItemNode { self.positionDisposable.set((currentItemNode.mediaStatus |> deliverOnMainQueue).start(next: { [weak self] statusAndVideoStartTimestamp in if let strongSelf = self { strongSelf.playerStatus = statusAndVideoStartTimestamp } })) } else { self.positionDisposable.set(nil) } } if hadOneStripNode && self.stripNodes.count > 1 { self.stripContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } let stripInset: CGFloat = 8.0 let stripSpacing: CGFloat = 4.0 let stripWidth: CGFloat = max(5.0, floor((size.width - stripInset * 2.0 - stripSpacing * CGFloat(self.stripNodes.count - 1)) / CGFloat(self.stripNodes.count))) let currentStripMinX = stripInset + CGFloat(self.currentIndex) * (stripWidth + stripSpacing) let currentStripMidX = floor(currentStripMinX + stripWidth / 2.0) let lastStripMaxX = stripInset + CGFloat(self.stripNodes.count - 1) * (stripWidth + stripSpacing) + stripWidth let stripOffset: CGFloat = min(0.0, max(size.width - stripInset - lastStripMaxX, size.width / 2.0 - currentStripMidX)) for i in 0 ..< self.stripNodes.count { let stripX: CGFloat = stripInset + CGFloat(i) * (stripWidth + stripSpacing) if i == 0 && self.stripNodes.count == 1 { self.stripNodes[i].isHidden = true } else { self.stripNodes[i].isHidden = false } let stripFrame = CGRect(origin: CGPoint(x: stripOffset + stripX, y: 0.0), size: CGSize(width: stripWidth + 1.0, height: 2.0)) stripTransition.updateFrame(node: self.stripNodes[i], frame: stripFrame) } if self.currentIndex >= 0 && self.currentIndex < self.stripNodes.count { var frame = self.stripNodes[self.currentIndex].frame stripTransition.updateFrame(node: self.loadingStripNode, frame: frame) if let playbackProgress = self.playbackProgress { frame.size.width = max(frame.size.height, frame.size.width * playbackProgress) } stripTransition.updateFrameAdditive(node: self.activeStripNode, frame: frame) stripTransition.updateAlpha(node: self.activeStripNode, alpha: self.loading ? 0.0 : 1.0) stripTransition.updateAlpha(node: self.loadingStripNode, alpha: self.loading ? 1.0 : 0.0) self.activeStripNode.isHidden = self.stripNodes.count < 2 self.loadingStripNode.isHidden = self.stripNodes.count < 2 || !self.loading } } private func updateItems(size: CGSize, update: Bool = false, transition: ContainedViewLayoutTransition, stripTransition: ContainedViewLayoutTransition, synchronous: Bool = false) { var validIds: [WrappedMediaResourceId] = [] var addedItemNodesForAdditiveTransition: [PeerInfoAvatarListItemNode] = [] var additiveTransitionOffset: CGFloat = 0.0 var itemsAdded = false if self.currentIndex >= 0 && self.currentIndex < self.items.count { let preloadSpan: Int = 2 for i in max(0, self.currentIndex - preloadSpan) ... min(self.currentIndex + preloadSpan, self.items.count - 1) { validIds.append(self.items[i].id) var itemNode: PeerInfoAvatarListItemNode? var wasAdded = false if let current = self.itemNodes[self.items[i].id] { itemNode = current if update { current.setup(item: self.items[i], synchronous: synchronous && i == self.currentIndex) } } else if let peer = self.peer { wasAdded = true let addedItemNode = PeerInfoAvatarListItemNode(context: self.context, peer: peer) itemNode = addedItemNode addedItemNode.setup(item: self.items[i], synchronous: (i == 0 && i == self.currentIndex) || (synchronous && i == self.currentIndex)) self.itemNodes[self.items[i].id] = addedItemNode self.contentNode.addSubnode(addedItemNode) } if let itemNode = itemNode { itemNode.delayCentralityLose = self.pageChangedByPan itemNode.isCentral = i == self.currentIndex itemNode.delayCentralityLose = false let indexOffset = CGFloat(i - self.currentIndex) let itemFrame = CGRect(origin: CGPoint(x: indexOffset * size.width + self.transitionFraction * size.width - size.width / 2.0, y: -size.height / 2.0), size: size) if wasAdded { itemsAdded = true addedItemNodesForAdditiveTransition.append(itemNode) itemNode.frame = itemFrame itemNode.update(size: size, transition: .immediate) } else { additiveTransitionOffset = itemNode.frame.minX - itemFrame.minX transition.updateFrame(node: itemNode, frame: itemFrame) itemNode.update(size: size, transition: .immediate) } } } } for itemNode in addedItemNodesForAdditiveTransition { transition.animatePositionAdditive(node: itemNode, offset: CGPoint(x: additiveTransitionOffset, y: 0.0)) } var removeIds: [WrappedMediaResourceId] = [] for (id, _) in self.itemNodes { if !validIds.contains(id) { removeIds.append(id) } } for id in removeIds { if let itemNode = self.itemNodes.removeValue(forKey: id) { itemNode.removeFromSupernode() } } self.updateStrips(size: size, itemsAdded: itemsAdded, stripTransition: stripTransition) if let item = self.items.first, let itemNode = self.itemNodes[item.id] { if !self.didSetReady { self.didSetReady = true self.isReady.set(itemNode.isReady.get()) } } } } final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { let context: AccountContext let avatarNode: AvatarNode fileprivate var videoNode: UniversalVideoNode? private var videoContent: NativeVideoContent? private var videoStartTimestamp: Double? var isExpanded: Bool = false var canAttachVideo: Bool = true { didSet { if oldValue != self.canAttachVideo { self.videoNode?.canAttachContent = !self.isExpanded && self.canAttachVideo } } } var tapped: (() -> Void)? private var isFirstAvatarLoading = true var item: PeerInfoAvatarListItem? private let playbackStartDisposable = MetaDisposable() init(context: AccountContext) { self.context = context let avatarFont = avatarPlaceholderFont(size: floor(100.0 * 16.0 / 37.0)) self.avatarNode = AvatarNode(font: avatarFont) super.init() self.addSubnode(self.avatarNode) self.avatarNode.frame = CGRect(origin: CGPoint(x: -50.0, y: -50.0), size: CGSize(width: 100.0, height: 100.0)) self.avatarNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } deinit { self.playbackStartDisposable.dispose() } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.tapped?() } } func updateTransitionFraction(_ fraction: CGFloat, transition: ContainedViewLayoutTransition) { if let videoNode = self.videoNode { if case .immediate = transition, fraction == 1.0 { return } if fraction > 0.0 { self.videoNode?.pause() } else { self.videoNode?.play() } transition.updateAlpha(node: videoNode, alpha: 1.0 - fraction) } } var removedPhotoResourceIds = Set() func update(peer: Peer?, item: PeerInfoAvatarListItem?, theme: PresentationTheme, avatarSize: CGFloat, isExpanded: Bool) { if let peer = peer { let previousItem = self.item var item = item self.item = item var overrideImage: AvatarNodeImageOverride? if peer.isDeleted { overrideImage = .deletedIcon } else if let previousItem = previousItem, item == nil { if case let .image(image) = previousItem, let rep = image.1.last { self.removedPhotoResourceIds.insert(rep.representation.resource.id.uniqueId) } overrideImage = AvatarNodeImageOverride.none item = nil } else if let rep = peer.profileImageRepresentations.last, self.removedPhotoResourceIds.contains(rep.resource.id.uniqueId) { overrideImage = AvatarNodeImageOverride.none item = nil } self.avatarNode.setPeer(context: self.context, theme: theme, peer: peer, overrideImage: overrideImage, synchronousLoad: self.isFirstAvatarLoading, displayDimensions: CGSize(width: avatarSize, height: avatarSize), storeUnrounded: true) self.isFirstAvatarLoading = false self.avatarNode.frame = CGRect(origin: CGPoint(x: -avatarSize / 2.0, y: -avatarSize / 2.0), size: CGSize(width: avatarSize, height: avatarSize)) self.avatarNode.font = avatarPlaceholderFont(size: floor(avatarSize * 16.0 / 37.0)) if let item = item { let representations: [ImageRepresentationWithReference] let videoRepresentations: [VideoRepresentationWithReference] let immediateThumbnailData: Data? var id: Int64 switch item { case let .topImage(topRepresentations, videoRepresentationsValue, immediateThumbnail): representations = topRepresentations videoRepresentations = videoRepresentationsValue immediateThumbnailData = immediateThumbnail id = Int64(peer.id.id) if let resource = videoRepresentations.first?.representation.resource as? CloudPhotoSizeMediaResource { id = id &+ resource.photoId } case let .image(reference, imageRepresentations, videoRepresentationsValue, immediateThumbnail): representations = imageRepresentations videoRepresentations = videoRepresentationsValue immediateThumbnailData = immediateThumbnail if case let .cloud(imageId, _, _) = reference { id = imageId } else { id = Int64(peer.id.id) } } if let video = videoRepresentations.last, let peerReference = PeerReference(peer) { let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [])])) let videoContent = NativeVideoContent(id: .profileVideo(id, nil), fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.representation.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, startTimestamp: video.representation.startTimestamp, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear) if videoContent.id != self.videoContent?.id { self.videoNode?.removeFromSupernode() let mediaManager = self.context.sharedContext.mediaManager let videoNode = UniversalVideoNode(postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .embedded) videoNode.isUserInteractionEnabled = false videoNode.isHidden = true if let startTimestamp = video.representation.startTimestamp { self.videoStartTimestamp = startTimestamp self.playbackStartDisposable.set((videoNode.status |> map { status -> Bool in if let status = status, case .playing = status.status { return true } else { return false } } |> filter { playing in return playing } |> take(1) |> deliverOnMainQueue).start(completed: { [weak self] in if let strongSelf = self { Queue.mainQueue().after(0.15) { strongSelf.videoNode?.isHidden = false } } })) } else { self.videoStartTimestamp = nil self.playbackStartDisposable.set(nil) videoNode.isHidden = false } self.videoContent = videoContent self.videoNode = videoNode let maskPath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: self.avatarNode.frame.size)) let shape = CAShapeLayer() shape.path = maskPath.cgPath videoNode.layer.mask = shape self.addSubnode(videoNode) } } else if let videoNode = self.videoNode { self.videoContent = nil self.videoNode = nil videoNode.removeFromSupernode() } } else if let videoNode = self.videoNode { self.videoContent = nil self.videoNode = nil videoNode.removeFromSupernode() } if let videoNode = self.videoNode { if self.canAttachVideo { videoNode.updateLayout(size: self.avatarNode.frame.size, transition: .immediate) } videoNode.frame = self.avatarNode.frame if isExpanded == videoNode.canAttachContent { self.isExpanded = isExpanded let update = { videoNode.canAttachContent = !self.isExpanded && self.canAttachVideo if videoNode.canAttachContent { videoNode.play() } } if isExpanded { DispatchQueue.main.async { update() } } else { update() } } } } } } final class PeerInfoEditingAvatarOverlayNode: ASDisplayNode { private let context: AccountContext private let imageNode: ImageNode private let updatingAvatarOverlay: ASImageNode private let iconNode: ASImageNode private var statusNode: RadialStatusNode private var currentRepresentation: TelegramMediaImageRepresentation? init(context: AccountContext) { self.context = context self.imageNode = ImageNode(enableEmpty: true) self.updatingAvatarOverlay = ASImageNode() self.updatingAvatarOverlay.displayWithoutProcessing = true self.updatingAvatarOverlay.displaysAsynchronously = false self.updatingAvatarOverlay.alpha = 0.0 self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.6)) self.statusNode.isUserInteractionEnabled = false self.iconNode = ASImageNode() self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIconLarge"), color: .white) self.iconNode.alpha = 0.0 super.init() self.imageNode.frame = CGRect(origin: CGPoint(x: -50.0, y: -50.0), size: CGSize(width: 100.0, height: 100.0)) self.updatingAvatarOverlay.frame = self.imageNode.frame let radialStatusSize: CGFloat = 50.0 let imagePosition = self.imageNode.position self.statusNode.frame = CGRect(origin: CGPoint(x: floor(imagePosition.x - radialStatusSize / 2.0), y: floor(imagePosition.y - radialStatusSize / 2.0)), size: CGSize(width: radialStatusSize, height: radialStatusSize)) if let image = self.iconNode.image { self.iconNode.frame = CGRect(origin: CGPoint(x: floor(imagePosition.x - image.size.width / 2.0), y: floor(imagePosition.y - image.size.height / 2.0)), size: image.size) } self.addSubnode(self.imageNode) self.addSubnode(self.updatingAvatarOverlay) self.addSubnode(self.statusNode) } func updateTransitionFraction(_ fraction: CGFloat, transition: ContainedViewLayoutTransition) { transition.updateAlpha(node: self, alpha: 1.0 - fraction) } func update(peer: Peer?, item: PeerInfoAvatarListItem?, updatingAvatar: PeerInfoUpdatingAvatar?, uploadProgress: CGFloat?, theme: PresentationTheme, avatarSize: CGFloat, isEditing: Bool) { guard let peer = peer else { return } self.imageNode.frame = CGRect(origin: CGPoint(x: -avatarSize / 2.0, y: -avatarSize / 2.0), size: CGSize(width: avatarSize, height: avatarSize)) self.updatingAvatarOverlay.frame = self.imageNode.frame let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .linear) if canEditPeerInfo(context: self.context, peer: peer) { var overlayHidden = true if let updatingAvatar = updatingAvatar { overlayHidden = false self.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: max(0.027, uploadProgress ?? 0.0), cancelEnabled: true, animateRotation: true)) if case let .image(representation) = updatingAvatar { if representation != self.currentRepresentation { self.currentRepresentation = representation if let signal = peerAvatarImage(account: context.account, peerReference: nil, authorOfMessage: nil, representation: representation, displayDimensions: CGSize(width: avatarSize, height: avatarSize), emptyColor: nil, synchronousLoad: false, provideUnrounded: false) { self.imageNode.setSignal(signal |> map { $0?.0 }) } } } transition.updateAlpha(node: self.updatingAvatarOverlay, alpha: overlayHidden ? 0.0 : 1.0) } else { let targetOverlayAlpha: CGFloat = overlayHidden ? 0.0 : 1.0 if self.updatingAvatarOverlay.alpha != targetOverlayAlpha { let update = { self.statusNode.transitionToState(.none) self.currentRepresentation = nil self.imageNode.setSignal(.single(nil)) transition.updateAlpha(node: self.updatingAvatarOverlay, alpha: overlayHidden ? 0.0 : 1.0) } Queue.mainQueue().after(0.3) { update() } } } if !overlayHidden && self.updatingAvatarOverlay.image == nil { self.updatingAvatarOverlay.image = generateFilledCircleImage(diameter: avatarSize, color: UIColor(white: 0.0, alpha: 0.4), backgroundColor: nil) } } else { self.statusNode.transitionToState(.none) self.currentRepresentation = nil transition.updateAlpha(node: self.iconNode, alpha: 0.0) transition.updateAlpha(node: self.updatingAvatarOverlay, alpha: 0.0) } } } final class PeerInfoEditingAvatarNode: ASDisplayNode { private let context: AccountContext let avatarNode: AvatarNode fileprivate var videoNode: UniversalVideoNode? private var videoContent: NativeVideoContent? private var videoStartTimestamp: Double? var item: PeerInfoAvatarListItem? var tapped: ((Bool) -> Void)? var canAttachVideo: Bool = true init(context: AccountContext) { self.context = context let avatarFont = avatarPlaceholderFont(size: floor(100.0 * 16.0 / 37.0)) self.avatarNode = AvatarNode(font: avatarFont) super.init() self.addSubnode(self.avatarNode) self.avatarNode.frame = CGRect(origin: CGPoint(x: -50.0, y: -50.0), size: CGSize(width: 100.0, height: 100.0)) self.avatarNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.tapped?(false) } } func reset() { guard let videoNode = self.videoNode else { return } videoNode.isHidden = true videoNode.seek(self.videoStartTimestamp ?? 0.0) Queue.mainQueue().after(0.15) { videoNode.isHidden = false } } var removedPhotoResourceIds = Set() func update(peer: Peer?, item: PeerInfoAvatarListItem?, updatingAvatar: PeerInfoUpdatingAvatar?, uploadProgress: CGFloat?, theme: PresentationTheme, avatarSize: CGFloat, isEditing: Bool) { guard let peer = peer else { return } let previousItem = self.item var item = item self.item = item let overrideImage: AvatarNodeImageOverride? if canEditPeerInfo(context: self.context, peer: peer), peer.profileImageRepresentations.isEmpty { overrideImage = .editAvatarIcon } else if let previousItem = previousItem, item == nil { if case let .image(image) = previousItem, let rep = image.1.last { self.removedPhotoResourceIds.insert(rep.representation.resource.id.uniqueId) } overrideImage = AvatarNodeImageOverride.none item = nil } else if let rep = peer.profileImageRepresentations.last, self.removedPhotoResourceIds.contains(rep.resource.id.uniqueId) { overrideImage = AvatarNodeImageOverride.none item = nil } else { overrideImage = nil } self.avatarNode.font = avatarPlaceholderFont(size: floor(avatarSize * 16.0 / 37.0)) self.avatarNode.setPeer(context: self.context, theme: theme, peer: peer, overrideImage: overrideImage, synchronousLoad: false, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) self.avatarNode.frame = CGRect(origin: CGPoint(x: -avatarSize / 2.0, y: -avatarSize / 2.0), size: CGSize(width: avatarSize, height: avatarSize)) if let item = item { let representations: [ImageRepresentationWithReference] let videoRepresentations: [VideoRepresentationWithReference] let immediateThumbnailData: Data? var id: Int64 switch item { case let .topImage(topRepresentations, videoRepresentationsValue, immediateThumbnail): representations = topRepresentations videoRepresentations = videoRepresentationsValue immediateThumbnailData = immediateThumbnail id = Int64(peer.id.id) if let resource = videoRepresentations.first?.representation.resource as? CloudPhotoSizeMediaResource { id = id &+ resource.photoId } case let .image(reference, imageRepresentations, videoRepresentationsValue, immediateThumbnail): representations = imageRepresentations videoRepresentations = videoRepresentationsValue immediateThumbnailData = immediateThumbnail if case let .cloud(imageId, _, _) = reference { id = imageId } else { id = Int64(peer.id.id) } } if let video = videoRepresentations.last, let peerReference = PeerReference(peer) { let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [])])) let videoContent = NativeVideoContent(id: .profileVideo(id, nil), fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.representation.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, startTimestamp: video.representation.startTimestamp, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear) if videoContent.id != self.videoContent?.id { self.videoNode?.removeFromSupernode() let mediaManager = self.context.sharedContext.mediaManager let videoNode = UniversalVideoNode(postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .gallery) videoNode.isUserInteractionEnabled = false self.videoStartTimestamp = video.representation.startTimestamp self.videoContent = videoContent self.videoNode = videoNode let maskPath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: self.avatarNode.frame.size)) let shape = CAShapeLayer() shape.path = maskPath.cgPath videoNode.layer.mask = shape self.insertSubnode(videoNode, aboveSubnode: self.avatarNode) } } else if let videoNode = self.videoNode { self.videoStartTimestamp = nil self.videoContent = nil self.videoNode = nil videoNode.removeFromSupernode() } } else if let videoNode = self.videoNode { self.videoStartTimestamp = nil self.videoContent = nil self.videoNode = nil videoNode.removeFromSupernode() } if let videoNode = self.videoNode { if self.canAttachVideo { videoNode.updateLayout(size: self.avatarNode.frame.size, transition: .immediate) } videoNode.frame = self.avatarNode.frame if isEditing != videoNode.canAttachContent { videoNode.canAttachContent = isEditing && self.canAttachVideo } } } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.avatarNode.frame.contains(point) { return self.avatarNode.view } return super.hitTest(point, with: event) } } final class PeerInfoAvatarListNode: ASDisplayNode { let avatarContainerNode: PeerInfoAvatarTransformContainerNode let listContainerTransformNode: ASDisplayNode let listContainerNode: PeerInfoAvatarListContainerNode let isReady = Promise() var arguments: (Peer?, PresentationTheme, CGFloat, Bool)? var item: PeerInfoAvatarListItem? var itemsUpdated: (([PeerInfoAvatarListItem]) -> Void)? init(context: AccountContext, readyWhenGalleryLoads: Bool) { self.avatarContainerNode = PeerInfoAvatarTransformContainerNode(context: context) self.listContainerTransformNode = ASDisplayNode() self.listContainerNode = PeerInfoAvatarListContainerNode(context: context) self.listContainerNode.clipsToBounds = true self.listContainerNode.isHidden = true super.init() self.addSubnode(self.avatarContainerNode) self.listContainerTransformNode.addSubnode(self.listContainerNode) self.addSubnode(self.listContainerTransformNode) let avatarReady = (self.avatarContainerNode.avatarNode.ready |> mapToSignal { _ -> Signal in return .complete() } |> then(.single(true))) let galleryReady = self.listContainerNode.isReady.get() |> filter { value in return value } |> take(1) let combinedSignal: Signal if readyWhenGalleryLoads { combinedSignal = combineLatest(queue: .mainQueue(), avatarReady, galleryReady ) |> map { lhs, rhs in return lhs && rhs } } else { combinedSignal = avatarReady } self.isReady.set(combinedSignal |> filter { value in return value } |> take(1)) self.listContainerNode.itemsUpdated = { [weak self] items in if let strongSelf = self { strongSelf.item = items.first strongSelf.itemsUpdated?(items) if let (peer, theme, avatarSize, isExpanded) = strongSelf.arguments { strongSelf.avatarContainerNode.update(peer: peer, item: strongSelf.item, theme: theme, avatarSize: avatarSize, isExpanded: isExpanded) } } } } func update(size: CGSize, avatarSize: CGFloat, isExpanded: Bool, peer: Peer?, theme: PresentationTheme, transition: ContainedViewLayoutTransition) { self.arguments = (peer, theme, avatarSize, isExpanded) self.avatarContainerNode.update(peer: peer, item: self.item, theme: theme, avatarSize: avatarSize, isExpanded: isExpanded) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.listContainerNode.isHidden { if let result = self.listContainerNode.view.hitTest(self.view.convert(point, to: self.listContainerNode.view), with: event) { return result } } else { if let result = self.avatarContainerNode.avatarNode.view.hitTest(self.view.convert(point, to: self.avatarContainerNode.avatarNode.view), with: event) { return result } } return super.hitTest(point, with: event) } func animateAvatarCollapse(transition: ContainedViewLayoutTransition) { if let currentItemNode = self.listContainerNode.currentItemNode, case .animated = transition { if let _ = self.avatarContainerNode.videoNode { } else if let unroundedImage = self.avatarContainerNode.avatarNode.unroundedImage { let avatarCopyView = UIImageView() avatarCopyView.image = unroundedImage avatarCopyView.frame = self.avatarContainerNode.avatarNode.frame avatarCopyView.center = currentItemNode.imageNode.position currentItemNode.view.addSubview(avatarCopyView) let scale = currentItemNode.imageNode.bounds.height / avatarCopyView.bounds.height avatarCopyView.layer.transform = CATransform3DMakeScale(scale, scale, scale) avatarCopyView.alpha = 0.0 transition.updateAlpha(layer: avatarCopyView.layer, alpha: 1.0, completion: { [weak avatarCopyView] _ in Queue.mainQueue().after(0.1, { avatarCopyView?.removeFromSuperview() }) }) } } } } final class PeerInfoHeaderNavigationButton: HighlightableButtonNode { private let regularTextNode: ImmediateTextNode private let whiteTextNode: ImmediateTextNode private let iconNode: ASImageNode private var key: PeerInfoHeaderNavigationButtonKey? private var theme: PresentationTheme? var isWhite: Bool = false { didSet { if self.isWhite != oldValue { self.regularTextNode.isHidden = self.isWhite self.whiteTextNode.isHidden = !self.isWhite } } } var action: (() -> Void)? init() { self.regularTextNode = ImmediateTextNode() self.whiteTextNode = ImmediateTextNode() self.whiteTextNode.isHidden = true self.iconNode = ASImageNode() self.iconNode.displaysAsynchronously = false self.iconNode.displayWithoutProcessing = true super.init(pointerStyle: .default) self.isAccessibilityElement = true self.accessibilityTraits = .button self.addSubnode(self.regularTextNode) self.addSubnode(self.whiteTextNode) self.addSubnode(self.iconNode) self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) } @objc private func pressed() { self.action?() } func update(key: PeerInfoHeaderNavigationButtonKey, presentationData: PresentationData, height: CGFloat) -> CGSize { let textSize: CGSize if self.key != key || self.theme !== presentationData.theme { self.key = key self.theme = presentationData.theme let text: String var icon: UIImage? var isBold = false switch key { case .edit: text = presentationData.strings.Common_Edit case .done, .cancel, .selectionDone: text = presentationData.strings.Common_Done isBold = true case .select: text = presentationData.strings.Common_Select case .search: text = "" icon = PresentationResourcesRootController.navigationCompactSearchIcon(presentationData.theme) case .editPhoto: text = presentationData.strings.Settings_EditPhoto case .editVideo: text = presentationData.strings.Settings_EditVideo } self.accessibilityLabel = text let font: UIFont = isBold ? Font.semibold(17.0) : Font.regular(17.0) self.regularTextNode.attributedText = NSAttributedString(string: text, font: font, textColor: presentationData.theme.rootController.navigationBar.accentTextColor) self.whiteTextNode.attributedText = NSAttributedString(string: text, font: font, textColor: .white) self.iconNode.image = icon textSize = self.regularTextNode.updateLayout(CGSize(width: 200.0, height: .greatestFiniteMagnitude)) let _ = self.whiteTextNode.updateLayout(CGSize(width: 200.0, height: .greatestFiniteMagnitude)) } else { textSize = self.regularTextNode.bounds.size } let inset: CGFloat = 0.0 let textFrame = CGRect(origin: CGPoint(x: inset, y: floor((height - textSize.height) / 2.0)), size: textSize) self.regularTextNode.frame = textFrame self.whiteTextNode.frame = textFrame if let image = self.iconNode.image { self.iconNode.frame = CGRect(origin: CGPoint(x: inset, y: floor((height - image.size.height) / 2.0)), size: image.size) return CGSize(width: image.size.width + inset * 2.0, height: height) } else { return CGSize(width: textSize.width + inset * 2.0, height: height) } } } enum PeerInfoHeaderNavigationButtonKey { case edit case done case cancel case select case selectionDone case search case editPhoto case editVideo } struct PeerInfoHeaderNavigationButtonSpec: Equatable { let key: PeerInfoHeaderNavigationButtonKey let isForExpandedView: Bool } final class PeerInfoHeaderNavigationButtonContainerNode: ASDisplayNode { private var buttonNodes: [PeerInfoHeaderNavigationButtonKey: PeerInfoHeaderNavigationButton] = [:] private var currentButtons: [PeerInfoHeaderNavigationButtonSpec] = [] var isWhite: Bool = false { didSet { if self.isWhite != oldValue { for (_, buttonNode) in self.buttonNodes { buttonNode.isWhite = self.isWhite } } } } var performAction: ((PeerInfoHeaderNavigationButtonKey) -> Void)? override init() { super.init() } func update(size: CGSize, presentationData: PresentationData, buttons: [PeerInfoHeaderNavigationButtonSpec], expandFraction: CGFloat, transition: ContainedViewLayoutTransition) { let maximumExpandOffset: CGFloat = 14.0 let expandOffset: CGFloat = -expandFraction * maximumExpandOffset if self.currentButtons != buttons { self.currentButtons = buttons var nextRegularButtonOrigin = size.width - 16.0 var nextExpandedButtonOrigin = size.width - 16.0 for spec in buttons.reversed() { let buttonNode: PeerInfoHeaderNavigationButton var wasAdded = false if let current = self.buttonNodes[spec.key] { buttonNode = current } else { wasAdded = true buttonNode = PeerInfoHeaderNavigationButton() self.buttonNodes[spec.key] = buttonNode self.addSubnode(buttonNode) buttonNode.isWhite = self.isWhite buttonNode.action = { [weak self] in self?.performAction?(spec.key) } } let buttonSize = buttonNode.update(key: spec.key, presentationData: presentationData, height: size.height) var nextButtonOrigin = spec.isForExpandedView ? nextExpandedButtonOrigin : nextRegularButtonOrigin let buttonFrame = CGRect(origin: CGPoint(x: nextButtonOrigin - buttonSize.width, y: expandOffset + (spec.isForExpandedView ? maximumExpandOffset : 0.0)), size: buttonSize) nextButtonOrigin -= buttonSize.width + 4.0 if spec.isForExpandedView { nextExpandedButtonOrigin = nextButtonOrigin } else { nextRegularButtonOrigin = nextButtonOrigin } let alphaFactor: CGFloat = spec.isForExpandedView ? expandFraction : (1.0 - expandFraction) if wasAdded { buttonNode.frame = buttonFrame buttonNode.alpha = 0.0 transition.updateAlpha(node: buttonNode, alpha: alphaFactor * alphaFactor) } else { transition.updateFrameAdditiveToCenter(node: buttonNode, frame: buttonFrame) transition.updateAlpha(node: buttonNode, alpha: alphaFactor * alphaFactor) } } var removeKeys: [PeerInfoHeaderNavigationButtonKey] = [] for (key, _) in self.buttonNodes { if !buttons.contains(where: { $0.key == key }) { removeKeys.append(key) } } for key in removeKeys { if let buttonNode = self.buttonNodes.removeValue(forKey: key) { buttonNode.removeFromSupernode() } } } else { var nextRegularButtonOrigin = size.width - 16.0 var nextExpandedButtonOrigin = size.width - 16.0 for spec in buttons.reversed() { if let buttonNode = self.buttonNodes[spec.key] { let buttonSize = buttonNode.bounds.size var nextButtonOrigin = spec.isForExpandedView ? nextExpandedButtonOrigin : nextRegularButtonOrigin let buttonFrame = CGRect(origin: CGPoint(x: nextButtonOrigin - buttonSize.width, y: expandOffset + (spec.isForExpandedView ? maximumExpandOffset : 0.0)), size: buttonSize) nextButtonOrigin -= buttonSize.width + 4.0 if spec.isForExpandedView { nextExpandedButtonOrigin = nextButtonOrigin } else { nextRegularButtonOrigin = nextButtonOrigin } transition.updateFrameAdditiveToCenter(node: buttonNode, frame: buttonFrame) let alphaFactor: CGFloat = spec.isForExpandedView ? expandFraction : (1.0 - expandFraction) var buttonTransition = transition if case let .animated(duration, curve) = buttonTransition, alphaFactor == 0.0 { buttonTransition = .animated(duration: duration * 0.25, curve: curve) } buttonTransition.updateAlpha(node: buttonNode, alpha: alphaFactor * alphaFactor) } } } } } final class PeerInfoHeaderRegularContentNode: ASDisplayNode { } enum PeerInfoHeaderTextFieldNodeKey { case firstName case lastName case title case description } protocol PeerInfoHeaderTextFieldNode: ASDisplayNode { var text: String { get } func update(width: CGFloat, safeInset: CGFloat, hasPrevious: Bool, placeholder: String, isEnabled: Bool, presentationData: PresentationData, updateText: String?) -> CGFloat } final class PeerInfoHeaderSingleLineTextFieldNode: ASDisplayNode, PeerInfoHeaderTextFieldNode, UITextFieldDelegate { private let textNode: TextFieldNode private let measureTextNode: ImmediateTextNode private let clearIconNode: ASImageNode private let clearButtonNode: HighlightableButtonNode private let topSeparator: ASDisplayNode private var theme: PresentationTheme? var text: String { return self.textNode.textField.text ?? "" } override init() { self.textNode = TextFieldNode() self.measureTextNode = ImmediateTextNode() self.measureTextNode.maximumNumberOfLines = 0 self.clearIconNode = ASImageNode() self.clearIconNode.isLayerBacked = true self.clearIconNode.displayWithoutProcessing = true self.clearIconNode.displaysAsynchronously = false self.clearIconNode.isHidden = true self.clearButtonNode = HighlightableButtonNode() self.clearButtonNode.isHidden = true self.topSeparator = ASDisplayNode() super.init() self.addSubnode(self.textNode) self.addSubnode(self.clearIconNode) self.addSubnode(self.clearButtonNode) self.addSubnode(self.topSeparator) self.textNode.textField.delegate = self self.clearButtonNode.addTarget(self, action: #selector(self.clearButtonPressed), forControlEvents: .touchUpInside) self.clearButtonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { strongSelf.clearIconNode.layer.removeAnimation(forKey: "opacity") strongSelf.clearIconNode.alpha = 0.4 } else { strongSelf.clearIconNode.alpha = 1.0 strongSelf.clearIconNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } } } @objc private func clearButtonPressed() { self.textNode.textField.text = "" self.updateClearButtonVisibility() } @objc func textFieldDidBeginEditing(_ textField: UITextField) { self.updateClearButtonVisibility() } @objc func textFieldDidEndEditing(_ textField: UITextField) { self.updateClearButtonVisibility() } private func updateClearButtonVisibility() { let isHidden = !self.textNode.textField.isFirstResponder || self.text.isEmpty self.clearIconNode.isHidden = isHidden self.clearButtonNode.isHidden = isHidden self.clearButtonNode.isAccessibilityElement = isHidden } func update(width: CGFloat, safeInset: CGFloat, hasPrevious: Bool, placeholder: String, isEnabled: Bool, presentationData: PresentationData, updateText: String?) -> CGFloat { let titleFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize) self.textNode.textField.font = titleFont if self.theme !== presentationData.theme { self.theme = presentationData.theme self.textNode.textField.textColor = presentationData.theme.list.itemPrimaryTextColor self.textNode.textField.keyboardAppearance = presentationData.theme.rootController.keyboardColor.keyboardAppearance self.textNode.textField.tintColor = presentationData.theme.list.itemAccentColor self.clearIconNode.image = PresentationResourcesItemList.itemListClearInputIcon(presentationData.theme) } let attributedPlaceholderText = NSAttributedString(string: placeholder, font: titleFont, textColor: presentationData.theme.list.itemPlaceholderTextColor) if self.textNode.textField.attributedPlaceholder == nil || !self.textNode.textField.attributedPlaceholder!.isEqual(to: attributedPlaceholderText) { self.textNode.textField.attributedPlaceholder = attributedPlaceholderText self.textNode.textField.accessibilityHint = attributedPlaceholderText.string } if let updateText = updateText { self.textNode.textField.text = updateText } self.topSeparator.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor self.topSeparator.frame = CGRect(origin: CGPoint(x: safeInset + (hasPrevious ? 16.0 : 0.0), y: 0.0), size: CGSize(width: width, height: UIScreenPixel)) let measureText = "|" let attributedMeasureText = NSAttributedString(string: measureText, font: titleFont, textColor: .black) self.measureTextNode.attributedText = attributedMeasureText let measureTextSize = self.measureTextNode.updateLayout(CGSize(width: width - safeInset * 2.0 - 16.0 * 2.0 - 38.0, height: .greatestFiniteMagnitude)) let height = measureTextSize.height + 22.0 let buttonSize = CGSize(width: 38.0, height: height) self.clearButtonNode.frame = CGRect(origin: CGPoint(x: width - safeInset - buttonSize.width, y: 0.0), size: buttonSize) if let image = self.clearIconNode.image { self.clearIconNode.frame = CGRect(origin: CGPoint(x: width - safeInset - buttonSize.width + floor((buttonSize.width - image.size.width) / 2.0), y: floor((height - image.size.height) / 2.0)), size: image.size) } self.textNode.frame = CGRect(origin: CGPoint(x: safeInset + 16.0, y: floor((height - 40.0) / 2.0)), size: CGSize(width: max(1.0, width - 16.0 * 2.0 - 32.0), height: 40.0)) self.textNode.isUserInteractionEnabled = isEnabled self.textNode.alpha = isEnabled ? 1.0 : 0.6 return height } } final class PeerInfoHeaderMultiLineTextFieldNode: ASDisplayNode, PeerInfoHeaderTextFieldNode, ASEditableTextNodeDelegate { private let textNode: EditableTextNode private let textNodeContainer: ASDisplayNode private let measureTextNode: ImmediateTextNode private let clearIconNode: ASImageNode private let clearButtonNode: HighlightableButtonNode private let topSeparator: ASDisplayNode private let requestUpdateHeight: () -> Void private var fontSize: PresentationFontSize? private var theme: PresentationTheme? private var currentParams: (width: CGFloat, safeInset: CGFloat)? private var currentMeasuredHeight: CGFloat? var text: String { return self.textNode.attributedText?.string ?? "" } init(requestUpdateHeight: @escaping () -> Void) { self.requestUpdateHeight = requestUpdateHeight self.textNode = EditableTextNode() self.textNode.clipsToBounds = false self.textNode.textView.clipsToBounds = false self.textNode.textContainerInset = UIEdgeInsets() self.textNodeContainer = ASDisplayNode() self.measureTextNode = ImmediateTextNode() self.measureTextNode.maximumNumberOfLines = 0 self.topSeparator = ASDisplayNode() self.clearIconNode = ASImageNode() self.clearIconNode.isLayerBacked = true self.clearIconNode.displayWithoutProcessing = true self.clearIconNode.displaysAsynchronously = false self.clearIconNode.isHidden = true self.clearButtonNode = HighlightableButtonNode() self.clearButtonNode.isHidden = true super.init() self.textNodeContainer.addSubnode(self.textNode) self.addSubnode(self.textNodeContainer) self.addSubnode(self.clearIconNode) self.addSubnode(self.clearButtonNode) self.addSubnode(self.topSeparator) self.clearButtonNode.addTarget(self, action: #selector(self.clearButtonPressed), forControlEvents: .touchUpInside) self.clearButtonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { strongSelf.clearIconNode.layer.removeAnimation(forKey: "opacity") strongSelf.clearIconNode.alpha = 0.4 } else { strongSelf.clearIconNode.alpha = 1.0 strongSelf.clearIconNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } } } @objc private func clearButtonPressed() { guard let theme = self.theme else { return } let font: UIFont if let fontSize = self.fontSize { font = Font.regular(fontSize.itemListBaseFontSize) } else { font = Font.regular(17.0) } let attributedText = NSAttributedString(string: "", font: font, textColor: theme.list.itemPrimaryTextColor) self.textNode.attributedText = attributedText self.requestUpdateHeight() self.updateClearButtonVisibility() } func update(width: CGFloat, safeInset: CGFloat, hasPrevious: Bool, placeholder: String, isEnabled: Bool, presentationData: PresentationData, updateText: String?) -> CGFloat { self.currentParams = (width, safeInset) self.fontSize = presentationData.listsFontSize let titleFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize) if self.theme !== presentationData.theme { self.theme = presentationData.theme let textColor = presentationData.theme.list.itemPrimaryTextColor self.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: titleFont, NSAttributedString.Key.foregroundColor.rawValue: textColor] self.textNode.clipsToBounds = true self.textNode.delegate = self self.textNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0) self.clearIconNode.image = PresentationResourcesItemList.itemListClearInputIcon(presentationData.theme) } self.topSeparator.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor self.topSeparator.frame = CGRect(origin: CGPoint(x: safeInset + (hasPrevious ? 16.0 : 0.0), y: 0.0), size: CGSize(width: width, height: UIScreenPixel)) let attributedPlaceholderText = NSAttributedString(string: placeholder, font: titleFont, textColor: presentationData.theme.list.itemPlaceholderTextColor) if self.textNode.attributedPlaceholderText == nil || !self.textNode.attributedPlaceholderText!.isEqual(to: attributedPlaceholderText) { self.textNode.attributedPlaceholderText = attributedPlaceholderText } if let updateText = updateText { let attributedText = NSAttributedString(string: updateText, font: titleFont, textColor: presentationData.theme.list.itemPrimaryTextColor) self.textNode.attributedText = attributedText } var measureText = self.textNode.attributedText?.string ?? "" if measureText.hasSuffix("\n") || measureText.isEmpty { measureText += "|" } let attributedMeasureText = NSAttributedString(string: measureText, font: titleFont, textColor: .black) self.measureTextNode.attributedText = attributedMeasureText let measureTextSize = self.measureTextNode.updateLayout(CGSize(width: width - safeInset * 2.0 - 16.0 * 2.0 - 38.0, height: .greatestFiniteMagnitude)) self.currentMeasuredHeight = measureTextSize.height let height = measureTextSize.height + 22.0 let buttonSize = CGSize(width: 38.0, height: height) self.clearButtonNode.frame = CGRect(origin: CGPoint(x: width - safeInset - buttonSize.width, y: 0.0), size: buttonSize) if let image = self.clearIconNode.image { self.clearIconNode.frame = CGRect(origin: CGPoint(x: width - safeInset - buttonSize.width + floor((buttonSize.width - image.size.width) / 2.0), y: floor((height - image.size.height) / 2.0)), size: image.size) } let textNodeFrame = CGRect(origin: CGPoint(x: safeInset + 16.0, y: 10.0), size: CGSize(width: width - safeInset * 2.0 - 16.0 * 2.0 - 38.0, height: max(height, 1000.0))) self.textNodeContainer.frame = textNodeFrame self.textNode.frame = CGRect(origin: CGPoint(), size: textNodeFrame.size) return height } func editableTextNodeDidBeginEditing(_ editableTextNode: ASEditableTextNode) { self.updateClearButtonVisibility() } func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) { self.updateClearButtonVisibility() } private func updateClearButtonVisibility() { let isHidden = !self.textNode.isFirstResponder() || self.text.isEmpty self.clearIconNode.isHidden = isHidden self.clearButtonNode.isHidden = isHidden self.clearButtonNode.isAccessibilityElement = isHidden } func editableTextNode(_ editableTextNode: ASEditableTextNode, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { guard let theme = self.theme else { return true } let updatedText = (editableTextNode.textView.text as NSString).replacingCharacters(in: range, with: text) if updatedText.count > 255 { let attributedText = NSAttributedString(string: String(updatedText[updatedText.startIndex.. 0.1 { self.requestUpdateHeight() } } } func editableTextNodeShouldPaste(_ editableTextNode: ASEditableTextNode) -> Bool { let text: String? = UIPasteboard.general.string if let _ = text { return true } else { return false } } } final class PeerInfoHeaderEditingContentNode: ASDisplayNode { private let context: AccountContext private let requestUpdateLayout: () -> Void var requestEditing: (() -> Void)? let avatarNode: PeerInfoEditingAvatarNode let avatarTextNode: ImmediateTextNode let avatarButtonNode: HighlightableButtonNode var itemNodes: [PeerInfoHeaderTextFieldNodeKey: PeerInfoHeaderTextFieldNode] = [:] init(context: AccountContext, requestUpdateLayout: @escaping () -> Void) { self.context = context self.requestUpdateLayout = requestUpdateLayout self.avatarNode = PeerInfoEditingAvatarNode(context: context) self.avatarTextNode = ImmediateTextNode() self.avatarButtonNode = HighlightableButtonNode() super.init() self.addSubnode(self.avatarNode) self.avatarButtonNode.addSubnode(self.avatarTextNode) self.avatarButtonNode.addTarget(self, action: #selector(textPressed), forControlEvents: .touchUpInside) } @objc private func textPressed() { self.requestEditing?() } func editingTextForKey(_ key: PeerInfoHeaderTextFieldNodeKey) -> String? { return self.itemNodes[key]?.text } func shakeTextForKey(_ key: PeerInfoHeaderTextFieldNodeKey) { self.itemNodes[key]?.layer.addShakeAnimation() } func update(width: CGFloat, safeInset: CGFloat, statusBarHeight: CGFloat, navigationHeight: CGFloat, isModalOverlay: Bool, peer: Peer?, cachedData: CachedPeerData?, isContact: Bool, isSettings: Bool, presentationData: PresentationData, transition: ContainedViewLayoutTransition) -> CGFloat { let avatarSize: CGFloat = isModalOverlay ? 200.0 : 100.0 let avatarFrame = CGRect(origin: CGPoint(x: floor((width - avatarSize) / 2.0), y: statusBarHeight + 10.0), size: CGSize(width: avatarSize, height: avatarSize)) transition.updateFrameAdditiveToCenter(node: self.avatarNode, frame: CGRect(origin: avatarFrame.center, size: CGSize())) var contentHeight: CGFloat = statusBarHeight + 10.0 + avatarSize + 20.0 if canEditPeerInfo(context: self.context, peer: peer) { if self.avatarButtonNode.supernode == nil { self.addSubnode(self.avatarButtonNode) } self.avatarTextNode.attributedText = NSAttributedString(string: presentationData.strings.Settings_SetNewProfilePhotoOrVideo, font: Font.regular(17.0), textColor: presentationData.theme.list.itemAccentColor) let avatarTextSize = self.avatarTextNode.updateLayout(CGSize(width: width, height: 32.0)) transition.updateFrame(node: self.avatarTextNode, frame: CGRect(origin: CGPoint(), size: avatarTextSize)) transition.updateFrame(node: self.avatarButtonNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((width - avatarTextSize.width) / 2.0), y: contentHeight - 1.0), size: avatarTextSize)) contentHeight += 32.0 } var fieldKeys: [PeerInfoHeaderTextFieldNodeKey] = [] if let user = peer as? TelegramUser { if !user.isDeleted { fieldKeys.append(.firstName) if user.botInfo == nil { fieldKeys.append(.lastName) } } } else if let _ = peer as? TelegramGroup { fieldKeys.append(.title) if canEditPeerInfo(context: self.context, peer: peer) { fieldKeys.append(.description) } } else if let _ = peer as? TelegramChannel { fieldKeys.append(.title) if canEditPeerInfo(context: self.context, peer: peer) { fieldKeys.append(.description) } } var hasPrevious = false for key in fieldKeys { let itemNode: PeerInfoHeaderTextFieldNode var updateText: String? if let current = self.itemNodes[key] { itemNode = current } else { var isMultiline = false switch key { case .firstName: updateText = (peer as? TelegramUser)?.firstName ?? "" case .lastName: updateText = (peer as? TelegramUser)?.lastName ?? "" case .title: updateText = peer?.debugDisplayTitle ?? "" case .description: isMultiline = true if let cachedData = cachedData as? CachedChannelData { updateText = cachedData.about ?? "" } else if let cachedData = cachedData as? CachedGroupData { updateText = cachedData.about ?? "" } else { updateText = "" } } if isMultiline { itemNode = PeerInfoHeaderMultiLineTextFieldNode(requestUpdateHeight: { [weak self] in self?.requestUpdateLayout() }) } else { itemNode = PeerInfoHeaderSingleLineTextFieldNode() } self.itemNodes[key] = itemNode self.addSubnode(itemNode) } let placeholder: String var isEnabled = true switch key { case .firstName: placeholder = presentationData.strings.UserInfo_FirstNamePlaceholder isEnabled = isContact || isSettings case .lastName: placeholder = presentationData.strings.UserInfo_LastNamePlaceholder isEnabled = isContact || isSettings case .title: if let channel = peer as? TelegramChannel, case .broadcast = channel.info { placeholder = presentationData.strings.GroupInfo_ChannelListNamePlaceholder } else { placeholder = presentationData.strings.GroupInfo_GroupNamePlaceholder } isEnabled = canEditPeerInfo(context: self.context, peer: peer) case .description: placeholder = presentationData.strings.Channel_Edit_AboutItem isEnabled = canEditPeerInfo(context: self.context, peer: peer) } let itemHeight = itemNode.update(width: width, safeInset: safeInset, hasPrevious: hasPrevious, placeholder: placeholder, isEnabled: isEnabled, presentationData: presentationData, updateText: updateText) transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: CGSize(width: width, height: itemHeight))) contentHeight += itemHeight hasPrevious = true } var removeKeys: [PeerInfoHeaderTextFieldNodeKey] = [] for (key, _) in self.itemNodes { if !fieldKeys.contains(key) { removeKeys.append(key) } } for key in removeKeys { if let itemNode = self.itemNodes.removeValue(forKey: key) { itemNode.removeFromSupernode() } } return contentHeight } } private let TitleNodeStateRegular = 0 private let TitleNodeStateExpanded = 1 final class PeerInfoHeaderNode: ASDisplayNode { private var context: AccountContext private var presentationData: PresentationData? private var state: PeerInfoState? private var peer: Peer? private var avatarSize: CGFloat? private let isOpenedFromChat: Bool private let isSettings: Bool private let videoCallsEnabled: Bool private(set) var isAvatarExpanded: Bool private(set) var twoLineInfo = false var skipCollapseCompletion = false var ignoreCollapse = false let avatarListNode: PeerInfoAvatarListNode let regularContentNode: PeerInfoHeaderRegularContentNode let editingContentNode: PeerInfoHeaderEditingContentNode let avatarOverlayNode: PeerInfoEditingAvatarOverlayNode let titleNodeContainer: ASDisplayNode let titleNodeRawContainer: ASDisplayNode let titleNode: MultiScaleTextNode let titleCredibilityIconNode: ASImageNode let titleExpandedCredibilityIconNode: ASImageNode let subtitleNodeContainer: ASDisplayNode let subtitleNodeRawContainer: ASDisplayNode let subtitleNode: MultiScaleTextNode let usernameNodeContainer: ASDisplayNode let usernameNodeRawContainer: ASDisplayNode let usernameNode: MultiScaleTextNode private var buttonNodes: [PeerInfoHeaderButtonKey: PeerInfoHeaderButtonNode] = [:] private let backgroundNode: ASDisplayNode private let expandedBackgroundNode: ASDisplayNode let separatorNode: ASDisplayNode let navigationBackgroundNode: ASDisplayNode var navigationTitle: String? let navigationTitleNode: ImmediateTextNode let navigationSeparatorNode: ASDisplayNode let navigationButtonContainer: PeerInfoHeaderNavigationButtonContainerNode var performButtonAction: ((PeerInfoHeaderButtonKey) -> Void)? var requestAvatarExpansion: ((Bool, [AvatarGalleryEntry], AvatarGalleryEntry?, (ASDisplayNode, CGRect, () -> (UIView?, UIView?))?) -> Void)? var requestOpenAvatarForEditing: ((Bool) -> Void)? var cancelUpload: (() -> Void)? var requestUpdateLayout: (() -> Void)? var displayCopyContextMenu: ((ASDisplayNode, Bool, Bool) -> Void)? var navigationTransition: PeerInfoHeaderNavigationTransition? init(context: AccountContext, avatarInitiallyExpanded: Bool, isOpenedFromChat: Bool, isSettings: Bool) { self.context = context self.isAvatarExpanded = avatarInitiallyExpanded self.isOpenedFromChat = isOpenedFromChat self.isSettings = isSettings self.videoCallsEnabled = VideoCallsConfiguration(appConfiguration: context.currentAppConfiguration.with { $0 }).areVideoCallsEnabled self.avatarListNode = PeerInfoAvatarListNode(context: context, readyWhenGalleryLoads: avatarInitiallyExpanded) self.titleNodeContainer = ASDisplayNode() self.titleNodeRawContainer = ASDisplayNode() self.titleNode = MultiScaleTextNode(stateKeys: [TitleNodeStateRegular, TitleNodeStateExpanded]) self.titleNode.displaysAsynchronously = false self.titleCredibilityIconNode = ASImageNode() self.titleCredibilityIconNode.displaysAsynchronously = false self.titleCredibilityIconNode.displayWithoutProcessing = true self.titleNode.stateNode(forKey: TitleNodeStateRegular)?.addSubnode(self.titleCredibilityIconNode) self.titleExpandedCredibilityIconNode = ASImageNode() self.titleExpandedCredibilityIconNode.displaysAsynchronously = false self.titleExpandedCredibilityIconNode.displayWithoutProcessing = true self.titleNode.stateNode(forKey: TitleNodeStateExpanded)?.addSubnode(self.titleExpandedCredibilityIconNode) self.subtitleNodeContainer = ASDisplayNode() self.subtitleNodeRawContainer = ASDisplayNode() self.subtitleNode = MultiScaleTextNode(stateKeys: [TitleNodeStateRegular, TitleNodeStateExpanded]) self.subtitleNode.displaysAsynchronously = false self.usernameNodeContainer = ASDisplayNode() self.usernameNodeRawContainer = ASDisplayNode() self.usernameNode = MultiScaleTextNode(stateKeys: [TitleNodeStateRegular, TitleNodeStateExpanded]) self.usernameNode.displaysAsynchronously = false self.regularContentNode = PeerInfoHeaderRegularContentNode() var requestUpdateLayoutImpl: (() -> Void)? self.editingContentNode = PeerInfoHeaderEditingContentNode(context: context, requestUpdateLayout: { requestUpdateLayoutImpl?() }) self.editingContentNode.alpha = 0.0 self.avatarOverlayNode = PeerInfoEditingAvatarOverlayNode(context: context) self.avatarOverlayNode.isUserInteractionEnabled = false self.navigationBackgroundNode = ASDisplayNode() self.navigationBackgroundNode.isUserInteractionEnabled = false self.navigationTitleNode = ImmediateTextNode() self.navigationSeparatorNode = ASDisplayNode() self.navigationButtonContainer = PeerInfoHeaderNavigationButtonContainerNode() self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true self.expandedBackgroundNode = ASDisplayNode() self.expandedBackgroundNode.isLayerBacked = true self.separatorNode = ASDisplayNode() self.separatorNode.isLayerBacked = true super.init() requestUpdateLayoutImpl = { [weak self] in self?.requestUpdateLayout?() } self.addSubnode(self.backgroundNode) self.addSubnode(self.expandedBackgroundNode) self.titleNodeContainer.addSubnode(self.titleNode) self.regularContentNode.addSubnode(self.titleNodeContainer) self.subtitleNodeContainer.addSubnode(self.subtitleNode) self.regularContentNode.addSubnode(self.subtitleNodeContainer) self.regularContentNode.addSubnode(self.subtitleNodeRawContainer) self.usernameNodeContainer.addSubnode(self.usernameNode) self.regularContentNode.addSubnode(self.usernameNodeContainer) self.regularContentNode.addSubnode(self.usernameNodeRawContainer) self.regularContentNode.addSubnode(self.avatarListNode) self.regularContentNode.addSubnode(self.avatarListNode.listContainerNode.controlsClippingOffsetNode) self.addSubnode(self.regularContentNode) self.addSubnode(self.editingContentNode) self.addSubnode(self.avatarOverlayNode) self.addSubnode(self.navigationBackgroundNode) self.navigationBackgroundNode.addSubnode(self.navigationTitleNode) self.navigationBackgroundNode.addSubnode(self.navigationSeparatorNode) self.addSubnode(self.navigationButtonContainer) self.addSubnode(self.separatorNode) self.avatarListNode.avatarContainerNode.tapped = { [weak self] in self?.initiateAvatarExpansion(gallery: false, first: false) } self.editingContentNode.avatarNode.tapped = { [weak self] confirm in self?.initiateAvatarExpansion(gallery: true, first: true) } self.editingContentNode.requestEditing = { [weak self] in self?.requestOpenAvatarForEditing?(true) } self.avatarListNode.itemsUpdated = { [weak self] items in guard let strongSelf = self, let state = strongSelf.state, let peer = strongSelf.peer, let presentationData = strongSelf.presentationData, let avatarSize = strongSelf.avatarSize else { return } strongSelf.editingContentNode.avatarNode.update(peer: peer, item: strongSelf.avatarListNode.item, updatingAvatar: state.updatingAvatar, uploadProgress: state.avatarUploadProgress, theme: presentationData.theme, avatarSize: avatarSize, isEditing: state.isEditing) } } override func didLoad() { super.didLoad() let usernameGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.handleUsernameLongPress(_:))) self.usernameNodeRawContainer.view.addGestureRecognizer(usernameGestureRecognizer) let phoneGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.handlePhoneLongPress(_:))) self.subtitleNodeRawContainer.view.addGestureRecognizer(phoneGestureRecognizer) } @objc private func handleUsernameLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) { if gestureRecognizer.state == .began { self.displayCopyContextMenu?(self.usernameNodeRawContainer, !self.isAvatarExpanded, true) } } @objc private func handlePhoneLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) { if gestureRecognizer.state == .began { self.displayCopyContextMenu?(self.subtitleNodeRawContainer, true, !self.isAvatarExpanded) } } func initiateAvatarExpansion(gallery: Bool, first: Bool) { if let peer = self.peer, peer.profileImageRepresentations.isEmpty && gallery { self.requestOpenAvatarForEditing?(false) return } if self.isAvatarExpanded || gallery { if let currentEntry = self.avatarListNode.listContainerNode.currentEntry, let firstEntry = self.avatarListNode.listContainerNode.galleryEntries.first { let entry = first ? firstEntry : currentEntry self.requestAvatarExpansion?(true, self.avatarListNode.listContainerNode.galleryEntries, entry, self.avatarTransitionArguments(entry: currentEntry)) } } else if let entry = self.avatarListNode.listContainerNode.galleryEntries.first { let _ = self.avatarListNode.avatarContainerNode.avatarNode self.requestAvatarExpansion?(false, self.avatarListNode.listContainerNode.galleryEntries, nil, self.avatarTransitionArguments(entry: entry)) } else { self.cancelUpload?() } } func avatarTransitionArguments(entry: AvatarGalleryEntry) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { if self.isAvatarExpanded { if let avatarNode = self.avatarListNode.listContainerNode.currentItemNode?.imageNode { return (avatarNode, avatarNode.bounds, { [weak avatarNode] in return (avatarNode?.view.snapshotContentTree(unhide: true), nil) }) } else { return nil } } else if entry == self.avatarListNode.listContainerNode.galleryEntries.first { let avatarNode = self.avatarListNode.avatarContainerNode.avatarNode return (avatarNode, avatarNode.bounds, { [weak avatarNode] in return (avatarNode?.view.snapshotContentTree(unhide: true), nil) }) } else { return nil } } func addToAvatarTransitionSurface(view: UIView) { if self.isAvatarExpanded { self.avatarListNode.listContainerNode.view.addSubview(view) } else { self.view.addSubview(view) } } func updateAvatarIsHidden(entry: AvatarGalleryEntry?) { if let entry = entry { self.avatarListNode.avatarContainerNode.avatarNode.isHidden = entry == self.avatarListNode.listContainerNode.galleryEntries.first self.editingContentNode.avatarNode.isHidden = entry == self.avatarListNode.listContainerNode.galleryEntries.first } else { self.avatarListNode.avatarContainerNode.avatarNode.isHidden = false self.editingContentNode.avatarNode.isHidden = false } self.avatarListNode.listContainerNode.updateEntryIsHidden(entry: entry) } func update(width: CGFloat, containerHeight: CGFloat, containerInset: CGFloat, statusBarHeight: CGFloat, navigationHeight: CGFloat, isModalOverlay: Bool, isMediaOnly: Bool, contentOffset: CGFloat, presentationData: PresentationData, peer: Peer?, cachedData: CachedPeerData?, notificationSettings: TelegramPeerNotificationSettings?, statusData: PeerInfoStatusData?, isSecretChat: Bool, isContact: Bool, isSettings: Bool, state: PeerInfoState, transition: ContainedViewLayoutTransition, additive: Bool) -> CGFloat { self.state = state self.peer = peer self.avatarListNode.listContainerNode.peer = peer let avatarSize: CGFloat = isModalOverlay ? 200.0 : 100.0 self.avatarSize = avatarSize var contentOffset = contentOffset if isMediaOnly { if isModalOverlay { contentOffset = 312.0 } else { contentOffset = 212.0 } } let themeUpdated = self.presentationData?.theme !== presentationData.theme self.presentationData = presentationData if themeUpdated { if let sourceImage = UIImage(bundleImageName: "Peer Info/VerifiedIcon") { let image = generateImage(sourceImage.size, contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(presentationData.theme.list.itemCheckColors.foregroundColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: size).insetBy(dx: 7.0, dy: 7.0)) context.setFillColor(presentationData.theme.list.itemCheckColors.fillColor.cgColor) context.clip(to: CGRect(origin: CGPoint(), size: size), mask: sourceImage.cgImage!) context.fill(CGRect(origin: CGPoint(), size: size)) }) self.titleCredibilityIconNode.image = image self.titleExpandedCredibilityIconNode.image = image } } self.regularContentNode.alpha = state.isEditing ? 0.0 : 1.0 self.editingContentNode.alpha = state.isEditing ? 1.0 : 0.0 let editingContentHeight = self.editingContentNode.update(width: width, safeInset: containerInset, statusBarHeight: statusBarHeight, navigationHeight: navigationHeight, isModalOverlay: isModalOverlay, peer: state.isEditing ? peer : nil, cachedData: cachedData, isContact: isContact, isSettings: isSettings, presentationData: presentationData, transition: transition) transition.updateFrame(node: self.editingContentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -contentOffset), size: CGSize(width: width, height: editingContentHeight))) let avatarOverlayFarme = self.editingContentNode.convert(self.editingContentNode.avatarNode.frame, to: self) transition.updateFrame(node: self.avatarOverlayNode, frame: avatarOverlayFarme) var transitionSourceHeight: CGFloat = 0.0 var transitionFraction: CGFloat = 0.0 var transitionSourceAvatarFrame = CGRect() var transitionSourceTitleFrame = CGRect() var transitionSourceSubtitleFrame = CGRect() self.backgroundNode.backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor self.expandedBackgroundNode.backgroundColor = presentationData.theme.rootController.navigationBar.backgroundColor if let navigationTransition = self.navigationTransition, let sourceAvatarNode = (navigationTransition.sourceNavigationBar.rightButtonNode.singleCustomNode as? ChatAvatarNavigationNode)?.avatarNode { transitionSourceHeight = navigationTransition.sourceNavigationBar.bounds.height transitionFraction = navigationTransition.fraction transitionSourceAvatarFrame = sourceAvatarNode.view.convert(sourceAvatarNode.view.bounds, to: navigationTransition.sourceNavigationBar.view) transitionSourceTitleFrame = navigationTransition.sourceTitleFrame transitionSourceSubtitleFrame = navigationTransition.sourceSubtitleFrame transition.updateAlpha(node: self.expandedBackgroundNode, alpha: transitionFraction) if self.isAvatarExpanded, case .animated = transition, transitionFraction == 1.0 { self.avatarListNode.animateAvatarCollapse(transition: transition) } } else { let backgroundTransitionFraction: CGFloat = max(0.0, min(1.0, contentOffset / (112.0 + avatarSize))) transition.updateAlpha(node: self.expandedBackgroundNode, alpha: backgroundTransitionFraction) } self.avatarListNode.avatarContainerNode.updateTransitionFraction(transitionFraction, transition: transition) self.avatarListNode.listContainerNode.currentItemNode?.updateTransitionFraction(transitionFraction, transition: transition) self.avatarOverlayNode.updateTransitionFraction(transitionFraction, transition: transition) if self.navigationTitle != presentationData.strings.EditProfile_Title || themeUpdated { self.navigationTitleNode.attributedText = NSAttributedString(string: presentationData.strings.EditProfile_Title, font: Font.bold(17.0), textColor: presentationData.theme.rootController.navigationBar.primaryTextColor) } let navigationTitleSize = self.navigationTitleNode.updateLayout(CGSize(width: width, height: navigationHeight)) self.navigationTitleNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((width - navigationTitleSize.width) / 2.0), y: navigationHeight - 44.0 + floorToScreenPixels((44.0 - navigationTitleSize.height) / 2.0)), size: navigationTitleSize) self.navigationBackgroundNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: navigationHeight)) self.navigationSeparatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: navigationHeight), size: CGSize(width: width, height: UIScreenPixel)) self.navigationBackgroundNode.backgroundColor = presentationData.theme.rootController.navigationBar.backgroundColor self.navigationSeparatorNode.backgroundColor = presentationData.theme.rootController.navigationBar.separatorColor transition.updateAlpha(node: self.navigationBackgroundNode, alpha: state.isEditing && self.isSettings ? min(1.0, contentOffset / (navigationHeight * 0.5)) : 0.0) self.separatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor let defaultButtonSize: CGFloat = 40.0 let expandedAvatarControlsHeight: CGFloat = 61.0 let expandedAvatarListHeight = min(width, containerHeight - expandedAvatarControlsHeight) let expandedAvatarListSize = CGSize(width: width, height: expandedAvatarListHeight) let buttonKeys: [PeerInfoHeaderButtonKey] = self.isSettings ? [] : peerInfoHeaderButtons(peer: peer, cachedData: cachedData, isOpenedFromChat: self.isOpenedFromChat, videoCallsEnabled: self.videoCallsEnabled, isSecretChat: isSecretChat, isContact: isContact) var isVerified = false let titleString: NSAttributedString let subtitleString: NSAttributedString let usernameString: NSAttributedString if let peer = peer, peer.isVerified { isVerified = true } if let peer = peer { if peer.id == self.context.account.peerId && !self.isSettings { titleString = NSAttributedString(string: presentationData.strings.Conversation_SavedMessages, font: Font.medium(24.0), textColor: presentationData.theme.list.itemPrimaryTextColor) } else if peer.id == self.context.account.peerId && !self.isSettings { titleString = NSAttributedString(string: presentationData.strings.DialogList_Replies, font: Font.medium(24.0), textColor: presentationData.theme.list.itemPrimaryTextColor) } else { titleString = NSAttributedString(string: peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), font: Font.medium(24.0), textColor: presentationData.theme.list.itemPrimaryTextColor) } if self.isSettings, let user = peer as? TelegramUser { let formattedPhone = formatPhoneNumber(user.phone ?? "") subtitleString = NSAttributedString(string: formattedPhone, font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor) var username = "" if let addressName = user.addressName, !addressName.isEmpty { username = "@\(addressName)" } usernameString = NSAttributedString(string: username, font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor) } else if let statusData = statusData { let subtitleColor: UIColor if statusData.isActivity { subtitleColor = presentationData.theme.list.itemAccentColor } else { subtitleColor = presentationData.theme.list.itemSecondaryTextColor } subtitleString = NSAttributedString(string: statusData.text, font: Font.regular(15.0), textColor: subtitleColor) usernameString = NSAttributedString(string: "", font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor) } else { subtitleString = NSAttributedString(string: " ", font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor) usernameString = NSAttributedString(string: "", font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor) } } else { titleString = NSAttributedString(string: " ", font: Font.medium(24.0), textColor: presentationData.theme.list.itemPrimaryTextColor) subtitleString = NSAttributedString(string: " ", font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor) usernameString = NSAttributedString(string: "", font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor) } let textSideInset: CGFloat = 44.0 let expandedAvatarHeight: CGFloat = expandedAvatarListSize.height + expandedAvatarControlsHeight let titleConstrainedSize = CGSize(width: width - textSideInset * 2.0 - (isVerified ? 16.0 : 0.0), height: .greatestFiniteMagnitude) let titleNodeLayout = self.titleNode.updateLayout(states: [ TitleNodeStateRegular: MultiScaleTextState(attributedText: titleString, constrainedSize: titleConstrainedSize), TitleNodeStateExpanded: MultiScaleTextState(attributedText: titleString, constrainedSize: CGSize(width: titleConstrainedSize.width, height: titleConstrainedSize.height)) ], mainState: TitleNodeStateRegular) let subtitleNodeLayout = self.subtitleNode.updateLayout(states: [ TitleNodeStateRegular: MultiScaleTextState(attributedText: subtitleString, constrainedSize: titleConstrainedSize), TitleNodeStateExpanded: MultiScaleTextState(attributedText: subtitleString, constrainedSize: CGSize(width: titleConstrainedSize.width - 82.0, height: titleConstrainedSize.height)) ], mainState: TitleNodeStateRegular) let usernameNodeLayout = self.usernameNode.updateLayout(states: [ TitleNodeStateRegular: MultiScaleTextState(attributedText: usernameString, constrainedSize: CGSize(width: titleConstrainedSize.width, height: titleConstrainedSize.height)), TitleNodeStateExpanded: MultiScaleTextState(attributedText: usernameString, constrainedSize: CGSize(width: width - titleNodeLayout[TitleNodeStateExpanded]!.size.width - 8.0, height: titleConstrainedSize.height)) ], mainState: TitleNodeStateRegular) let avatarFrame = CGRect(origin: CGPoint(x: floor((width - avatarSize) / 2.0), y: statusBarHeight + 10.0), size: CGSize(width: avatarSize, height: avatarSize)) let avatarCenter = CGPoint(x: (1.0 - transitionFraction) * avatarFrame.midX + transitionFraction * transitionSourceAvatarFrame.midX, y: (1.0 - transitionFraction) * avatarFrame.midY + transitionFraction * transitionSourceAvatarFrame.midY) let titleSize = titleNodeLayout[TitleNodeStateRegular]!.size let titleExpandedSize = titleNodeLayout[TitleNodeStateExpanded]!.size let subtitleSize = subtitleNodeLayout[TitleNodeStateRegular]!.size let usernameSize = usernameNodeLayout[TitleNodeStateRegular]!.size if let image = self.titleCredibilityIconNode.image { transition.updateFrame(node: self.titleCredibilityIconNode, frame: CGRect(origin: CGPoint(x: titleSize.width + 4.0, y: floor((titleSize.height - image.size.height) / 2.0) + 1.0), size: image.size)) self.titleCredibilityIconNode.isHidden = !isVerified transition.updateFrame(node: self.titleExpandedCredibilityIconNode, frame: CGRect(origin: CGPoint(x: titleExpandedSize.width + 4.0, y: floor((titleExpandedSize.height - image.size.height) / 2.0) + 1.0), size: image.size)) self.titleExpandedCredibilityIconNode.isHidden = !isVerified } let titleFrame: CGRect let subtitleFrame: CGRect let usernameFrame: CGRect let usernameSpacing: CGFloat = 4.0 var twoLineInfo = false if self.isAvatarExpanded { let minTitleSize = CGSize(width: titleSize.width * 0.7, height: titleSize.height * 0.7) let minTitleFrame = CGRect(origin: CGPoint(x: 16.0, y: expandedAvatarHeight - expandedAvatarControlsHeight + 9.0 + (subtitleSize.height.isZero ? 10.0 : 0.0)), size: minTitleSize) titleFrame = CGRect(origin: CGPoint(x: minTitleFrame.midX - titleSize.width / 2.0, y: minTitleFrame.midY - titleSize.height / 2.0), size: titleSize) subtitleFrame = CGRect(origin: CGPoint(x: 16.0, y: minTitleFrame.maxY + 4.0), size: subtitleSize) usernameFrame = CGRect(origin: CGPoint(x: width - usernameSize.width - 16.0, y: minTitleFrame.midY - usernameSize.height / 2.0), size: usernameSize) } else { titleFrame = CGRect(origin: CGPoint(x: floor((width - titleSize.width) / 2.0), y: avatarFrame.maxY + 10.0 + (subtitleSize.height.isZero ? 11.0 : 0.0)), size: titleSize) let totalSubtitleWidth = subtitleSize.width + usernameSpacing + usernameSize.width twoLineInfo = true if usernameSize.width == 0.0 || twoLineInfo { subtitleFrame = CGRect(origin: CGPoint(x: floor((width - subtitleSize.width) / 2.0), y: titleFrame.maxY + 1.0), size: subtitleSize) usernameFrame = CGRect(origin: CGPoint(x: floor((width - usernameSize.width) / 2.0), y: subtitleFrame.maxY + 1.0), size: usernameSize) } else { subtitleFrame = CGRect(origin: CGPoint(x: floor((width - totalSubtitleWidth) / 2.0), y: titleFrame.maxY + 1.0), size: subtitleSize) usernameFrame = CGRect(origin: CGPoint(x: subtitleFrame.maxX + usernameSpacing, y: titleFrame.maxY + 1.0), size: usernameSize) } } self.twoLineInfo = twoLineInfo let singleTitleLockOffset: CGFloat = (peer?.id == self.context.account.peerId || subtitleSize.height.isZero) ? 8.0 : 0.0 let titleLockOffset: CGFloat = 7.0 + singleTitleLockOffset let titleMaxLockOffset: CGFloat = 7.0 let titleCollapseOffset = titleFrame.midY - statusBarHeight - titleLockOffset let titleOffset = -min(titleCollapseOffset, contentOffset) let titleCollapseFraction = max(0.0, min(1.0, contentOffset / titleCollapseOffset)) let titleMinScale: CGFloat = 0.7 let subtitleMinScale: CGFloat = 0.8 let avatarMinScale: CGFloat = 0.7 let apparentTitleLockOffset = (1.0 - titleCollapseFraction) * 0.0 + titleCollapseFraction * titleMaxLockOffset self.titleNode.update(stateFractions: [ TitleNodeStateRegular: self.isAvatarExpanded ? 0.0 : 1.0, TitleNodeStateExpanded: self.isAvatarExpanded ? 1.0 : 0.0 ], transition: transition) let subtitleAlpha: CGFloat = self.isSettings ? 1.0 - titleCollapseFraction : 1.0 self.subtitleNode.update(stateFractions: [ TitleNodeStateRegular: self.isAvatarExpanded ? 0.0 : 1.0, TitleNodeStateExpanded: self.isAvatarExpanded ? 1.0 : 0.0 ], alpha: subtitleAlpha, transition: transition) self.usernameNode.update(stateFractions: [ TitleNodeStateRegular: self.isAvatarExpanded ? 0.0 : 1.0, TitleNodeStateExpanded: self.isAvatarExpanded ? 1.0 : 0.0 ], alpha: subtitleAlpha, transition: transition) let avatarScale: CGFloat let avatarOffset: CGFloat if self.navigationTransition != nil { avatarScale = ((1.0 - transitionFraction) * avatarFrame.width + transitionFraction * transitionSourceAvatarFrame.width) / avatarFrame.width avatarOffset = 0.0 } else { avatarScale = 1.0 * (1.0 - titleCollapseFraction) + avatarMinScale * titleCollapseFraction avatarOffset = apparentTitleLockOffset + 0.0 * (1.0 - titleCollapseFraction) + 10.0 * titleCollapseFraction } if self.isAvatarExpanded { self.avatarListNode.listContainerNode.isHidden = false if !transitionSourceAvatarFrame.width.isZero { transition.updateCornerRadius(node: self.avatarListNode.listContainerNode, cornerRadius: transitionFraction * transitionSourceAvatarFrame.width / 2.0) transition.updateCornerRadius(node: self.avatarListNode.listContainerNode.controlsClippingNode, cornerRadius: transitionFraction * transitionSourceAvatarFrame.width / 2.0) } else { transition.updateCornerRadius(node: self.avatarListNode.listContainerNode, cornerRadius: 0.0) transition.updateCornerRadius(node: self.avatarListNode.listContainerNode.controlsClippingNode, cornerRadius: 0.0) } } else if self.avatarListNode.listContainerNode.cornerRadius != avatarSize / 2.0 { transition.updateCornerRadius(node: self.avatarListNode.listContainerNode.controlsClippingNode, cornerRadius: avatarSize / 2.0) transition.updateCornerRadius(node: self.avatarListNode.listContainerNode, cornerRadius: avatarSize / 2.0, completion: { [weak self] _ in guard let strongSelf = self else { return } strongSelf.avatarListNode.avatarContainerNode.canAttachVideo = true strongSelf.avatarListNode.listContainerNode.isHidden = true if !strongSelf.skipCollapseCompletion { DispatchQueue.main.async { strongSelf.avatarListNode.listContainerNode.isCollapsing = false } } }) } self.avatarListNode.update(size: CGSize(), avatarSize: avatarSize, isExpanded: self.isAvatarExpanded, peer: peer, theme: presentationData.theme, transition: transition) self.editingContentNode.avatarNode.update(peer: peer, item: self.avatarListNode.item, updatingAvatar: state.updatingAvatar, uploadProgress: state.avatarUploadProgress, theme: presentationData.theme, avatarSize: avatarSize, isEditing: state.isEditing) self.avatarOverlayNode.update(peer: peer, item: self.avatarListNode.item, updatingAvatar: state.updatingAvatar, uploadProgress: state.avatarUploadProgress, theme: presentationData.theme, avatarSize: avatarSize, isEditing: state.isEditing) if additive { transition.updateSublayerTransformScaleAdditive(node: self.avatarListNode.avatarContainerNode, scale: avatarScale) transition.updateSublayerTransformScaleAdditive(node: self.avatarOverlayNode, scale: avatarScale) } else { transition.updateSublayerTransformScale(node: self.avatarListNode.avatarContainerNode, scale: avatarScale) transition.updateSublayerTransformScale(node: self.avatarOverlayNode, scale: avatarScale) } let apparentAvatarFrame: CGRect let controlsClippingFrame: CGRect if self.isAvatarExpanded { let expandedAvatarCenter = CGPoint(x: expandedAvatarListSize.width / 2.0, y: expandedAvatarListSize.height / 2.0 - contentOffset / 2.0) apparentAvatarFrame = CGRect(origin: CGPoint(x: expandedAvatarCenter.x * (1.0 - transitionFraction) + transitionFraction * avatarCenter.x, y: expandedAvatarCenter.y * (1.0 - transitionFraction) + transitionFraction * avatarCenter.y), size: CGSize()) if !transitionSourceAvatarFrame.width.isZero { let expandedFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: expandedAvatarListSize) controlsClippingFrame = CGRect(origin: CGPoint(x: transitionFraction * transitionSourceAvatarFrame.minX + (1.0 - transitionFraction) * expandedFrame.minX, y: transitionFraction * transitionSourceAvatarFrame.minY + (1.0 - transitionFraction) * expandedFrame.minY), size: CGSize(width: transitionFraction * transitionSourceAvatarFrame.width + (1.0 - transitionFraction) * expandedFrame.width, height: transitionFraction * transitionSourceAvatarFrame.height + (1.0 - transitionFraction) * expandedFrame.height)) } else { controlsClippingFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: expandedAvatarListSize) } } else { apparentAvatarFrame = CGRect(origin: CGPoint(x: avatarCenter.x - avatarFrame.width / 2.0, y: -contentOffset + avatarOffset + avatarCenter.y - avatarFrame.height / 2.0), size: avatarFrame.size) controlsClippingFrame = apparentAvatarFrame } transition.updateFrameAdditive(node: self.avatarListNode, frame: CGRect(origin: apparentAvatarFrame.center, size: CGSize())) transition.updateFrameAdditive(node: self.avatarOverlayNode, frame: CGRect(origin: apparentAvatarFrame.center, size: CGSize())) let avatarListContainerFrame: CGRect let avatarListContainerScale: CGFloat if self.isAvatarExpanded { if !transitionSourceAvatarFrame.width.isZero { let neutralAvatarListContainerSize = expandedAvatarListSize let avatarListContainerSize = CGSize(width: neutralAvatarListContainerSize.width * (1.0 - transitionFraction) + transitionSourceAvatarFrame.width * transitionFraction, height: neutralAvatarListContainerSize.height * (1.0 - transitionFraction) + transitionSourceAvatarFrame.height * transitionFraction) avatarListContainerFrame = CGRect(origin: CGPoint(x: -avatarListContainerSize.width / 2.0, y: -avatarListContainerSize.height / 2.0), size: avatarListContainerSize) } else { avatarListContainerFrame = CGRect(origin: CGPoint(x: -expandedAvatarListSize.width / 2.0, y: -expandedAvatarListSize.height / 2.0), size: expandedAvatarListSize) } avatarListContainerScale = 1.0 + max(0.0, -contentOffset / avatarListContainerFrame.height) } else { avatarListContainerFrame = CGRect(origin: CGPoint(x: -apparentAvatarFrame.width / 2.0, y: -apparentAvatarFrame.height / 2.0), size: apparentAvatarFrame.size) avatarListContainerScale = avatarScale } transition.updateFrame(node: self.avatarListNode.listContainerNode, frame: avatarListContainerFrame) let innerScale = avatarListContainerFrame.height / expandedAvatarListSize.height let innerDeltaX = (avatarListContainerFrame.width - expandedAvatarListSize.width) / 2.0 let innerDeltaY = (avatarListContainerFrame.height - expandedAvatarListSize.height) / 2.0 transition.updateSublayerTransformScale(node: self.avatarListNode.listContainerNode, scale: innerScale) transition.updateFrameAdditive(node: self.avatarListNode.listContainerNode.contentNode, frame: CGRect(origin: CGPoint(x: innerDeltaX + expandedAvatarListSize.width / 2.0, y: innerDeltaY + expandedAvatarListSize.height / 2.0), size: CGSize())) transition.updateFrameAdditive(node: self.avatarListNode.listContainerNode.controlsClippingOffsetNode, frame: CGRect(origin: controlsClippingFrame.center, size: CGSize())) transition.updateFrame(node: self.avatarListNode.listContainerNode.controlsClippingNode, frame: CGRect(origin: CGPoint(x: -controlsClippingFrame.width / 2.0, y: -controlsClippingFrame.height / 2.0), size: controlsClippingFrame.size)) transition.updateFrameAdditive(node: self.avatarListNode.listContainerNode.controlsContainerNode, frame: CGRect(origin: CGPoint(x: -controlsClippingFrame.minX, y: -controlsClippingFrame.minY), size: CGSize(width: expandedAvatarListSize.width, height: expandedAvatarListSize.height))) transition.updateFrame(node: self.avatarListNode.listContainerNode.shadowNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: expandedAvatarListSize.width, height: navigationHeight + 20.0))) transition.updateFrame(node: self.avatarListNode.listContainerNode.stripContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: statusBarHeight < 25.0 ? (statusBarHeight + 2.0) : (statusBarHeight - 3.0)), size: CGSize(width: expandedAvatarListSize.width, height: 2.0))) transition.updateFrame(node: self.avatarListNode.listContainerNode.highlightContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: expandedAvatarListSize.width, height: expandedAvatarListSize.height))) transition.updateAlpha(node: self.avatarListNode.listContainerNode.controlsContainerNode, alpha: self.isAvatarExpanded ? (1.0 - transitionFraction) : 0.0) if additive { transition.updateSublayerTransformScaleAdditive(node: self.avatarListNode.listContainerTransformNode, scale: avatarListContainerScale) } else { transition.updateSublayerTransformScale(node: self.avatarListNode.listContainerTransformNode, scale: avatarListContainerScale) } self.avatarListNode.listContainerNode.update(size: expandedAvatarListSize, peer: peer, isExpanded: self.isAvatarExpanded, transition: transition) if self.avatarListNode.listContainerNode.isCollapsing && !self.ignoreCollapse { self.avatarListNode.avatarContainerNode.canAttachVideo = false } var panelWithAvatarHeight: CGFloat = (self.isSettings ? 40.0 : 112.0) + avatarSize if twoLineInfo { panelWithAvatarHeight += 17.0 } let buttonsCollapseStart = titleCollapseOffset let buttonsCollapseEnd = panelWithAvatarHeight - (navigationHeight - statusBarHeight) + 10.0 let buttonsCollapseFraction = max(0.0, contentOffset - buttonsCollapseStart) / (buttonsCollapseEnd - buttonsCollapseStart) let rawHeight: CGFloat let height: CGFloat if self.isAvatarExpanded { rawHeight = expandedAvatarHeight height = max(navigationHeight, rawHeight - contentOffset) } else { rawHeight = navigationHeight + panelWithAvatarHeight height = navigationHeight + max(0.0, panelWithAvatarHeight - contentOffset) } let apparentHeight = (1.0 - transitionFraction) * height + transitionFraction * transitionSourceHeight if !titleSize.width.isZero && !titleSize.height.isZero { if self.navigationTransition != nil { var neutralTitleScale: CGFloat = 1.0 var neutralSubtitleScale: CGFloat = 1.0 if self.isAvatarExpanded { neutralTitleScale = 0.7 neutralSubtitleScale = 1.0 } let titleScale = (transitionFraction * transitionSourceTitleFrame.height + (1.0 - transitionFraction) * titleFrame.height * neutralTitleScale) / (titleFrame.height) let subtitleScale = max(0.01, min(10.0, (transitionFraction * transitionSourceSubtitleFrame.height + (1.0 - transitionFraction) * subtitleFrame.height * neutralSubtitleScale) / (subtitleFrame.height))) let titleCenter = CGPoint(x: transitionFraction * transitionSourceTitleFrame.midX + (1.0 - transitionFraction) * titleFrame.midX, y: transitionFraction * transitionSourceTitleFrame.midY + (1.0 - transitionFraction) * titleFrame.midY) let subtitleCenter = CGPoint(x: transitionFraction * transitionSourceSubtitleFrame.midX + (1.0 - transitionFraction) * subtitleFrame.midX, y: transitionFraction * transitionSourceSubtitleFrame.midY + (1.0 - transitionFraction) * subtitleFrame.midY) let rawTitleFrame = CGRect(origin: CGPoint(x: titleCenter.x - titleFrame.size.width * neutralTitleScale / 2.0, y: titleCenter.y - titleFrame.size.height * neutralTitleScale / 2.0), size: CGSize(width: titleFrame.size.width * neutralTitleScale, height: titleFrame.size.height * neutralTitleScale)) self.titleNodeRawContainer.frame = rawTitleFrame transition.updateFrameAdditiveToCenter(node: self.titleNodeContainer, frame: CGRect(origin: rawTitleFrame.center, size: CGSize())) transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(), size: CGSize())) let rawSubtitleFrame = CGRect(origin: CGPoint(x: subtitleCenter.x - subtitleFrame.size.width / 2.0, y: subtitleCenter.y - subtitleFrame.size.height / 2.0), size: subtitleFrame.size) self.subtitleNodeRawContainer.frame = rawSubtitleFrame transition.updateFrameAdditiveToCenter(node: self.subtitleNodeContainer, frame: CGRect(origin: rawSubtitleFrame.center, size: CGSize())) transition.updateFrame(node: self.subtitleNode, frame: CGRect(origin: CGPoint(), size: CGSize())) transition.updateFrame(node: self.usernameNode, frame: CGRect(origin: CGPoint(), size: CGSize())) transition.updateSublayerTransformScale(node: self.titleNodeContainer, scale: titleScale) transition.updateSublayerTransformScale(node: self.subtitleNodeContainer, scale: subtitleScale) transition.updateSublayerTransformScale(node: self.usernameNodeContainer, scale: subtitleScale) } else { let titleScale: CGFloat let subtitleScale: CGFloat if self.isAvatarExpanded { titleScale = 0.7 subtitleScale = 1.0 } else { titleScale = (1.0 - titleCollapseFraction) * 1.0 + titleCollapseFraction * titleMinScale subtitleScale = (1.0 - titleCollapseFraction) * 1.0 + titleCollapseFraction * subtitleMinScale } let rawTitleFrame = titleFrame self.titleNodeRawContainer.frame = rawTitleFrame transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(), size: CGSize())) let rawSubtitleFrame = subtitleFrame self.subtitleNodeRawContainer.frame = rawSubtitleFrame let rawUsernameFrame = usernameFrame self.usernameNodeRawContainer.frame = rawUsernameFrame if self.isAvatarExpanded { transition.updateFrameAdditive(node: self.titleNodeContainer, frame: CGRect(origin: rawTitleFrame.center, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset + apparentTitleLockOffset)) transition.updateFrameAdditive(node: self.subtitleNodeContainer, frame: CGRect(origin: rawSubtitleFrame.center, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset)) transition.updateFrameAdditive(node: self.usernameNodeContainer, frame: CGRect(origin: rawUsernameFrame.center, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset)) } else { transition.updateFrameAdditiveToCenter(node: self.titleNodeContainer, frame: CGRect(origin: rawTitleFrame.center, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset + apparentTitleLockOffset)) var subtitleCenter = rawSubtitleFrame.center subtitleCenter.x = rawTitleFrame.center.x + (subtitleCenter.x - rawTitleFrame.center.x) * subtitleScale transition.updateFrameAdditiveToCenter(node: self.subtitleNodeContainer, frame: CGRect(origin: subtitleCenter, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset)) var usernameCenter = rawUsernameFrame.center usernameCenter.x = rawTitleFrame.center.x + (usernameCenter.x - rawTitleFrame.center.x) * subtitleScale transition.updateFrameAdditiveToCenter(node: self.usernameNodeContainer, frame: CGRect(origin: usernameCenter, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset)) } transition.updateFrame(node: self.subtitleNode, frame: CGRect(origin: CGPoint(), size: CGSize())) transition.updateFrame(node: self.usernameNode, frame: CGRect(origin: CGPoint(), size: CGSize())) transition.updateSublayerTransformScaleAdditive(node: self.titleNodeContainer, scale: titleScale) transition.updateSublayerTransformScaleAdditive(node: self.subtitleNodeContainer, scale: subtitleScale) transition.updateSublayerTransformScaleAdditive(node: self.usernameNodeContainer, scale: subtitleScale) } } let buttonSpacing: CGFloat if self.isAvatarExpanded { buttonSpacing = 16.0 } else { let normWidth = min(width, containerHeight) let buttonSpacingValue = floor((normWidth - floor(CGFloat(buttonKeys.count) * defaultButtonSize)) / CGFloat(buttonKeys.count + 1)) buttonSpacing = min(buttonSpacingValue, 160.0) } let expandedButtonSize: CGFloat = 32.0 let buttonsWidth = buttonSpacing * CGFloat(buttonKeys.count - 1) + CGFloat(buttonKeys.count) * defaultButtonSize var buttonRightOrigin: CGPoint if self.isAvatarExpanded { buttonRightOrigin = CGPoint(x: width - 16.0, y: apparentHeight - 74.0) } else { buttonRightOrigin = CGPoint(x: floor((width - buttonsWidth) / 2.0) + buttonsWidth, y: apparentHeight - 74.0) } let buttonsScale: CGFloat let buttonsAlpha: CGFloat let apparentButtonSize: CGFloat let buttonsVerticalOffset: CGFloat var buttonsAlphaTransition = transition if self.navigationTransition != nil { if case let .animated(duration, curve) = transition, transitionFraction >= 1.0 - CGFloat.ulpOfOne { buttonsAlphaTransition = .animated(duration: duration * 0.6, curve: curve) } if self.isAvatarExpanded { apparentButtonSize = expandedButtonSize } else { apparentButtonSize = defaultButtonSize } let neutralButtonsScale = apparentButtonSize / defaultButtonSize buttonsScale = (1.0 - transitionFraction) * neutralButtonsScale + 0.2 * transitionFraction buttonsAlpha = 1.0 - transitionFraction let neutralButtonsOffset: CGFloat if self.isAvatarExpanded { neutralButtonsOffset = 74.0 - 15.0 - defaultButtonSize + (defaultButtonSize - apparentButtonSize) / 2.0 } else { neutralButtonsOffset = (1.0 - buttonsScale) * apparentButtonSize } buttonsVerticalOffset = (1.0 - transitionFraction) * neutralButtonsOffset + ((1.0 - buttonsScale) * apparentButtonSize) * transitionFraction } else { apparentButtonSize = self.isAvatarExpanded ? expandedButtonSize : defaultButtonSize if self.isAvatarExpanded { buttonsScale = apparentButtonSize / defaultButtonSize buttonsVerticalOffset = 74.0 - 15.0 - defaultButtonSize + (defaultButtonSize - apparentButtonSize) / 2.0 } else { buttonsScale = (1.0 - buttonsCollapseFraction) * 1.0 + 0.2 * buttonsCollapseFraction buttonsVerticalOffset = (1.0 - buttonsScale) * apparentButtonSize } buttonsAlpha = 1.0 - buttonsCollapseFraction } let buttonsScaledOffset = (defaultButtonSize - apparentButtonSize) / 2.0 for buttonKey in buttonKeys.reversed() { let buttonNode: PeerInfoHeaderButtonNode var wasAdded = false if let current = self.buttonNodes[buttonKey] { buttonNode = current } else { wasAdded = true buttonNode = PeerInfoHeaderButtonNode(key: buttonKey, action: { [weak self] buttonNode in self?.buttonPressed(buttonNode) }) self.buttonNodes[buttonKey] = buttonNode self.regularContentNode.addSubnode(buttonNode) } let buttonFrame = CGRect(origin: CGPoint(x: buttonRightOrigin.x - defaultButtonSize + buttonsScaledOffset, y: buttonRightOrigin.y), size: CGSize(width: defaultButtonSize, height: defaultButtonSize)) let buttonTransition: ContainedViewLayoutTransition = wasAdded ? .immediate : transition let apparentButtonFrame = buttonFrame.offsetBy(dx: 0.0, dy: buttonsVerticalOffset) if additive { buttonTransition.updateFrameAdditiveToCenter(node: buttonNode, frame: apparentButtonFrame) } else { buttonTransition.updateFrame(node: buttonNode, frame: apparentButtonFrame) } let buttonText: String let buttonIcon: PeerInfoHeaderButtonIcon switch buttonKey { case .message: buttonText = presentationData.strings.PeerInfo_ButtonMessage buttonIcon = .message case .discussion: buttonText = presentationData.strings.PeerInfo_ButtonDiscuss buttonIcon = .message case .call: buttonText = presentationData.strings.PeerInfo_ButtonCall buttonIcon = .call case .videoCall: buttonText = presentationData.strings.PeerInfo_ButtonVideoCall buttonIcon = .videoCall case .voiceChat: buttonText = presentationData.strings.PeerInfo_ButtonVoiceChat buttonIcon = .voiceChat case .mute: if let notificationSettings = notificationSettings, case .muted = notificationSettings.muteState { buttonText = presentationData.strings.PeerInfo_ButtonUnmute buttonIcon = .unmute } else { buttonText = presentationData.strings.PeerInfo_ButtonMute buttonIcon = .mute } case .more: buttonText = presentationData.strings.PeerInfo_ButtonMore buttonIcon = .more case .addMember: buttonText = presentationData.strings.PeerInfo_ButtonAddMember buttonIcon = .addMember case .search: buttonText = presentationData.strings.PeerInfo_ButtonSearch buttonIcon = .search case .leave: buttonText = presentationData.strings.PeerInfo_ButtonLeave buttonIcon = .leave } buttonNode.update(size: buttonFrame.size, text: buttonText, icon: buttonIcon, isExpanded: self.isAvatarExpanded, presentationData: presentationData, transition: buttonTransition) transition.updateSublayerTransformScaleAdditive(node: buttonNode, scale: buttonsScale) if wasAdded { buttonNode.alpha = 0.0 } buttonsAlphaTransition.updateAlpha(node: buttonNode, alpha: buttonsAlpha) let hiddenWhileExpanded: Bool if buttonKeys.count > 3 { if self.isOpenedFromChat { switch buttonKey { case .message, .search, .mute: hiddenWhileExpanded = true default: hiddenWhileExpanded = false } } else { switch buttonKey { case .mute, .search, .videoCall: hiddenWhileExpanded = true default: hiddenWhileExpanded = false } } } else { hiddenWhileExpanded = false } if self.isAvatarExpanded, hiddenWhileExpanded { if case let .animated(duration, curve) = transition { ContainedViewLayoutTransition.animated(duration: duration * 0.3, curve: curve).updateAlpha(node: buttonNode.containerNode, alpha: 0.0) } else { transition.updateAlpha(node: buttonNode.containerNode, alpha: 0.0) } } else { if case .mute = buttonKey, buttonNode.containerNode.alpha.isZero, additive { if case let .animated(duration, curve) = transition { ContainedViewLayoutTransition.animated(duration: duration * 0.3, curve: curve).updateAlpha(node: buttonNode.containerNode, alpha: 1.0) } else { transition.updateAlpha(node: buttonNode.containerNode, alpha: 1.0) } } else { transition.updateAlpha(node: buttonNode.containerNode, alpha: 1.0) } buttonRightOrigin.x -= apparentButtonSize + buttonSpacing } } for key in self.buttonNodes.keys { if !buttonKeys.contains(key) { if let buttonNode = self.buttonNodes[key] { self.buttonNodes.removeValue(forKey: key) buttonNode.removeFromSupernode() } } } let resolvedRegularHeight: CGFloat if self.isAvatarExpanded { resolvedRegularHeight = expandedAvatarListSize.height + expandedAvatarControlsHeight } else { resolvedRegularHeight = panelWithAvatarHeight + navigationHeight } let backgroundFrame: CGRect let separatorFrame: CGRect let resolvedHeight: CGFloat if state.isEditing { resolvedHeight = editingContentHeight backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: -2000.0 + max(navigationHeight, resolvedHeight - contentOffset)), size: CGSize(width: width, height: 2000.0)) separatorFrame = CGRect(origin: CGPoint(x: 0.0, y: max(navigationHeight, resolvedHeight - contentOffset)), size: CGSize(width: width, height: UIScreenPixel)) } else { resolvedHeight = resolvedRegularHeight backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: -2000.0 + apparentHeight), size: CGSize(width: width, height: 2000.0)) separatorFrame = CGRect(origin: CGPoint(x: 0.0, y: apparentHeight), size: CGSize(width: width, height: UIScreenPixel)) } transition.updateFrame(node: self.regularContentNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: width, height: resolvedHeight))) if additive { transition.updateFrameAdditive(node: self.backgroundNode, frame: backgroundFrame) transition.updateFrameAdditive(node: self.expandedBackgroundNode, frame: backgroundFrame) transition.updateFrameAdditive(node: self.separatorNode, frame: separatorFrame) } else { transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame) transition.updateFrame(node: self.expandedBackgroundNode, frame: backgroundFrame) transition.updateFrame(node: self.separatorNode, frame: separatorFrame) } return resolvedHeight } private func buttonPressed(_ buttonNode: PeerInfoHeaderButtonNode) { self.performButtonAction?(buttonNode.key) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard let result = super.hitTest(point, with: event) else { return nil } if result.isDescendant(of: self.navigationButtonContainer.view) { return result } if !self.backgroundNode.frame.contains(point) { return nil } if result == self.view || result == self.regularContentNode.view || result == self.editingContentNode.view { return nil } return result } func updateIsAvatarExpanded(_ isAvatarExpanded: Bool, transition: ContainedViewLayoutTransition) { if self.isAvatarExpanded != isAvatarExpanded { self.isAvatarExpanded = isAvatarExpanded if isAvatarExpanded { self.avatarListNode.listContainerNode.selectFirstItem() } if case .animated = transition, !isAvatarExpanded { self.avatarListNode.animateAvatarCollapse(transition: transition) } } } }