import Foundation import UIKit import AsyncDisplayKit import Display import ComponentFlow import SwiftSignalKit import TelegramCore import AccountContext import TelegramPresentationData import TelegramUIPreferences import TextFormat import LocalizedPeerData import UrlEscaping import TelegramStringFormatting import WallpaperBackgroundNode import ReactionSelectionNode import ChatControllerInteraction import ShimmerEffect import Markdown import ChatMessageBubbleContentNode import ChatMessagePollBubbleContentNode import ChatMessageItemCommon import RoundedRectWithTailPath import AvatarNode import MultilineTextComponent import BundleIconComponent import ChatMessageBackground import ContextUI import UndoUI private func attributedServiceMessageString(theme: ChatPresentationThemeData, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, dateTimeFormat: PresentationDateTimeFormat, message: EngineMessage, accountPeerId: EnginePeer.Id) -> NSAttributedString? { return universalServiceMessageString(presentationData: (theme.theme, theme.wallpaper), strings: strings, nameDisplayOrder: nameDisplayOrder, dateTimeFormat: dateTimeFormat, message: message, accountPeerId: accountPeerId, forChatList: false, forForumOverview: false) } private func generateCloseButtonImage(color: UIColor) -> UIImage? { return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setAlpha(color.alpha) context.setBlendMode(.copy) context.setLineWidth(2.0) context.setLineCap(.round) context.setStrokeColor(color.withAlphaComponent(1.0).cgColor) context.move(to: CGPoint(x: 10.0, y: 10.0)) context.addLine(to: CGPoint(x: 20.0, y: 20.0)) context.strokePath() context.move(to: CGPoint(x: 20.0, y: 10.0)) context.addLine(to: CGPoint(x: 10.0, y: 20.0)) context.strokePath() }) } public class ChatMessageJoinedChannelBubbleContentNode: ChatMessageBubbleContentNode { private let labelNode: TextNode private var backgroundNode: WallpaperBubbleBackgroundNode? private let backgroundMaskNode: ASImageNode private var linkHighlightingNode: LinkHighlightingNode? private let panelNode: ASDisplayNode private let panelBackgroundNode: MessageBackgroundNode private let titleNode: TextNode private let closeButtonNode: HighlightTrackingButtonNode private let closeIconNode: ASImageNode private let panelListView = ComponentView() private var cachedMaskBackgroundImage: (CGPoint, UIImage, [CGRect])? private var absoluteRect: (CGRect, CGSize)? private var currentMaskSize: CGSize? private var panelMaskLayer: CAShapeLayer? private var isExpanded: Bool? required public init() { self.labelNode = TextNode() self.labelNode.isUserInteractionEnabled = false self.labelNode.displaysAsynchronously = false self.backgroundMaskNode = ASImageNode() self.panelNode = ASDisplayNode() self.panelBackgroundNode = MessageBackgroundNode() self.titleNode = TextNode() self.titleNode.isUserInteractionEnabled = false self.titleNode.displaysAsynchronously = false self.closeButtonNode = HighlightTrackingButtonNode() self.closeIconNode = ASImageNode() self.closeIconNode.displaysAsynchronously = false self.closeIconNode.isUserInteractionEnabled = false super.init() self.addSubnode(self.labelNode) self.panelNode.anchorPoint = CGPoint(x: 0.5, y: -0.1) self.addSubnode(self.panelNode) self.panelNode.addSubnode(self.panelBackgroundNode) self.panelNode.addSubnode(self.titleNode) self.panelNode.addSubnode(self.closeIconNode) self.panelNode.addSubnode(self.closeButtonNode) self.closeButtonNode.highligthedChanged = { [weak self] highlighted in guard let self else { return } if highlighted { self.closeIconNode.layer.removeAnimation(forKey: "opacity") self.closeIconNode.alpha = 0.4 } else { self.closeIconNode.alpha = 1.0 self.closeIconNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } self.closeButtonNode.addTarget(self, action: #selector(self.closeButtonPressed), forControlEvents: .touchUpInside) } required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func didLoad() { super.didLoad() self.panelMaskLayer = CAShapeLayer() } @objc private func pressed() { guard let item = self.item else { return } if let recommendedChannels = item.associatedData.recommendedChannels { let _ = item.context.engine.peers.toggleRecommendedChannelsHidden(peerId: item.message.id.peerId, hidden: !recommendedChannels.isHidden).startStandalone() } else { let _ = item.context.engine.peers.requestRecommendedChannels(peerId: item.message.id.peerId).startStandalone() } } @objc private func closeButtonPressed() { guard let item = self.item else { return } let _ = item.context.engine.peers.toggleRecommendedChannelsHidden(peerId: item.message.id.peerId, hidden: true).startStandalone() } override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, unboundSize: CGSize?, maxWidth: CGFloat, layout: (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { let makeLabelLayout = TextNode.asyncLayout(self.labelNode) let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let cachedMaskBackgroundImage = self.cachedMaskBackgroundImage return { item, layoutConstants, _, _, constrainedSize, _ in let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: true, headerSpacing: 0.0, hidesBackground: .always, forceFullCorners: false, forceAlignment: .center) let unboundWidth: CGFloat = constrainedSize.width - 10.0 * 2.0 return (contentProperties, nil, unboundWidth, { constrainedSize, position in let attributedString = attributedServiceMessageString(theme: item.presentationData.theme, strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, dateTimeFormat: item.presentationData.dateTimeFormat, message: EngineMessage(item.message), accountPeerId: item.context.account.peerId) let (labelLayout, labelApply) = makeLabelLayout(TextNodeLayoutArguments(attributedString: attributedString, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: constrainedSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.presentationData.strings.Chat_SimilarChannels, font: Font.semibold(15.0), textColor: item.presentationData.theme.theme.chat.message.incoming.primaryTextColor, paragraphAlignment: .center), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: constrainedSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) var labelRects = labelLayout.linesRects() if labelRects.count > 1 { let sortedIndices = (0 ..< labelRects.count).sorted(by: { labelRects[$0].width > labelRects[$1].width }) for i in 0 ..< sortedIndices.count { let index = sortedIndices[i] for j in -1 ... 1 { if j != 0 && index + j >= 0 && index + j < sortedIndices.count { if abs(labelRects[index + j].width - labelRects[index].width) < 40.0 { labelRects[index + j].size.width = max(labelRects[index + j].width, labelRects[index].width) labelRects[index].size.width = labelRects[index + j].size.width } } } } } for i in 0 ..< labelRects.count { labelRects[i] = labelRects[i].insetBy(dx: -6.0, dy: floor((labelRects[i].height - 20.0) / 2.0)) labelRects[i].size.height = 20.0 labelRects[i].origin.x = floor((labelLayout.size.width - labelRects[i].width) / 2.0) } let backgroundMaskImage: (CGPoint, UIImage)? var backgroundMaskUpdated = false if let (currentOffset, currentImage, currentRects) = cachedMaskBackgroundImage, currentRects == labelRects { backgroundMaskImage = (currentOffset, currentImage) } else { backgroundMaskImage = LinkHighlightingNode.generateImage(color: .black, inset: 0.0, innerRadius: 10.0, outerRadius: 10.0, rects: labelRects, useModernPathCalculation: false) backgroundMaskUpdated = true } let isExpanded: Bool if let recommendedChannels = item.associatedData.recommendedChannels, !recommendedChannels.channels.isEmpty && !recommendedChannels.isHidden { isExpanded = true } else { isExpanded = false } let spacing: CGFloat = 17.0 let margin: CGFloat = 4.0 var contentSize = CGSize(width: constrainedSize.width, height: labelLayout.size.height) if isExpanded { contentSize.height += spacing + 140.0 + margin } else { contentSize.height += margin } return (contentSize.width, { boundingWidth in return (contentSize, { [weak self] animation, synchronousLoads, info in if let strongSelf = self { let themeUpdated = strongSelf.item?.presentationData.theme !== item.presentationData.theme strongSelf.item = item strongSelf.isExpanded = isExpanded info?.setInvertOffsetDirection() let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: labelLayout.size.height + spacing - 14.0), size: CGSize(width: constrainedSize.width, height: 140.0)) strongSelf.panelNode.position = CGPoint(x: panelFrame.midX, y: panelFrame.minY) strongSelf.panelNode.bounds = CGRect(origin: .zero, size: panelFrame.size) let panelInnerSize = CGSize(width: panelFrame.width + 8.0, height: panelFrame.height + 10.0) if let backgroundNode = item.controllerInteraction.presentationContext.backgroundNode { let graphics = PresentationResourcesChat.principalGraphics(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper, bubbleCorners: item.presentationData.chatBubbleCorners) strongSelf.panelBackgroundNode.update(size: panelInnerSize, theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper, graphics: graphics, wallpaperBackgroundNode: backgroundNode, transition: .immediate) } strongSelf.panelBackgroundNode.frame = CGRect(origin: CGPoint(x: -7.0, y: -8.0), size: panelInnerSize) if strongSelf.panelBackgroundNode.layer.mask == nil { strongSelf.panelBackgroundNode.layer.mask = strongSelf.panelMaskLayer } strongSelf.panelMaskLayer?.frame = CGRect(origin: .zero, size: panelInnerSize) if strongSelf.panelMaskLayer?.path == nil { let path = generateRoundedRectWithTailPath(rectSize: CGSize(width: panelFrame.width, height: panelFrame.height), cornerRadius: 16.0, tailSize: CGSize(width: 16.0, height: 6.0), tailRadius: 2.0, tailPosition: 0.5, transformTail: false) path.apply(CGAffineTransform(translationX: 7.0, y: 2.0)) strongSelf.panelMaskLayer?.path = path.cgPath } if themeUpdated { strongSelf.closeIconNode.image = generateCloseButtonImage(color: item.presentationData.theme.theme.chat.message.incoming.secondaryTextColor) } let _ = labelApply() let _ = titleApply() let labelFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentSize.width - labelLayout.size.width) / 2.0), y: 2.0), size: labelLayout.size) strongSelf.labelNode.frame = labelFrame let titleFrame = CGRect(origin: CGPoint(x: 16.0, y: 11.0), size: titleLayout.size) strongSelf.titleNode.frame = titleFrame if let icon = strongSelf.closeIconNode.image { let closeFrame = CGRect(origin: CGPoint(x: panelFrame.width - 5.0 - icon.size.width, y: 5.0), size: icon.size) strongSelf.closeIconNode.frame = closeFrame strongSelf.closeButtonNode.frame = closeFrame.insetBy(dx: -4.0, dy: -4.0) } if isExpanded { animation.animator.updateAlpha(layer: strongSelf.panelNode.layer, alpha: 1.0, completion: nil) animation.animator.updateScale(layer: strongSelf.panelNode.layer, scale: 1.0, completion: nil) } else { animation.animator.updateAlpha(layer: strongSelf.panelNode.layer, alpha: 0.0, completion: nil) animation.animator.updateScale(layer: strongSelf.panelNode.layer, scale: 0.1, completion: nil) } let baseBackgroundFrame = labelFrame.offsetBy(dx: 0.0, dy: -11.0) if let (offset, image) = backgroundMaskImage { if strongSelf.backgroundNode == nil { if let backgroundNode = item.controllerInteraction.presentationContext.backgroundNode?.makeBubbleBackground(for: .free) { strongSelf.backgroundNode = backgroundNode strongSelf.insertSubnode(backgroundNode, at: 0) backgroundNode.view.addGestureRecognizer(UITapGestureRecognizer(target: strongSelf, action: #selector(strongSelf.pressed))) } } if backgroundMaskUpdated, let backgroundNode = strongSelf.backgroundNode { if labelRects.count == 1 { backgroundNode.clipsToBounds = true backgroundNode.cornerRadius = labelRects[0].height / 2.0 backgroundNode.view.mask = nil } else { backgroundNode.clipsToBounds = false backgroundNode.cornerRadius = 0.0 backgroundNode.view.mask = strongSelf.backgroundMaskNode.view } } if let backgroundNode = strongSelf.backgroundNode { backgroundNode.frame = CGRect(origin: CGPoint(x: baseBackgroundFrame.minX + offset.x, y: baseBackgroundFrame.minY + offset.y), size: image.size) } strongSelf.backgroundMaskNode.image = image strongSelf.backgroundMaskNode.frame = CGRect(origin: CGPoint(), size: image.size) strongSelf.cachedMaskBackgroundImage = (offset, image, labelRects) } if let (rect, size) = strongSelf.absoluteRect { strongSelf.updateAbsoluteRect(rect, within: size) } strongSelf.updateList() } }) }) }) } } private func updateList() { guard let item = self.item, let recommendedChannels = item.associatedData.recommendedChannels else { return } let listSize = self.panelListView.update( transition: .immediate, component: AnyComponent( ChannelListPanelComponent( context: item.context, theme: item.presentationData.theme.theme, strings: item.presentationData.strings, peers: recommendedChannels, action: { peer in if let peer { var jsonString: String = "{" jsonString += "\"ref_channel_id\": \"\(item.message.id.peerId.id._internalGetInt64Value())\"," jsonString += "\"open_channel_id\": \"\(peer.id.id._internalGetInt64Value())\"" jsonString += "}" if let data = jsonString.data(using: .utf8), let json = JSON(data: data) { addAppLogEvent(postbox: item.context.account.postbox, type: "channels.open_recommended_channel", data: json) } item.controllerInteraction.openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: nil), nil, .default) } else { let context = item.context let presentationData = item.context.sharedContext.currentPresentationData.with { $0 } let controller = UndoOverlayController( presentationData: presentationData, content: .premiumPaywall(title: nil, text: "Subcribe to [Telegram Premium]() to unlock up to **100** channels.", customUndoText: nil, timeout: nil, linkAction: nil), elevatedLayout: false, action: { [weak self] action in if case .info = action { if let self, let item = self.item { let controller = context.sharedContext.makePremiumIntroController(context: context, source: .ads, forceDark: false, dismissed: nil) item.controllerInteraction.navigationController()?.pushViewController(controller) } } return true } ) item.controllerInteraction.presentControllerInCurrent(controller, nil) } }, contextAction: { peer, sourceView, gesture in item.controllerInteraction.openRecommendedChannelContextMenu(peer, sourceView, gesture) } ) ), environment: {}, containerSize: CGSize(width: self.panelNode.frame.width, height: 100.0) ) if let view = self.panelListView.view { if view.superview == nil { self.panelNode.view.addSubview(view) } view.frame = CGRect(origin: CGPoint(x: 0.0, y: 42.0), size: listSize) } } override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { self.absoluteRect = (rect, containerSize) if let backgroundNode = self.backgroundNode { var backgroundFrame = backgroundNode.frame backgroundFrame.origin.x += rect.minX backgroundFrame.origin.y += rect.minY backgroundNode.update(rect: backgroundFrame, within: containerSize, transition: .immediate) } var panelBackgroundFrame = panelBackgroundNode.frame panelBackgroundFrame.origin.x += self.panelNode.frame.minX + rect.minX panelBackgroundFrame.origin.y += self.panelNode.frame.minY + rect.minY self.panelBackgroundNode.updateAbsoluteRect(panelBackgroundFrame, within: containerSize) } override public func applyAbsoluteOffset(value: CGPoint, animationCurve: ContainedViewLayoutTransitionCurve, duration: Double) { if let backgroundNode = self.backgroundNode { backgroundNode.offset(value: value, animationCurve: animationCurve, duration: duration) } } override public func applyAbsoluteOffsetSpring(value: CGFloat, duration: Double, damping: CGFloat) { if let backgroundNode = self.backgroundNode { backgroundNode.offsetSpring(value: value, duration: duration, damping: damping) } } override public func updateTouchesAtPoint(_ point: CGPoint?) { if let item = self.item { var rects: [(CGRect, CGRect)]? let textNodeFrame = self.labelNode.frame if let point = point { if let (index, attributes) = self.labelNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY - 10.0)) { let possibleNames: [String] = [ TelegramTextAttributes.URL, TelegramTextAttributes.PeerMention, TelegramTextAttributes.PeerTextMention, TelegramTextAttributes.BotCommand, TelegramTextAttributes.Hashtag ] for name in possibleNames { if let _ = attributes[NSAttributedString.Key(rawValue: name)] { rects = self.labelNode.lineAndAttributeRects(name: name, at: index) break } } } } if let rects = rects { var mappedRects: [CGRect] = [] for i in 0 ..< rects.count { let lineRect = rects[i].0 var itemRect = rects[i].1 itemRect.origin.x = floor((textNodeFrame.size.width - lineRect.width) / 2.0) + itemRect.origin.x mappedRects.append(itemRect) } let linkHighlightingNode: LinkHighlightingNode if let current = self.linkHighlightingNode { linkHighlightingNode = current } else { let serviceColor = serviceMessageColorComponents(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper) linkHighlightingNode = LinkHighlightingNode(color: serviceColor.linkHighlight) linkHighlightingNode.inset = 2.5 self.linkHighlightingNode = linkHighlightingNode self.insertSubnode(linkHighlightingNode, belowSubnode: self.labelNode) } linkHighlightingNode.frame = self.labelNode.frame.offsetBy(dx: 0.0, dy: 1.5) linkHighlightingNode.updateRects(mappedRects) } else if let linkHighlightingNode = self.linkHighlightingNode { self.linkHighlightingNode = nil linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in linkHighlightingNode?.removeFromSupernode() }) } } } override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { let textNodeFrame = self.labelNode.frame if let (index, attributes) = self.labelNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY - 10.0)), gesture == .tap { if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { var concealed = true if let (attributeText, fullText) = self.labelNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) { concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText) } return ChatMessageBubbleContentTapAction(content: .url(ChatMessageBubbleContentTapAction.Url(url: url, concealed: concealed))) } else if let peerMention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention { return ChatMessageBubbleContentTapAction(content: .peerMention(peerId: peerMention.peerId, mention: peerMention.mention, openProfile: false)) } else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { return ChatMessageBubbleContentTapAction(content: .textMention(peerName)) } else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String { return ChatMessageBubbleContentTapAction(content: .botCommand(botCommand)) } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { return ChatMessageBubbleContentTapAction(content: .hashtag(hashtag.peerName, hashtag.hashtag)) } } if let backgroundNode = self.backgroundNode, backgroundNode.frame.contains(point) { return ChatMessageBubbleContentTapAction(content: .ignore) } if self.panelNode.frame.contains(point) { let panelPoint = self.view.convert(point, to: self.panelNode.view) if self.closeButtonNode.frame.contains(panelPoint) { return ChatMessageBubbleContentTapAction(content: .ignore) } } return ChatMessageBubbleContentTapAction(content: .none) } } private class MessageBackgroundNode: ASDisplayNode { private let backgroundWallpaperNode: ChatMessageBubbleBackdrop private let backgroundNode: ChatMessageBackground override init() { self.backgroundWallpaperNode = ChatMessageBubbleBackdrop() self.backgroundNode = ChatMessageBackground() self.backgroundNode.backdropNode = self.backgroundWallpaperNode super.init() self.addSubnode(self.backgroundNode) self.addSubnode(self.backgroundWallpaperNode) } private var absoluteRect: (CGRect, CGSize)? func update(size: CGSize, theme: PresentationTheme, wallpaper: TelegramWallpaper, graphics: PrincipalThemeEssentialGraphics, wallpaperBackgroundNode: WallpaperBackgroundNode, transition: ContainedViewLayoutTransition) { self.backgroundNode.setType(type: .incoming(.Extracted), highlighted: false, graphics: graphics, maskMode: false, hasWallpaper: wallpaper.hasWallpaper, transition: transition, backgroundNode: wallpaperBackgroundNode) self.backgroundWallpaperNode.setType(type: .incoming(.Extracted), theme: ChatPresentationThemeData(theme: theme, wallpaper: wallpaper), essentialGraphics: graphics, maskMode: false, backgroundNode: wallpaperBackgroundNode) let backgroundFrame = CGRect(origin: CGPoint(), size: size) self.backgroundNode.updateLayout(size: backgroundFrame.size, transition: transition) self.backgroundWallpaperNode.updateFrame(backgroundFrame, transition: transition) if let (rect, size) = self.absoluteRect { self.updateAbsoluteRect(rect, within: size) } } func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { self.absoluteRect = (rect, containerSize) var backgroundWallpaperFrame = self.backgroundWallpaperNode.frame backgroundWallpaperFrame.origin.x += rect.minX backgroundWallpaperFrame.origin.y += rect.minY self.backgroundWallpaperNode.update(rect: backgroundWallpaperFrame, within: containerSize) } } private let itemSize = CGSize(width: 84.0, height: 90.0) private final class ChannelItemComponent: Component { let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings let peers: [EnginePeer] let isLocked: Bool let title: String let subtitle: String let action: (EnginePeer?) -> Void let contextAction: ((EnginePeer, UIView, ContextGesture?) -> Void)? init( context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, peers: [EnginePeer], isLocked: Bool, title: String, subtitle: String, action: @escaping (EnginePeer?) -> Void, contextAction: ((EnginePeer, UIView, ContextGesture?) -> Void)? ) { self.context = context self.theme = theme self.strings = strings self.peers = peers self.isLocked = isLocked self.title = title self.subtitle = subtitle self.action = action self.contextAction = contextAction } static func ==(lhs: ChannelItemComponent, rhs: ChannelItemComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.theme !== rhs.theme { return false } if lhs.peers != rhs.peers { return false } if lhs.isLocked != rhs.isLocked { return false } if lhs.title != rhs.title { return false } if lhs.subtitle != rhs.subtitle { return false } return true } final class View: UIView { private let contextContainer: ContextControllerSourceView private let containerButton: HighlightTrackingButton private let title = ComponentView() private let subtitle = ComponentView() private let circleView: UIImageView private let circleView2: UIImageView private let avatarNode: AvatarNode private var mergedAvatarsNode: MergedAvatarsNode? private let avatarBadge: AvatarBadgeView private let subtitleIcon = ComponentView() private var component: ChannelItemComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { self.contextContainer = ContextControllerSourceView() self.circleView = UIImageView(image: UIImage(bundleImageName: "Avatar/SampleAvatar1")) self.circleView.clipsToBounds = true self.circleView.layer.cornerRadius = 30 self.circleView.clipsToBounds = true self.circleView.layer.cornerRadius = 30 let colors: NSArray = [UIColor(rgb: 0x000000).cgColor, UIColor(rgb: 0x6a2267).cgColor] self.circleView2 = UIImageView(image: generateGradientFilledCircleImage(diameter: 60, colors: colors)) self.circleView2.clipsToBounds = true self.circleView2.layer.cornerRadius = 30 self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0)) self.avatarNode.isUserInteractionEnabled = false self.avatarBadge = AvatarBadgeView(frame: CGRect()) self.containerButton = HighlightTrackingButton() super.init(frame: frame) self.addSubview(self.contextContainer) self.contextContainer.addSubview(self.containerButton) self.contextContainer.addSubview(self.circleView2) self.contextContainer.addSubview(self.circleView) self.contextContainer.addSubnode(self.avatarNode) self.contextContainer.addSubview(self.avatarBadge) self.avatarNode.badgeView = self.avatarBadge self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) self.contextContainer.activated = { [weak self] gesture, point in if let self, let component = self.component, let peer = component.peers.first { component.contextAction?(peer, self.contextContainer, gesture) } } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func pressed() { guard let component = self.component, let peer = component.peers.first else { return } if !component.isLocked { component.action(peer) } else { component.action(nil) } } func update(component: ChannelItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state self.contextContainer.isGestureEnabled = component.contextAction != nil let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.title, font: Font.regular(11.0), textColor: component.isLocked || component.peers.count > 1 ? component.theme.chat.message.incoming.secondaryTextColor : component.theme.chat.message.incoming.primaryTextColor)), horizontalAlignment: .center, maximumNumberOfLines: 2 )), environment: {}, containerSize: CGSize(width: itemSize.width - 9.0, height: 100.0) ) let subtitleSize = self.subtitle.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.subtitle, font: Font.with(size: 9.0, design: .round, weight: .bold), textColor: .white)) )), environment: {}, containerSize: CGSize(width: itemSize.width - 6.0, height: 100.0) ) var subtitleIconSize: CGSize if component.peers.count == 1 { subtitleIconSize = self.subtitleIcon.update( transition: .immediate, component: AnyComponent(BundleIconComponent(name: component.isLocked ? "Chat List/StatusLockIcon" : "Chat/Message/Subscriber", tintColor: .white)), environment: {}, containerSize: CGSize(width: itemSize.width - 6.0, height: 100.0) ) if component.isLocked { subtitleIconSize = subtitleIconSize.fitted(CGSize(width: 8.0, height: 8.0)) } } else { subtitleIconSize = .zero } let avatarSize = CGSize(width: 60.0, height: 60.0) var avatarFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((itemSize.width - avatarSize.width) / 2.0), y: 0.0), size: avatarSize) let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((itemSize.width - titleSize.width) / 2.0), y: avatarFrame.maxY + 4.0), size: titleSize) let subtitleSpacing: CGFloat = 1.0 + UIScreenPixel let subtitleTotalWidth = subtitleIconSize.width + subtitleSize.width + subtitleSpacing let subtitleOriginX = floorToScreenPixels((itemSize.width - subtitleTotalWidth) / 2.0) + 1.0 - UIScreenPixel var iconOriginX = subtitleOriginX var textOriginX = subtitleOriginX if subtitleIconSize.width > 0.0 { textOriginX += subtitleIconSize.width + subtitleSpacing } if component.isLocked { textOriginX = subtitleOriginX iconOriginX = subtitleOriginX + subtitleSize.width + subtitleSpacing } let subtitleIconFrame = CGRect(origin: CGPoint(x: iconOriginX, y: avatarFrame.maxY - subtitleSize.height + 1.0 - UIScreenPixel), size: subtitleIconSize) let subtitleFrame = CGRect(origin: CGPoint(x: textOriginX, y: avatarFrame.maxY - subtitleSize.height - UIScreenPixel), size: subtitleSize) var avatarHorizontalOffset: CGFloat = 0.0 if component.isLocked { avatarHorizontalOffset = -10.0 } avatarFrame = avatarFrame.offsetBy(dx: avatarHorizontalOffset, dy: 0.0) self.avatarNode.frame = avatarFrame if let peer = component.peers.first { self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer) } if component.peers.count > 1 { let mergedAvatarsNode: MergedAvatarsNode if let current = self.mergedAvatarsNode { mergedAvatarsNode = current } else { mergedAvatarsNode = MergedAvatarsNode() self.contextContainer.insertSubview(mergedAvatarsNode.view, aboveSubview: self.avatarNode.view) self.mergedAvatarsNode = mergedAvatarsNode } mergedAvatarsNode.update(context: component.context, peers: component.peers.map { $0._asPeer() }, synchronousLoad: false, imageSize: 60.0, imageSpacing: 10.0, borderWidth: 2.0) let avatarsSize = CGSize(width: avatarSize.width + 20.0, height: avatarSize.height) mergedAvatarsNode.updateLayout(size: avatarsSize) mergedAvatarsNode.frame = CGRect(origin: CGPoint(x: avatarFrame.midX - avatarsSize.width / 2.0, y: avatarFrame.minY), size: avatarsSize) self.avatarNode.isHidden = true } else { self.mergedAvatarsNode?.view.removeFromSuperview() self.mergedAvatarsNode = nil self.avatarNode.isHidden = false } if component.isLocked { self.circleView.isHidden = false self.circleView.frame = avatarFrame.offsetBy(dx: 10.0, dy: 0.0) self.circleView2.frame = avatarFrame.offsetBy(dx: 20.0, dy: 0.0) self.circleView2.isHidden = false } else { self.circleView.isHidden = true self.circleView2.isHidden = true } if let titleView = self.title.view { if titleView.superview == nil { titleView.isUserInteractionEnabled = false self.contextContainer.addSubview(titleView) } titleView.frame = titleFrame } if let subtitleView = self.subtitle.view { if subtitleView.superview == nil { subtitleView.isUserInteractionEnabled = false self.contextContainer.addSubview(subtitleView) } subtitleView.frame = subtitleFrame } if let subtitleIconView = self.subtitleIcon.view { if subtitleIconView.superview == nil { subtitleIconView.isUserInteractionEnabled = false self.contextContainer.addSubview(subtitleIconView) } subtitleIconView.frame = subtitleIconFrame } let strokeWidth: CGFloat = 1.0 + UIScreenPixel let avatarBadgeSize = CGSize(width: subtitleSize.width + subtitleIconSize.width + 10.0, height: 15.0) self.avatarBadge.update(size: avatarBadgeSize, text: "", hasTimeoutIcon: false, useSolidColor: true, strokeColor: component.theme.chat.message.incoming.bubble.withoutWallpaper.fill.first!) let avatarBadgeFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((itemSize.width - avatarBadgeSize.width) / 2.0), y: avatarFrame.minY + avatarFrame.height - avatarBadgeSize.height + 2.0), size: avatarBadgeSize).insetBy(dx: -strokeWidth, dy: -strokeWidth) self.avatarBadge.frame = avatarBadgeFrame let bounds = CGRect(origin: .zero, size: itemSize) self.contextContainer.frame = bounds self.containerButton.frame = bounds return itemSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private let channelsLimit: Int32 = 10 final class ChannelListPanelComponent: Component { typealias EnvironmentType = Empty let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings let peers: RecommendedChannels let action: (EnginePeer?) -> Void let contextAction: (EnginePeer, UIView, ContextGesture?) -> Void init( context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, peers: RecommendedChannels, action: @escaping (EnginePeer?) -> Void, contextAction: @escaping (EnginePeer, UIView, ContextGesture?) -> Void ) { self.context = context self.theme = theme self.strings = strings self.peers = peers self.action = action self.contextAction = contextAction } static func ==(lhs: ChannelListPanelComponent, rhs: ChannelListPanelComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.theme !== rhs.theme { return false } if lhs.peers != rhs.peers { return false } return true } private struct ItemLayout: Equatable { let containerInsets: UIEdgeInsets let containerHeight: CGFloat let itemWidth: CGFloat let itemCount: Int let contentWidth: CGFloat init( containerInsets: UIEdgeInsets, containerHeight: CGFloat, itemWidth: CGFloat, itemCount: Int ) { self.containerInsets = containerInsets self.containerHeight = containerHeight self.itemWidth = itemWidth self.itemCount = itemCount self.contentWidth = containerInsets.left + containerInsets.right + CGFloat(itemCount) * itemWidth } func visibleItems(for rect: CGRect) -> Range? { let offsetRect = rect.offsetBy(dx: -self.containerInsets.left, dy: -self.containerInsets.top) var minVisibleRow = Int(floor((offsetRect.minX) / (self.itemWidth))) minVisibleRow = max(0, minVisibleRow) let maxVisibleRow = Int(ceil((offsetRect.maxX) / (self.itemWidth))) let minVisibleIndex = minVisibleRow let maxVisibleIndex = maxVisibleRow if maxVisibleIndex >= minVisibleIndex { return minVisibleIndex ..< (maxVisibleIndex + 1) } else { return nil } } func itemFrame(for index: Int) -> CGRect { return CGRect(origin: CGPoint(x: self.containerInsets.left + CGFloat(index) * self.itemWidth, y: 0.0), size: CGSize(width: self.itemWidth, height: self.containerHeight)) } } private final class ScrollViewImpl: UIScrollView { override func touchesShouldCancel(in view: UIView) -> Bool { return true } } class View: UIView, UIScrollViewDelegate { private let scrollView: ScrollViewImpl private let measureItem = ComponentView() private var visibleItems: [EnginePeer.Id: ComponentView] = [:] private var ignoreScrolling: Bool = false private var component: ChannelListPanelComponent? private var itemLayout: ItemLayout? override init(frame: CGRect) { self.scrollView = ScrollViewImpl() super.init(frame: frame) self.scrollView.delaysContentTouches = true self.scrollView.canCancelContentTouches = true self.scrollView.clipsToBounds = false if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { self.scrollView.contentInsetAdjustmentBehavior = .never } if #available(iOS 13.0, *) { self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false } self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = true self.scrollView.scrollsToTop = false self.scrollView.delegate = self self.scrollView.clipsToBounds = true self.addSubview(self.scrollView) self.disablesInteractiveTransitionGestureRecognizer = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.ignoreScrolling { self.updateScrolling(transition: .immediate) } } private func updateScrolling(transition: Transition) { guard let component = self.component, let itemLayout = self.itemLayout else { return } let visibleBounds = self.scrollView.bounds.insetBy(dx: -100.0, dy: 0.0) let hasMore = component.peers.count > channelsLimit var validIds = Set() if let visibleItems = itemLayout.visibleItems(for: visibleBounds) { let upperBound = min(Int(channelsLimit), visibleItems.upperBound) for index in visibleItems.lowerBound ..< upperBound { if index >= component.peers.channels.count { continue } let item = component.peers.channels[index] let id = item.peer.id validIds.insert(id) var itemTransition = transition let itemView: ComponentView if let current = self.visibleItems[id] { itemView = current } else { itemTransition = .immediate itemView = ComponentView() self.visibleItems[id] = itemView } let title: String let subtitle: String var isLocked = false var isLast = false if index == upperBound - 1 && hasMore { if !component.context.isPremium { isLocked = true } title = isLocked ? "Unlock More Channels" : "View More Channels" subtitle = "+\(component.peers.count - channelsLimit)" isLast = true } else { title = item.peer.compactDisplayTitle subtitle = countString(Int64(item.subscribers)) } var peers: [EnginePeer] = [item.peer] if isLast && !isLocked { for i in index + 1 ..< index + 3 { if i < component.peers.channels.count { peers.append(component.peers.channels[i].peer) } } } let _ = itemView.update( transition: itemTransition, component: AnyComponent(ChannelItemComponent( context: component.context, theme: component.theme, strings: component.strings, peers: peers, isLocked: isLocked, title: title, subtitle: subtitle, action: component.action, contextAction: !isLocked && peers.count == 1 ? component.contextAction : nil )), environment: {}, containerSize: CGSize(width: itemLayout.itemWidth, height: itemLayout.containerHeight) ) let itemFrame = itemLayout.itemFrame(for: index) if let itemComponentView = itemView.view { if itemComponentView.superview == nil { self.scrollView.addSubview(itemComponentView) } itemTransition.setFrame(view: itemComponentView, frame: itemFrame) } } } var removeIds: [EnginePeer.Id] = [] for (id, itemView) in self.visibleItems { if !validIds.contains(id) { removeIds.append(id) if let itemComponentView = itemView.view { transition.setAlpha(view: itemComponentView, alpha: 0.0, completion: { [weak itemComponentView] _ in itemComponentView?.removeFromSuperview() }) } } } for id in removeIds { self.visibleItems.removeValue(forKey: id) } } func update(component: ChannelListPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component let itemLayout = ItemLayout( containerInsets: UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0), containerHeight: availableSize.height, itemWidth: itemSize.width, itemCount: min(Int(channelsLimit), component.peers.channels.count) ) self.itemLayout = itemLayout self.ignoreScrolling = true let contentOffset = self.scrollView.bounds.minY transition.setPosition(view: self.scrollView, position: CGRect(origin: CGPoint(), size: availableSize).center) var scrollBounds = self.scrollView.bounds scrollBounds.size = availableSize transition.setBounds(view: self.scrollView, bounds: scrollBounds) let contentSize = CGSize(width: itemLayout.contentWidth, height: availableSize.height) if self.scrollView.contentSize != contentSize { self.scrollView.contentSize = contentSize } if !transition.animation.isImmediate && self.scrollView.bounds.minY != contentOffset { let deltaOffset = self.scrollView.bounds.minY - contentOffset transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: -deltaOffset), to: CGPoint(), additive: true) } self.ignoreScrolling = false self.updateScrolling(transition: transition) return availableSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }