import Foundation import UIKit import AsyncDisplayKit import Display import TelegramCore import Postbox import SwiftSignalKit import TelegramPresentationData import AnimationUI import AppBundle import AccountContext import Emoji import Accelerate import ComponentFlow import AvatarStoryIndicatorComponent import DirectMediaImageCache private let deletedIcon = UIImage(bundleImageName: "Avatar/DeletedIcon")?.precomposed() private let phoneIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/PhoneIcon"), color: .white) public let savedMessagesIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/SavedMessagesIcon"), color: .white) public let repostStoryIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/RepostStoryIcon"), color: .white) private let archivedChatsIcon = UIImage(bundleImageName: "Avatar/ArchiveAvatarIcon")?.precomposed() private let repliesIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/RepliesMessagesIcon"), color: .white) private let anonymousSavedMessagesIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/AnonymousSenderIcon"), color: .white) private let myNotesIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/MyNotesIcon"), color: .white) public func avatarPlaceholderFont(size: CGFloat) -> UIFont { return Font.with(size: size, design: .round, weight: .bold) } public enum AvatarNodeClipStyle { case none case round case roundedRect } private class AvatarNodeParameters: NSObject { let theme: PresentationTheme? let accountPeerId: EnginePeer.Id? let peerId: EnginePeer.Id? let colors: [UIColor] let letters: [String] let font: UIFont let icon: AvatarNodeIcon let explicitColorIndex: Int? let hasImage: Bool let clipStyle: AvatarNodeClipStyle init(theme: PresentationTheme?, accountPeerId: EnginePeer.Id?, peerId: EnginePeer.Id?, colors: [UIColor], letters: [String], font: UIFont, icon: AvatarNodeIcon, explicitColorIndex: Int?, hasImage: Bool, clipStyle: AvatarNodeClipStyle) { self.theme = theme self.accountPeerId = accountPeerId self.peerId = peerId self.colors = colors self.letters = letters self.font = font self.icon = icon self.explicitColorIndex = explicitColorIndex self.hasImage = hasImage self.clipStyle = clipStyle super.init() } func withUpdatedHasImage(_ hasImage: Bool) -> AvatarNodeParameters { return AvatarNodeParameters(theme: self.theme, accountPeerId: self.accountPeerId, peerId: self.peerId, colors: self.colors, letters: self.letters, font: self.font, icon: self.icon, explicitColorIndex: self.explicitColorIndex, hasImage: hasImage, clipStyle: self.clipStyle) } } private func calculateColors(context: AccountContext?, explicitColorIndex: Int?, peerId: EnginePeer.Id?, nameColor: PeerNameColor?, icon: AvatarNodeIcon, theme: PresentationTheme?) -> [UIColor] { let colorIndex: Int if let explicitColorIndex = explicitColorIndex { colorIndex = explicitColorIndex } else { if let peerId { if peerId.namespace == .max { colorIndex = -1 } else { colorIndex = abs(Int(clamping: peerId.id._internalGetInt64Value())) } } else { colorIndex = -1 } } let colors: [UIColor] if icon != .none { if case .deletedIcon = icon { colors = AvatarNode.grayscaleColors } else if case .phoneIcon = icon { colors = AvatarNode.grayscaleColors } else if case .savedMessagesIcon = icon { colors = AvatarNode.savedMessagesColors } else if case .repostIcon = icon { colors = AvatarNode.repostColors } else if case .repliesIcon = icon { colors = AvatarNode.savedMessagesColors } else if case let .anonymousSavedMessagesIcon(isColored) = icon { if isColored { colors = AvatarNode.savedMessagesColors } else { colors = AvatarNode.grayscaleColors } } else if case .myNotesIcon = icon { colors = AvatarNode.savedMessagesColors } else if case .editAvatarIcon = icon, let theme { colors = [theme.list.itemAccentColor.withAlphaComponent(0.1), theme.list.itemAccentColor.withAlphaComponent(0.1)] } else if case let .archivedChatsIcon(hiddenByDefault) = icon, let theme = theme { let backgroundColors: (UIColor, UIColor) if hiddenByDefault { backgroundColors = theme.chatList.unpinnedArchiveAvatarColor.backgroundColors.colors } else { backgroundColors = theme.chatList.pinnedArchiveAvatarColor.backgroundColors.colors } colors = [backgroundColors.1, backgroundColors.0] } else { colors = AvatarNode.grayscaleColors } } else if colorIndex == -1 { if let theme { let backgroundColors = theme.chatList.unpinnedArchiveAvatarColor.backgroundColors.colors colors = [backgroundColors.1, backgroundColors.0] } else { colors = AvatarNode.grayscaleColors } } else { if let nameColor { if let context, nameColor.rawValue > 13 { let nameColors = context.peerNameColors.get(nameColor) let hue = nameColors.main.hsb.h var index: Int = 0 if hue > 0.9 || hue < 0.02 { index = 0 } else if hue < 0.1 { index = 1 } else if hue < 0.4 { index = 3 } else if hue < 0.5 { index = 4 } else if hue < 0.6 { index = 5 } else if hue < 0.75 { index = 2 } else { index = 6 } colors = AvatarNode.gradientColors[index % AvatarNode.gradientColors.count] } else { colors = AvatarNode.gradientColors[Int(nameColor.rawValue) % AvatarNode.gradientColors.count] } } else { colors = AvatarNode.gradientColors[colorIndex % AvatarNode.gradientColors.count] } } return colors } public enum AvatarNodeExplicitIcon { case phone } private enum AvatarNodeState: Equatable { case empty case peerAvatar(EnginePeer.Id, PeerNameColor?, [String], TelegramMediaImageRepresentation?, AvatarNodeClipStyle) case custom(letter: [String], explicitColorIndex: Int?, explicitIcon: AvatarNodeExplicitIcon?) } private func ==(lhs: AvatarNodeState, rhs: AvatarNodeState) -> Bool { switch (lhs, rhs) { case (.empty, .empty): return true case let (.peerAvatar(lhsPeerId, lhsPeerNameColor, lhsLetters, lhsPhotoRepresentations, lhsClipStyle), .peerAvatar(rhsPeerId, rhsPeerNameColor, rhsLetters, rhsPhotoRepresentations, rhsClipStyle)): return lhsPeerId == rhsPeerId && lhsPeerNameColor == rhsPeerNameColor && lhsLetters == rhsLetters && lhsPhotoRepresentations == rhsPhotoRepresentations && lhsClipStyle == rhsClipStyle case let (.custom(lhsLetters, lhsIndex, lhsIcon), .custom(rhsLetters, rhsIndex, rhsIcon)): return lhsLetters == rhsLetters && lhsIndex == rhsIndex && lhsIcon == rhsIcon default: return false } } private enum AvatarNodeIcon: Equatable { case none case savedMessagesIcon case repliesIcon case anonymousSavedMessagesIcon(isColored: Bool) case myNotesIcon case archivedChatsIcon(hiddenByDefault: Bool) case editAvatarIcon case deletedIcon case phoneIcon case repostIcon } public enum AvatarNodeImageOverride: Equatable { case none case image(TelegramMediaImageRepresentation) case savedMessagesIcon case repliesIcon case anonymousSavedMessagesIcon(isColored: Bool) case myNotesIcon case archivedChatsIcon(hiddenByDefault: Bool) case editAvatarIcon(forceNone: Bool) case deletedIcon case phoneIcon case repostIcon } public enum AvatarNodeColorOverride { case blue } public final class AvatarEditOverlayNode: ASDisplayNode { override public init() { super.init() self.isOpaque = false self.displaysAsynchronously = true } @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { assertNotOnMainThread() let context = UIGraphicsGetCurrentContext()! if !isRasterizing { context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) context.fill(bounds) } context.beginPath() context.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: bounds.size.height)) context.clip() context.setFillColor(UIColor(rgb: 0x000000, alpha: 0.4).cgColor) context.fill(bounds) context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) context.setBlendMode(.normal) if bounds.width > 90.0 { if let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIconLarge"), color: .white) { context.draw(editAvatarIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - editAvatarIcon.size.width) / 2.0) + 0.5, y: floor((bounds.size.height - editAvatarIcon.size.height) / 2.0) + 1.0), size: editAvatarIcon.size)) } } else { if let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIcon"), color: .white) { context.draw(editAvatarIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - editAvatarIcon.size.width) / 2.0) + 0.5, y: floor((bounds.size.height - editAvatarIcon.size.height) / 2.0) + 1.0), size: editAvatarIcon.size)) } } } } public final class AvatarNode: ASDisplayNode { public static let gradientColors: [[UIColor]] = [ [UIColor(rgb: 0xff516a), UIColor(rgb: 0xff885e)], [UIColor(rgb: 0xffa85c), UIColor(rgb: 0xffcd6a)], [UIColor(rgb: 0x665fff), UIColor(rgb: 0x82b1ff)], [UIColor(rgb: 0x54cb68), UIColor(rgb: 0xa0de7e)], [UIColor(rgb: 0x4acccd), UIColor(rgb: 0x00fcfd)], [UIColor(rgb: 0x2a9ef1), UIColor(rgb: 0x72d5fd)], [UIColor(rgb: 0xd669ed), UIColor(rgb: 0xe0a2f3)], ] static let grayscaleColors: [UIColor] = [ UIColor(rgb: 0xb1b1b1), UIColor(rgb: 0xcdcdcd) ] static let savedMessagesColors: [UIColor] = [ UIColor(rgb: 0x2a9ef1), UIColor(rgb: 0x72d5fd) ] static let repostColors: [UIColor] = [ UIColor(rgb: 0x34C76F), UIColor(rgb: 0x3DA1FD) ] public final class ContentNode: ASDisplayNode { private struct Params: Equatable { let peerId: EnginePeer.Id? let resourceId: String? let clipStyle: AvatarNodeClipStyle init( peerId: EnginePeer.Id?, resourceId: String?, clipStyle: AvatarNodeClipStyle ) { self.peerId = peerId self.resourceId = resourceId self.clipStyle = clipStyle } } public var font: UIFont { didSet { if oldValue.pointSize != font.pointSize { if let parameters = self.parameters { self.parameters = AvatarNodeParameters(theme: parameters.theme, accountPeerId: parameters.accountPeerId, peerId: parameters.peerId, colors: parameters.colors, letters: parameters.letters, font: self.font, icon: parameters.icon, explicitColorIndex: parameters.explicitColorIndex, hasImage: parameters.hasImage, clipStyle: parameters.clipStyle) } if !self.displaySuspended { self.setNeedsDisplay() } } } } private var parameters: AvatarNodeParameters? private var theme: PresentationTheme? private var overrideImage: AvatarNodeImageOverride? public let imageNode: ImageNode public var editOverlayNode: AvatarEditOverlayNode? private let imageReadyDisposable = MetaDisposable() fileprivate var state: AvatarNodeState = .empty public var unroundedImage: UIImage? private var currentImage: UIImage? private var params: Params? private var loadDisposable: Disposable? var clipStyle: AvatarNodeClipStyle { if let params = self.params { return params.clipStyle } else if case let .peerAvatar(_, _, _, _, clipStyle) = self.state { return clipStyle } return .none } public var badgeView: AvatarBadgeView? { didSet { if self.badgeView !== oldValue { if let badgeView = self.badgeView, let parameters = self.parameters { if parameters.hasImage { if let currentImage = self.currentImage { badgeView.update(content: .image(currentImage)) } } else { let badgeColor: UIColor if parameters.colors.isEmpty { badgeColor = .white } else { badgeColor = parameters.colors[parameters.colors.count - 1] } badgeView.update(content: .color(badgeColor)) } } } } } private let imageReady = Promise(false) public var ready: Signal { let imageReady = self.imageReady return Signal { subscriber in return imageReady.get().start(next: { next in if next { subscriber.putCompletion() } }) } } public init(font: UIFont) { self.font = font self.imageNode = ImageNode(enableHasImage: true, enableAnimatedTransition: true) super.init() self.isOpaque = false self.displaysAsynchronously = true self.disableClearContentsOnHide = true self.imageNode.isLayerBacked = true self.addSubnode(self.imageNode) self.imageNode.contentUpdated = { [weak self] image in guard let self else { return } self.currentImage = image guard let badgeView = self.badgeView, let parameters = self.parameters else { return } if parameters.hasImage, let image { badgeView.update(content: .image(image)) } } } deinit { self.loadDisposable?.dispose() } override public func didLoad() { super.didLoad() if #available(iOSApplicationExtension 11.0, iOS 11.0, *), !self.isLayerBacked { self.view.accessibilityIgnoresInvertColors = true } } public func updateSize(size: CGSize) { self.imageNode.frame = CGRect(origin: CGPoint(), size: size) self.editOverlayNode?.frame = self.imageNode.frame if !self.displaySuspended { self.setNeedsDisplay() self.editOverlayNode?.setNeedsDisplay() } } public func playArchiveAnimation() { guard let theme = self.theme else { return } var iconColor = theme.chatList.unpinnedArchiveAvatarColor.foregroundColor var backgroundColor = theme.chatList.unpinnedArchiveAvatarColor.backgroundColors.topColor let animationBackgroundNode = ASImageNode() animationBackgroundNode.isUserInteractionEnabled = false animationBackgroundNode.frame = self.imageNode.frame if let overrideImage = self.overrideImage, case let .archivedChatsIcon(hiddenByDefault) = overrideImage { let backgroundColors: (UIColor, UIColor) if hiddenByDefault { backgroundColors = theme.chatList.unpinnedArchiveAvatarColor.backgroundColors.colors iconColor = theme.chatList.unpinnedArchiveAvatarColor.foregroundColor } else { backgroundColors = theme.chatList.pinnedArchiveAvatarColor.backgroundColors.colors iconColor = theme.chatList.pinnedArchiveAvatarColor.foregroundColor } let colors: NSArray = [backgroundColors.1.cgColor, backgroundColors.0.cgColor] backgroundColor = backgroundColors.1.mixedWith(backgroundColors.0, alpha: 0.5) animationBackgroundNode.image = generateGradientFilledCircleImage(diameter: self.imageNode.frame.width, colors: colors) } self.addSubnode(animationBackgroundNode) let animationNode = AnimationNode(animation: "anim_archiveAvatar", colors: ["box1.box1.Fill 1": iconColor, "box3.box3.Fill 1": iconColor, "box2.box2.Fill 1": backgroundColor], scale: 0.1653828) animationNode.isUserInteractionEnabled = false animationNode.completion = { [weak animationBackgroundNode, weak self] in self?.imageNode.isHidden = false animationBackgroundNode?.removeFromSupernode() } animationBackgroundNode.addSubnode(animationNode) animationBackgroundNode.layer.animateScale(from: 1.0, to: 1.07, duration: 0.12, removeOnCompletion: false, completion: { [weak animationBackgroundNode] finished in animationBackgroundNode?.layer.animateScale(from: 1.07, to: 1.0, duration: 0.12, removeOnCompletion: false) }) if var size = animationNode.preferredSize() { size = CGSize(width: ceil(size.width), height: ceil(size.height)) animationNode.frame = CGRect(x: floor((self.bounds.width - size.width) / 2.0), y: floor((self.bounds.height - size.height) / 2.0) + 1.0, width: size.width, height: size.height) animationNode.play() } self.imageNode.isHidden = true } public func playRepostAnimation() { let animationNode = AnimationNode(animation: "anim_storyrepost", colors: [:], scale: 0.11) animationNode.isUserInteractionEnabled = false self.addSubnode(animationNode) if var size = animationNode.preferredSize() { size = CGSize(width: ceil(size.width), height: ceil(size.height)) animationNode.frame = CGRect(x: floor((self.bounds.width - size.width) / 2.0), y: floor((self.bounds.height - size.height) / 2.0) + 1.0, width: size.width, height: size.height) Queue.mainQueue().after(0.15, { animationNode.play() }) } } public func setPeer( accountPeerId: EnginePeer.Id, postbox: Postbox, network: Network, contentSettings: ContentSettings, theme: PresentationTheme, peer: EnginePeer?, authorOfMessage: MessageReference? = nil, overrideImage: AvatarNodeImageOverride? = nil, emptyColor: UIColor? = nil, clipStyle: AvatarNodeClipStyle = .round, synchronousLoad: Bool = false, displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0), storeUnrounded: Bool = false ) { var synchronousLoad = synchronousLoad var representation: TelegramMediaImageRepresentation? var icon = AvatarNodeIcon.none if let overrideImage = overrideImage { switch overrideImage { case .none: representation = nil case let .image(image): representation = image synchronousLoad = false case .savedMessagesIcon: representation = nil icon = .savedMessagesIcon case .repostIcon: representation = nil icon = .repostIcon case .repliesIcon: representation = nil icon = .repliesIcon case let .anonymousSavedMessagesIcon(isColored): representation = nil icon = .anonymousSavedMessagesIcon(isColored: isColored) case .myNotesIcon: representation = nil icon = .myNotesIcon case let .archivedChatsIcon(hiddenByDefault): representation = nil icon = .archivedChatsIcon(hiddenByDefault: hiddenByDefault) case let .editAvatarIcon(forceNone): representation = forceNone ? nil : peer?.smallProfileImage icon = .editAvatarIcon case .deletedIcon: representation = nil icon = .deletedIcon case .phoneIcon: representation = nil icon = .phoneIcon } } else if peer?.restrictionText(platform: "ios", contentSettings: contentSettings) == nil { representation = peer?.smallProfileImage } let updatedState: AvatarNodeState = .peerAvatar(peer?.id ?? EnginePeer.Id(0), peer?.nameColor, peer?.displayLetters ?? [], representation, clipStyle) if updatedState != self.state || overrideImage != self.overrideImage || theme !== self.theme { self.state = updatedState self.overrideImage = overrideImage self.theme = theme let parameters: AvatarNodeParameters if let peer = peer, let signal = peerAvatarImage(postbox: postbox, network: network, peerReference: PeerReference(peer._asPeer()), authorOfMessage: authorOfMessage, representation: representation, displayDimensions: displayDimensions, clipStyle: clipStyle, emptyColor: emptyColor, synchronousLoad: synchronousLoad, provideUnrounded: storeUnrounded) { self.contents = nil self.displaySuspended = true self.imageReady.set(self.imageNode.contentReady) self.imageNode.setSignal(signal |> beforeNext { [weak self] next in Queue.mainQueue().async { self?.unroundedImage = next?.1 } } |> map { next -> UIImage? in return next?.0 }) if case .editAvatarIcon = icon { if self.editOverlayNode == nil { let editOverlayNode = AvatarEditOverlayNode() editOverlayNode.frame = self.imageNode.frame editOverlayNode.isUserInteractionEnabled = false self.addSubnode(editOverlayNode) self.editOverlayNode = editOverlayNode } self.editOverlayNode?.isHidden = false } else { self.editOverlayNode?.isHidden = true } parameters = AvatarNodeParameters(theme: theme, accountPeerId: accountPeerId, peerId: peer.id, colors: calculateColors(context: nil, explicitColorIndex: nil, peerId: peer.id, nameColor: peer.nameColor, icon: icon, theme: theme), letters: peer.displayLetters, font: self.font, icon: icon, explicitColorIndex: nil, hasImage: true, clipStyle: clipStyle) } else { self.imageReady.set(.single(true)) self.displaySuspended = false if self.isNodeLoaded { self.imageNode.contents = nil } self.editOverlayNode?.isHidden = true let colors = calculateColors(context: nil, explicitColorIndex: nil, peerId: peer?.id ?? EnginePeer.Id(0), nameColor: peer?.nameColor, icon: icon, theme: theme) parameters = AvatarNodeParameters(theme: theme, accountPeerId: accountPeerId, peerId: peer?.id ?? EnginePeer.Id(0), colors: colors, letters: peer?.displayLetters ?? [], font: self.font, icon: icon, explicitColorIndex: nil, hasImage: false, clipStyle: clipStyle) if let badgeView = self.badgeView { let badgeColor: UIColor if colors.isEmpty { badgeColor = .white } else { badgeColor = colors[colors.count - 1] } badgeView.update(content: .color(badgeColor)) } } if self.parameters == nil || self.parameters != parameters { self.parameters = parameters self.setNeedsDisplay() if synchronousLoad { self.recursivelyEnsureDisplaySynchronously(true) } } } } func setPeerV2( context genericContext: AccountContext, account: Account? = nil, theme: PresentationTheme, peer: EnginePeer?, authorOfMessage: MessageReference? = nil, overrideImage: AvatarNodeImageOverride? = nil, emptyColor: UIColor? = nil, clipStyle: AvatarNodeClipStyle = .round, synchronousLoad: Bool = false, displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0), storeUnrounded: Bool = false ) { let smallProfileImage = peer?.smallProfileImage let params = Params( peerId: peer?.id, resourceId: smallProfileImage?.resource.id.stringRepresentation, clipStyle: clipStyle ) if self.params == params { return } self.params = params switch clipStyle { case .none: self.imageNode.clipsToBounds = false self.imageNode.cornerRadius = 0.0 case .round: self.imageNode.clipsToBounds = true self.imageNode.cornerRadius = displayDimensions.height * 0.5 case .roundedRect: self.imageNode.clipsToBounds = true self.imageNode.cornerRadius = displayDimensions.height * 0.25 } if let imageCache = genericContext.imageCache as? DirectMediaImageCache, let peer, let smallProfileImage = peer.smallProfileImage, let peerReference = PeerReference(peer._asPeer()) { if let result = imageCache.getAvatarImage(peer: peerReference, resource: MediaResourceReference.avatar(peer: peerReference, resource: smallProfileImage.resource), immediateThumbnail: peer.profileImageRepresentations.first?.immediateThumbnailData, size: Int(displayDimensions.width * UIScreenScale), synchronous: synchronousLoad) { if let image = result.image { self.imageNode.contents = image.cgImage } if let loadSignal = result.loadSignal { self.loadDisposable = (loadSignal |> deliverOnMainQueue).start(next: { [weak self] image in guard let self else { return } self.imageNode.contents = image?.cgImage }).strict() } } } } public func setPeer( context genericContext: AccountContext, account: Account? = nil, theme: PresentationTheme, peer: EnginePeer?, authorOfMessage: MessageReference? = nil, overrideImage: AvatarNodeImageOverride? = nil, emptyColor: UIColor? = nil, clipStyle: AvatarNodeClipStyle = .round, synchronousLoad: Bool = false, displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0), storeUnrounded: Bool = false ) { var synchronousLoad = synchronousLoad var representation: TelegramMediaImageRepresentation? var icon = AvatarNodeIcon.none if let overrideImage = overrideImage { switch overrideImage { case .none: representation = nil case let .image(image): representation = image synchronousLoad = false case .savedMessagesIcon: representation = nil icon = .savedMessagesIcon case .repostIcon: representation = nil icon = .repostIcon case .repliesIcon: representation = nil icon = .repliesIcon case let .anonymousSavedMessagesIcon(isColored): representation = nil icon = .anonymousSavedMessagesIcon(isColored: isColored) case .myNotesIcon: representation = nil icon = .myNotesIcon case let .archivedChatsIcon(hiddenByDefault): representation = nil icon = .archivedChatsIcon(hiddenByDefault: hiddenByDefault) case let .editAvatarIcon(forceNone): representation = forceNone ? nil : peer?.smallProfileImage icon = .editAvatarIcon case .deletedIcon: representation = nil icon = .deletedIcon case .phoneIcon: representation = nil icon = .phoneIcon } } else if peer?.restrictionText(platform: "ios", contentSettings: genericContext.currentContentSettings.with { $0 }) == nil { representation = peer?.smallProfileImage } let updatedState: AvatarNodeState = .peerAvatar(peer?.id ?? EnginePeer.Id(0), peer?.nameColor, peer?.displayLetters ?? [], representation, clipStyle) if updatedState != self.state || overrideImage != self.overrideImage || theme !== self.theme { self.state = updatedState self.overrideImage = overrideImage self.theme = theme let parameters: AvatarNodeParameters let account = account ?? genericContext.account if let peer = peer, let signal = peerAvatarImage(account: account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: authorOfMessage, representation: representation, displayDimensions: displayDimensions, clipStyle: clipStyle, emptyColor: emptyColor, synchronousLoad: synchronousLoad, provideUnrounded: storeUnrounded) { self.contents = nil self.displaySuspended = true self.imageReady.set(self.imageNode.contentReady) self.imageNode.setSignal(signal |> beforeNext { [weak self] next in Queue.mainQueue().async { self?.unroundedImage = next?.1 } } |> map { next -> UIImage? in return next?.0 }) if case .editAvatarIcon = icon { if self.editOverlayNode == nil { let editOverlayNode = AvatarEditOverlayNode() editOverlayNode.frame = self.imageNode.frame editOverlayNode.isUserInteractionEnabled = false self.addSubnode(editOverlayNode) self.editOverlayNode = editOverlayNode } self.editOverlayNode?.isHidden = false } else { self.editOverlayNode?.isHidden = true } parameters = AvatarNodeParameters(theme: theme, accountPeerId: account.peerId, peerId: peer.id, colors: calculateColors(context: genericContext, explicitColorIndex: nil, peerId: peer.id, nameColor: peer.nameColor, icon: icon, theme: theme), letters: peer.displayLetters, font: self.font, icon: icon, explicitColorIndex: nil, hasImage: true, clipStyle: clipStyle) } else { self.imageReady.set(.single(true)) self.displaySuspended = false if self.isNodeLoaded { self.imageNode.contents = nil } self.editOverlayNode?.isHidden = true let colors = calculateColors(context: genericContext, explicitColorIndex: nil, peerId: peer?.id ?? EnginePeer.Id(0), nameColor: peer?.nameColor, icon: icon, theme: theme) parameters = AvatarNodeParameters(theme: theme, accountPeerId: account.peerId, peerId: peer?.id ?? EnginePeer.Id(0), colors: colors, letters: peer?.displayLetters ?? [], font: self.font, icon: icon, explicitColorIndex: nil, hasImage: false, clipStyle: clipStyle) if let badgeView = self.badgeView { let badgeColor: UIColor if colors.isEmpty { badgeColor = .white } else { badgeColor = colors[colors.count - 1] } badgeView.update(content: .color(badgeColor)) } } if self.parameters == nil || self.parameters != parameters { self.parameters = parameters self.setNeedsDisplay() if synchronousLoad { self.recursivelyEnsureDisplaySynchronously(true) } } } } public func setCustomLetters(_ letters: [String], explicitColor: AvatarNodeColorOverride? = nil, icon: AvatarNodeExplicitIcon? = nil) { var explicitIndex: Int? if let explicitColor = explicitColor { switch explicitColor { case .blue: explicitIndex = 5 } } let updatedState: AvatarNodeState = .custom(letter: letters, explicitColorIndex: explicitIndex, explicitIcon: icon) if updatedState != self.state { self.state = updatedState let parameters: AvatarNodeParameters if let icon = icon, case .phone = icon { parameters = AvatarNodeParameters(theme: nil, accountPeerId: nil, peerId: nil, colors: calculateColors(context: nil, explicitColorIndex: explicitIndex, peerId: nil, nameColor: nil, icon: .phoneIcon, theme: nil), letters: [], font: self.font, icon: .phoneIcon, explicitColorIndex: explicitIndex, hasImage: false, clipStyle: .round) } else { parameters = AvatarNodeParameters(theme: nil, accountPeerId: nil, peerId: nil, colors: calculateColors(context: nil, explicitColorIndex: explicitIndex, peerId: nil, nameColor: nil, icon: .none, theme: nil), letters: letters, font: self.font, icon: .none, explicitColorIndex: explicitIndex, hasImage: false, clipStyle: .round) } self.displaySuspended = true self.contents = nil self.imageReady.set(.single(true)) self.displaySuspended = false if self.parameters == nil || self.parameters != parameters { self.parameters = parameters self.setNeedsDisplay() } } } override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol { return parameters ?? NSObject() } @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! if !isRasterizing { context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) context.fill(bounds) } if !(parameters is AvatarNodeParameters) { return } let colors: [UIColor] if let parameters = parameters as? AvatarNodeParameters { colors = parameters.colors if case .round = parameters.clipStyle { context.beginPath() context.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: bounds.size.height)) context.clip() } else if case .roundedRect = parameters.clipStyle { context.beginPath() context.addPath(UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: bounds.size.height), cornerRadius: floor(bounds.size.width * 0.25)).cgPath) context.clip() } } else { colors = grayscaleColors } let colorsArray: NSArray = colors.map(\.cgColor) as NSArray var iconColor = UIColor.white var diagonal = false if let parameters = parameters as? AvatarNodeParameters, parameters.icon != .none { if case .repostIcon = parameters.icon { diagonal = true } if case let .archivedChatsIcon(hiddenByDefault) = parameters.icon, let theme = parameters.theme { if hiddenByDefault { iconColor = theme.chatList.unpinnedArchiveAvatarColor.foregroundColor } else { iconColor = theme.chatList.pinnedArchiveAvatarColor.foregroundColor } } } var locations: [CGFloat] = [1.0, 0.0] let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colorsArray, locations: &locations)! if diagonal { context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: bounds.size.height), end: CGPoint(x: bounds.size.width, y: 0.0), options: CGGradientDrawingOptions()) } else { context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: bounds.size.height), options: CGGradientDrawingOptions()) } context.setBlendMode(.normal) if let parameters = parameters as? AvatarNodeParameters { if case .deletedIcon = parameters.icon { let factor = bounds.size.width / 60.0 context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) if let deletedIcon = deletedIcon { context.draw(deletedIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - deletedIcon.size.width) / 2.0), y: floor((bounds.size.height - deletedIcon.size.height) / 2.0)), size: deletedIcon.size)) } } else if case .phoneIcon = parameters.icon { let factor: CGFloat = 1.0 context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) if let phoneIcon = phoneIcon { context.draw(phoneIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - phoneIcon.size.width) / 2.0), y: floor((bounds.size.height - phoneIcon.size.height) / 2.0)), size: phoneIcon.size)) } } else if case .savedMessagesIcon = parameters.icon { let factor = bounds.size.width / 60.0 context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) if let savedMessagesIcon = savedMessagesIcon { context.draw(savedMessagesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - savedMessagesIcon.size.width) / 2.0), y: floor((bounds.size.height - savedMessagesIcon.size.height) / 2.0)), size: savedMessagesIcon.size)) } } else if case .repostIcon = parameters.icon { if !"".isEmpty { let factor = bounds.size.width / 60.0 context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) if let repostStoryIcon = repostStoryIcon { context.draw(repostStoryIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - repostStoryIcon.size.width) / 2.0), y: floor((bounds.size.height - repostStoryIcon.size.height) / 2.0)), size: repostStoryIcon.size)) } } } else if case .repliesIcon = parameters.icon { let factor = bounds.size.width / 60.0 context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) if let repliesIcon = repliesIcon { context.draw(repliesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - repliesIcon.size.width) / 2.0), y: floor((bounds.size.height - repliesIcon.size.height) / 2.0)), size: repliesIcon.size)) } } else if case .anonymousSavedMessagesIcon = parameters.icon { let factor = bounds.size.width / 60.0 context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) if let anonymousSavedMessagesIcon = anonymousSavedMessagesIcon { context.draw(anonymousSavedMessagesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - anonymousSavedMessagesIcon.size.width) / 2.0), y: floor((bounds.size.height - anonymousSavedMessagesIcon.size.height) / 2.0)), size: anonymousSavedMessagesIcon.size)) } } else if case .myNotesIcon = parameters.icon { let factor = bounds.size.width / 60.0 context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) if let myNotesIcon = myNotesIcon { context.draw(myNotesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - myNotesIcon.size.width) / 2.0), y: floor((bounds.size.height - myNotesIcon.size.height) / 2.0)), size: myNotesIcon.size)) } } else if case .editAvatarIcon = parameters.icon, let theme = parameters.theme, !parameters.hasImage { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) if bounds.width > 90.0, let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIconLarge"), color: theme.list.itemAccentColor) { context.draw(editAvatarIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - editAvatarIcon.size.width) / 2.0) + 0.5, y: floor((bounds.size.height - editAvatarIcon.size.height) / 2.0) + 1.0), size: editAvatarIcon.size)) } else if let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIcon"), color: theme.list.itemAccentColor) { context.draw(editAvatarIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - editAvatarIcon.size.width) / 2.0) + 0.5, y: floor((bounds.size.height - editAvatarIcon.size.height) / 2.0) + 1.0), size: editAvatarIcon.size)) } } else if case .archivedChatsIcon = parameters.icon { let factor = bounds.size.width / 60.0 context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) if let archivedChatsIcon = generateTintedImage(image: archivedChatsIcon, color: iconColor) { context.draw(archivedChatsIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - archivedChatsIcon.size.width) / 2.0), y: floor((bounds.size.height - archivedChatsIcon.size.height) / 2.0)), size: archivedChatsIcon.size)) } } else { var letters = parameters.letters if letters.count == 2 && letters[0].isSingleEmoji && letters[1].isSingleEmoji { letters = [letters[0]] } let string = letters.count == 0 ? "" : (letters[0] + (letters.count == 1 ? "" : letters[1])) let attributedString = NSAttributedString(string: string, attributes: [NSAttributedString.Key.font: parameters.font, NSAttributedString.Key.foregroundColor: UIColor.white]) let line = CTLineCreateWithAttributedString(attributedString) let lineBounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds) let lineOffset = CGPoint(x: string == "B" ? 1.0 : 0.0, y: 0.0) let lineOrigin = CGPoint(x: floorToScreenPixels(-lineBounds.origin.x + (bounds.size.width - lineBounds.size.width) / 2.0) + lineOffset.x, y: floorToScreenPixels(-lineBounds.origin.y + (bounds.size.height - lineBounds.size.height) / 2.0)) context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) context.translateBy(x: lineOrigin.x, y: lineOrigin.y) CTLineDraw(line, context) context.translateBy(x: -lineOrigin.x, y: -lineOrigin.y) } } } } public let contentNode: ContentNode private var storyIndicator: ComponentView? public private(set) var storyPresentationParams: StoryPresentationParams? private var loadingStatuses = Bag() public struct StoryStats: Equatable { public var totalCount: Int public var unseenCount: Int public var hasUnseenCloseFriendsItems: Bool public var progress: Float? public init( totalCount: Int, unseenCount: Int, hasUnseenCloseFriendsItems: Bool, progress: Float? = nil ) { self.totalCount = totalCount self.unseenCount = unseenCount self.hasUnseenCloseFriendsItems = hasUnseenCloseFriendsItems self.progress = progress } } public private(set) var storyStats: StoryStats? public var font: UIFont { get { return self.contentNode.font } set(value) { self.contentNode.font = value } } public var editOverlayNode: AvatarEditOverlayNode? { get { return self.contentNode.editOverlayNode } set(value) { self.contentNode.editOverlayNode = value } } public var unroundedImage: UIImage? { get { return self.contentNode.unroundedImage } set(value) { self.contentNode.unroundedImage = value } } public var badgeView: AvatarBadgeView? { get { return self.contentNode.badgeView } set(value) { self.contentNode.badgeView = value } } public var ready: Signal { return self.contentNode.ready } public var imageNode: ImageNode { return self.contentNode.imageNode } public init(font: UIFont) { self.contentNode = ContentNode(font: font) super.init() self.onDidLoad { [weak self] _ in guard let self else { return } self.updateStoryIndicator(transition: .immediate) } self.addSubnode(self.contentNode) } deinit { self.cancelLoading() } override public var frame: CGRect { get { return super.frame } set(value) { let updateImage = !value.size.equalTo(super.frame.size) super.frame = value if updateImage { self.updateSize(size: value.size) } } } override public func nodeDidLoad() { super.nodeDidLoad() } public func updateSize(size: CGSize) { self.contentNode.position = CGRect(origin: CGPoint(), size: size).center self.contentNode.bounds = CGRect(origin: CGPoint(), size: size) self.contentNode.updateSize(size: size) self.updateStoryIndicator(transition: .immediate) } public func playArchiveAnimation() { self.contentNode.playArchiveAnimation() } public func playRepostAnimation() { self.contentNode.playRepostAnimation() } public func setPeer( accountPeerId: EnginePeer.Id, postbox: Postbox, network: Network, contentSettings: ContentSettings, theme: PresentationTheme, peer: EnginePeer?, authorOfMessage: MessageReference? = nil, overrideImage: AvatarNodeImageOverride? = nil, emptyColor: UIColor? = nil, clipStyle: AvatarNodeClipStyle = .round, synchronousLoad: Bool = false, displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0), storeUnrounded: Bool = false ) { self.contentNode.setPeer( accountPeerId: accountPeerId, postbox: postbox, network: network, contentSettings: contentSettings, theme: theme, peer: peer, authorOfMessage: authorOfMessage, overrideImage: overrideImage, emptyColor: emptyColor, clipStyle: clipStyle, synchronousLoad: synchronousLoad, displayDimensions: displayDimensions, storeUnrounded: storeUnrounded ) } public func setPeerV2( context genericContext: AccountContext, theme: PresentationTheme, peer: EnginePeer?, authorOfMessage: MessageReference? = nil, overrideImage: AvatarNodeImageOverride? = nil, emptyColor: UIColor? = nil, clipStyle: AvatarNodeClipStyle = .round, synchronousLoad: Bool = false, displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0), storeUnrounded: Bool = false ) { self.contentNode.setPeerV2( context: genericContext, theme: theme, peer: peer, authorOfMessage: authorOfMessage, overrideImage: overrideImage, emptyColor: emptyColor, clipStyle: clipStyle, synchronousLoad: synchronousLoad, displayDimensions: displayDimensions, storeUnrounded: storeUnrounded ) } public func setPeer( context: AccountContext, account: Account? = nil, theme: PresentationTheme, peer: EnginePeer?, authorOfMessage: MessageReference? = nil, overrideImage: AvatarNodeImageOverride? = nil, emptyColor: UIColor? = nil, clipStyle: AvatarNodeClipStyle = .round, synchronousLoad: Bool = false, displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0), storeUnrounded: Bool = false ) { self.contentNode.setPeer( context: context, account: account, theme: theme, peer: peer, authorOfMessage: authorOfMessage, overrideImage: overrideImage, emptyColor: emptyColor, clipStyle: clipStyle, synchronousLoad: synchronousLoad, displayDimensions: displayDimensions, storeUnrounded: storeUnrounded ) } public func setCustomLetters(_ letters: [String], explicitColor: AvatarNodeColorOverride? = nil, icon: AvatarNodeExplicitIcon? = nil) { self.contentNode.setCustomLetters(letters, explicitColor: explicitColor, icon: icon) } public func setStoryStats(storyStats: StoryStats?, presentationParams: StoryPresentationParams, transition: ComponentTransition) { if self.storyStats != storyStats || self.storyPresentationParams != presentationParams { self.storyStats = storyStats self.storyPresentationParams = presentationParams self.updateStoryIndicator(transition: transition) } } public struct Colors: Equatable { public var unseenColors: [UIColor] public var unseenCloseFriendsColors: [UIColor] public var seenColors: [UIColor] public init( unseenColors: [UIColor], unseenCloseFriendsColors: [UIColor], seenColors: [UIColor] ) { self.unseenColors = unseenColors self.unseenCloseFriendsColors = unseenCloseFriendsColors self.seenColors = seenColors } public init(theme: PresentationTheme) { self.unseenColors = [theme.chatList.storyUnseenColors.topColor, theme.chatList.storyUnseenColors.bottomColor] self.unseenCloseFriendsColors = [theme.chatList.storyUnseenPrivateColors.topColor, theme.chatList.storyUnseenPrivateColors.bottomColor] self.seenColors = [theme.chatList.storySeenColors.topColor, theme.chatList.storySeenColors.bottomColor] } } public struct StoryPresentationParams: Equatable { public var colors: Colors public var lineWidth: CGFloat public var inactiveLineWidth: CGFloat public var forceRoundedRect: Bool public init( colors: Colors, lineWidth: CGFloat, inactiveLineWidth: CGFloat, forceRoundedRect: Bool = false ) { self.colors = colors self.lineWidth = lineWidth self.inactiveLineWidth = inactiveLineWidth self.forceRoundedRect = forceRoundedRect } } private func updateStoryIndicator(transition: ComponentTransition) { if !self.isNodeLoaded { return } if self.bounds.isEmpty { return } guard let storyPresentationParams = self.storyPresentationParams else { return } let size = self.bounds.size if let storyStats = self.storyStats { let activeLineWidth = storyPresentationParams.lineWidth let inactiveLineWidth = storyPresentationParams.inactiveLineWidth let indicatorSize = CGSize(width: size.width - activeLineWidth * 4.0, height: size.height - activeLineWidth * 4.0) let avatarScale = (size.width - activeLineWidth * 4.0) / size.width let storyIndicator: ComponentView var indicatorTransition = transition if let current = self.storyIndicator { storyIndicator = current } else { indicatorTransition = transition.withAnimation(.none) storyIndicator = ComponentView() self.storyIndicator = storyIndicator } var mappedProgress: AvatarStoryIndicatorComponent.Progress? if let value = storyStats.progress { mappedProgress = .definite(value) } else if !self.loadingStatuses.isEmpty { mappedProgress = .indefinite } let _ = storyIndicator.update( transition: indicatorTransition, component: AnyComponent(AvatarStoryIndicatorComponent( hasUnseen: storyStats.unseenCount != 0, hasUnseenCloseFriendsItems: storyStats.hasUnseenCloseFriendsItems, colors: AvatarStoryIndicatorComponent.Colors( unseenColors: storyPresentationParams.colors.unseenColors, unseenCloseFriendsColors: storyPresentationParams.colors.unseenCloseFriendsColors, seenColors: storyPresentationParams.colors.seenColors ), activeLineWidth: activeLineWidth, inactiveLineWidth: inactiveLineWidth, counters: AvatarStoryIndicatorComponent.Counters( totalCount: storyStats.totalCount, unseenCount: storyStats.unseenCount ), progress: mappedProgress, isRoundedRect: self.contentNode.clipStyle == .roundedRect || storyPresentationParams.forceRoundedRect )), environment: {}, containerSize: indicatorSize ) if let storyIndicatorView = storyIndicator.view { if storyIndicatorView.superview == nil { self.view.insertSubview(storyIndicatorView, aboveSubview: self.contentNode.view) } indicatorTransition.setFrame(view: storyIndicatorView, frame: CGRect(origin: CGPoint(x: (size.width - indicatorSize.width) * 0.5, y: (size.height - indicatorSize.height) * 0.5), size: indicatorSize)) } transition.setScale(view: self.contentNode.view, scale: avatarScale) } else { transition.setScale(view: self.contentNode.view, scale: 1.0) if let storyIndicator = self.storyIndicator { self.storyIndicator = nil if let storyIndicatorView = storyIndicator.view { transition.setAlpha(view: storyIndicatorView, alpha: 0.0, completion: { [weak storyIndicatorView] _ in storyIndicatorView?.removeFromSuperview() }) } } } } public func cancelLoading() { for disposable in self.loadingStatuses.copyItems() { disposable.dispose() } self.loadingStatuses.removeAll() self.updateStoryIndicator(transition: .immediate) } public func pushLoadingStatus(signal: Signal) -> Disposable { let disposable = MetaDisposable() for d in self.loadingStatuses.copyItems() { d.dispose() } self.loadingStatuses.removeAll() let index = self.loadingStatuses.add(disposable) DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.2, execute: { [weak self] in self?.updateStoryIndicator(transition: .immediate) }) disposable.set(signal.start(completed: { [weak self] in Queue.mainQueue().async { guard let self else { return } if let previousDisposable = self.loadingStatuses.copyItemsWithIndices().first(where: { $0.0 == index })?.1 { previousDisposable.dispose() } self.loadingStatuses.remove(index) if self.loadingStatuses.isEmpty { self.updateStoryIndicator(transition: .immediate) } } })) return ActionDisposable { [weak self] in guard let self else { return } if let previousDisposable = self.loadingStatuses.copyItemsWithIndices().first(where: { $0.0 == index })?.1 { previousDisposable.dispose() } self.loadingStatuses.remove(index) if self.loadingStatuses.isEmpty { self.updateStoryIndicator(transition: .immediate) } } } }