import Foundation import Postbox import TelegramCore import SwiftSignalKit import AsyncDisplayKit import Display import SafariServices final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate { private let account: Account private var settings: InstantPagePresentationSettings? private var presentationTheme: PresentationTheme private var strings: PresentationStrings private var dateTimeFormat: PresentationDateTimeFormat private var theme: InstantPageTheme? private var manualThemeOverride: InstantPageThemeType? private let getNavigationController: () -> NavigationController? private let present: (ViewController, Any?) -> Void private let pushController: (ViewController) -> Void private let openPeer: (PeerId) -> Void private var webPage: TelegramMediaWebpage? private var initialAnchor: String? private var containerLayout: ContainerViewLayout? private var setupScrollOffsetOnLayout: Bool = false private let statusBar: StatusBar private let navigationBar: InstantPageNavigationBar private let scrollNode: ASScrollNode private let scrollNodeHeader: ASDisplayNode private let scrollNodeFooter: ASDisplayNode private var linkHighlightingNode: LinkHighlightingNode? private var textSelectionNode: LinkHighlightingNode? private var settingsNode: InstantPageSettingsNode? private var settingsDimNode: ASDisplayNode? var currentLayout: InstantPageLayout? var currentLayoutTiles: [InstantPageTile] = [] var currentLayoutItemsWithNodes: [InstantPageItem] = [] var distanceThresholdGroupCount: [Int: Int] = [:] var visibleTiles: [Int: InstantPageTileNode] = [:] var visibleItemsWithNodes: [Int: InstantPageNode] = [:] var currentWebEmbedHeights: [Int : CGFloat] = [:] var currentExpandedDetails: [Int : Bool]? var currentDetailsItems: [InstantPageDetailsItem] = [] var previousContentOffset: CGPoint? var isDeceleratingBecauseOfDragging = false private let hiddenMediaDisposable = MetaDisposable() private let resolveUrlDisposable = MetaDisposable() private let loadWebpageDisposable = MetaDisposable() private let updateLayoutDisposable = MetaDisposable() private var themeReferenceDate: Date? init(account: Account, settings: InstantPagePresentationSettings?, presentationTheme: PresentationTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, statusBar: StatusBar, getNavigationController: @escaping () -> NavigationController?, present: @escaping (ViewController, Any?) -> Void, pushController: @escaping (ViewController) -> Void, openPeer: @escaping (PeerId) -> Void, navigateBack: @escaping () -> Void) { self.account = account self.presentationTheme = presentationTheme self.dateTimeFormat = dateTimeFormat self.strings = strings self.settings = settings let themeReferenceDate = Date() self.themeReferenceDate = themeReferenceDate self.theme = settings.flatMap { settings in return instantPageThemeForType(instantPageThemeTypeForSettingsAndTime(presentationTheme: presentationTheme, settings: settings, time: themeReferenceDate), settings: settings) } self.statusBar = statusBar self.getNavigationController = getNavigationController self.present = present self.pushController = pushController self.openPeer = openPeer self.navigationBar = InstantPageNavigationBar(strings: strings) self.scrollNode = ASScrollNode() self.scrollNodeHeader = ASDisplayNode() self.scrollNodeHeader.backgroundColor = .black self.scrollNodeFooter = ASDisplayNode() self.scrollNodeFooter.backgroundColor = .black super.init() self.setViewBlock({ return UITracingLayerView() }) if let theme = self.theme { self.backgroundColor = theme.pageBackgroundColor self.scrollNodeFooter.backgroundColor = theme.panelBackgroundColor } self.addSubnode(self.scrollNode) self.scrollNode.addSubnode(self.scrollNodeHeader) self.scrollNode.addSubnode(self.scrollNodeFooter) self.addSubnode(self.navigationBar) self.scrollNode.view.delaysContentTouches = false self.scrollNode.view.delegate = self self.navigationBar.back = navigateBack self.navigationBar.share = { [weak self] in if let strongSelf = self, let webPage = strongSelf.webPage, case let .Loaded(content) = webPage.content { let shareController = ShareController(account: account, subject: .url(content.url)) strongSelf.present(shareController, nil) } } self.navigationBar.settings = { [weak self] in if let strongSelf = self { strongSelf.presentSettings() } } self.navigationBar.scrollToTop = { [weak self] in if let strongSelf = self { strongSelf.scrollNode.view.setContentOffset(CGPoint(x: 0.0, y: -strongSelf.scrollNode.view.contentInset.top), animated: true) } } } deinit { self.hiddenMediaDisposable.dispose() self.resolveUrlDisposable.dispose() self.loadWebpageDisposable.dispose() } func update(settings: InstantPagePresentationSettings, strings: PresentationStrings) { if self.settings != settings || self.strings !== strings { let previousSettings = self.settings var updateLayout = previousSettings == nil if let previousSettings = previousSettings { if previousSettings.themeType != settings.themeType { self.themeReferenceDate = nil } } self.settings = settings let themeType = instantPageThemeTypeForSettingsAndTime(presentationTheme: self.presentationTheme, settings: settings, time: self.themeReferenceDate) let theme = instantPageThemeForType(themeType, settings: settings) self.theme = theme self.strings = strings self.settingsNode?.updateSettingsAndCurrentThemeType(settings: settings, type: themeType) var animated = false if let previousSettings = previousSettings { if previousSettings.themeType != settings.themeType || previousSettings.autoNightMode != settings.autoNightMode { updateLayout = true animated = true } if previousSettings.fontSize != settings.fontSize || previousSettings.forceSerif != settings.forceSerif { animated = false updateLayout = true } } self.backgroundColor = theme.pageBackgroundColor if updateLayout { if animated { if let snapshotView = self.scrollNode.view.snapshotView(afterScreenUpdates: false) { self.view.insertSubview(snapshotView, aboveSubview: self.scrollNode.view) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) } } self.updateLayout() self.scrollNodeFooter.backgroundColor = theme.panelBackgroundColor for (_, itemNode) in self.visibleItemsWithNodes { itemNode.update(strings: strings, theme: theme) } self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds) self.updateNavigationBar() self.recursivelyEnsureDisplaySynchronously(true) } } } func tapActionAtPoint(_ point: CGPoint) -> TapLongTapOrDoubleTapGestureRecognizerAction { if let currentLayout = self.currentLayout { for item in currentLayout.items { let frame = self.effectiveFrameForItem(item) if frame.contains(point) { if item is InstantPagePeerReferenceItem { return .fail } else if item is InstantPageAudioItem { return .fail } else if item is InstantPageArticleItem { return .fail } else if item is InstantPageFeedbackItem { return .fail } else if let item = item as? InstantPageDetailsItem { for (_, itemNode) in self.visibleItemsWithNodes { if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item { return itemNode.tapActionAtPoint(point.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY)) } } } break } } } return .waitForSingleTap } override func didLoad() { super.didLoad() if #available(iOSApplicationExtension 11.0, *) { self.scrollNode.view.contentInsetAdjustmentBehavior = .never } let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) recognizer.delaysTouchesBegan = false recognizer.tapActionAtPoint = { [weak self] point in if let strongSelf = self { return strongSelf.tapActionAtPoint(point) } return .waitForSingleTap } recognizer.highlight = { [weak self] point in if let strongSelf = self { strongSelf.updateTouchesAtPoint(point) } } self.scrollNode.view.addGestureRecognizer(recognizer) } func updateWebPage(_ webPage: TelegramMediaWebpage?, anchor: String?) { if self.webPage != webPage { if self.webPage != nil && self.currentLayout != nil { if let snaphotView = self.scrollNode.view.snapshotView(afterScreenUpdates: false) { self.scrollNode.view.superview?.insertSubview(snaphotView, aboveSubview: self.scrollNode.view) snaphotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snaphotView] _ in snaphotView?.removeFromSuperview() }) } } self.setupScrollOffsetOnLayout = self.webPage == nil self.webPage = webPage self.initialAnchor = anchor self.currentLayout = nil self.updateLayout() self.scrollNode.frame = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0) if let containerLayout = self.containerLayout { self.containerLayoutUpdated(containerLayout, navigationBarHeight: 0.0, transition: .immediate) } } } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { self.containerLayout = layout if let settingsDimNode = self.settingsDimNode { transition.updateFrame(node: settingsDimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) } if let settingsNode = self.settingsNode { settingsNode.updateLayout(layout: layout, transition: transition) transition.updateFrame(node: settingsNode, frame: CGRect(origin: CGPoint(), size: layout.size)) } let maxBarHeight: CGFloat if !layout.safeInsets.top.isZero { maxBarHeight = layout.safeInsets.top + 34.0 } else { maxBarHeight = (layout.statusBarHeight ?? 0.0) + 44.0 } let scrollInsetTop = maxBarHeight let resetOffset = self.scrollNode.bounds.size.width.isZero || self.setupScrollOffsetOnLayout let widthUpdated = !self.scrollNode.bounds.size.width.isEqual(to: layout.size.width) var shouldUpdateVisibleItems = false if self.scrollNode.bounds.size != layout.size || !self.scrollNode.view.contentInset.top.isEqual(to: scrollInsetTop) { self.scrollNode.frame = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) self.scrollNodeHeader.frame = CGRect(origin: CGPoint(x: 0.0, y: -2000.0), size: CGSize(width: layout.size.width, height: 2000.0)) self.scrollNode.view.contentInset = UIEdgeInsetsMake(scrollInsetTop, 0.0, layout.intrinsicInsets.bottom, 0.0) if widthUpdated { self.updateLayout() } shouldUpdateVisibleItems = true self.updateNavigationBar() } if resetOffset { var contentOffset = CGPoint(x: 0.0, y: -self.scrollNode.view.contentInset.top) if let anchor = self.initialAnchor, !anchor.isEmpty { if let items = self.currentLayout?.items { self.setupScrollOffsetOnLayout = false outer: for item in items { if let item = item as? InstantPageAnchorItem, item.anchor == anchor { contentOffset = CGPoint(x: 0.0, y: item.frame.origin.y - self.scrollNode.view.contentInset.top) break outer } } } } else { self.setupScrollOffsetOnLayout = false } self.scrollNode.view.contentOffset = contentOffset } if shouldUpdateVisibleItems { self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds) } } private func updateLayout() { guard let containerLayout = self.containerLayout, let webPage = self.webPage, let theme = self.theme else { return } let currentLayout = instantPageLayoutForWebPage(webPage, boundingWidth: containerLayout.size.width, safeInset: containerLayout.safeInsets.left, strings: self.strings, theme: theme, dateTimeFormat: self.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights) for (_, tileNode) in self.visibleTiles { tileNode.removeFromSupernode() } self.visibleTiles.removeAll() let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: containerLayout.size.width) var currentDetailsItems: [InstantPageDetailsItem] = [] var currentLayoutItemsWithNodes: [InstantPageItem] = [] var distanceThresholdGroupCount: [Int : Int] = [:] var expandedDetails: [Int : Bool] = [:] var detailsIndex = -1 for item in currentLayout.items { if item.wantsNode { currentLayoutItemsWithNodes.append(item) if let group = item.distanceThresholdGroup() { let count: Int if let currentCount = distanceThresholdGroupCount[Int(group)] { count = currentCount } else { count = 0 } distanceThresholdGroupCount[Int(group)] = count + 1 } if let detailsItem = item as? InstantPageDetailsItem { detailsIndex += 1 expandedDetails[detailsIndex] = detailsItem.initiallyExpanded currentDetailsItems.append(detailsItem) } } } if self.currentExpandedDetails == nil { self.currentExpandedDetails = expandedDetails } self.currentLayout = currentLayout self.currentLayoutTiles = currentLayoutTiles self.currentLayoutItemsWithNodes = currentLayoutItemsWithNodes self.currentDetailsItems = currentDetailsItems self.distanceThresholdGroupCount = distanceThresholdGroupCount self.scrollNode.view.contentSize = currentLayout.contentSize self.scrollNodeFooter.frame = CGRect(origin: CGPoint(x: 0.0, y: currentLayout.contentSize.height), size: CGSize(width: containerLayout.size.width, height: 2000.0)) } func updateVisibleItems(visibleBounds: CGRect, animated: Bool = false) { guard let theme = self.theme else { return } var visibleTileIndices = Set() var visibleItemIndices = Set() var topNode: ASDisplayNode? let topTileNode = topNode if let scrollSubnodes = self.scrollNode.subnodes { for node in scrollSubnodes.reversed() { if let node = node as? InstantPageTileNode { topNode = node break } } } var collapseOffset: CGFloat = 0.0 let transition: ContainedViewLayoutTransition if animated { transition = .animated(duration: 0.3, curve: .spring) } else { transition = .immediate } var itemIndex = -1 var embedIndex = -1 var detailsIndex = -1 for item in self.currentLayoutItemsWithNodes { itemIndex += 1 if item is InstantPageWebEmbedItem { embedIndex += 1 } if item is InstantPageDetailsItem { detailsIndex += 1 } var itemThreshold: CGFloat = 0.0 if let group = item.distanceThresholdGroup() { var count: Int = 0 if let currentCount = self.distanceThresholdGroupCount[group] { count = currentCount } itemThreshold = item.distanceThresholdWithGroupCount(count) } var itemFrame = item.frame.offsetBy(dx: 0.0, dy: -collapseOffset) var thresholdedItemFrame = itemFrame thresholdedItemFrame.origin.y -= itemThreshold thresholdedItemFrame.size.height += itemThreshold * 2.0 if let detailsItem = item as? InstantPageDetailsItem, let expanded = self.currentExpandedDetails?[detailsIndex] { let height = expanded ? self.effectiveSizeForDetails(detailsItem).height : detailsItem.titleHeight collapseOffset += itemFrame.height - height itemFrame = CGRect(origin: itemFrame.origin, size: CGSize(width: itemFrame.width, height: height)) } if visibleBounds.intersects(thresholdedItemFrame) { visibleItemIndices.insert(itemIndex) var itemNode = self.visibleItemsWithNodes[itemIndex] if let currentItemNode = itemNode { if !item.matchesNode(currentItemNode) { (currentItemNode as! ASDisplayNode).removeFromSupernode() self.visibleItemsWithNodes.removeValue(forKey: itemIndex) itemNode = nil } } if itemNode == nil { let itemIndex = itemIndex let embedIndex = embedIndex let detailsIndex = detailsIndex if let newNode = item.node(account: self.account, strings: self.strings, theme: theme, openMedia: { [weak self] media in self?.openMedia(media) }, openPeer: { [weak self] peerId in self?.openPeer(peerId) }, openUrl: { [weak self] url in self?.openUrl(url) }, updateWebEmbedHeight: { [weak self] height in self?.updateWebEmbedHeight(embedIndex, height) }, updateDetailsExpanded: { [weak self] expanded in self?.updateDetailsExpanded(detailsIndex, expanded) }, currentExpandedDetails: self.currentExpandedDetails) { newNode.frame = itemFrame newNode.updateLayout(size: itemFrame.size, transition: transition) // if case let .animated(duration, _) = transition { // newNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) // } if let topNode = topNode { self.scrollNode.insertSubnode(newNode, aboveSubnode: topNode) } else { self.scrollNode.insertSubnode(newNode, at: 0) } topNode = newNode self.visibleItemsWithNodes[itemIndex] = newNode itemNode = newNode if let itemNode = itemNode as? InstantPageDetailsNode { itemNode.requestLayoutUpdate = { [weak self] in if let strongSelf = self { strongSelf.updateVisibleItems(visibleBounds: strongSelf.scrollNode.view.bounds, animated: true) } } } } } else { if (itemNode as! ASDisplayNode).frame != itemFrame { transition.updateFrame(node: (itemNode as! ASDisplayNode), frame: itemFrame) itemNode?.updateLayout(size: itemFrame.size, transition: transition) } } if let itemNode = itemNode as? InstantPageDetailsNode { itemNode.updateVisibleItems(visibleBounds: visibleBounds.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY), animated: animated) } } } topNode = topTileNode var tileIndex = -1 for tile in self.currentLayoutTiles { tileIndex += 1 let tileFrame = effectiveFrameForTile(tile) var tileVisibleFrame = tileFrame tileVisibleFrame.origin.y -= 400.0 tileVisibleFrame.size.height += 400.0 * 2.0 if tileVisibleFrame.intersects(visibleBounds) { visibleTileIndices.insert(tileIndex) if self.visibleTiles[tileIndex] == nil { let tileNode = InstantPageTileNode(tile: tile, backgroundColor: theme.pageBackgroundColor) tileNode.frame = tileFrame // if case let .animated(duration, _) = transition { // tileNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) // } if let topNode = topNode { self.scrollNode.insertSubnode(tileNode, aboveSubnode: topNode) } else { self.scrollNode.insertSubnode(tileNode, at: 0) } topNode = tileNode self.visibleTiles[tileIndex] = tileNode } else { if visibleTiles[tileIndex]!.frame != tileFrame { transition.updateFrame(node: self.visibleTiles[tileIndex]!, frame: tileFrame) } } } } if let currentLayout = self.currentLayout, collapseOffset > 0.0 { let effectiveContentHeight = currentLayout.contentSize.height - collapseOffset if effectiveContentHeight != self.scrollNode.view.contentSize.height { transition.animateView { self.scrollNode.view.contentSize = CGSize(width: currentLayout.contentSize.width, height: effectiveContentHeight) } let previousFrame = self.scrollNodeFooter.frame self.scrollNodeFooter.frame = CGRect(origin: CGPoint(x: 0.0, y: effectiveContentHeight), size: CGSize(width: previousFrame.width, height: 2000.0)) transition.animateFrame(node: self.scrollNodeFooter, from: previousFrame) } } var removeTileIndices: [Int] = [] for (index, tileNode) in self.visibleTiles { if !visibleTileIndices.contains(index) { removeTileIndices.append(index) tileNode.removeFromSupernode() } } for index in removeTileIndices { self.visibleTiles.removeValue(forKey: index) } var removeItemIndices: [Int] = [] for (index, itemNode) in self.visibleItemsWithNodes { if !visibleItemIndices.contains(index) { removeItemIndices.append(index) (itemNode as! ASDisplayNode).removeFromSupernode() } else { var itemFrame = (itemNode as! ASDisplayNode).frame let itemThreshold: CGFloat = 200.0 itemFrame.origin.y -= itemThreshold itemFrame.size.height += itemThreshold * 2.0 itemNode.updateIsVisible(visibleBounds.intersects(itemFrame)) } } for index in removeItemIndices { self.visibleItemsWithNodes.removeValue(forKey: index) } } func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds) self.updateNavigationBar() self.previousContentOffset = self.scrollNode.view.contentOffset } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { self.isDeceleratingBecauseOfDragging = decelerate if !decelerate { self.updateNavigationBar(forceState: true) } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { self.updateNavigationBar(forceState: true) self.isDeceleratingBecauseOfDragging = false } func updateNavigationBar(forceState: Bool = false) { guard let containerLayout = self.containerLayout else { return } let bounds = self.scrollNode.view.bounds let contentOffset = self.scrollNode.view.contentOffset let maxBarHeight: CGFloat let minBarHeight: CGFloat if !containerLayout.safeInsets.top.isZero { maxBarHeight = containerLayout.safeInsets.top + 34.0 minBarHeight = containerLayout.safeInsets.top + 8.0 } else { maxBarHeight = (containerLayout.statusBarHeight ?? 0.0) + 44.0 minBarHeight = 20.0 } var pageProgress: CGFloat = 0.0 if !self.scrollNode.view.contentSize.height.isZero { let value = (contentOffset.y + self.scrollNode.view.contentInset.top) / (self.scrollNode.view.contentSize.height - bounds.size.height + self.scrollNode.view.contentInset.top) pageProgress = max(0.0, min(1.0, value)) } let delta: CGFloat if let previousContentOffset = self.previousContentOffset { delta = contentOffset.y - previousContentOffset.y } else { delta = 0.0 } self.previousContentOffset = contentOffset var transition: ContainedViewLayoutTransition = .immediate var navigationBarFrame = self.navigationBar.frame navigationBarFrame.size.width = bounds.size.width if navigationBarFrame.size.height.isZero { navigationBarFrame.size.height = maxBarHeight } if forceState { transition = .animated(duration: 0.3, curve: .spring) let transitionFactor = (navigationBarFrame.size.height - minBarHeight) / (maxBarHeight - minBarHeight) if contentOffset.y <= -self.scrollNode.view.contentInset.top || transitionFactor > 0.4 { navigationBarFrame.size.height = maxBarHeight } else { navigationBarFrame.size.height = minBarHeight } } else { if contentOffset.y <= -self.scrollNode.view.contentInset.top { navigationBarFrame.size.height = maxBarHeight } else { navigationBarFrame.size.height -= delta } navigationBarFrame.size.height = max(minBarHeight, min(maxBarHeight, navigationBarFrame.size.height)) } let transitionFactor = (navigationBarFrame.size.height - minBarHeight) / (maxBarHeight - minBarHeight) if containerLayout.safeInsets.top.isZero { let statusBarAlpha = min(1.0, max(0.0, transitionFactor)) transition.updateAlpha(node: self.statusBar, alpha: statusBarAlpha * statusBarAlpha) self.statusBar.verticalOffset = navigationBarFrame.size.height - maxBarHeight } else { transition.updateAlpha(node: self.statusBar, alpha: 1.0) self.statusBar.verticalOffset = 0.0 } transition.updateFrame(node: self.navigationBar, frame: navigationBarFrame) self.navigationBar.updateLayout(size: navigationBarFrame.size, minHeight: minBarHeight, maxHeight: maxBarHeight, topInset: containerLayout.safeInsets.top, leftInset: containerLayout.safeInsets.left, rightInset: containerLayout.safeInsets.right, pageProgress: pageProgress, transition: transition) transition.animateView { self.scrollNode.view.scrollIndicatorInsets = UIEdgeInsets(top: navigationBarFrame.size.height, left: 0.0, bottom: containerLayout.intrinsicInsets.bottom, right: 0.0) } } private func updateTouchesAtPoint(_ location: CGPoint?) { var rects: [CGRect]? if let location = location, let currentLayout = self.currentLayout { for item in currentLayout.items { let itemFrame = effectiveFrameForItem(item) if itemFrame.contains(location) { var contentOffset = CGPoint() if let item = item as? InstantPageTableItem { contentOffset = tableContentOffset(item: item) } var itemRects = item.linkSelectionRects(at: location.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY)) for i in 0 ..< itemRects.count { itemRects[i] = itemRects[i].offsetBy(dx: itemFrame.minX - contentOffset.x, dy: itemFrame.minY).insetBy(dx: -2.0, dy: -2.0) } if !itemRects.isEmpty { rects = itemRects break } } } } if let rects = rects { let linkHighlightingNode: LinkHighlightingNode if let current = self.linkHighlightingNode { linkHighlightingNode = current } else { linkHighlightingNode = LinkHighlightingNode(color: UIColor(rgb: 0x007be8).withAlphaComponent(0.4)) linkHighlightingNode.isUserInteractionEnabled = false self.linkHighlightingNode = linkHighlightingNode self.scrollNode.addSubnode(linkHighlightingNode) } linkHighlightingNode.frame = CGRect(origin: CGPoint(), size: self.scrollNode.bounds.size) linkHighlightingNode.updateRects(rects) } 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() }) } } private func tableContentOffset(item: InstantPageTableItem) -> CGPoint { var contentOffset = CGPoint() for (_, itemNode) in self.visibleItemsWithNodes { if let itemNode = itemNode as? InstantPageTableNode, itemNode.item === item { contentOffset = itemNode.contentOffset break } } return contentOffset } private func effectiveSizeForDetails(_ item: InstantPageDetailsItem) -> CGSize { for (_, itemNode) in self.visibleItemsWithNodes { if let detailsNode = itemNode as? InstantPageDetailsNode, detailsNode.item === item { return CGSize(width: item.frame.width, height: detailsNode.effectiveContentSize.height + item.titleHeight) } } return item.frame.size } private func effectiveFrameForTile(_ tile: InstantPageTile) -> CGRect { let layoutOrigin = tile.frame.origin var origin = layoutOrigin for item in self.currentDetailsItems { let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded if layoutOrigin.y >= item.frame.maxY { let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight origin.y += height - item.frame.height } } return CGRect(origin: origin, size: tile.frame.size) } private func effectiveFrameForItem(_ item: InstantPageItem) -> CGRect { let layoutOrigin = item.frame.origin var origin = layoutOrigin for item in self.currentDetailsItems { let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded if layoutOrigin.y >= item.frame.maxY { let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight origin.y += height - item.frame.height } } if let item = item as? InstantPageDetailsItem { let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight return CGRect(origin: origin, size: CGSize(width: item.frame.width, height: height)) } else { return CGRect(origin: origin, size: item.frame.size) } } private func textItemAtLocation(_ location: CGPoint) -> (InstantPageTextItem, CGPoint)? { if let currentLayout = self.currentLayout { for item in currentLayout.items { let itemFrame = self.effectiveFrameForItem(item) if itemFrame.contains(location) { if let item = item as? InstantPageTextItem, item.selectable { return (item, CGPoint(x: itemFrame.minX - item.frame.minX, y: itemFrame.minY - item.frame.minY)) } else if let item = item as? InstantPageTableItem { let contentOffset = tableContentOffset(item: item) if let (textItem, parentOffset) = item.textItemAtLocation(location.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY)) { return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x - contentOffset.x, dy: parentOffset.y)) } } else if let item = item as? InstantPageDetailsItem { for (_, itemNode) in self.visibleItemsWithNodes { if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item { if let (textItem, parentOffset) = itemNode.textItemAtLocation(location.offsetBy(dx: -itemFrame.minX, dy: -itemFrame.minY)) { return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x, dy: parentOffset.y)) } } } } } } } return nil } private func urlForTapLocation(_ location: CGPoint) -> InstantPageUrlItem? { if let (item, parentOffset) = self.textItemAtLocation(location) { return item.urlAttribute(at: location.offsetBy(dx: -item.frame.minX - parentOffset.x, dy: -item.frame.minY - parentOffset.y)) } return nil } @objc private func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { switch recognizer.state { case .ended: if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { switch gesture { case .tap: if let url = self.urlForTapLocation(location) { self.openUrl(url) } case .longTap: if let url = self.urlForTapLocation(location) { let canOpenIn = availableOpenInOptions(applicationContext: self.account.telegramApplicationContext, item: .url(url: url.url)).count > 1 let openText = canOpenIn ? self.strings.Conversation_FileOpenIn : self.strings.Conversation_LinkDialogOpen let actionSheet = ActionSheetController(presentationTheme: self.presentationTheme) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: url.url), ActionSheetButtonItem(title: openText, color: .accent, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() if let strongSelf = self { strongSelf.openUrl(url) } }), ActionSheetButtonItem(title: self.strings.ShareMenu_CopyShareLink, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() UIPasteboard.general.string = url.url }), ActionSheetButtonItem(title: self.strings.Conversation_AddToReadingList, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() if let link = URL(string: url.url) { let _ = try? SSReadingList.default()?.addItem(with: link, title: nil, previewText: nil) } }) ]), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: self.strings.Common_Cancel, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ])]) self.present(actionSheet, nil) } else if let (item, parentOffset) = self.textItemAtLocation(location) { let textFrame = item.frame var itemRects = item.lineRects() for i in 0 ..< itemRects.count { itemRects[i] = itemRects[i].offsetBy(dx: parentOffset.x + textFrame.minX, dy: parentOffset.y + textFrame.minY).insetBy(dx: -2.0, dy: -2.0) } self.updateTextSelectionRects(itemRects, text: item.plainText()) } default: break } } default: break } } private func updateTextSelectionRects(_ rects: [CGRect], text: String?) { if let text = text, !rects.isEmpty { let textSelectionNode: LinkHighlightingNode if let current = self.textSelectionNode { textSelectionNode = current } else { textSelectionNode = LinkHighlightingNode(color: UIColor.lightGray.withAlphaComponent(0.4)) textSelectionNode.isUserInteractionEnabled = false self.textSelectionNode = textSelectionNode self.scrollNode.addSubnode(textSelectionNode) } textSelectionNode.frame = CGRect(origin: CGPoint(), size: self.scrollNode.bounds.size) textSelectionNode.updateRects(rects) var coveringRect = rects[0] for i in 1 ..< rects.count { coveringRect = coveringRect.union(rects[i]) } let controller = ContextMenuController(actions: [ContextMenuAction(content: .text(self.strings.Conversation_ContextMenuCopy), action: { UIPasteboard.general.string = text }), ContextMenuAction(content: .text(self.strings.Conversation_ContextMenuShare), action: { [weak self] in if let strongSelf = self, let webPage = strongSelf.webPage, case let .Loaded(content) = webPage.content { strongSelf.present(ShareController(account: strongSelf.account, subject: .quote(text: text, url: content.url)), nil) } })]) controller.dismissed = { [weak self] in self?.updateTextSelectionRects([], text: nil) } self.present(controller, ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in if let strongSelf = self { return (strongSelf.scrollNode, coveringRect.insetBy(dx: -3.0, dy: -3.0), strongSelf, strongSelf.bounds) } else { return nil } })) textSelectionNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18) } else if let textSelectionNode = self.textSelectionNode { self.textSelectionNode = nil textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in textSelectionNode?.removeFromSupernode() }) } } private func findAnchorItem(_ anchor: String, items: [InstantPageItem]) -> InstantPageAnchorItem? { for item in items { if let item = item as? InstantPageAnchorItem, item.anchor == anchor { return item } else if let item = item as? InstantPageDetailsItem { if let anchorItem = findAnchorItem(anchor, items: item.items) { return anchorItem } } } return nil } private func openUrl(_ url: InstantPageUrlItem) { guard let items = self.currentLayout?.items else { return } if let webPage = self.webPage, url.webpageId == webPage.id, let anchorRange = url.url.range(of: "#") { let anchor = url.url[anchorRange.upperBound...] if !anchor.isEmpty { if let anchorItem = findAnchorItem(String(anchor), items: items) { self.scrollNode.view.setContentOffset(CGPoint(x: 0.0, y: anchorItem.frame.origin.y - self.scrollNode.view.contentInset.top), animated: true) return } } } var cancelImpl: (() -> Void)? let progressSignal = Signal { [weak self] subscriber in guard let strongSelf = self else { return EmptyDisposable } let controller = OverlayStatusController(theme: strongSelf.presentationTheme, strings: strongSelf.strings, type: .loading(cancelled: { cancelImpl?() })) strongSelf.present(controller, nil) return ActionDisposable { [weak controller] in Queue.mainQueue().async() { controller?.dismiss() } } } |> runOn(Queue.mainQueue()) |> delay(0.15, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() let resolveSignal = resolveUrl(account: self.account, url: url.url) |> afterDisposed { Queue.mainQueue().async { progressDisposable.dispose() } } cancelImpl = { [weak self] in self?.resolveUrlDisposable.set(nil) } self.resolveUrlDisposable.set((resolveSignal |> deliverOnMainQueue).start(next: { [weak self] result in if let strongSelf = self { switch result { case let .externalUrl(externalUrl): if let webpageId = url.webpageId { var anchor: String? if let anchorRange = externalUrl.range(of: "#") { anchor = String(externalUrl[anchorRange.upperBound...]) } strongSelf.loadWebpageDisposable.set((webpagePreview(account: strongSelf.account, url: externalUrl, webpageId: webpageId) |> deliverOnMainQueue).start(next: { webpage in if let strongSelf = self, let webpage = webpage { strongSelf.pushController(InstantPageController(account: strongSelf.account, webPage: webpage, anchor: anchor)) } })) } else { openExternalUrl(account: strongSelf.account, url: externalUrl, presentationData: strongSelf.account.telegramApplicationContext.currentPresentationData.with { $0 }, applicationContext: strongSelf.account.telegramApplicationContext, navigationController: strongSelf.getNavigationController(), dismissInput: { self?.view.endEditing(true) }) } default: openResolvedUrl(result, account: strongSelf.account, navigationController: strongSelf.getNavigationController(), openPeer: { peerId, navigation in switch navigation { case let .chat(_, messageId): if let navigationController = strongSelf.getNavigationController() { navigateToChatController(navigationController: navigationController, account: strongSelf.account, chatLocation: .peer(peerId), messageId: messageId) } case let .withBotStartPayload(botStart): if let navigationController = strongSelf.getNavigationController() { navigateToChatController(navigationController: navigationController, account: strongSelf.account, chatLocation: .peer(peerId), botStart: botStart, keepStack: .always) } case .info: let _ = (strongSelf.account.postbox.loadedPeerWithId(peerId) |> deliverOnMainQueue).start(next: { peer in if let strongSelf = self { if let controller = peerInfoController(account: strongSelf.account, peer: peer) { strongSelf.getNavigationController()?.pushViewController(controller) } } }) default: break } }, present: { c, a in self?.present(c, a) }, dismissInput: { self?.view.endEditing(true) }) } } })) } private func mediasFromItems(_ items: [InstantPageItem]) -> [InstantPageMedia] { var medias: [InstantPageMedia] = [] for item in items { if let detailsItem = item as? InstantPageDetailsItem { medias.append(contentsOf: mediasFromItems(detailsItem.items)) } else { medias.append(contentsOf: item.medias) } } return medias } private func openMedia(_ media: InstantPageMedia) { guard let items = self.currentLayout?.items, let webPage = self.webPage else { return } if let map = media.media as? TelegramMediaMap { let controller = legacyLocationController(message: nil, mapMedia: map, account: self.account, modal: false, openPeer: { _ in }, sendLiveLocation: { _, _ in }, stopLiveLocation: { }, openUrl: { _ in }) self.pushController(controller) return } if let file = media.media as? TelegramMediaFile, (file.isVoice || file.isMusic) { var medias: [InstantPageMedia] = [] var initialIndex = 0 for item in items { for itemMedia in item.medias { if let itemFile = itemMedia.media as? TelegramMediaFile, (itemFile.isVoice || itemFile.isMusic) { if itemMedia.index == media.index { initialIndex = medias.count } medias.append(itemMedia) } } } self.account.telegramApplicationContext.mediaManager?.setPlaylist(InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex), type: file.isVoice ? .voice : .music) return } var medias: [InstantPageMedia] = mediasFromItems(items) medias = medias.filter { $0.media is TelegramMediaImage } var entries: [InstantPageGalleryEntry] = [] for media in medias { entries.append(InstantPageGalleryEntry(index: Int32(media.index), pageId: webPage.webpageId, media: media, caption: media.caption, credit: media.credit, location: InstantPageGalleryEntryLocation(position: Int32(entries.count), totalCount: Int32(medias.count)))) } var centralIndex: Int? for i in 0 ..< entries.count { if entries[i].media == media { centralIndex = i break } } if let centralIndex = centralIndex { let controller = InstantPageGalleryController(account: self.account, webPage: webPage, entries: entries, centralIndex: centralIndex, replaceRootController: { _, _ in }, baseNavigationController: self.getNavigationController()) self.hiddenMediaDisposable.set((controller.hiddenMedia |> deliverOnMainQueue).start(next: { [weak self] entry in if let strongSelf = self { for (_, itemNode) in strongSelf.visibleItemsWithNodes { itemNode.updateHiddenMedia(media: entry?.media) } } })) self.present(controller, InstantPageGalleryControllerPresentationArguments(transitionArguments: { [weak self] entry -> GalleryTransitionArguments? in if let strongSelf = self { for (_, itemNode) in strongSelf.visibleItemsWithNodes { if let transitionNode = itemNode.transitionNode(media: entry.media) { return GalleryTransitionArguments(transitionNode: transitionNode, addToTransitionSurface: { _ in }) } } } return nil })) } } private func updateWebEmbedHeight(_ index: Int, _ height: CGFloat) { let currentHeight = self.currentWebEmbedHeights[index] if height != currentHeight { if let currentHeight = currentHeight, currentHeight > height { return } self.currentWebEmbedHeights[index] = height let signal: Signal = (.complete() |> delay(0.08, queue: Queue.mainQueue())) self.updateLayoutDisposable.set(signal.start(completed: { [weak self] in if let strongSelf = self { strongSelf.updateLayout() strongSelf.updateVisibleItems(visibleBounds: strongSelf.scrollNode.view.bounds) } })) } } private func updateDetailsExpanded(_ index: Int, _ expanded: Bool) { if var currentExpandedDetails = self.currentExpandedDetails { currentExpandedDetails[index] = expanded self.currentExpandedDetails = currentExpandedDetails } self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds, animated: true) } private func presentSettings() { guard let settings = self.settings, let containerLayout = self.containerLayout else { return } if self.settingsNode == nil { let settingsNode = InstantPageSettingsNode(strings: self.strings, settings: settings, currentThemeType: instantPageThemeTypeForSettingsAndTime(presentationTheme: self.presentationTheme, settings: settings, time: self.themeReferenceDate), applySettings: { [weak self] settings in if let strongSelf = self { strongSelf.update(settings: settings, strings: strongSelf.strings) let _ = updateInstantPagePresentationSettingsInteractively(postbox: strongSelf.account.postbox, { _ in return settings }).start() } }, openInSafari: { [weak self] in if let strongSelf = self, let webPage = strongSelf.webPage, case let .Loaded(content) = webPage.content { strongSelf.account.telegramApplicationContext.applicationBindings.openUrl(content.url) } }) self.addSubnode(settingsNode) self.settingsNode = settingsNode let settingsDimNode = ASDisplayNode() settingsDimNode.backgroundColor = UIColor(rgb: 0, alpha: 0.1) settingsDimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(settingsDimTapped(_:)))) self.insertSubnode(settingsDimNode, belowSubnode: self.navigationBar) self.settingsDimNode = settingsDimNode settingsDimNode.frame = CGRect(origin: CGPoint(), size: containerLayout.size) settingsNode.frame = CGRect(origin: CGPoint(), size: containerLayout.size) settingsNode.updateLayout(layout: containerLayout, transition: .immediate) settingsNode.animateIn() settingsDimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut) self.navigationBar.updateDimmed(true, transition: transition) transition.updateAlpha(node: self.statusBar, alpha: 0.5) } } @objc func settingsDimTapped(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { if let settingsNode = self.settingsNode { self.settingsNode = nil settingsNode.animateOut(completion: { [weak settingsNode] in settingsNode?.removeFromSupernode() }) } if let settingsDimNode = self.settingsDimNode { self.settingsDimNode = nil settingsDimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak settingsDimNode] _ in settingsDimNode?.removeFromSupernode() }) } let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut) self.navigationBar.updateDimmed(false, transition: transition) transition.updateAlpha(node: self.statusBar, alpha: 1.0) } } }