From 5978a5278d5c908dc79df31bd6fa4b024240a138 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 29 Aug 2024 11:44:05 +0400 Subject: [PATCH] Local instant view --- .../BrowserUI/Sources/BrowserContent.swift | 41 +- .../Sources/BrowserDocumentContent.swift | 7 +- .../Sources/BrowserInstantPageContent.swift | 47 +- .../BrowserUI/Sources/BrowserPdfContent.swift | 83 +- .../Sources/BrowserReadability.swift | 585 ++++ .../BrowserUI/Sources/BrowserScreen.swift | 100 +- .../BrowserUI/Sources/BrowserWebContent.swift | 82 +- submodules/InstantPageUI/BUILD | 1 + .../Sources/InstantPageAnchorItem.swift | 2 +- .../Sources/InstantPageArticleItem.swift | 2 +- .../Sources/InstantPageAudioItem.swift | 2 +- .../Sources/InstantPageContentNode.swift | 6 +- .../Sources/InstantPageControllerNode.swift | 2 +- .../Sources/InstantPageDetailsItem.swift | 4 +- .../Sources/InstantPageDetailsNode.swift | 4 +- .../InstantPageExternalMediaResource.swift | 48 + .../Sources/InstantPageFeedbackItem.swift | 2 +- .../Sources/InstantPageImageItem.swift | 4 +- .../Sources/InstantPageImageNode.swift | 129 +- .../Sources/InstantPageItem.swift | 2 +- .../Sources/InstantPageLayout.swift | 8 +- .../InstantPagePeerReferenceItem.swift | 2 +- .../InstantPagePlayableVideoItem.swift | 2 +- .../InstantPageReferenceControllerNode.swift | 2 +- .../Sources/InstantPageShapeItem.swift | 2 +- .../Sources/InstantPageSlideshowItem.swift | 2 +- .../InstantPageSlideshowItemNode.swift | 2 +- .../Sources/InstantPageSubContentNode.swift | 2 +- .../Sources/InstantPageTableItem.swift | 4 +- .../Sources/InstantPageTextItem.swift | 6 +- .../Sources/InstantPageTextStyleStack.swift | 4 +- .../Sources/InstantPageWebEmbedItem.swift | 2 +- .../Sources/GiveawayInfoController.swift | 17 +- .../Sources/Network/FetchHttpResource.swift | 8 +- .../Resources/TelegramEngineResources.swift | 4 +- .../TelegramCore/Sources/WebpagePreview.swift | 22 +- .../Sources/NavigationStackComponent.swift | 80 +- .../Resources/Readability/Readability.js | 2754 +++++++++++++++++ .../Resources/Readability/ReaderMode.js | 50 + .../Resources/Readability/purify.min.js | 3 + .../TelegramUI/Sources/OpenChatMessage.swift | 2 +- 41 files changed, 3967 insertions(+), 164 deletions(-) create mode 100644 submodules/BrowserUI/Sources/BrowserReadability.swift create mode 100644 submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift create mode 100644 submodules/TelegramUI/Resources/Readability/Readability.js create mode 100644 submodules/TelegramUI/Resources/Readability/ReaderMode.js create mode 100644 submodules/TelegramUI/Resources/Readability/purify.min.js diff --git a/submodules/BrowserUI/Sources/BrowserContent.swift b/submodules/BrowserUI/Sources/BrowserContent.swift index 7422bed87e..2aef1273e7 100644 --- a/submodules/BrowserUI/Sources/BrowserContent.swift +++ b/submodules/BrowserUI/Sources/BrowserContent.swift @@ -41,6 +41,8 @@ final class BrowserContentState: Equatable { let contentType: ContentType let favicon: UIImage? let isSecure: Bool + let hasInstantView: Bool + let isInnerInstantViewEnabled: Bool let canGoBack: Bool let canGoForward: Bool @@ -56,6 +58,8 @@ final class BrowserContentState: Equatable { contentType: ContentType, favicon: UIImage? = nil, isSecure: Bool = false, + hasInstantView: Bool = false, + isInnerInstantViewEnabled: Bool = false, canGoBack: Bool = false, canGoForward: Bool = false, backList: [HistoryItem] = [], @@ -68,6 +72,8 @@ final class BrowserContentState: Equatable { self.contentType = contentType self.favicon = favicon self.isSecure = isSecure + self.hasInstantView = hasInstantView + self.isInnerInstantViewEnabled = isInnerInstantViewEnabled self.canGoBack = canGoBack self.canGoForward = canGoForward self.backList = backList @@ -96,6 +102,9 @@ final class BrowserContentState: Equatable { if lhs.isSecure != rhs.isSecure { return false } + if lhs.hasInstantView != rhs.hasInstantView { + return false + } if lhs.canGoBack != rhs.canGoBack { return false } @@ -112,43 +121,51 @@ final class BrowserContentState: Equatable { } func withUpdatedTitle(_ title: String) -> BrowserContentState { - return BrowserContentState(title: title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedUrl(_ url: String) -> BrowserContentState { - return BrowserContentState(title: self.title, url: url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedIsSecure(_ isSecure: Bool) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + } + + func withUpdatedHasInstantView(_ hasInstantView: Bool) -> BrowserContentState { + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + } + + func withUpdatedIsInnerInstantViewEnabled(_ isInnerInstantViewEnabled: Bool) -> BrowserContentState { + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedEstimatedProgress(_ estimatedProgress: Double) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedReadingProgress(_ readingProgress: Double) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedFavicon(_ favicon: UIImage?) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedCanGoBack(_ canGoBack: Bool) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedCanGoForward(_ canGoForward: Bool) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedBackList(_ backList: [HistoryItem]) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: backList, forwardList: self.forwardList) } func withUpdatedForwardList(_ forwardList: [HistoryItem]) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: forwardList) } } @@ -158,7 +175,7 @@ protocol BrowserContent: UIView { var currentState: BrowserContentState { get } var state: Signal { get } - var pushContent: (BrowserScreen.Subject) -> Void { get set } + var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void { get set } var present: (ViewController, Any?) -> Void { get set } var presentInGlobalOverlay: (ViewController) -> Void { get set } var getNavigationController: () -> NavigationController? { get set } @@ -177,6 +194,8 @@ protocol BrowserContent: UIView { func navigateForward() func navigateTo(historyItem: BrowserContentState.HistoryItem) + func toggleInstantView(_ enabled: Bool) + func updatePresentationData(_ presentationData: PresentationData) func updateFontState(_ state: BrowserPresentationState.FontState) diff --git a/submodules/BrowserUI/Sources/BrowserDocumentContent.swift b/submodules/BrowserUI/Sources/BrowserDocumentContent.swift index c17a2911ed..93acd7ab16 100644 --- a/submodules/BrowserUI/Sources/BrowserDocumentContent.swift +++ b/submodules/BrowserUI/Sources/BrowserDocumentContent.swift @@ -36,7 +36,7 @@ final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate return self.statePromise.get() } - var pushContent: (BrowserScreen.Subject) -> Void = { _ in } + var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in } var openAppUrl: (String) -> Void = { _ in } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } var minimize: () -> Void = { } @@ -122,6 +122,9 @@ final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate self.webView.evaluateJavaScript(js) { _, _ in } } + func toggleInstantView(_ enabled: Bool) { + } + private var didSetupSearch = false private func setupSearch(completion: @escaping () -> Void) { guard !self.didSetupSearch else { @@ -385,7 +388,7 @@ final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate navigationController._keepModalDismissProgress = true navigationController.pushViewController(controller) } else { - self.pushContent(subject) + self.pushContent(subject, nil) } } diff --git a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift index bc327edadf..e8a205721d 100644 --- a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift +++ b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift @@ -28,6 +28,8 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg private var theme: InstantPageTheme private var settings: InstantPagePresentationSettings = .defaultSettings private let sourceLocation: InstantPageSourceLocation + private let preloadedResouces: [Any]? + private var originalContent: BrowserContent? private var webPage: TelegramMediaWebpage? @@ -66,7 +68,8 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg var currentAccessibilityAreas: [AccessibilityAreaNode] = [] - var pushContent: (BrowserScreen.Subject) -> Void = { _ in } + var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in } + var restoreContent: (BrowserContent) -> Void = { _ in } var openAppUrl: (String) -> Void = { _ in } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } var minimize: () -> Void = { } @@ -84,19 +87,21 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg private let loadWebpageDisposable = MetaDisposable() private let resolveUrlDisposable = MetaDisposable() private let updateLayoutDisposable = MetaDisposable() - + private let loadProgress = ValuePromise(1.0, ignoreRepeated: true) private let readingProgress = ValuePromise(1.0, ignoreRepeated: true) private var containerLayout: (size: CGSize, insets: UIEdgeInsets, fullInsets: UIEdgeInsets)? private var setupScrollOffsetOnLayout = false - init(context: AccountContext, presentationData: PresentationData, webPage: TelegramMediaWebpage, anchor: String?, url: String, sourceLocation: InstantPageSourceLocation) { + init(context: AccountContext, presentationData: PresentationData, webPage: TelegramMediaWebpage, anchor: String?, url: String, sourceLocation: InstantPageSourceLocation, preloadedResouces: [Any]?, originalContent: BrowserContent? = nil) { self.context = context self.webPage = webPage self.presentationData = presentationData self.theme = instantPageThemeForType(presentationData.theme.overallDarkAppearance ? .dark : .light, settings: .defaultSettings) self.sourceLocation = sourceLocation + self.preloadedResouces = preloadedResouces + self.originalContent = originalContent self.uuid = UUID() @@ -107,7 +112,8 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg title = "" } - self._state = BrowserContentState(title: title, url: url, estimatedProgress: 0.0, readingProgress: 0.0, contentType: .instantPage) + let isInnerInstantViewEnabled = originalContent != nil + self._state = BrowserContentState(title: title, url: url, estimatedProgress: 0.0, readingProgress: 0.0, contentType: .instantPage, isInnerInstantViewEnabled: isInnerInstantViewEnabled) self.statePromise = Promise(self._state) self.wrapperNode = ASDisplayNode() @@ -126,7 +132,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg self.readingProgress.get() ) |> map { estimatedProgress, readingProgress in - return BrowserContentState(title: title, url: url, estimatedProgress: estimatedProgress, readingProgress: readingProgress, contentType: .instantPage) + return BrowserContentState(title: title, url: url, estimatedProgress: estimatedProgress, readingProgress: readingProgress, contentType: .instantPage, isInnerInstantViewEnabled: isInnerInstantViewEnabled) } )) @@ -359,6 +365,12 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg self.updatePageLayout() self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds) } + + func toggleInstantView(_ enabled: Bool) { + if !enabled, let originalContent = self.originalContent { + self.restoreContent(originalContent) + } + } func setSearch(_ query: String?, completion: ((Int) -> Void)?) { @@ -603,7 +615,22 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg self?.updateWebEmbedHeight(embedIndex, height) }, updateDetailsExpanded: { [weak self] expanded in self?.updateDetailsExpanded(detailsIndex, expanded) - }, currentExpandedDetails: self.currentExpandedDetails) { + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { [weak self] url in + if let preloadedResouces = self?.preloadedResouces { + var cleanUrl = url + var components = URLComponents(string: url) + components?.queryItems = nil + cleanUrl = components?.url?.absoluteString ?? cleanUrl + for resource in preloadedResouces { + if let resource = resource as? [String: Any], let resourceUrl = resource["WebResourceURL"] as? String { + if resourceUrl == url || resourceUrl.hasPrefix(cleanUrl) { + return resource["WebResourceData"] as? Data + } + } + } + } + return nil + }) { newNode.frame = itemFrame newNode.updateLayout(size: itemFrame.size, transition: transition) if let topNode = topNode { @@ -878,7 +905,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg baseUrl = String(baseUrl[..() + private var pageNumber: (Int, Int)? + private var pageTimer: SwiftSignalKit.Timer? + let uuid: UUID private var _state: BrowserContentState @@ -37,7 +42,7 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF return self.statePromise.get() } - var pushContent: (BrowserScreen.Subject) -> Void = { _ in } + var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in } var openAppUrl: (String) -> Void = { _ in } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } var minimize: () -> Void = { } @@ -57,6 +62,10 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF self.pdfView = PDFView() self.pdfView.clipsToBounds = false + self.pageIndicatorBackgorund = UIVisualEffectView(effect: UIBlurEffect(style: .light)) + self.pageIndicatorBackgorund.clipsToBounds = true + self.pageIndicatorBackgorund.layer.cornerRadius = 10.0 + var scrollView: UIScrollView? for view in self.pdfView.subviews { if let view = view as? UIScrollView { @@ -104,6 +113,10 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF scrollView.delegate = self } } + + self.pageNumber = (1, self.pdfView.document?.pageCount ?? 1) + + self.startPageIndicatorTimer() } required init?(coder: NSCoder) { @@ -120,12 +133,27 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF } } + func startPageIndicatorTimer() { + self.pageTimer?.invalidate() + + self.pageTimer = SwiftSignalKit.Timer(timeout: 2.0, repeat: false, completion: { [weak self] in + guard let self else { + return + } + let transition = ComponentTransition.easeInOut(duration: 0.25) + transition.setAlpha(view: self.pageIndicatorBackgorund, alpha: 0.0) + }, queue: Queue.mainQueue()) + self.pageTimer?.start() + } func updateFontState(_ state: BrowserPresentationState.FontState) { } func updateFontState(_ state: BrowserPresentationState.FontState, force: Bool) { } + + func toggleInstantView(_ enabled: Bool) { + } private var findSession: Any? private var previousQuery: String? @@ -308,6 +336,29 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF let pdfViewFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: size.width - insets.left - insets.right, height: size.height - insets.top - bottomInset)) transition.setFrame(view: self.pdfView, frame: pdfViewFrame) + let pageIndicatorSize = self.pageIndicator.update( + transition: .immediate, + component: AnyComponent( + Text(text: "\(self.pageNumber?.0 ?? 1) of \(self.pageNumber?.1 ?? 1)", font: Font.with(size: 15.0, weight: .semibold, traits: .monospacedNumbers), color: self.presentationData.theme.list.itemSecondaryTextColor) + ), + environment: {}, + containerSize: size + ) + if let view = self.pageIndicator.view { + if view.superview == nil { + self.addSubview(self.pageIndicatorBackgorund) + self.pageIndicatorBackgorund.contentView.addSubview(view) + } + + let horizontalPadding: CGFloat = 10.0 + let verticalPadding: CGFloat = 8.0 + let pageBackgroundFrame = CGRect(origin: CGPoint(x: insets.left + 20.0, y: insets.top + 16.0), size: CGSize(width: horizontalPadding * 2.0 + pageIndicatorSize.width, height: verticalPadding * 2.0 + pageIndicatorSize.height)) + + self.pageIndicatorBackgorund.bounds = CGRect(origin: .zero, size: pageBackgroundFrame.size) + transition.setPosition(view: self.pageIndicatorBackgorund, position: pageBackgroundFrame.center) + view.frame = CGRect(origin: CGPoint(x: horizontalPadding, y: verticalPadding), size: pageIndicatorSize) + } + if isFirstTime { self.pdfView.setNeedsLayout() self.pdfView.layoutIfNeeded() @@ -370,9 +421,31 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF } if !scrollView.isZooming && !self.wasZooming { self.updateScrollingOffset(isReset: false, transition: .immediate) + + if let document = self.pdfView.document, let page = self.pdfView.currentPage { + let number = document.index(for: page) + 1 + if number != self.pageNumber?.0 { + self.pageNumber = (number, document.pageCount) + if let (size, insets, fullInsets) = self.validLayout { + self.updateLayout(size: size, insets: insets, fullInsets: fullInsets, safeInsets: .zero, transition: .immediate) + } + } + } } } + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + if let scrollViewDelegate = scrollView as? UIScrollViewDelegate { + scrollViewDelegate.scrollViewWillBeginDragging?(scrollView) + } + + let transition = ComponentTransition.easeInOut(duration: 0.1) + transition.setAlpha(view: self.pageIndicatorBackgorund, alpha: 1.0) + + self.pageTimer?.invalidate() + self.pageTimer = nil + } + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if let scrollViewDelegate = scrollView as? UIScrollViewDelegate { scrollViewDelegate.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) @@ -383,6 +456,8 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF if self.ignoreUpdatesUntilScrollingStopped { self.ignoreUpdatesUntilScrollingStopped = false } + + self.startPageIndicatorTimer() } } @@ -395,6 +470,8 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF if self.ignoreUpdatesUntilScrollingStopped { self.ignoreUpdatesUntilScrollingStopped = false } + + self.startPageIndicatorTimer() } private func updateScrollingOffset(isReset: Bool, transition: ComponentTransition) { @@ -449,7 +526,7 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF navigationController._keepModalDismissProgress = true navigationController.pushViewController(controller) } else { - self.pushContent(subject) + self.pushContent(subject, nil) } } diff --git a/submodules/BrowserUI/Sources/BrowserReadability.swift b/submodules/BrowserUI/Sources/BrowserReadability.swift new file mode 100644 index 0000000000..5c7975c087 --- /dev/null +++ b/submodules/BrowserUI/Sources/BrowserReadability.swift @@ -0,0 +1,585 @@ +import Foundation +import WebKit +import AppBundle +import Postbox +import TelegramCore +import InstantPageUI + +public class Readability: NSObject, WKNavigationDelegate { + private let url: URL + let webView: WKWebView + private let completionHandler: ((_ webPage: (TelegramMediaWebpage, [Any]?)?, _ error: Error?) -> Void) + private var hasRenderedReadabilityHTML = false + + private var subresources: [Any]? + + init(url: URL, archiveData: Data, completionHandler: @escaping (_ webPage: (TelegramMediaWebpage, [Any]?)?, _ error: Error?) -> Void) { + self.url = url + self.completionHandler = completionHandler + + let preferences = WKPreferences() + + let configuration = WKWebViewConfiguration() + configuration.preferences = preferences + configuration.userContentController.addUserScript(ReadabilityUserScript()) + + self.webView = WKWebView(frame: CGRect.zero, configuration: configuration) + + super.init() + + self.webView.configuration.suppressesIncrementalRendering = true + self.webView.navigationDelegate = self + if #available(iOS 16.4, *) { + self.webView.isInspectable = true + } + + if let (html, subresources) = extractHtmlString(from: archiveData) { + self.subresources = subresources + self.webView.loadHTMLString(html, baseURL: url.baseURL) + } + } + + private func initializeReadability(completion: @escaping (_ result: TelegramMediaWebpage?, _ error: Error?) -> Void) { + guard let readabilityInitializationJS = loadFile(name: "ReaderMode", type: "js") else { + return + } + + self.webView.evaluateJavaScript(readabilityInitializationJS) { (result, error) in + guard let result = result as? [String: Any] else { + completion(nil, error) + return + } + guard let page = parseJson(result, url: self.url.absoluteString) else { + return + } + completion(page, nil) + } + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + if !self.hasRenderedReadabilityHTML { + self.initializeReadability() { [weak self] (webPage: TelegramMediaWebpage?, error: Error?) in + guard let self else { + return + } + self.hasRenderedReadabilityHTML = true + guard let webPage else { + self.completionHandler(nil, error) + return + } + self.completionHandler((webPage, self.subresources), error) + } + } + } +} + +class ReadabilityUserScript: WKUserScript { + convenience override init() { + guard let js = loadFile(name: "Readability", type: "js") else { + fatalError() + } + self.init(source: js, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + } +} + +func loadFile(name: String, type: String) -> String? { + let bundle = getAppBundle() + guard let userScriptPath = bundle.path(forResource: name, ofType: type) else { + return nil + } + guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else { + return nil + } + guard let userScript = String(data: userScriptData, encoding: .utf8) else { + return nil + } + return userScript +} + +private func extractHtmlString(from webArchiveData: Data) -> (String, [Any]?)? { + if let webArchiveDict = try? PropertyListSerialization.propertyList(from: webArchiveData, format: nil) as? [String: Any], + let mainResource = webArchiveDict["WebMainResource"] as? [String: Any], + let htmlData = mainResource["WebResourceData"] as? Data { + + guard let htmlString = String(data: htmlData, encoding: .utf8) else { + return nil + } + return (htmlString, webArchiveDict["WebSubresources"] as? [Any]) + } + return nil +} + +private func parseJson(_ input: [String: Any], url: String) -> TelegramMediaWebpage? { + let siteName = input["siteName"] as? String + let title = input["title"] as? String + let byline = input["byline"] as? String + let excerpt = input["excerpt"] as? String + + var media: [MediaId: Media] = [:] + let blocks = parseContent(input, url, &media) + + guard !blocks.isEmpty else { + return nil + } + return TelegramMediaWebpage( + webpageId: MediaId(namespace: 0, id: 0), + content: .Loaded( + TelegramMediaWebpageLoadedContent( + url: url, + displayUrl: url, + hash: 0, + type: "article", + websiteName: siteName, + title: title, + text: excerpt, + embedUrl: nil, + embedType: nil, + embedSize: nil, + duration: nil, + author: byline, + isMediaLargeByDefault: nil, + image: nil, + file: nil, + story: nil, + attributes: [], + instantPage: InstantPage( + blocks: blocks, + media: media, + isComplete: true, + rtl: false, + url: url, + views: nil + ) + ) + ) + ) +} + +private func parseContent(_ input: [String: Any], _ url: String, _ media: inout [MediaId: Media]) -> [InstantPageBlock] { + let title = input["title"] as? String + let byline = input["byline"] as? String + let date = input["publishedTime"] as? String + + let _ = date + + guard let content = input["content"] as? [Any] else { + return [] + } + var blocks = parsePageBlocks(content, url, &media) + if case .header = blocks.first { + } else { + if var byline { + byline = byline.replacingOccurrences(of: "[\n\t]+", with: " ", options: .regularExpression, range: nil) + blocks.insert(.authorDate(author: trim(parseRichText(byline)), date: 0), at: 0) + } + if let title { + blocks.insert(.title(trim(parseRichText(title))), at: 0) + } + } + + return blocks +} + +private func parseRichText(_ input: String) -> RichText { + return .plain(input) +} + +private func parseRichText(_ input: [String: Any], _ media: inout [MediaId: Media]) -> RichText { + var text: RichText + if let string = input["content"] as? String { + text = parseRichText(string) + } else if let array = input["content"] as? [Any] { + text = parseRichText(array, &media) + } else { + text = .empty + } + text = applyAnchor(text, item: input) + if let _ = input["bold"] { + text = .bold(text) + } + if let _ = input["italic"] { + text = .italic(text) + } + return text +} + +private func parseRichText(_ input: [Any], _ media: inout [MediaId: Media]) -> RichText { + var result: [RichText] = [] + + for item in input { + if let string = item as? String { + result.append(parseRichText(string)) + } else if let item = item as? [String: Any], let tag = item["tag"] as? String { + var text: RichText? + switch tag { + case "b", "strong": + text = .bold(parseRichText(item, &media)) + case "i": + text = .italic(parseRichText(item, &media)) + case "s": + text = .strikethrough(parseRichText(item, &media)) + case "p": + text = parseRichText(item, &media) + case "a": + if let href = item["href"] as? String { + let telString = "tel:" + let mailtoString = "mailto:" + if href.hasPrefix("tel:") { + text = .phone(text: parseRichText(item, &media), phone: String(href[href.index(href.startIndex, offsetBy: telString.distance(from: telString.startIndex, to: telString.endIndex))...])) + } else if href.hasPrefix(mailtoString) { + text = .email(text: parseRichText(item, &media), email: String(href[href.index(href.startIndex, offsetBy: mailtoString.distance(from: mailtoString.startIndex, to: mailtoString.endIndex))...])) + } else { + text = .url(text: parseRichText(item, &media), url: href, webpageId: nil) + } + } else { + text = parseRichText(item, &media) + } + case "pre", "code": + text = .fixed(parseRichText(item, &media)) + case "mark": + text = .marked(parseRichText(item, &media)) + case "sub": + text = .subscript(parseRichText(item, &media)) + case "sup": + text = .superscript(parseRichText(item, &media)) + case "img": + if let src = item["src"] as? String, !src.isEmpty { + let width: Int32 + if let value = item["width"] as? String, let intValue = Int32(value) { + width = intValue + } else { + width = 0 + } + let height: Int32 + if let value = item["height"] as? String, let intValue = Int32(value) { + height = intValue + } else { + height = 0 + } + let id = MediaId(namespace: Namespaces.Media.CloudFile, id: Int64(media.count)) + media[id] = TelegramMediaImage( + imageId: id, + representations: [ + TelegramMediaImageRepresentation( + dimensions: PixelDimensions(width: width, height: height), + resource: InstantPageExternalMediaResource(url: src), + progressiveSizes: [], + immediateThumbnailData: nil + ) + ], + immediateThumbnailData: nil, + reference: nil, + partialReference: nil, + flags: [] + ) + text = .image(id: id, dimensions: PixelDimensions(width: width, height: height)) + } + case "br": + if let last = result.last { + result[result.count - 1] = addNewLine(last) + } + default: + text = parseRichText(item, &media) + } + if var text { + text = applyAnchor(text, item: item) + result.append(text) + } + } + } + + if !result.isEmpty { + return .concat(result) + } else if result.count == 1, let text = result.first { + return text + } else { + return .empty + } +} + +private func trimStart(_ input: RichText) -> RichText { + return input +} + +private func trimEnd(_ input: RichText) -> RichText { + return input +} + +private func trim(_ input: RichText) -> RichText { + return input +} + +private func addNewLine(_ input: RichText) -> RichText { + var text = input + switch input { + case .empty: + text = .empty + case let .plain(string): + text = .plain(string + "\n") + case let .bold(richText): + text = .bold(addNewLine(richText)) + case let .italic(richText): + text = .italic(addNewLine(richText)) + case let .underline(richText): + text = .underline(addNewLine(richText)) + case let .strikethrough(richText): + text = .strikethrough(addNewLine(richText)) + case let .fixed(richText): + text = .fixed(addNewLine(richText)) + case let .url(richText, url, webpageId): + text = .url(text: addNewLine(richText), url: url, webpageId: webpageId) + case let .email(richText, email): + text = .email(text: addNewLine(richText), email: email) + case let .subscript(richText): + text = .subscript(addNewLine(richText)) + case let .superscript(richText): + text = .superscript(addNewLine(richText)) + case let .marked(richText): + text = .marked(addNewLine(richText)) + case let .phone(richText, phone): + text = .phone(text: addNewLine(richText), phone: phone) + case let .anchor(richText, name): + text = .anchor(text: addNewLine(richText), name: name) + case var .concat(array): + array[array.count - 1] = addNewLine(array[array.count - 1]) + text = .concat(array) + case .image: + break + } + return text +} + +private func applyAnchor(_ input: RichText, item: [String: Any]) -> RichText { + guard let id = item["id"] as? String, !id.isEmpty else { + return input + } + return .anchor(text: input, name: id) +} + +private func parseTable(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock { + let title = (input["title"] as? String) ?? "" + return .table( + title: trim(applyAnchor(parseRichText(title), item: input)), + rows: parseTableRows((input["content"] as? [Any]) ?? [], &media), + bordered: true, + striped: true + ) +} + +private func parseTableRows(_ input: [Any], _ media: inout [MediaId: Media]) -> [InstantPageTableRow] { + var result: [InstantPageTableRow] = [] + for item in input { + if let item = item as? [String: Any] { + let tag = item["tag"] as? String + if tag == "tr" { + result.append(parseTableRow(item, &media)) + } else if let content = item["content"] as? [Any] { + result.append(contentsOf: parseTableRows(content, &media)) + } + } + } + return result +} + +private func parseTableRow(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageTableRow { + var cells: [InstantPageTableCell] = [] + + if let content = input["content"] as? [Any] { + for item in content { + guard let item = item as? [String: Any] else { + continue + } + let tag = item["tag"] as? String + guard ["td", "th"].contains(tag) else { + continue + } + var text: RichText? + if let content = item["content"] as? [Any] { + text = trim(parseRichText(content, &media)) + if let currentText = text { + if let _ = item["bold"] { + text = .bold(currentText) + } + if let _ = item["italic"] { + text = .italic(currentText) + } + } + } + cells.append(InstantPageTableCell( + text: text, + header: tag == "th", + alignment: item["xcenter"] != nil ? .center : .left, + verticalAlignment: .middle, + colspan: ((item["colspan"] as? String).flatMap { Int32($0) }) ?? 0, + rowspan: ((item["rowspan"] as? String).flatMap { Int32($0) }) ?? 0 + )) + } + } + + return InstantPageTableRow(cells: cells) +} + +private func parseDetails(_ item: [String: Any], _ url: String, _ media: inout [MediaId: Media]) -> InstantPageBlock? { + guard var content = item["contant"] as? [Any] else { + return nil + } + var title: RichText = .empty + var titleIndex: Int? + for i in 0 ..< content.count { + if let subitem = content[i] as? [String: Any], let tag = subitem["tag"] as? String, tag == "summary" { + title = trim(parseRichText(subitem, &media)) + titleIndex = i + break + } + } + if let titleIndex { + content.remove(at: titleIndex) + } + return .details( + title: title, + blocks: parsePageBlocks(content, url, &media), + expanded: item["open"] != nil + ) +} + +private func parseList(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock? { + guard let content = input["content"] as? [Any], let tag = input["tag"] as? String else { + return nil + } + var items: [InstantPageListItem] = [] + for item in content { + guard let item = item as? [String: Any], let tag = item["tag"] as? String, tag == "li" else { + continue + } + items.append(.text(trim(parseRichText(item, &media)), nil)) + } + let ordered = tag == "ol" + return .list(items: items, ordered: ordered) +} + +private func parseImage(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock? { + guard let src = input["src"] as? String else { + return nil + } + + let caption: InstantPageCaption + if let alt = input["alt"] as? String { + caption = InstantPageCaption( + text: trim(parseRichText(alt)), + credit: .empty + ) + } else { + caption = InstantPageCaption(text: .empty, credit: .empty) + } + + let width: Int32 + if let value = input["width"] as? String, let intValue = Int32(value) { + width = intValue + } else { + width = 0 + } + + let height: Int32 + if let value = input["height"] as? String, let intValue = Int32(value) { + height = intValue + } else { + height = 0 + } + + let id = MediaId(namespace: Namespaces.Media.CloudImage, id: Int64(media.count)) + media[id] = TelegramMediaImage( + imageId: id, + representations: [ + TelegramMediaImageRepresentation( + dimensions: PixelDimensions(width: width, height: height), + resource: InstantPageExternalMediaResource(url: src), + progressiveSizes: [], + immediateThumbnailData: nil + ) + ], + immediateThumbnailData: nil, + reference: nil, + partialReference: nil, + flags: [] + ) + + return .image( + id: id, + caption: caption, + url: nil, + webpageId: nil + ) +} + +private func parseFigure(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock? { + guard let content = input["content"] as? [Any] else { + return nil + } + var block: InstantPageBlock? + var caption: RichText? + for item in content { + if let item = item as? [String: Any], let tag = item["tag"] as? String { + if tag == "img" { + block = parseImage(item, &media) + } else if tag == "figurecaption" { + caption = trim(parseRichText(item, &media)) + } + } + } + guard var block else { + return nil + } + if let caption, case let .image(id, _, url, webpageId) = block { + block = .image(id: id, caption: InstantPageCaption(text: caption, credit: .empty), url: url, webpageId: webpageId) + } + return block +} + +private func parsePageBlocks(_ input: [Any], _ url: String, _ media: inout [MediaId: Media]) -> [InstantPageBlock] { + var result: [InstantPageBlock] = [] + for item in input { + if let string = item as? String { + result.append(.paragraph(parseRichText(string))) + } else if let item = item as? [String: Any], let tag = item["tag"] as? String { + let content = item["content"] as? [Any] + switch tag { + case "p": + result.append(.paragraph(trim(parseRichText(item, &media)))) + case "h1", "h2": + result.append(.header(trim(parseRichText(item, &media)))) + case "h3", "h4", "h5", "h6": + result.append(.subheader(trim(parseRichText(item, &media)))) + case "pre": + result.append(.preformatted(.fixed(trim(parseRichText(item, &media))))) + case "blockquote": + result.append(.blockQuote(text: .italic(trim(parseRichText(item, &media))), caption: .empty)) + case "img": + if let image = parseImage(item, &media) { + result.append(image) + } + break + case "figure": + if let figure = parseFigure(item, &media) { + result.append(figure) + } + case "table": + result.append(parseTable(item, &media)) + case "ul", "ol": + if let list = parseList(item, &media) { + result.append(list) + } + case "hr": + result.append(.divider) + case "details": + if let details = parseDetails(item, url, &media) { + result.append(details) + } + default: + if let content { + result.append(contentsOf: parsePageBlocks(content, url, &media)) + } + } + } + } + return result +} diff --git a/submodules/BrowserUI/Sources/BrowserScreen.swift b/submodules/BrowserUI/Sources/BrowserScreen.swift index 16bd42554c..704a3bcdf2 100644 --- a/submodules/BrowserUI/Sources/BrowserScreen.swift +++ b/submodules/BrowserUI/Sources/BrowserScreen.swift @@ -492,6 +492,7 @@ public class BrowserScreen: ViewController, MinimizableController { case increaseFontSize case resetFontSize case updateFontIsSerif(Bool) + case toggleInstantView(Bool) case addBookmark case openBookmarks case openAddressBar @@ -519,7 +520,7 @@ public class BrowserScreen: ViewController, MinimizableController { private var presentationData: PresentationData private var presentationDataDisposable: Disposable? private var validLayout: (ContainerViewLayout, CGFloat)? - + init(controller: BrowserScreen) { self.context = controller.context self.controller = controller @@ -755,6 +756,8 @@ public class BrowserScreen: ViewController, MinimizableController { return updatedState }) content.updateFontState(self.presentationState.fontState) + case let .toggleInstantView(enabled): + content.toggleInstantView(enabled) case .addBookmark: if let content = self.content.last { self.addBookmark(content.currentState.url, showArrow: true) @@ -822,7 +825,7 @@ public class BrowserScreen: ViewController, MinimizableController { self.requestLayout(transition: transition) } - func pushContent(_ content: BrowserScreen.Subject, transition: ComponentTransition) { + func pushContent(_ content: BrowserScreen.Subject, additionalContent: BrowserContent? = nil, transition: ComponentTransition) { let browserContent: BrowserContent switch content { case let .webPage(url): @@ -834,25 +837,37 @@ public class BrowserScreen: ViewController, MinimizableController { } browserContent = webContent self.controller?.preferredConfiguration = nil - case let .instantPage(webPage, anchor, sourceLocation): - let instantPageContent = BrowserInstantPageContent(context: self.context, presentationData: self.presentationData, webPage: webPage, anchor: anchor, url: webPage.content.url ?? "", sourceLocation: sourceLocation) + case let .instantPage(webPage, anchor, sourceLocation, preloadedResouces): + let instantPageContent = BrowserInstantPageContent(context: self.context, presentationData: self.presentationData, webPage: webPage, anchor: anchor, url: webPage.content.url ?? "", sourceLocation: sourceLocation, preloadedResouces: preloadedResouces, originalContent: additionalContent) instantPageContent.openPeer = { [weak self] peer in guard let self else { return } self.openPeer(peer) } + instantPageContent.restoreContent = { [weak self, weak instantPageContent] content in + guard let self, let instantPageContent else { + return + } + self.pushBrowserContent(content, additionalContent: instantPageContent, transition: .easeInOut(duration: 0.3).withUserData(NavigationStackComponent.CurlTransition.hide)) + } browserContent = instantPageContent case let .document(file, _): browserContent = BrowserDocumentContent(context: self.context, presentationData: self.presentationData, file: file) case let .pdfDocument(file, _): browserContent = BrowserPdfContent(context: self.context, presentationData: self.presentationData, file: file) } - browserContent.pushContent = { [weak self] content in + browserContent.pushContent = { [weak self] content, additionalContent in guard let self else { return } - self.pushContent(content, transition: .spring(duration: 0.4)) + var transition: ComponentTransition + if let _ = additionalContent { + transition = .easeInOut(duration: 0.3).withUserData(NavigationStackComponent.CurlTransition.show) + } else { + transition = .spring(duration: 0.4) + } + self.pushContent(content, additionalContent: additionalContent, transition: transition) } browserContent.openAppUrl = { [weak self] url in guard let self else { @@ -896,7 +911,15 @@ public class BrowserScreen: ViewController, MinimizableController { } } - self.content.append(browserContent) + self.pushBrowserContent(browserContent, additionalContent: additionalContent, transition: transition) + } + + func pushBrowserContent(_ browserContent: BrowserContent, additionalContent: BrowserContent? = nil, transition: ComponentTransition) { + if let additionalContent, let index = self.content.firstIndex(where: { $0 === additionalContent }) { + self.content[index] = browserContent + } else { + self.content.append(browserContent) + } self.requestLayout(transition: transition) self.setupContentStateUpdates() @@ -1050,16 +1073,20 @@ public class BrowserScreen: ViewController, MinimizableController { return } + guard let controller = self.controller, let content = self.content.last else { + return + } + if let animationComponentView = referenceView.componentView.view as? LottieComponent.View { animationComponentView.playOnce() } self.view.endEditing(true) - let checkIcon: (PresentationTheme) -> UIImage? = { theme in return generateTintedImage(image: UIImage(bundleImageName: "Instant View/Settings/Check"), color: theme.contextMenu.primaryColor) } - let emptyIcon: (PresentationTheme) -> UIImage? = { _ in - return nil - } +// let checkIcon: (PresentationTheme) -> UIImage? = { theme in return generateTintedImage(image: UIImage(bundleImageName: "Instant View/Settings/Check"), color: theme.contextMenu.primaryColor) } +// let emptyIcon: (PresentationTheme) -> UIImage? = { _ in +// return nil +// } let settings = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.webBrowserSettings]) |> take(1) @@ -1071,17 +1098,21 @@ public class BrowserScreen: ViewController, MinimizableController { } } - let _ = (settings - |> deliverOnMainQueue).start(next: { [weak self] settings in - guard let self, let controller = self.controller, let contentState = self.contentState, let layout = self.validLayout?.0 else { - return + let source: ContextContentSource = .reference(BrowserReferenceContentSource(controller: controller, sourceView: referenceView.referenceNode.view)) + + let items: Signal = combineLatest( + queue: Queue.mainQueue(), + settings, + content.state + ) + |> map { [weak self] settings, contentState -> ContextController.Items in + guard let self, let layout = self.validLayout?.0 else { + return ContextController.Items(content: .list([])) } - let source: ContextContentSource = .reference(BrowserReferenceContentSource(controller: controller, sourceView: referenceView.referenceNode.view)) - let performAction = self.performAction - let forceIsSerif = self.presentationState.fontState.isSerif +// let forceIsSerif = self.presentationState.fontState.isSerif let fontItem = BrowserFontSizeContextMenuItem( value: self.presentationState.fontState.size, decrease: { [weak self] in @@ -1140,16 +1171,20 @@ public class BrowserScreen: ViewController, MinimizableController { } else { items.append(.custom(fontItem, false)) + //TODO:localize - items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.InstantPage_FontSanFrancisco, icon: forceIsSerif ? emptyIcon : checkIcon, action: { (controller, action) in - performAction.invoke(.updateFontIsSerif(false)) - action(.default) - }))) - - items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.InstantPage_FontNewYork, textFont: .custom(font: Font.with(size: 17.0, design: .serif, traits: []), height: nil, verticalOffset: nil), icon: forceIsSerif ? checkIcon : emptyIcon, action: { (controller, action) in - performAction.invoke(.updateFontIsSerif(true)) - action(.default) - }))) + if case .webPage = contentState.contentType { + let isAvailable = contentState.hasInstantView + items.append(.action(ContextMenuActionItem(text: "Show Instant View", textColor: isAvailable ? .primary : .disabled, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Boost"), color: isAvailable ? theme.contextMenu.primaryColor : theme.contextMenu.primaryColor.withAlphaComponent(0.3)) }, action: isAvailable ? { (controller, action) in + performAction.invoke(.toggleInstantView(true)) + action(.default) + } : nil))) + } else if case .instantPage = contentState.contentType, contentState.isInnerInstantViewEnabled { + items.append(.action(ContextMenuActionItem(text: "Hide Instant View", textColor: .primary, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Boost"), color: theme.contextMenu.primaryColor) }, action: { (controller, action) in + performAction.invoke(.toggleInstantView(false)) + action(.default) + }))) + } } if !items.isEmpty { @@ -1190,10 +1225,11 @@ public class BrowserScreen: ViewController, MinimizableController { }))) } } - - let contextController = ContextController(presentationData: self.presentationData, source: source, items: .single(ContextController.Items(content: .list(items)))) - self.controller?.present(contextController, in: .window(.root)) - }) + return ContextController.Items(content: .list(items)) + } + + let contextController = ContextController(presentationData: self.presentationData, source: source, items: items) + self.controller?.present(contextController, in: .window(.root)) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { @@ -1434,7 +1470,7 @@ public class BrowserScreen: ViewController, MinimizableController { public enum Subject { case webPage(url: String) - case instantPage(webPage: TelegramMediaWebpage, anchor: String?, sourceLocation: InstantPageSourceLocation) + case instantPage(webPage: TelegramMediaWebpage, anchor: String?, sourceLocation: InstantPageSourceLocation, preloadedResources: [Any]?) case document(file: TelegramMediaFile, canShare: Bool) case pdfDocument(file: TelegramMediaFile, canShare: Bool) } diff --git a/submodules/BrowserUI/Sources/BrowserWebContent.swift b/submodules/BrowserUI/Sources/BrowserWebContent.swift index c2b9d1ae67..567111ff02 100644 --- a/submodules/BrowserUI/Sources/BrowserWebContent.swift +++ b/submodules/BrowserUI/Sources/BrowserWebContent.swift @@ -173,6 +173,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU private var presentationData: PresentationData let webView: WebView + var readability: Readability? private let errorView: ComponentHostView private var currentError: Error? @@ -191,7 +192,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU private let faviconDisposable = MetaDisposable() - var pushContent: (BrowserScreen.Subject) -> Void = { _ in } + var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in } var openAppUrl: (String) -> Void = { _ in } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } var minimize: () -> Void = { } @@ -238,7 +239,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU contentController.add(WeakScriptMessageHandler { message in handleScriptMessageImpl?(message) }, name: "performAction") - + configuration.userContentController = contentController configuration.applicationNameForUserAgent = computedUserAgent() } @@ -377,13 +378,36 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU self.webView.evaluateJavaScript(js) { _, _ in } } + func toggleInstantView(_ enabled: Bool) { + if enabled { + if let instantPage = self.instantPage { + self.pushContent(.instantPage(webPage: instantPage, anchor: nil, sourceLocation: InstantPageSourceLocation(userLocation: .other, peerType: .channel), preloadedResources: self.instantPageResources), self) + } else if let readability = self.readability { + readability.webView.frame = self.webView.frame + self.addSubview(readability.webView) + + var collapsedFrame = readability.webView.frame + collapsedFrame.size.height = 0.0 + readability.webView.clipsToBounds = true + readability.webView.layer.animateFrame(from: collapsedFrame, to: readability.webView.frame, duration: 0.3) + } + } else if let readability = self.readability { + var collapsedFrame = readability.webView.frame + collapsedFrame.size.height = 0.0 + readability.webView.layer.animateFrame(from: readability.webView.frame, to: collapsedFrame, duration: 0.3, removeOnCompletion: false, completion: { _ in + readability.webView.removeFromSuperview() + readability.webView.layer.removeAllAnimations() + }) + } + } + private var didSetupSearch = false private func setupSearch(completion: @escaping () -> Void) { guard !self.didSetupSearch else { completion() return } - + let bundle = getAppBundle() guard let scriptPath = bundle.path(forResource: "UIWebViewSearch", ofType: "js") else { return @@ -764,6 +788,9 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU } } + private var instantPage: TelegramMediaWebpage? + private var instantPageResources: [Any]? + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { if let _ = self.currentError { self.currentError = nil @@ -772,6 +799,10 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU } } self.updateFontState(self.currentFontState, force: true) + + self.readability = nil + self.instantPage = nil + self.instantPageResources = nil } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -780,6 +811,49 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU .withUpdatedForwardList(webView.backForwardList.forwardList.map { BrowserContentState.HistoryItem(webItem: $0) }) } self.parseFavicon() + + guard let url = URL(string: self._state.url) else { + return + } + + if #available(iOS 14.5, *) { + self.webView.createWebArchiveData { [weak self] result in + guard let self, case let .success(data) = result else { + return + } + let readability = Readability(url: url, archiveData: data, completionHandler: { [weak self] result, error in + guard let self else { + return + } + if let (webPage, resources) = result { + self.updateState {$0 + .withUpdatedHasInstantView(true) + } + self.instantPage = webPage + self.instantPageResources = resources + let _ = (updatedRemoteWebpage(postbox: self.context.account.postbox, network: self.context.account.network, accountPeerId: self.context.account.peerId, webPage: WebpageReference(TelegramMediaWebpage(webpageId: MediaId(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(url: self._state.url, displayUrl: "", hash: 0, type: nil, websiteName: nil, title: nil, text: nil, embedUrl: nil, embedType: nil, embedSize: nil, duration: nil, author: nil, isMediaLargeByDefault: nil, image: nil, file: nil, story: nil, attributes: [], instantPage: nil))))) + |> deliverOnMainQueue).start(next: { [weak self] webPage in + guard let self, let webPage, case let .Loaded(result) = webPage.content, let _ = result.instantPage else { + return + } +// let _ = self +// #if DEBUG +// +// #else + self.instantPage = webPage +// #endif + }) + } else { + self.instantPage = nil + self.instantPageResources = nil + self.updateState {$0 + .withUpdatedHasInstantView(false) + } + } + }) + self.readability = readability + } + } } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { @@ -951,7 +1025,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU navigationController.pushViewController(controller) return (controller.node.content.last as? BrowserWebContent)?.webView } else { - self.pushContent(subject) + self.pushContent(subject, nil) } return nil } diff --git a/submodules/InstantPageUI/BUILD b/submodules/InstantPageUI/BUILD index 6b849ce87a..52f8e2ec5f 100644 --- a/submodules/InstantPageUI/BUILD +++ b/submodules/InstantPageUI/BUILD @@ -26,6 +26,7 @@ swift_library( "//submodules/LocationResources:LocationResources", "//submodules/UndoUI:UndoUI", "//submodules/TranslateUI:TranslateUI", + "//submodules/Tuples:Tuples", ], visibility = [ "//visibility:public", diff --git a/submodules/InstantPageUI/Sources/InstantPageAnchorItem.swift b/submodules/InstantPageUI/Sources/InstantPageAnchorItem.swift index 3ba3e794a6..185d9fd4d5 100644 --- a/submodules/InstantPageUI/Sources/InstantPageAnchorItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageAnchorItem.swift @@ -27,7 +27,7 @@ public final class InstantPageAnchorItem: InstantPageItem { public func drawInTile(context: CGContext) { } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return nil } diff --git a/submodules/InstantPageUI/Sources/InstantPageArticleItem.swift b/submodules/InstantPageUI/Sources/InstantPageArticleItem.swift index 38d2a817e1..c602f5d974 100644 --- a/submodules/InstantPageUI/Sources/InstantPageArticleItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageArticleItem.swift @@ -36,7 +36,7 @@ public final class InstantPageArticleItem: InstantPageItem { self.hasRTL = hasRTL } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPageArticleNode(context: context, item: self, webPage: self.webPage, strings: strings, theme: theme, contentItems: self.contentItems, contentSize: self.contentSize, cover: self.cover, url: self.url, webpageId: self.webpageId, openUrl: openUrl) } diff --git a/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift b/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift index b1962eb684..f007db8530 100644 --- a/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift @@ -23,7 +23,7 @@ public final class InstantPageAudioItem: InstantPageItem { self.medias = [media] } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, openMedia: openMedia) } diff --git a/submodules/InstantPageUI/Sources/InstantPageContentNode.swift b/submodules/InstantPageUI/Sources/InstantPageContentNode.swift index cf0c2aafbd..71505c13ee 100644 --- a/submodules/InstantPageUI/Sources/InstantPageContentNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageContentNode.swift @@ -22,6 +22,7 @@ public final class InstantPageContentNode : ASDisplayNode { private let openUrl: (InstantPageUrlItem) -> Void private let activatePinchPreview: ((PinchSourceContainerNode) -> Void)? private let pinchPreviewFinished: ((InstantPageNode) -> Void)? + private let getPreloadedResource: (String) -> Data? var currentLayoutTiles: [InstantPageTile] = [] var currentLayoutItemsWithNodes: [InstantPageItem] = [] @@ -42,7 +43,7 @@ public final class InstantPageContentNode : ASDisplayNode { private var previousVisibleBounds: CGRect? - init(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, items: [InstantPageItem], contentSize: CGSize, inOverlayPanel: Bool = false, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void) { + init(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, items: [InstantPageItem], contentSize: CGSize, inOverlayPanel: Bool = false, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) { self.context = context self.strings = strings self.nameDisplayOrder = nameDisplayOrder @@ -55,6 +56,7 @@ public final class InstantPageContentNode : ASDisplayNode { self.pinchPreviewFinished = pinchPreviewFinished self.openPeer = openPeer self.openUrl = openUrl + self.getPreloadedResource = getPreloadedResource self.currentLayout = InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) self.contentSize = contentSize @@ -210,7 +212,7 @@ public final class InstantPageContentNode : ASDisplayNode { }, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { [weak self] expanded in self?.updateDetailsExpanded(detailsIndex, expanded) - }, currentExpandedDetails: self.currentExpandedDetails) { + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: self.getPreloadedResource) { newNode.frame = itemFrame newNode.updateLayout(size: itemFrame.size, transition: transition) if let topNode = topNode { diff --git a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift index e2e9a15e1e..b5a98b2eef 100644 --- a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift @@ -649,7 +649,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { self?.updateWebEmbedHeight(embedIndex, height) }, updateDetailsExpanded: { [weak self] expanded in self?.updateDetailsExpanded(detailsIndex, expanded) - }, currentExpandedDetails: self.currentExpandedDetails) { + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) { newNode.frame = itemFrame newNode.updateLayout(size: itemFrame.size, transition: transition) if let topNode = topNode { diff --git a/submodules/InstantPageUI/Sources/InstantPageDetailsItem.swift b/submodules/InstantPageUI/Sources/InstantPageDetailsItem.swift index a590813aa1..cbf3545fd5 100644 --- a/submodules/InstantPageUI/Sources/InstantPageDetailsItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageDetailsItem.swift @@ -39,12 +39,12 @@ public final class InstantPageDetailsItem: InstantPageItem { self.index = index } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { var expanded: Bool? if let expandedDetails = currentExpandedDetails, let currentlyExpanded = expandedDetails[self.index] { expanded = currentlyExpanded } - return InstantPageDetailsNode(context: context, sourceLocation: sourceLocation, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, item: self, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, openPeer: openPeer, openUrl: openUrl, currentlyExpanded: expanded, updateDetailsExpanded: updateDetailsExpanded) + return InstantPageDetailsNode(context: context, sourceLocation: sourceLocation, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, item: self, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, openPeer: openPeer, openUrl: openUrl, currentlyExpanded: expanded, updateDetailsExpanded: updateDetailsExpanded, getPreloadedResource: getPreloadedResource) } public func matchesAnchor(_ anchor: String) -> Bool { diff --git a/submodules/InstantPageUI/Sources/InstantPageDetailsNode.swift b/submodules/InstantPageUI/Sources/InstantPageDetailsNode.swift index db6a1f7445..667213af39 100644 --- a/submodules/InstantPageUI/Sources/InstantPageDetailsNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageDetailsNode.swift @@ -35,7 +35,7 @@ public final class InstantPageDetailsNode: ASDisplayNode, InstantPageNode { public var requestLayoutUpdate: ((Bool) -> Void)? - init(context: AccountContext, sourceLocation: InstantPageSourceLocation, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, item: InstantPageDetailsItem, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, currentlyExpanded: Bool?, updateDetailsExpanded: @escaping (Bool) -> Void) { + init(context: AccountContext, sourceLocation: InstantPageSourceLocation, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, item: InstantPageDetailsItem, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, currentlyExpanded: Bool?, updateDetailsExpanded: @escaping (Bool) -> Void, getPreloadedResource: @escaping (String) -> Data?) { self.context = context self.strings = strings self.nameDisplayOrder = nameDisplayOrder @@ -65,7 +65,7 @@ public final class InstantPageDetailsNode: ASDisplayNode, InstantPageNode { self.arrowNode = InstantPageDetailsArrowNode(color: theme.controlColor, open: self.expanded) self.separatorNode = ASDisplayNode() - self.contentNode = InstantPageContentNode(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, sourceLocation: sourceLocation, theme: theme, items: item.items, contentSize: CGSize(width: item.frame.width, height: item.frame.height - item.titleHeight), openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, openPeer: openPeer, openUrl: openUrl) + self.contentNode = InstantPageContentNode(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, sourceLocation: sourceLocation, theme: theme, items: item.items, contentSize: CGSize(width: item.frame.width, height: item.frame.height - item.titleHeight), openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, openPeer: openPeer, openUrl: openUrl, getPreloadedResource: getPreloadedResource) super.init() diff --git a/submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift b/submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift new file mode 100644 index 0000000000..f26d07a080 --- /dev/null +++ b/submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift @@ -0,0 +1,48 @@ +import Foundation +import Postbox +import TelegramCore +import PersistentStringHash + +public struct InstantPageExternalMediaResourceId { + public let url: String + + public var uniqueId: String { + return "instantpage-media-\(persistentHash32(self.url))" + } + + public var hashValue: Int { + return self.uniqueId.hashValue + } +} + +public class InstantPageExternalMediaResource: TelegramMediaResource { + public let url: String + + public var size: Int64? { + return nil + } + + public init(url: String) { + self.url = url + } + + public required init(decoder: PostboxDecoder) { + self.url = decoder.decodeStringForKey("u", orElse: "") + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeString(self.url, forKey: "u") + } + + public var id: MediaResourceId { + return MediaResourceId(InstantPageExternalMediaResourceId(url: self.url).uniqueId) + } + + public func isEqual(to: MediaResource) -> Bool { + if let to = to as? InstantPageExternalMediaResource { + return self.url == to.url + } else { + return false + } + } +} diff --git a/submodules/InstantPageUI/Sources/InstantPageFeedbackItem.swift b/submodules/InstantPageUI/Sources/InstantPageFeedbackItem.swift index 84d2350655..dcbcb72a98 100644 --- a/submodules/InstantPageUI/Sources/InstantPageFeedbackItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageFeedbackItem.swift @@ -20,7 +20,7 @@ public final class InstantPageFeedbackItem: InstantPageItem { self.webPage = webPage } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPageFeedbackNode(context: context, strings: strings, theme: theme, webPage: self.webPage, openUrl: openUrl) } diff --git a/submodules/InstantPageUI/Sources/InstantPageImageItem.swift b/submodules/InstantPageUI/Sources/InstantPageImageItem.swift index 86bfdbbfda..e29fcab2d7 100644 --- a/submodules/InstantPageUI/Sources/InstantPageImageItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageImageItem.swift @@ -44,8 +44,8 @@ public final class InstantPageImageItem: InstantPageItem { self.fit = fit } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { - return InstantPageImageNode(context: context, sourceLocation: sourceLocation, theme: theme, webPage: self.webPage, media: self.media, attributes: self.attributes, interactive: self.interactive, roundCorners: self.roundCorners, fit: self.fit, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished) + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { + return InstantPageImageNode(context: context, sourceLocation: sourceLocation, theme: theme, webPage: self.webPage, media: self.media, attributes: self.attributes, interactive: self.interactive, roundCorners: self.roundCorners, fit: self.fit, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, getPreloadedResource: getPreloadedResource) } public func matchesAnchor(_ anchor: String) -> Bool { diff --git a/submodules/InstantPageUI/Sources/InstantPageImageNode.swift b/submodules/InstantPageUI/Sources/InstantPageImageNode.swift index 3607bbdff3..3b9b354ae3 100644 --- a/submodules/InstantPageUI/Sources/InstantPageImageNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageImageNode.swift @@ -14,6 +14,7 @@ import LiveLocationPositionNode import AppBundle import TelegramUIPreferences import ContextUI +import Tuples private struct FetchControls { let fetch: (Bool) -> Void @@ -48,7 +49,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { private var themeUpdated: Bool = false - init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, attributes: [InstantPageImageAttribute], interactive: Bool, roundCorners: Bool, fit: Bool, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?) { + init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, attributes: [InstantPageImageAttribute], interactive: Bool, roundCorners: Bool, fit: Bool, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, getPreloadedResource: @escaping (String) -> Data?) { self.context = context self.theme = theme self.webPage = webPage @@ -72,51 +73,103 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { self.addSubnode(self.pinchContainerNode) if case let .image(image) = media.media, let largest = largestImageRepresentation(image.representations) { - let imageReference = ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image) - self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, photoReference: imageReference)) - - if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: image) { - self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start()) - } - - self.fetchControls = FetchControls(fetch: { [weak self] manual in - if let strongSelf = self { - strongSelf.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start()) + if let externalResource = largest.resource as? InstantPageExternalMediaResource { + var url = externalResource.url + if !url.hasPrefix("http") && !url.hasPrefix("https") { + url = "https:\(url)" } - }, cancel: { - chatMessagePhotoCancelInteractiveFetch(account: context.account, photoReference: imageReference) - }) - - if interactive { - self.statusDisposable.set((context.account.postbox.mediaBox.resourceStatus(largest.resource) |> deliverOnMainQueue).start(next: { [weak self] status in - displayLinkDispatcher.dispatch { - if let strongSelf = self { - strongSelf.fetchStatus = EngineMediaResource.FetchStatus(status) - strongSelf.updateFetchStatus() + let photoData: Signal, NoError> + if let preloadedData = getPreloadedResource(externalResource.url) { + photoData = .single(Tuple4(nil, preloadedData, .full, true)) + } else { + photoData = context.engine.resources.httpData(url: url, preserveExactUrl: true) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> map { data in + if let data { + return Tuple4(nil, data, .full, true) + } else { + return Tuple4(nil, nil, .full, false) } } - })) - - if media.url != nil { - self.linkIconNode.image = UIImage(bundleImageName: "Instant View/ImageLink") - self.pinchContainerNode.contentNode.addSubnode(self.linkIconNode) } - - self.pinchContainerNode.contentNode.addSubnode(self.statusNode) + self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData) + |> map { _, _, generate in + return generate + }) + } else { + let imageReference = ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image) + self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, photoReference: imageReference)) + + if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: image) { + self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start()) + } + + self.fetchControls = FetchControls(fetch: { [weak self] manual in + if let strongSelf = self { + strongSelf.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start()) + } + }, cancel: { + chatMessagePhotoCancelInteractiveFetch(account: context.account, photoReference: imageReference) + }) + + if interactive { + self.statusDisposable.set((context.account.postbox.mediaBox.resourceStatus(largest.resource) |> deliverOnMainQueue).start(next: { [weak self] status in + displayLinkDispatcher.dispatch { + if let strongSelf = self { + strongSelf.fetchStatus = EngineMediaResource.FetchStatus(status) + strongSelf.updateFetchStatus() + } + } + })) + + if media.url != nil { + self.linkIconNode.image = UIImage(bundleImageName: "Instant View/ImageLink") + self.pinchContainerNode.contentNode.addSubnode(self.linkIconNode) + } + + self.pinchContainerNode.contentNode.addSubnode(self.statusNode) + } } } else if case let .file(file) = media.media { - let fileReference = FileMediaReference.webPage(webPage: WebpageReference(webPage), media: file) - if file.mimeType.hasPrefix("image/") { - if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: file) { - _ = freeMediaFileInteractiveFetched(account: context.account, userLocation: sourceLocation.userLocation, fileReference: fileReference).start() + if let externalResource = file.resource as? InstantPageExternalMediaResource { + let photoData: Signal, NoError> + if let preloadedData = getPreloadedResource(externalResource.url) { + photoData = .single(Tuple4(nil, preloadedData, .full, true)) + } else { + photoData = context.engine.resources.httpData(url: externalResource.url, preserveExactUrl: true) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> map { data in + if let data { + return Tuple4(nil, data, .full, true) + } else { + return Tuple4(nil, nil, .full, false) + } + } } - self.imageNode.setSignal(instantPageImageFile(account: context.account, userLocation: sourceLocation.userLocation, fileReference: fileReference, fetched: true)) + self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData) + |> map { _, _, generate in + return generate + }) } else { - self.imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, videoReference: fileReference)) - } - if file.isVideo { - self.statusNode.transitionToState(.play(.white), animated: false, completion: {}) - self.pinchContainerNode.contentNode.addSubnode(self.statusNode) + let fileReference = FileMediaReference.webPage(webPage: WebpageReference(webPage), media: file) + if file.mimeType.hasPrefix("image/") { + if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: file) { + _ = freeMediaFileInteractiveFetched(account: context.account, userLocation: sourceLocation.userLocation, fileReference: fileReference).start() + } + self.imageNode.setSignal(instantPageImageFile(account: context.account, userLocation: sourceLocation.userLocation, fileReference: fileReference, fetched: true)) + } else { + self.imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, videoReference: fileReference)) + } + if file.isVideo { + self.statusNode.transitionToState(.play(.white), animated: false, completion: {}) + self.pinchContainerNode.contentNode.addSubnode(self.statusNode) + } } } else if case let .geo(map) = media.media { self.addSubnode(self.pinNode) diff --git a/submodules/InstantPageUI/Sources/InstantPageItem.swift b/submodules/InstantPageUI/Sources/InstantPageItem.swift index 9daf839f5c..c58ff351db 100644 --- a/submodules/InstantPageUI/Sources/InstantPageItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageItem.swift @@ -15,7 +15,7 @@ public protocol InstantPageItem { func matchesAnchor(_ anchor: String) -> Bool func drawInTile(context: CGContext) - func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? + func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? func matchesNode(_ node: InstantPageNode) -> Bool func linkSelectionRects(at point: CGPoint) -> [CGRect] diff --git a/submodules/InstantPageUI/Sources/InstantPageLayout.swift b/submodules/InstantPageUI/Sources/InstantPageLayout.swift index be4312b6e6..27d11e6dd0 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayout.swift @@ -884,9 +884,11 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, userLoc let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil) contentSize.height += closingSpacing - let feedbackItem = InstantPageFeedbackItem(frame: CGRect(x: 0.0, y: contentSize.height, width: boundingWidth, height: 40.0), webPage: webPage) - contentSize.height += feedbackItem.frame.height - items.append(feedbackItem) + if webPage.webpageId.id != 0 { + let feedbackItem = InstantPageFeedbackItem(frame: CGRect(x: 0.0, y: contentSize.height, width: boundingWidth, height: 40.0), webPage: webPage) + contentSize.height += feedbackItem.frame.height + items.append(feedbackItem) + } return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) } diff --git a/submodules/InstantPageUI/Sources/InstantPagePeerReferenceItem.swift b/submodules/InstantPageUI/Sources/InstantPagePeerReferenceItem.swift index a5f7adf60a..c429d9ca76 100644 --- a/submodules/InstantPageUI/Sources/InstantPagePeerReferenceItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPagePeerReferenceItem.swift @@ -26,7 +26,7 @@ public final class InstantPagePeerReferenceItem: InstantPageItem { self.rtl = rtl } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPagePeerReferenceNode(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, initialPeer: self.initialPeer, safeInset: self.safeInset, transparent: self.transparent, rtl: self.rtl, openPeer: openPeer) } diff --git a/submodules/InstantPageUI/Sources/InstantPagePlayableVideoItem.swift b/submodules/InstantPageUI/Sources/InstantPagePlayableVideoItem.swift index 778b703305..fdfa50afa8 100644 --- a/submodules/InstantPageUI/Sources/InstantPagePlayableVideoItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPagePlayableVideoItem.swift @@ -28,7 +28,7 @@ public final class InstantPagePlayableVideoItem: InstantPageItem { self.interactive = interactive } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPagePlayableVideoNode(context: context, userLocation: sourceLocation.userLocation, webPage: self.webPage, theme: theme, media: self.media, interactive: self.interactive, openMedia: openMedia) } diff --git a/submodules/InstantPageUI/Sources/InstantPageReferenceControllerNode.swift b/submodules/InstantPageUI/Sources/InstantPageReferenceControllerNode.swift index 9ef8a460f5..887574d31a 100644 --- a/submodules/InstantPageUI/Sources/InstantPageReferenceControllerNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageReferenceControllerNode.swift @@ -203,7 +203,7 @@ class InstantPageReferenceControllerNode: ViewControllerTracingNode, ASScrollVie let sideInset: CGFloat = 16.0 let (_, items, contentSize) = layoutTextItemWithString(self.anchorText, boundingWidth: width - sideInset * 2.0, offset: CGPoint(x: sideInset, y: sideInset), media: media, webpage: self.webPage) - let contentNode = InstantPageContentNode(context: self.context, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, sourceLocation: self.sourceLocation, theme: self.theme, items: items, contentSize: CGSize(width: width, height: contentSize.height), inOverlayPanel: true, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in }) + let contentNode = InstantPageContentNode(context: self.context, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, sourceLocation: self.sourceLocation, theme: self.theme, items: items, contentSize: CGSize(width: width, height: contentSize.height), inOverlayPanel: true, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in }, getPreloadedResource: { url in return nil}) transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: titleAreaHeight), size: CGSize(width: width, height: contentSize.height))) self.contentContainerNode.insertSubnode(contentNode, at: 0) self.contentNode = contentNode diff --git a/submodules/InstantPageUI/Sources/InstantPageShapeItem.swift b/submodules/InstantPageUI/Sources/InstantPageShapeItem.swift index 0d463b3d90..c7510a5c96 100644 --- a/submodules/InstantPageUI/Sources/InstantPageShapeItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageShapeItem.swift @@ -61,7 +61,7 @@ public final class InstantPageShapeItem: InstantPageItem { return false } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return nil } diff --git a/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift b/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift index d812d7e85e..d9a39cabfc 100644 --- a/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift @@ -20,7 +20,7 @@ final class InstantPageSlideshowItem: InstantPageItem { self.medias = medias } - func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPageSlideshowNode(context: context, sourceLocation: sourceLocation, theme: theme, webPage: webPage, medias: self.medias, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished) } diff --git a/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift b/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift index 5551d7343b..6baa616027 100644 --- a/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift @@ -187,7 +187,7 @@ private final class InstantPageSlideshowPagerNode: ASDisplayNode, ASScrollViewDe let media = self.items[index] let contentNode: ASDisplayNode if case .image = media.media { - contentNode = InstantPageImageNode(context: self.context, sourceLocation: self.sourceLocation, theme: self.theme, webPage: self.webPage, media: media, attributes: [], interactive: true, roundCorners: false, fit: false, openMedia: self.openMedia, longPressMedia: self.longPressMedia, activatePinchPreview: self.activatePinchPreview, pinchPreviewFinished: self.pinchPreviewFinished) + contentNode = InstantPageImageNode(context: self.context, sourceLocation: self.sourceLocation, theme: self.theme, webPage: self.webPage, media: media, attributes: [], interactive: true, roundCorners: false, fit: false, openMedia: self.openMedia, longPressMedia: self.longPressMedia, activatePinchPreview: self.activatePinchPreview, pinchPreviewFinished: self.pinchPreviewFinished, getPreloadedResource: { _ in return nil }) } else if case .file = media.media { contentNode = ASDisplayNode() } else { diff --git a/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift b/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift index bc552dc995..30bdf1c6d9 100644 --- a/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift @@ -198,7 +198,7 @@ final class InstantPageSubContentNode : ASDisplayNode { }, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { [weak self] expanded in self?.updateDetailsExpanded(detailsIndex, expanded) - }, currentExpandedDetails: self.currentExpandedDetails) { + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) { newNode.frame = itemFrame newNode.updateLayout(size: itemFrame.size, transition: transition) if let topNode = topNode { diff --git a/submodules/InstantPageUI/Sources/InstantPageTableItem.swift b/submodules/InstantPageUI/Sources/InstantPageTableItem.swift index 01895ba2ac..8be13ed574 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTableItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTableItem.swift @@ -199,12 +199,12 @@ public final class InstantPageTableItem: InstantPageScrollableItem { return false } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { var additionalNodes: [InstantPageNode] = [] for cell in self.cells { for item in cell.additionalItems { if item.wantsNode { - if let node = item.node(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, sourceLocation: sourceLocation, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in}, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { _ in }, currentExpandedDetails: nil) { + if let node = item.node(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, sourceLocation: sourceLocation, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in}, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { _ in }, currentExpandedDetails: nil, getPreloadedResource: getPreloadedResource) { node.frame = item.frame.offsetBy(dx: cell.frame.minX, dy: cell.frame.minY) additionalNodes.append(node) } diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index af427754de..a26675e595 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -435,7 +435,7 @@ public final class InstantPageTextItem: InstantPageItem { return false } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return nil } @@ -484,11 +484,11 @@ final class InstantPageScrollableTextItem: InstantPageScrollableItem { context.restoreGState() } - func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { var additionalNodes: [InstantPageNode] = [] for item in additionalItems { if item.wantsNode { - if let node = item.node(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, sourceLocation: sourceLocation, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in}, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { _ in }, currentExpandedDetails: nil) { + if let node = item.node(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, sourceLocation: sourceLocation, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in}, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { _ in }, currentExpandedDetails: nil, getPreloadedResource: getPreloadedResource) { node.frame = item.frame additionalNodes.append(node) } diff --git a/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift b/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift index 335b66684f..413ff55840 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift @@ -100,12 +100,12 @@ final class InstantPageTextStyleStack { } case .subscript: if baselineOffset == nil { - baselineOffset = 0.35 + baselineOffset = -0.35 underline = false } case .superscript: if baselineOffset == nil { - baselineOffset = -0.35 + baselineOffset = 0.35 } case let .markerColor(color): if markerColor == nil { diff --git a/submodules/InstantPageUI/Sources/InstantPageWebEmbedItem.swift b/submodules/InstantPageUI/Sources/InstantPageWebEmbedItem.swift index b8863934f5..ef16f96043 100644 --- a/submodules/InstantPageUI/Sources/InstantPageWebEmbedItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageWebEmbedItem.swift @@ -24,7 +24,7 @@ public final class InstantPageWebEmbedItem: InstantPageItem { self.enableScrolling = enableScrolling } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPageWebEmbedNode(frame: self.frame, url: self.url, html: self.html, enableScrolling: self.enableScrolling, updateWebEmbedHeight: updateWebEmbedHeight) } diff --git a/submodules/PremiumUI/Sources/GiveawayInfoController.swift b/submodules/PremiumUI/Sources/GiveawayInfoController.swift index 811ecfd5d4..9893b76288 100644 --- a/submodules/PremiumUI/Sources/GiveawayInfoController.swift +++ b/submodules/PremiumUI/Sources/GiveawayInfoController.swift @@ -81,11 +81,21 @@ public func presentGiveawayInfoController( onlyNewSubscribers = true } - let author = message.forwardInfo?.author ?? message.author?._asPeer() + var author = message.forwardInfo?.author ?? message.author?._asPeer() + if author is TelegramChannel { + } else { + if let peer = message.forwardInfo?.source ?? message.peers[message.id.peerId] { + author = peer + } + } var isGroup = false if let channel = author as? TelegramChannel, case .group = channel.info { isGroup = true } + var peerName = "" + if let author { + peerName = EnginePeer(author).compactDisplayTitle + } var groupsAndChannels = false var channelsCount: Int32 = 1 @@ -113,10 +123,7 @@ public func presentGiveawayInfoController( let presentationData = context.sharedContext.currentPresentationData.with { $0 } - var peerName = "" - if let channel = author as? TelegramChannel { - peerName = EnginePeer(channel).compactDisplayTitle - } + let timeZone = TimeZone.current let untilDate = stringForDate(timestamp: untilDateValue, timeZone: timeZone, strings: presentationData.strings) diff --git a/submodules/TelegramCore/Sources/Network/FetchHttpResource.swift b/submodules/TelegramCore/Sources/Network/FetchHttpResource.swift index e7f122d904..6133e8798c 100644 --- a/submodules/TelegramCore/Sources/Network/FetchHttpResource.swift +++ b/submodules/TelegramCore/Sources/Network/FetchHttpResource.swift @@ -3,8 +3,12 @@ import Postbox import SwiftSignalKit import MtProtoKit -public func fetchHttpResource(url: String) -> Signal { - if let urlString = url.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed), let url = URL(string: urlString) { +public func fetchHttpResource(url: String, preserveExactUrl: Bool = false) -> Signal { + var urlString: String? = url + if !preserveExactUrl { + urlString = url.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) + } + if let urlString, let url = URL(string: urlString) { let signal = MTHttpRequestOperation.data(forHttpUrl: url)! return Signal { subscriber in subscriber.putNext(.reset) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift b/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift index ded57ff0d6..9423e2c582 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift @@ -341,8 +341,8 @@ public extension TelegramEngine { } } - public func httpData(url: String) -> Signal { - return fetchHttpResource(url: url) + public func httpData(url: String, preserveExactUrl: Bool = false) -> Signal { + return fetchHttpResource(url: url, preserveExactUrl: preserveExactUrl) |> mapError { _ -> EngineMediaResource.Fetch.Error in return .generic } diff --git a/submodules/TelegramCore/Sources/WebpagePreview.swift b/submodules/TelegramCore/Sources/WebpagePreview.swift index 21f7e46032..d1c6064370 100644 --- a/submodules/TelegramCore/Sources/WebpagePreview.swift +++ b/submodules/TelegramCore/Sources/WebpagePreview.swift @@ -262,7 +262,7 @@ public func actualizedWebpage(account: Account, webpage: TelegramMediaWebpage) - } } -func updatedRemoteWebpage(postbox: Postbox, network: Network, accountPeerId: EnginePeer.Id, webPage: WebpageReference) -> Signal { +public func updatedRemoteWebpage(postbox: Postbox, network: Network, accountPeerId: EnginePeer.Id, webPage: WebpageReference) -> Signal { if case let .webPage(id, url) = webPage.content { return network.request(Api.functions.messages.getWebPage(url: url, hash: 0)) |> map(Optional.init) @@ -270,14 +270,20 @@ func updatedRemoteWebpage(postbox: Postbox, network: Network, accountPeerId: Eng return .single(nil) } |> mapToSignal { result -> Signal in - if let result = result, case let .webPage(webpage, chats, users) = result, let updatedWebpage = telegramMediaWebpageFromApiWebpage(webpage), case .Loaded = updatedWebpage.content, updatedWebpage.webpageId.id == id { - return postbox.transaction { transaction -> TelegramMediaWebpage? in - let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users) - updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: parsedPeers) - if transaction.getMedia(updatedWebpage.webpageId) != nil { - updateMessageMedia(transaction: transaction, id: updatedWebpage.webpageId, media: updatedWebpage) + if let result = result, case let .webPage(webpage, chats, users) = result, let updatedWebpage = telegramMediaWebpageFromApiWebpage(webpage), case .Loaded = updatedWebpage.content { + if updatedWebpage.webpageId.id == id { + return postbox.transaction { transaction -> TelegramMediaWebpage? in + let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users) + updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: parsedPeers) + if transaction.getMedia(updatedWebpage.webpageId) != nil { + updateMessageMedia(transaction: transaction, id: updatedWebpage.webpageId, media: updatedWebpage) + } + return updatedWebpage } - return updatedWebpage + } else if id == 0 { + return .single(updatedWebpage) + } else { + return .single(nil) } } else { return .single(nil) diff --git a/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift b/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift index 0a0a12c4a4..e7a795459f 100644 --- a/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift +++ b/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift @@ -79,6 +79,11 @@ private final class NavigationContainer: UIView, UIGestureRecognizerDelegate { } public final class NavigationStackComponent: Component { + public enum CurlTransition { + case show + case hide + } + public let items: [AnyComponentWithIdentity] public let requestPop: () -> Void @@ -105,7 +110,7 @@ public final class NavigationStackComponent: Compon super.init(frame: frame) self.dimView.alpha = 0.0 - self.dimView.backgroundColor = UIColor.black.withAlphaComponent(0.2) + self.dimView.backgroundColor = UIColor.black.withAlphaComponent(0.3) self.dimView.isUserInteractionEnabled = false self.addSubview(self.dimView) } @@ -166,11 +171,20 @@ public final class NavigationStackComponent: Compon self.component = component self.state = state + var transition = transition + var curlTransition: NavigationStackComponent.CurlTransition? + if let curlTransitionValue = transition.userData(NavigationStackComponent.CurlTransition.self) { + transition = .immediate + curlTransition = curlTransitionValue + } + let navigationTransitionFraction = self.navigationContainer.transitionFraction self.navigationContainer.isNavigationEnabled = component.items.count > 1 var validItemIds: [AnyHashable] = [] + var removeImpl: (() -> Void)? + var readyItems: [ReadyItem] = [] for i in 0 ..< component.items.count { let item = component.items[i] @@ -184,6 +198,7 @@ public final class NavigationStackComponent: Compon } else { itemTransition = itemTransition.withAnimation(.none) itemView = ItemView() + itemView.clipsToBounds = true self.itemViews[itemId] = itemView itemView.contents.parentState = state } @@ -242,7 +257,18 @@ public final class NavigationStackComponent: Compon readyItem.itemTransition.setFrame(view: readyItem.itemView.dimView, frame: CGRect(origin: .zero, size: availableSize)) readyItem.itemTransition.setAlpha(view: readyItem.itemView.dimView, alpha: 1.0 - alphaTransitionFraction) - if readyItem.index > 0 && isAdded { + if curlTransition == .show && isAdded { + var fromFrame = itemFrame + fromFrame.size.height = 0.0 + let transition = ComponentTransition.easeInOut(duration: 0.3) + transition.animateBoundsSize(view: readyItem.itemView, from: fromFrame.size, to: itemFrame.size, completion: { _ in + removeImpl?() + }) + transition.animatePosition(view: readyItem.itemView, from: fromFrame.center, to: itemFrame.center) + } else if curlTransition == .hide && isAdded { + let transition = ComponentTransition.easeInOut(duration: 0.3) + transition.animateAlpha(view: readyItem.itemView.dimView, from: 1.0, to: 0.0) + } else if readyItem.index > 0 && isAdded { transition.animatePosition(view: itemComponentView, from: CGPoint(x: itemFrame.width, y: 0.0), to: .zero, additive: true, completion: nil) } } @@ -263,23 +289,49 @@ public final class NavigationStackComponent: Compon removedItemIds.append(id) } } - for id in removedItemIds { - guard let itemView = self.itemViews[id] else { - continue - } - if let itemComponeentView = itemView.contents.view { - var position = itemComponeentView.center - position.x += itemComponeentView.bounds.width - transition.setPosition(view: itemComponeentView, position: position, completion: { _ in + + removeImpl = { + for id in removedItemIds { + guard let itemView = self.itemViews[id] else { + continue + } + if let itemComponentView = itemView.contents.view, curlTransition != .show { + if curlTransition == .hide { + itemView.superview?.bringSubviewToFront(itemView) + var toFrame = itemView.frame + toFrame.size.height = 0.0 + let transition = ComponentTransition.easeInOut(duration: 0.3) + transition.setFrame(view: itemView, frame: toFrame, completion: { _ in + itemView.removeFromSuperview() + self.itemViews.removeValue(forKey: id) + }) + } else { + var position = itemComponentView.center + position.x += itemComponentView.bounds.width + transition.setPosition(view: itemComponentView, position: position, completion: { _ in + itemView.removeFromSuperview() + self.itemViews.removeValue(forKey: id) + }) + } + } else { itemView.removeFromSuperview() self.itemViews.removeValue(forKey: id) - }) - } else { - itemView.removeFromSuperview() - self.itemViews.removeValue(forKey: id) + } } } + if curlTransition == .show { + let transition = ComponentTransition.easeInOut(duration: 0.3) + for id in removedItemIds { + guard let itemView = self.itemViews[id] else { + continue + } + transition.setAlpha(view: itemView.dimView, alpha: 1.0) + } + } else { + removeImpl?() + } + let contentSize = CGSize(width: availableSize.width, height: contentHeight) self.navigationContainer.frame = CGRect(origin: .zero, size: contentSize) diff --git a/submodules/TelegramUI/Resources/Readability/Readability.js b/submodules/TelegramUI/Resources/Readability/Readability.js new file mode 100644 index 0000000000..535afffd4d --- /dev/null +++ b/submodules/TelegramUI/Resources/Readability/Readability.js @@ -0,0 +1,2754 @@ +/* + * Copyright (c) 2010 Arc90 Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * This code is heavily based on Arc90's readability.js (1.7.1) script + * available at: http://code.google.com/p/arc90labs-readability + */ + +/** + * Public constructor. + * @param {HTMLDocument} doc The document to parse. + * @param {Object} options The options object. + */ +function Readability(doc, options) { + // In some older versions, people passed a URI as the first argument. Cope: + if (options && options.documentElement) { + doc = options; + options = arguments[2]; + } else if (!doc || !doc.documentElement) { + throw new Error( + "First argument to Readability constructor should be a document object." + ); + } + options = options || {}; + + this._doc = doc; + this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__; + this._articleTitle = null; + this._articleByline = null; + this._articleDir = null; + this._articleSiteName = null; + this._attempts = []; + this._metadata = {}; + + // Configurable options + this._debug = !!options.debug; + this._maxElemsToParse = + options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE; + this._nbTopCandidates = + options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES; + this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD; + this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat( + options.classesToPreserve || [] + ); + this._keepClasses = !!options.keepClasses; + this._serializer = + options.serializer || + function (el) { + return el.innerHTML; + }; + this._disableJSONLD = !!options.disableJSONLD; + this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos; + this._linkDensityModifier = options.linkDensityModifier || 0; + + // Start with all flags set + this._flags = + this.FLAG_STRIP_UNLIKELYS | + this.FLAG_WEIGHT_CLASSES | + this.FLAG_CLEAN_CONDITIONALLY; + + // Control whether log messages are sent to the console + if (this._debug) { + let logNode = function (node) { + if (node.nodeType == node.TEXT_NODE) { + return `${node.nodeName} ("${node.textContent}")`; + } + let attrPairs = Array.from(node.attributes || [], function (attr) { + return `${attr.name}="${attr.value}"`; + }).join(" "); + return `<${node.localName} ${attrPairs}>`; + }; + this.log = function () { + if (typeof console !== "undefined") { + let args = Array.from(arguments, arg => { + if (arg && arg.nodeType == this.ELEMENT_NODE) { + return logNode(arg); + } + return arg; + }); + args.unshift("Reader: (Readability)"); + // eslint-disable-next-line no-console + console.log(...args); + } else if (typeof dump !== "undefined") { + /* global dump */ + var msg = Array.prototype.map + .call(arguments, function (x) { + return x && x.nodeName ? logNode(x) : x; + }) + .join(" "); + dump("Reader: (Readability) " + msg + "\n"); + } + }; + } else { + this.log = function () {}; + } +} + +Readability.prototype = { + FLAG_STRIP_UNLIKELYS: 0x1, + FLAG_WEIGHT_CLASSES: 0x2, + FLAG_CLEAN_CONDITIONALLY: 0x4, + + // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + ELEMENT_NODE: 1, + TEXT_NODE: 3, + + // Max number of nodes supported by this parser. Default: 0 (no limit) + DEFAULT_MAX_ELEMS_TO_PARSE: 0, + + // The number of top candidates to consider when analysing how + // tight the competition is among candidates. + DEFAULT_N_TOP_CANDIDATES: 5, + + // Element tags to score by default. + DEFAULT_TAGS_TO_SCORE: "section,h2,h3,h4,h5,h6,p,td,pre" + .toUpperCase() + .split(","), + + // The default number of chars an article must have in order to return a result + DEFAULT_CHAR_THRESHOLD: 500, + + // All of the regular expressions in use within readability. + // Defined up here so we don't instantiate them repeatedly in loops. + REGEXPS: { + // NOTE: These two regular expressions are duplicated in + // Readability-readerable.js. Please keep both copies in sync. + unlikelyCandidates: + /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, + okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i, + + positive: + /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i, + negative: + /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i, + extraneous: + /print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i, + byline: /byline|author|dateline|writtenby|p-author/i, + replaceFonts: /<(\/?)font[^>]*>/gi, + normalize: /\s{2,}/g, + videos: + /\/\/(www\.)?((dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq)\.com|(archive|upload\.wikimedia)\.org|player\.twitch\.tv)/i, + shareElements: /(\b|_)(share|sharedaddy)(\b|_)/i, + nextLink: /(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i, + prevLink: /(prev|earl|old|new|<|«)/i, + tokenize: /\W+/g, + whitespace: /^\s*$/, + hasContent: /\S$/, + hashUrl: /^#.+/, + srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g, + b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i, + // Commas as used in Latin, Sindhi, Chinese and various other scripts. + // see: https://en.wikipedia.org/wiki/Comma#Comma_variants + commas: /\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g, + // See: https://schema.org/Article + jsonLdArticleTypes: + /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/, + // used to see if a node's content matches words commonly used for ad blocks or loading indicators + adWords: + /^(ad(vertising|vertisement)?|pub(licité)?|werb(ung)?|广告|Реклама|Anuncio)$/iu, + loadingWords: + /^((loading|正在加载|Загрузка|chargement|cargando)(…|\.\.\.)?)$/iu, + }, + + UNLIKELY_ROLES: [ + "menu", + "menubar", + "complementary", + "navigation", + "alert", + "alertdialog", + "dialog", + ], + + DIV_TO_P_ELEMS: new Set([ + "BLOCKQUOTE", + "DL", + "DIV", + "IMG", + "OL", + "P", + "PRE", + "TABLE", + "UL", + ]), + + ALTER_TO_DIV_EXCEPTIONS: ["DIV", "ARTICLE", "SECTION", "P"], + + PRESENTATIONAL_ATTRIBUTES: [ + "align", + "background", + "bgcolor", + "border", + "cellpadding", + "cellspacing", + "frame", + "hspace", + "rules", + "style", + "valign", + "vspace", + ], + + DEPRECATED_SIZE_ATTRIBUTE_ELEMS: ["TABLE", "TH", "TD", "HR", "PRE"], + + // The commented out elements qualify as phrasing content but tend to be + // removed by readability when put into paragraphs, so we ignore them here. + PHRASING_ELEMS: [ + // "CANVAS", "IFRAME", "SVG", "VIDEO", + "ABBR", + "AUDIO", + "B", + "BDO", + "BR", + "BUTTON", + "CITE", + "CODE", + "DATA", + "DATALIST", + "DFN", + "EM", + "EMBED", + "I", + "IMG", + "INPUT", + "KBD", + "LABEL", + "MARK", + "MATH", + "METER", + "NOSCRIPT", + "OBJECT", + "OUTPUT", + "PROGRESS", + "Q", + "RUBY", + "SAMP", + "SCRIPT", + "SELECT", + "SMALL", + "SPAN", + "STRONG", + "SUB", + "SUP", + "TEXTAREA", + "TIME", + "VAR", + "WBR", + ], + + // These are the classes that readability sets itself. + CLASSES_TO_PRESERVE: ["page"], + + // These are the list of HTML entities that need to be escaped. + HTML_ESCAPE_MAP: { + lt: "<", + gt: ">", + amp: "&", + quot: '"', + apos: "'", + }, + + /** + * Run any post-process modifications to article content as necessary. + * + * @param Element + * @return void + **/ + _postProcessContent(articleContent) { + // Readability cannot open relative uris so we convert them to absolute uris. + this._fixRelativeUris(articleContent); + + this._simplifyNestedElements(articleContent); + + if (!this._keepClasses) { + // Remove classes. + this._cleanClasses(articleContent); + } + }, + + /** + * Iterates over a NodeList, calls `filterFn` for each node and removes node + * if function returned `true`. + * + * If function is not passed, removes all the nodes in node list. + * + * @param NodeList nodeList The nodes to operate on + * @param Function filterFn the function to use as a filter + * @return void + */ + _removeNodes(nodeList, filterFn) { + // Avoid ever operating on live node lists. + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _removeNodes"); + } + for (var i = nodeList.length - 1; i >= 0; i--) { + var node = nodeList[i]; + var parentNode = node.parentNode; + if (parentNode) { + if (!filterFn || filterFn.call(this, node, i, nodeList)) { + parentNode.removeChild(node); + } + } + } + }, + + /** + * Iterates over a NodeList, and calls _setNodeTag for each node. + * + * @param NodeList nodeList The nodes to operate on + * @param String newTagName the new tag name to use + * @return void + */ + _replaceNodeTags(nodeList, newTagName) { + // Avoid ever operating on live node lists. + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _replaceNodeTags"); + } + for (const node of nodeList) { + this._setNodeTag(node, newTagName); + } + }, + + /** + * Iterate over a NodeList, which doesn't natively fully implement the Array + * interface. + * + * For convenience, the current object context is applied to the provided + * iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return void + */ + _forEachNode(nodeList, fn) { + Array.prototype.forEach.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, and return the first node that passes + * the supplied test function + * + * For convenience, the current object context is applied to the provided + * test function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The test function. + * @return void + */ + _findNode(nodeList, fn) { + return Array.prototype.find.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if any of the provided iterate + * function calls returns true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _someNode(nodeList, fn) { + return Array.prototype.some.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if all of the provided iterate + * function calls return true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _everyNode(nodeList, fn) { + return Array.prototype.every.call(nodeList, fn, this); + }, + + _getAllNodesWithTag(node, tagNames) { + if (node.querySelectorAll) { + return node.querySelectorAll(tagNames.join(",")); + } + return [].concat.apply( + [], + tagNames.map(function (tag) { + var collection = node.getElementsByTagName(tag); + return Array.isArray(collection) ? collection : Array.from(collection); + }) + ); + }, + + /** + * Removes the class="" attribute from every element in the given + * subtree, except those that match CLASSES_TO_PRESERVE and + * the classesToPreserve array from the options object. + * + * @param Element + * @return void + */ + _cleanClasses(node) { + var classesToPreserve = this._classesToPreserve; + var className = (node.getAttribute("class") || "") + .split(/\s+/) + .filter(cls => classesToPreserve.includes(cls)) + .join(" "); + + if (className) { + node.setAttribute("class", className); + } else { + node.removeAttribute("class"); + } + + for (node = node.firstElementChild; node; node = node.nextElementSibling) { + this._cleanClasses(node); + } + }, + + /** + * Converts each and uri in the given element to an absolute URI, + * ignoring #ref URIs. + * + * @param Element + * @return void + */ + _fixRelativeUris(articleContent) { + var baseURI = this._doc.baseURI; + var documentURI = this._doc.documentURI; + function toAbsoluteURI(uri) { + // Leave hash links alone if the base URI matches the document URI: + if (baseURI == documentURI && uri.charAt(0) == "#") { + return uri; + } + + // Otherwise, resolve against base URI: + try { + return new URL(uri, baseURI).href; + } catch (ex) { + // Something went wrong, just return the original: + } + return uri; + } + + var links = this._getAllNodesWithTag(articleContent, ["a"]); + this._forEachNode(links, function (link) { + var href = link.getAttribute("href"); + if (href) { + // Remove links with javascript: URIs, since + // they won't work after scripts have been removed from the page. + if (href.indexOf("javascript:") === 0) { + // if the link only contains simple text content, it can be converted to a text node + if ( + link.childNodes.length === 1 && + link.childNodes[0].nodeType === this.TEXT_NODE + ) { + var text = this._doc.createTextNode(link.textContent); + link.parentNode.replaceChild(text, link); + } else { + // if the link has multiple children, they should all be preserved + var container = this._doc.createElement("span"); + while (link.firstChild) { + container.appendChild(link.firstChild); + } + link.parentNode.replaceChild(container, link); + } + } else { + link.setAttribute("href", toAbsoluteURI(href)); + } + } + }); + + var medias = this._getAllNodesWithTag(articleContent, [ + "img", + "picture", + "figure", + "video", + "audio", + "source", + ]); + + this._forEachNode(medias, function (media) { + var src = media.getAttribute("src"); + var poster = media.getAttribute("poster"); + var srcset = media.getAttribute("srcset"); + + if (src) { + media.setAttribute("src", toAbsoluteURI(src)); + } + + if (poster) { + media.setAttribute("poster", toAbsoluteURI(poster)); + } + + if (srcset) { + var newSrcset = srcset.replace( + this.REGEXPS.srcsetUrl, + function (_, p1, p2, p3) { + return toAbsoluteURI(p1) + (p2 || "") + p3; + } + ); + + media.setAttribute("srcset", newSrcset); + } + }); + }, + + _simplifyNestedElements(articleContent) { + var node = articleContent; + + while (node) { + if ( + node.parentNode && + ["DIV", "SECTION"].includes(node.tagName) && + !(node.id && node.id.startsWith("readability")) + ) { + if (this._isElementWithoutContent(node)) { + node = this._removeAndGetNext(node); + continue; + } else if ( + this._hasSingleTagInsideElement(node, "DIV") || + this._hasSingleTagInsideElement(node, "SECTION") + ) { + var child = node.children[0]; + for (var i = 0; i < node.attributes.length; i++) { + child.setAttribute( + node.attributes[i].name, + node.attributes[i].value + ); + } + node.parentNode.replaceChild(child, node); + node = child; + continue; + } + } + + node = this._getNextNode(node); + } + }, + + /** + * Get the article title as an H1. + * + * @return string + **/ + _getArticleTitle() { + var doc = this._doc; + var curTitle = ""; + var origTitle = ""; + + try { + curTitle = origTitle = doc.title.trim(); + + // If they had an element with id "title" in their HTML + if (typeof curTitle !== "string") { + curTitle = origTitle = this._getInnerText( + doc.getElementsByTagName("title")[0] + ); + } + } catch (e) { + /* ignore exceptions setting the title. */ + } + + var titleHadHierarchicalSeparators = false; + function wordCount(str) { + return str.split(/\s+/).length; + } + + // If there's a separator in the title, first remove the final part + if (/ [\|\-\\\/>»] /.test(curTitle)) { + titleHadHierarchicalSeparators = / [\\\/>»] /.test(curTitle); + curTitle = origTitle.replace(/(.*)[\|\-\\\/>»] .*/gi, "$1"); + + // If the resulting title is too short, remove the first part instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.replace(/[^\|\-\\\/>»]*[\|\-\\\/>»](.*)/gi, "$1"); + } + } else if (curTitle.includes(": ")) { + // Check if we have an heading containing this exact string, so we + // could assume it's the full title. + var headings = this._getAllNodesWithTag(doc, ["h1", "h2"]); + var trimmedTitle = curTitle.trim(); + var match = this._someNode(headings, function (heading) { + return heading.textContent.trim() === trimmedTitle; + }); + + // If we don't, let's extract the title out of the original title string. + if (!match) { + curTitle = origTitle.substring(origTitle.lastIndexOf(":") + 1); + + // If the title is now too short, try the first colon instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.substring(origTitle.indexOf(":") + 1); + // But if we have too many words before the colon there's something weird + // with the titles and the H tags so let's just use the original title instead + } else if (wordCount(origTitle.substr(0, origTitle.indexOf(":"))) > 5) { + curTitle = origTitle; + } + } + } else if (curTitle.length > 150 || curTitle.length < 15) { + var hOnes = doc.getElementsByTagName("h1"); + + if (hOnes.length === 1) { + curTitle = this._getInnerText(hOnes[0]); + } + } + + curTitle = curTitle.trim().replace(this.REGEXPS.normalize, " "); + // If we now have 4 words or fewer as our title, and either no + // 'hierarchical' separators (\, /, > or ») were found in the original + // title or we decreased the number of words by more than 1 word, use + // the original title. + var curTitleWordCount = wordCount(curTitle); + if ( + curTitleWordCount <= 4 && + (!titleHadHierarchicalSeparators || + curTitleWordCount != + wordCount(origTitle.replace(/[\|\-\\\/>»]+/g, "")) - 1) + ) { + curTitle = origTitle; + } + + return curTitle; + }, + + /** + * Prepare the HTML document for readability to scrape it. + * This includes things like stripping javascript, CSS, and handling terrible markup. + * + * @return void + **/ + _prepDocument() { + var doc = this._doc; + + // Remove all style tags in head + this._removeNodes(this._getAllNodesWithTag(doc, ["style"])); + + if (doc.body) { + this._replaceBrs(doc.body); + } + + this._replaceNodeTags(this._getAllNodesWithTag(doc, ["font"]), "SPAN"); + }, + + /** + * Finds the next node, starting from the given node, and ignoring + * whitespace in between. If the given node is an element, the same node is + * returned. + */ + _nextNode(node) { + var next = node; + while ( + next && + next.nodeType != this.ELEMENT_NODE && + this.REGEXPS.whitespace.test(next.textContent) + ) { + next = next.nextSibling; + } + return next; + }, + + /** + * Replaces 2 or more successive
elements with a single

. + * Whitespace between
elements are ignored. For example: + *

foo
bar


abc
+ * will become: + *
foo
bar

abc

+ */ + _replaceBrs(elem) { + this._forEachNode(this._getAllNodesWithTag(elem, ["br"]), function (br) { + var next = br.nextSibling; + + // Whether 2 or more
elements have been found and replaced with a + //

block. + var replaced = false; + + // If we find a
chain, remove the
s until we hit another node + // or non-whitespace. This leaves behind the first
in the chain + // (which will be replaced with a

later). + while ((next = this._nextNode(next)) && next.tagName == "BR") { + replaced = true; + var brSibling = next.nextSibling; + next.remove(); + next = brSibling; + } + + // If we removed a
chain, replace the remaining
with a

. Add + // all sibling nodes as children of the

until we hit another
+ // chain. + if (replaced) { + var p = this._doc.createElement("p"); + br.parentNode.replaceChild(p, br); + + next = p.nextSibling; + while (next) { + // If we've hit another

, we're done adding children to this

. + if (next.tagName == "BR") { + var nextElem = this._nextNode(next.nextSibling); + if (nextElem && nextElem.tagName == "BR") { + break; + } + } + + if (!this._isPhrasingContent(next)) { + break; + } + + // Otherwise, make this node a child of the new

. + var sibling = next.nextSibling; + p.appendChild(next); + next = sibling; + } + + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + + if (p.parentNode.tagName === "P") { + this._setNodeTag(p.parentNode, "DIV"); + } + } + }); + }, + + _setNodeTag(node, tag) { + this.log("_setNodeTag", node, tag); + if (this._docJSDOMParser) { + node.localName = tag.toLowerCase(); + node.tagName = tag.toUpperCase(); + return node; + } + + var replacement = node.ownerDocument.createElement(tag); + while (node.firstChild) { + replacement.appendChild(node.firstChild); + } + node.parentNode.replaceChild(replacement, node); + if (node.readability) { + replacement.readability = node.readability; + } + + for (var i = 0; i < node.attributes.length; i++) { + try { + replacement.setAttribute( + node.attributes[i].name, + node.attributes[i].value + ); + } catch (ex) { + /* it's possible for setAttribute() to throw if the attribute name + * isn't a valid XML Name. Such attributes can however be parsed from + * source in HTML docs, see https://github.com/whatwg/html/issues/4275, + * so we can hit them here and then throw. We don't care about such + * attributes so we ignore them. + */ + } + } + return replacement; + }, + + /** + * Prepare the article node for display. Clean out any inline styles, + * iframes, forms, strip extraneous

tags, etc. + * + * @param Element + * @return void + **/ + _prepArticle(articleContent) { + this._cleanStyles(articleContent); + + // Check for data tables before we continue, to avoid removing items in + // those tables, which will often be isolated even though they're + // visually linked to other content-ful elements (text, images, etc.). + this._markDataTables(articleContent); + + this._fixLazyImages(articleContent); + + // Clean out junk from the article content + this._cleanConditionally(articleContent, "form"); + this._cleanConditionally(articleContent, "fieldset"); + this._clean(articleContent, "object"); + this._clean(articleContent, "embed"); + this._clean(articleContent, "footer"); + this._clean(articleContent, "link"); + this._clean(articleContent, "aside"); + + // Clean out elements with little content that have "share" in their id/class combinations from final top candidates, + // which means we don't remove the top candidates even they have "share". + + var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD; + + this._forEachNode(articleContent.children, function (topCandidate) { + this._cleanMatchedNodes(topCandidate, function (node, matchString) { + return ( + this.REGEXPS.shareElements.test(matchString) && + node.textContent.length < shareElementThreshold + ); + }); + }); + + this._clean(articleContent, "iframe"); + this._clean(articleContent, "input"); + this._clean(articleContent, "textarea"); + this._clean(articleContent, "select"); + this._clean(articleContent, "button"); + this._cleanHeaders(articleContent); + + // Do these last as the previous stuff may have removed junk + // that will affect these + this._cleanConditionally(articleContent, "table"); + this._cleanConditionally(articleContent, "ul"); + this._cleanConditionally(articleContent, "div"); + + // replace H1 with H2 as H1 should be only title that is displayed separately + this._replaceNodeTags( + this._getAllNodesWithTag(articleContent, ["h1"]), + "h2" + ); + + // Remove extra paragraphs + this._removeNodes( + this._getAllNodesWithTag(articleContent, ["p"]), + function (paragraph) { + var imgCount = paragraph.getElementsByTagName("img").length; + var embedCount = paragraph.getElementsByTagName("embed").length; + var objectCount = paragraph.getElementsByTagName("object").length; + // At this point, nasty iframes have been removed, only remain embedded video ones. + var iframeCount = paragraph.getElementsByTagName("iframe").length; + var totalCount = imgCount + embedCount + objectCount + iframeCount; + + return totalCount === 0 && !this._getInnerText(paragraph, false); + } + ); + + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["br"]), + function (br) { + var next = this._nextNode(br.nextSibling); + if (next && next.tagName == "P") { + br.remove(); + } + } + ); + + // Remove single-cell tables + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["table"]), + function (table) { + var tbody = this._hasSingleTagInsideElement(table, "TBODY") + ? table.firstElementChild + : table; + if (this._hasSingleTagInsideElement(tbody, "TR")) { + var row = tbody.firstElementChild; + if (this._hasSingleTagInsideElement(row, "TD")) { + var cell = row.firstElementChild; + cell = this._setNodeTag( + cell, + this._everyNode(cell.childNodes, this._isPhrasingContent) + ? "P" + : "DIV" + ); + table.parentNode.replaceChild(cell, table); + } + } + } + ); + }, + + /** + * Initialize a node with the readability object. Also checks the + * className/id for special names to add to its score. + * + * @param Element + * @return void + **/ + _initializeNode(node) { + node.readability = { contentScore: 0 }; + + switch (node.tagName) { + case "DIV": + node.readability.contentScore += 5; + break; + + case "PRE": + case "TD": + case "BLOCKQUOTE": + node.readability.contentScore += 3; + break; + + case "ADDRESS": + case "OL": + case "UL": + case "DL": + case "DD": + case "DT": + case "LI": + case "FORM": + node.readability.contentScore -= 3; + break; + + case "H1": + case "H2": + case "H3": + case "H4": + case "H5": + case "H6": + case "TH": + node.readability.contentScore -= 5; + break; + } + + node.readability.contentScore += this._getClassWeight(node); + }, + + _removeAndGetNext(node) { + var nextNode = this._getNextNode(node, true); + node.remove(); + return nextNode; + }, + + /** + * Traverse the DOM from node to node, starting at the node passed in. + * Pass true for the second parameter to indicate this node itself + * (and its kids) are going away, and we want the next node over. + * + * Calling this in a loop will traverse the DOM depth-first. + */ + _getNextNode(node, ignoreSelfAndKids) { + // First check for kids if those aren't being ignored + if (!ignoreSelfAndKids && node.firstElementChild) { + return node.firstElementChild; + } + // Then for siblings... + if (node.nextElementSibling) { + return node.nextElementSibling; + } + // And finally, move up the parent chain *and* find a sibling + // (because this is depth-first traversal, we will have already + // seen the parent nodes themselves). + do { + node = node.parentNode; + } while (node && !node.nextElementSibling); + return node && node.nextElementSibling; + }, + + // compares second text to first one + // 1 = same text, 0 = completely different text + // works the way that it splits both texts into words and then finds words that are unique in second text + // the result is given by the lower length of unique parts + _textSimilarity(textA, textB) { + var tokensA = textA + .toLowerCase() + .split(this.REGEXPS.tokenize) + .filter(Boolean); + var tokensB = textB + .toLowerCase() + .split(this.REGEXPS.tokenize) + .filter(Boolean); + if (!tokensA.length || !tokensB.length) { + return 0; + } + var uniqTokensB = tokensB.filter(token => !tokensA.includes(token)); + var distanceB = uniqTokensB.join(" ").length / tokensB.join(" ").length; + return 1 - distanceB; + }, + + _checkByline(node, matchString) { + if (this._articleByline || this._metadata.byline) { + return false; + } + + if (node.getAttribute !== undefined) { + var rel = node.getAttribute("rel"); + var itemprop = node.getAttribute("itemprop"); + } + + if ( + (rel === "author" || + (itemprop && itemprop.includes("author")) || + this.REGEXPS.byline.test(matchString)) && + this._isValidByline(node.textContent) + ) { + this._articleByline = node.textContent.trim(); + return true; + } + + return false; + }, + + _getNodeAncestors(node, maxDepth) { + maxDepth = maxDepth || 0; + var i = 0, + ancestors = []; + while (node.parentNode) { + ancestors.push(node.parentNode); + if (maxDepth && ++i === maxDepth) { + break; + } + node = node.parentNode; + } + return ancestors; + }, + + /*** + * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is + * most likely to be the stuff a user wants to read. Then return it wrapped up in a div. + * + * @param page a document to run upon. Needs to be a full document, complete with body. + * @return Element + **/ + /* eslint-disable-next-line complexity */ + _grabArticle(page) { + this.log("**** grabArticle ****"); + var doc = this._doc; + var isPaging = page !== null; + page = page ? page : this._doc.body; + + // We can't grab an article if we don't have a page! + if (!page) { + this.log("No body found in document. Abort."); + return null; + } + + var pageCacheHtml = page.innerHTML; + + while (true) { + this.log("Starting grabArticle loop"); + var stripUnlikelyCandidates = this._flagIsActive( + this.FLAG_STRIP_UNLIKELYS + ); + + // First, node prepping. Trash nodes that look cruddy (like ones with the + // class name "comment", etc), and turn divs into P tags where they have been + // used inappropriately (as in, where they contain no other block level elements.) + var elementsToScore = []; + var node = this._doc.documentElement; + + let shouldRemoveTitleHeader = true; + + while (node) { + if (node.tagName === "HTML") { + this._articleLang = node.getAttribute("lang"); + } + + var matchString = node.className + " " + node.id; + + if (!this._isProbablyVisible(node)) { + this.log("Removing hidden node - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + + // User is not able to see elements applied with both "aria-modal = true" and "role = dialog" + if ( + node.getAttribute("aria-modal") == "true" && + node.getAttribute("role") == "dialog" + ) { + node = this._removeAndGetNext(node); + continue; + } + + // Check to see if this node is a byline, and remove it if it is. + if (this._checkByline(node, matchString)) { + node = this._removeAndGetNext(node); + continue; + } + + if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) { + this.log( + "Removing header: ", + node.textContent.trim(), + this._articleTitle.trim() + ); + shouldRemoveTitleHeader = false; + node = this._removeAndGetNext(node); + continue; + } + + // Remove unlikely candidates + if (stripUnlikelyCandidates) { + if ( + this.REGEXPS.unlikelyCandidates.test(matchString) && + !this.REGEXPS.okMaybeItsACandidate.test(matchString) && + !this._hasAncestorTag(node, "table") && + !this._hasAncestorTag(node, "code") && + node.tagName !== "BODY" && + node.tagName !== "A" + ) { + this.log("Removing unlikely candidate - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + + if (this.UNLIKELY_ROLES.includes(node.getAttribute("role"))) { + this.log( + "Removing content with role " + + node.getAttribute("role") + + " - " + + matchString + ); + node = this._removeAndGetNext(node); + continue; + } + } + + // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe). + if ( + (node.tagName === "DIV" || + node.tagName === "SECTION" || + node.tagName === "HEADER" || + node.tagName === "H1" || + node.tagName === "H2" || + node.tagName === "H3" || + node.tagName === "H4" || + node.tagName === "H5" || + node.tagName === "H6") && + this._isElementWithoutContent(node) + ) { + node = this._removeAndGetNext(node); + continue; + } + + if (this.DEFAULT_TAGS_TO_SCORE.includes(node.tagName)) { + elementsToScore.push(node); + } + + // Turn all divs that don't have children block level elements into p's + if (node.tagName === "DIV") { + // Put phrasing content into paragraphs. + var p = null; + var childNode = node.firstChild; + while (childNode) { + var nextSibling = childNode.nextSibling; + if (this._isPhrasingContent(childNode)) { + if (p !== null) { + p.appendChild(childNode); + } else if (!this._isWhitespace(childNode)) { + p = doc.createElement("p"); + node.replaceChild(p, childNode); + p.appendChild(childNode); + } + } else if (p !== null) { + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + p = null; + } + childNode = nextSibling; + } + + // Sites like http://mobile.slate.com encloses each paragraph with a DIV + // element. DIVs with only a P element inside and no text content can be + // safely converted into plain P elements to avoid confusing the scoring + // algorithm with DIVs with are, in practice, paragraphs. + if ( + this._hasSingleTagInsideElement(node, "P") && + this._getLinkDensity(node) < 0.25 + ) { + var newNode = node.children[0]; + node.parentNode.replaceChild(newNode, node); + node = newNode; + elementsToScore.push(node); + } else if (!this._hasChildBlockElement(node)) { + node = this._setNodeTag(node, "P"); + elementsToScore.push(node); + } + } + node = this._getNextNode(node); + } + + /** + * Loop through all paragraphs, and assign a score to them based on how content-y they look. + * Then add their score to their parent node. + * + * A score is determined by things like number of commas, class names, etc. Maybe eventually link density. + **/ + var candidates = []; + this._forEachNode(elementsToScore, function (elementToScore) { + if ( + !elementToScore.parentNode || + typeof elementToScore.parentNode.tagName === "undefined" + ) { + return; + } + + // If this paragraph is less than 25 characters, don't even count it. + var innerText = this._getInnerText(elementToScore); + if (innerText.length < 25) { + return; + } + + // Exclude nodes with no ancestor. + var ancestors = this._getNodeAncestors(elementToScore, 5); + if (ancestors.length === 0) { + return; + } + + var contentScore = 0; + + // Add a point for the paragraph itself as a base. + contentScore += 1; + + // Add points for any commas within this paragraph. + contentScore += innerText.split(this.REGEXPS.commas).length; + + // For every 100 characters in this paragraph, add another point. Up to 3 points. + contentScore += Math.min(Math.floor(innerText.length / 100), 3); + + // Initialize and score ancestors. + this._forEachNode(ancestors, function (ancestor, level) { + if ( + !ancestor.tagName || + !ancestor.parentNode || + typeof ancestor.parentNode.tagName === "undefined" + ) { + return; + } + + if (typeof ancestor.readability === "undefined") { + this._initializeNode(ancestor); + candidates.push(ancestor); + } + + // Node score divider: + // - parent: 1 (no division) + // - grandparent: 2 + // - great grandparent+: ancestor level * 3 + if (level === 0) { + var scoreDivider = 1; + } else if (level === 1) { + scoreDivider = 2; + } else { + scoreDivider = level * 3; + } + ancestor.readability.contentScore += contentScore / scoreDivider; + }); + }); + + // After we've calculated scores, loop through all of the possible + // candidate nodes we found and find the one with the highest score. + var topCandidates = []; + for (var c = 0, cl = candidates.length; c < cl; c += 1) { + var candidate = candidates[c]; + + // Scale the final candidates score based on link density. Good content + // should have a relatively small link density (5% or less) and be mostly + // unaffected by this operation. + var candidateScore = + candidate.readability.contentScore * + (1 - this._getLinkDensity(candidate)); + candidate.readability.contentScore = candidateScore; + + this.log("Candidate:", candidate, "with score " + candidateScore); + + for (var t = 0; t < this._nbTopCandidates; t++) { + var aTopCandidate = topCandidates[t]; + + if ( + !aTopCandidate || + candidateScore > aTopCandidate.readability.contentScore + ) { + topCandidates.splice(t, 0, candidate); + if (topCandidates.length > this._nbTopCandidates) { + topCandidates.pop(); + } + break; + } + } + } + + var topCandidate = topCandidates[0] || null; + var neededToCreateTopCandidate = false; + var parentOfTopCandidate; + + // If we still have no top candidate, just use the body as a last resort. + // We also have to copy the body node so it is something we can modify. + if (topCandidate === null || topCandidate.tagName === "BODY") { + // Move all of the page's children into topCandidate + topCandidate = doc.createElement("DIV"); + neededToCreateTopCandidate = true; + // Move everything (not just elements, also text nodes etc.) into the container + // so we even include text directly in the body: + while (page.firstChild) { + this.log("Moving child out:", page.firstChild); + topCandidate.appendChild(page.firstChild); + } + + page.appendChild(topCandidate); + + this._initializeNode(topCandidate); + } else if (topCandidate) { + // Find a better top candidate node if it contains (at least three) nodes which belong to `topCandidates` array + // and whose scores are quite closed with current `topCandidate` node. + var alternativeCandidateAncestors = []; + for (var i = 1; i < topCandidates.length; i++) { + if ( + topCandidates[i].readability.contentScore / + topCandidate.readability.contentScore >= + 0.75 + ) { + alternativeCandidateAncestors.push( + this._getNodeAncestors(topCandidates[i]) + ); + } + } + var MINIMUM_TOPCANDIDATES = 3; + if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) { + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate.tagName !== "BODY") { + var listsContainingThisAncestor = 0; + for ( + var ancestorIndex = 0; + ancestorIndex < alternativeCandidateAncestors.length && + listsContainingThisAncestor < MINIMUM_TOPCANDIDATES; + ancestorIndex++ + ) { + listsContainingThisAncestor += Number( + alternativeCandidateAncestors[ancestorIndex].includes( + parentOfTopCandidate + ) + ); + } + if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) { + topCandidate = parentOfTopCandidate; + break; + } + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + + // Because of our bonus system, parents of candidates might have scores + // themselves. They get half of the node. There won't be nodes with higher + // scores than our topCandidate, but if we see the score going *up* in the first + // few steps up the tree, that's a decent sign that there might be more content + // lurking in other places that we want to unify in. The sibling stuff + // below does some of that - but only if we've looked high enough up the DOM + // tree. + parentOfTopCandidate = topCandidate.parentNode; + var lastScore = topCandidate.readability.contentScore; + // The scores shouldn't get too low. + var scoreThreshold = lastScore / 3; + while (parentOfTopCandidate.tagName !== "BODY") { + if (!parentOfTopCandidate.readability) { + parentOfTopCandidate = parentOfTopCandidate.parentNode; + continue; + } + var parentScore = parentOfTopCandidate.readability.contentScore; + if (parentScore < scoreThreshold) { + break; + } + if (parentScore > lastScore) { + // Alright! We found a better parent to use. + topCandidate = parentOfTopCandidate; + break; + } + lastScore = parentOfTopCandidate.readability.contentScore; + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + + // If the top candidate is the only child, use parent instead. This will help sibling + // joining logic when adjacent content is actually located in parent's sibling node. + parentOfTopCandidate = topCandidate.parentNode; + while ( + parentOfTopCandidate.tagName != "BODY" && + parentOfTopCandidate.children.length == 1 + ) { + topCandidate = parentOfTopCandidate; + parentOfTopCandidate = topCandidate.parentNode; + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + } + + // Now that we have the top candidate, look through its siblings for content + // that might also be related. Things like preambles, content split by ads + // that we removed, etc. + var articleContent = doc.createElement("DIV"); + if (isPaging) { + articleContent.id = "readability-content"; + } + + var siblingScoreThreshold = Math.max( + 10, + topCandidate.readability.contentScore * 0.2 + ); + // Keep potential top candidate's parent node to try to get text direction of it later. + parentOfTopCandidate = topCandidate.parentNode; + var siblings = parentOfTopCandidate.children; + + for (var s = 0, sl = siblings.length; s < sl; s++) { + var sibling = siblings[s]; + var append = false; + + this.log( + "Looking at sibling node:", + sibling, + sibling.readability + ? "with score " + sibling.readability.contentScore + : "" + ); + this.log( + "Sibling has score", + sibling.readability ? sibling.readability.contentScore : "Unknown" + ); + + if (sibling === topCandidate) { + append = true; + } else { + var contentBonus = 0; + + // Give a bonus if sibling nodes and top candidates have the example same classname + if ( + sibling.className === topCandidate.className && + topCandidate.className !== "" + ) { + contentBonus += topCandidate.readability.contentScore * 0.2; + } + + if ( + sibling.readability && + sibling.readability.contentScore + contentBonus >= + siblingScoreThreshold + ) { + append = true; + } else if (sibling.nodeName === "P") { + var linkDensity = this._getLinkDensity(sibling); + var nodeContent = this._getInnerText(sibling); + var nodeLength = nodeContent.length; + + if (nodeLength > 80 && linkDensity < 0.25) { + append = true; + } else if ( + nodeLength < 80 && + nodeLength > 0 && + linkDensity === 0 && + nodeContent.search(/\.( |$)/) !== -1 + ) { + append = true; + } + } + } + + if (append) { + this.log("Appending node:", sibling); + + if (!this.ALTER_TO_DIV_EXCEPTIONS.includes(sibling.nodeName)) { + // We have a node that isn't a common block level element, like a form or td tag. + // Turn it into a div so it doesn't get filtered out later by accident. + this.log("Altering sibling:", sibling, "to div."); + + sibling = this._setNodeTag(sibling, "DIV"); + } + + articleContent.appendChild(sibling); + // Fetch children again to make it compatible + // with DOM parsers without live collection support. + siblings = parentOfTopCandidate.children; + // siblings is a reference to the children array, and + // sibling is removed from the array when we call appendChild(). + // As a result, we must revisit this index since the nodes + // have been shifted. + s -= 1; + sl -= 1; + } + } + + if (this._debug) { + this.log("Article content pre-prep: " + articleContent.innerHTML); + } + // So we have all of the content that we need. Now we clean it up for presentation. + this._prepArticle(articleContent); + if (this._debug) { + this.log("Article content post-prep: " + articleContent.innerHTML); + } + + if (neededToCreateTopCandidate) { + // We already created a fake div thing, and there wouldn't have been any siblings left + // for the previous loop, so there's no point trying to create a new div, and then + // move all the children over. Just assign IDs and class names here. No need to append + // because that already happened anyway. + topCandidate.id = "readability-page-1"; + topCandidate.className = "page"; + } else { + var div = doc.createElement("DIV"); + div.id = "readability-page-1"; + div.className = "page"; + while (articleContent.firstChild) { + div.appendChild(articleContent.firstChild); + } + articleContent.appendChild(div); + } + + if (this._debug) { + this.log("Article content after paging: " + articleContent.innerHTML); + } + + var parseSuccessful = true; + + // Now that we've gone through the full algorithm, check to see if + // we got any meaningful content. If we didn't, we may need to re-run + // grabArticle with different flags set. This gives us a higher likelihood of + // finding the content, and the sieve approach gives us a higher likelihood of + // finding the -right- content. + var textLength = this._getInnerText(articleContent, true).length; + if (textLength < this._charThreshold) { + parseSuccessful = false; + // eslint-disable-next-line no-unsanitized/property + page.innerHTML = pageCacheHtml; + + this._attempts.push({ + articleContent, + textLength, + }); + + if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) { + this._removeFlag(this.FLAG_STRIP_UNLIKELYS); + } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) { + this._removeFlag(this.FLAG_WEIGHT_CLASSES); + } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) { + this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY); + } else { + // No luck after removing flags, just return the longest text we found during the different loops + this._attempts.sort(function (a, b) { + return b.textLength - a.textLength; + }); + + // But first check if we actually have something + if (!this._attempts[0].textLength) { + return null; + } + + articleContent = this._attempts[0].articleContent; + parseSuccessful = true; + } + } + + if (parseSuccessful) { + // Find out text direction from ancestors of final top candidate. + var ancestors = [parentOfTopCandidate, topCandidate].concat( + this._getNodeAncestors(parentOfTopCandidate) + ); + this._someNode(ancestors, function (ancestor) { + if (!ancestor.tagName) { + return false; + } + var articleDir = ancestor.getAttribute("dir"); + if (articleDir) { + this._articleDir = articleDir; + return true; + } + return false; + }); + return articleContent; + } + } + }, + + /** + * Check whether the input string could be a byline. + * This verifies that the input is a string, and that the length + * is less than 100 chars. + * + * @param possibleByline {string} - a string to check whether its a byline. + * @return Boolean - whether the input string is a byline. + */ + _isValidByline(byline) { + if (typeof byline == "string" || byline instanceof String) { + byline = byline.trim(); + return !!byline.length && byline.length < 100; + } + return false; + }, + + /** + * Converts some of the common HTML entities in string to their corresponding characters. + * + * @param str {string} - a string to unescape. + * @return string without HTML entity. + */ + _unescapeHtmlEntities(str) { + if (!str) { + return str; + } + + var htmlEscapeMap = this.HTML_ESCAPE_MAP; + return str + .replace(/&(quot|amp|apos|lt|gt);/g, function (_, tag) { + return htmlEscapeMap[tag]; + }) + .replace(/&#(?:x([0-9a-f]+)|([0-9]+));/gi, function (_, hex, numStr) { + var num = parseInt(hex || numStr, hex ? 16 : 10); + + // these character references are replaced by a conforming HTML parser + if (num == 0 || num > 0x10ffff || (num >= 0xd800 && num <= 0xdfff)) { + num = 0xfffd; + } + + return String.fromCodePoint(num); + }); + }, + + /** + * Try to extract metadata from JSON-LD object. + * For now, only Schema.org objects of type Article or its subtypes are supported. + * @return Object with any metadata that could be extracted (possibly none) + */ + _getJSONLD(doc) { + var scripts = this._getAllNodesWithTag(doc, ["script"]); + + var metadata; + + this._forEachNode(scripts, function (jsonLdElement) { + if ( + !metadata && + jsonLdElement.getAttribute("type") === "application/ld+json" + ) { + try { + // Strip CDATA markers if present + var content = jsonLdElement.textContent.replace( + /^\s*\s*$/g, + "" + ); + var parsed = JSON.parse(content); + if ( + !parsed["@context"] || + !parsed["@context"].match(/^https?\:\/\/schema\.org\/?$/) + ) { + return; + } + + if (!parsed["@type"] && Array.isArray(parsed["@graph"])) { + parsed = parsed["@graph"].find(function (it) { + return (it["@type"] || "").match(this.REGEXPS.jsonLdArticleTypes); + }); + } + + if ( + !parsed || + !parsed["@type"] || + !parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes) + ) { + return; + } + + metadata = {}; + + if ( + typeof parsed.name === "string" && + typeof parsed.headline === "string" && + parsed.name !== parsed.headline + ) { + // we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz + // put their own name into "name" and the article title to "headline" which confuses Readability. So we try to check if either + // "name" or "headline" closely matches the html title, and if so, use that one. If not, then we use "name" by default. + + var title = this._getArticleTitle(); + var nameMatches = this._textSimilarity(parsed.name, title) > 0.75; + var headlineMatches = + this._textSimilarity(parsed.headline, title) > 0.75; + + if (headlineMatches && !nameMatches) { + metadata.title = parsed.headline; + } else { + metadata.title = parsed.name; + } + } else if (typeof parsed.name === "string") { + metadata.title = parsed.name.trim(); + } else if (typeof parsed.headline === "string") { + metadata.title = parsed.headline.trim(); + } + if (parsed.author) { + if (typeof parsed.author.name === "string") { + metadata.byline = parsed.author.name.trim(); + } else if ( + Array.isArray(parsed.author) && + parsed.author[0] && + typeof parsed.author[0].name === "string" + ) { + metadata.byline = parsed.author + .filter(function (author) { + return author && typeof author.name === "string"; + }) + .map(function (author) { + return author.name.trim(); + }) + .join(", "); + } + } + if (typeof parsed.description === "string") { + metadata.excerpt = parsed.description.trim(); + } + if (parsed.publisher && typeof parsed.publisher.name === "string") { + metadata.siteName = parsed.publisher.name.trim(); + } + if (typeof parsed.datePublished === "string") { + metadata.datePublished = parsed.datePublished.trim(); + } + } catch (err) { + this.log(err.message); + } + } + }); + return metadata ? metadata : {}; + }, + + /** + * Attempts to get excerpt and byline metadata for the article. + * + * @param {Object} jsonld — object containing any metadata that + * could be extracted from JSON-LD object. + * + * @return Object with optional "excerpt" and "byline" properties + */ + _getArticleMetadata(jsonld) { + var metadata = {}; + var values = {}; + var metaElements = this._doc.getElementsByTagName("meta"); + + // property is a space-separated list of values + var propertyPattern = + /\s*(article|dc|dcterm|og|twitter)\s*:\s*(author|creator|description|published_time|title|site_name)\s*/gi; + + // name is a single value + var namePattern = + /^\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(article|webpage))\s*[-\.:]\s*)?(author|creator|pub-date|description|title|site_name)\s*$/i; + + // Find description tags. + this._forEachNode(metaElements, function (element) { + var elementName = element.getAttribute("name"); + var elementProperty = element.getAttribute("property"); + var content = element.getAttribute("content"); + if (!content) { + return; + } + var matches = null; + var name = null; + + if (elementProperty) { + matches = elementProperty.match(propertyPattern); + if (matches) { + // Convert to lowercase, and remove any whitespace + // so we can match below. + name = matches[0].toLowerCase().replace(/\s/g, ""); + // multiple authors + values[name] = content.trim(); + } + } + if (!matches && elementName && namePattern.test(elementName)) { + name = elementName; + if (content) { + // Convert to lowercase, remove any whitespace, and convert dots + // to colons so we can match below. + name = name.toLowerCase().replace(/\s/g, "").replace(/\./g, ":"); + values[name] = content.trim(); + } + } + }); + + // get title + metadata.title = + jsonld.title || + values["dc:title"] || + values["dcterm:title"] || + values["og:title"] || + values["weibo:article:title"] || + values["weibo:webpage:title"] || + values.title || + values["twitter:title"] || + values["parsely-title"]; + + if (!metadata.title) { + metadata.title = this._getArticleTitle(); + } + + // get author + metadata.byline = + jsonld.byline || + values["dc:creator"] || + values["dcterm:creator"] || + values.author || + values["parsely-author"]; + + // get description + metadata.excerpt = + jsonld.excerpt || + values["dc:description"] || + values["dcterm:description"] || + values["og:description"] || + values["weibo:article:description"] || + values["weibo:webpage:description"] || + values.description || + values["twitter:description"]; + + // get site name + metadata.siteName = jsonld.siteName || values["og:site_name"]; + + // get article published time + metadata.publishedTime = + jsonld.datePublished || + values["article:published_time"] || + values["parsely-pub-date"] || + null; + + // in many sites the meta value is escaped with HTML entities, + // so here we need to unescape it + metadata.title = this._unescapeHtmlEntities(metadata.title); + metadata.byline = this._unescapeHtmlEntities(metadata.byline); + metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt); + metadata.siteName = this._unescapeHtmlEntities(metadata.siteName); + metadata.publishedTime = this._unescapeHtmlEntities(metadata.publishedTime); + + return metadata; + }, + + /** + * Check if node is image, or if node contains exactly only one image + * whether as a direct child or as its descendants. + * + * @param Element + **/ + _isSingleImage(node) { + while (node) { + if (node.tagName === "IMG") { + return true; + } + if (node.children.length !== 1 || node.textContent.trim() !== "") { + return false; + } + node = node.children[0]; + } + return false; + }, + + /** + * Find all