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 ChatMessageItemCommon import RoundedRectWithTailPath import AvatarNode import MultilineTextComponent import ChatMessageBackground 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, peers: recommendedChannels, action: { peer in 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) } ) ), 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 peer: EnginePeer let subtitle: String let action: (EnginePeer) -> Void init( context: AccountContext, theme: PresentationTheme, peer: EnginePeer, subtitle: String, action: @escaping (EnginePeer) -> Void ) { self.context = context self.theme = theme self.peer = peer self.subtitle = subtitle self.action = action } static func ==(lhs: ChannelItemComponent, rhs: ChannelItemComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.theme !== rhs.theme { return false } if lhs.peer != rhs.peer { return false } if lhs.subtitle != rhs.subtitle { return false } return true } final class View: UIView { private let containerButton: HighlightTrackingButton private let title = ComponentView() private let subtitle = ComponentView() private let avatarNode: AvatarNode private var component: ChannelItemComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0)) self.avatarNode.isUserInteractionEnabled = false self.containerButton = HighlightTrackingButton() super.init(frame: frame) self.addSubview(self.containerButton) self.addSubnode(self.avatarNode) self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func pressed() { guard let component = self.component else { return } component.action(component.peer) } func update(component: ChannelItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.peer.compactDisplayTitle, font: Font.regular(11.0), textColor: component.theme.chat.message.incoming.primaryTextColor)) )), environment: {}, containerSize: CGSize(width: itemSize.width - 20.0, height: 100.0) ) let subtitleSize = self.subtitle.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.subtitle, font: Font.regular(10.0), textColor: component.theme.chat.message.incoming.secondaryTextColor)) )), environment: {}, containerSize: CGSize(width: itemSize.width - 6.0, height: 100.0) ) let avatarSize = CGSize(width: 60.0, height: 60.0) let 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 subtitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((itemSize.width - subtitleSize.width) / 2.0), y: titleFrame.maxY + 1.0), size: subtitleSize) self.avatarNode.frame = avatarFrame self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: component.peer) if let titleView = self.title.view { if titleView.superview == nil { titleView.isUserInteractionEnabled = false self.containerButton.addSubview(titleView) } titleView.frame = titleFrame } if let subtitleView = self.subtitle.view { if subtitleView.superview == nil { subtitleView.isUserInteractionEnabled = false self.containerButton.addSubview(subtitleView) } subtitleView.frame = subtitleFrame } self.containerButton.frame = CGRect(origin: .zero, size: itemSize) 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) } } final class ChannelListPanelComponent: Component { typealias EnvironmentType = Empty let context: AccountContext let theme: PresentationTheme let peers: RecommendedChannels let action: (EnginePeer) -> Void init( context: AccountContext, theme: PresentationTheme, peers: RecommendedChannels, action: @escaping (EnginePeer) -> Void ) { self.context = context self.theme = theme self.peers = peers self.action = action } 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) var validIds = Set() if let visibleItems = itemLayout.visibleItems(for: visibleBounds) { for index in visibleItems.lowerBound ..< visibleItems.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 subtitle = countString(Int64(item.subscribers)) let _ = itemView.update( transition: itemTransition, component: AnyComponent(ChannelItemComponent( context: component.context, theme: component.theme, peer: item.peer, subtitle: subtitle, action: component.action )), 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: 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) } }