diff --git a/Multiplatform/Shared/AppAssets.swift b/Multiplatform/Shared/AppAssets.swift index ef22695f5..32811e2c9 100644 --- a/Multiplatform/Shared/AppAssets.swift +++ b/Multiplatform/Shared/AppAssets.swift @@ -110,6 +110,18 @@ struct AppAssets { return Image(systemName: "info.circle") }() + #if os(macOS) + static var iconBackgroundColor: NSColor = { + return NSColor(named: "IconBackgroundColor")! + }() + #endif + + #if os(iOS) + static var iconBackgroundColor: UIColor = { + return UIColor(named: "IconBackgroundColor")! + }() + #endif + static var nextArticleImage: Image = { return Image(systemName: "chevron.down") }() diff --git a/Multiplatform/Shared/AppDefaults.swift b/Multiplatform/Shared/AppDefaults.swift index 9cee5fd2e..52452c851 100644 --- a/Multiplatform/Shared/AppDefaults.swift +++ b/Multiplatform/Shared/AppDefaults.swift @@ -193,6 +193,8 @@ final class AppDefaults: ObservableObject { // MARK: Articles @AppStorage(wrappedValue: false, Key.articleFullscreenAvailable, store: store) var articleFullscreenAvailable: Bool + @AppStorage(wrappedValue: false, Key.articleFullscreenEnabled, store: store) var articleFullscreenEnabled: Bool + // MARK: Refresh var lastRefresh: Date? { set { diff --git a/Multiplatform/Shared/Article/ArticleContainerView.swift b/Multiplatform/Shared/Article/ArticleContainerView.swift index 50f247015..dbf6820b6 100644 --- a/Multiplatform/Shared/Article/ArticleContainerView.swift +++ b/Multiplatform/Shared/Article/ArticleContainerView.swift @@ -16,13 +16,8 @@ struct ArticleContainerView: View { var article: Article @ViewBuilder var body: some View { - ArticleView() + ArticleView(sceneModel: sceneModel, articleModel: articleModel, article: article) .modifier(ArticleToolbarModifier()) - .environmentObject(articleModel) - .onAppear { - sceneModel.articleModel = articleModel - articleModel.delegate = sceneModel - } } } diff --git a/Multiplatform/Shared/Article/ArticleModel.swift b/Multiplatform/Shared/Article/ArticleModel.swift index 7e2248d0b..8dc100836 100644 --- a/Multiplatform/Shared/Article/ArticleModel.swift +++ b/Multiplatform/Shared/Article/ArticleModel.swift @@ -14,12 +14,37 @@ import Account import Articles protocol ArticleModelDelegate: class { - func timelineRequestedWebFeedSelection(_: TimelineModel, webFeed: WebFeed) + #if os(iOS) + var webViewProvider: WebViewProvider? { get } + #endif + func findPrevArticle(_: ArticleModel, article: Article) -> Article? + func findNextArticle(_: ArticleModel, article: Article) -> Article? + func selectArticle(_: ArticleModel, article: Article) } class ArticleModel: ObservableObject { weak var delegate: ArticleModelDelegate? + #if os(iOS) + var webViewProvider: WebViewProvider? { + return delegate?.webViewProvider + } + #endif + + // MARK: API + + func findPrevArticle(_ article: Article) -> Article? { + return delegate?.findPrevArticle(self, article: article) + } + + func findNextArticle(_ article: Article) -> Article? { + return delegate?.findNextArticle(self, article: article) + } + + func selectArticle(_ article: Article) { + delegate?.selectArticle(self, article: article) + } + } diff --git a/Multiplatform/Shared/Article/blank.html b/Multiplatform/Shared/Article/blank.html new file mode 100644 index 000000000..6e02cf3a6 --- /dev/null +++ b/Multiplatform/Shared/Article/blank.html @@ -0,0 +1,11 @@ + + + + + + + diff --git a/Multiplatform/Shared/Article/main_multiplatform.js b/Multiplatform/Shared/Article/main_multiplatform.js new file mode 100644 index 000000000..2de36d94b --- /dev/null +++ b/Multiplatform/Shared/Article/main_multiplatform.js @@ -0,0 +1,481 @@ +var activeImageViewer = null; + +class ImageViewer { + constructor(img) { + this.img = img; + this.loadingInterval = null; + this.activityIndicator = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjAiIHdpZHRoPSI2NHB4IiBoZWlnaHQ9IjY0cHgiIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB4bWw6c3BhY2U9InByZXNlcnZlIj48Zz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiMwMDAwMDAiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiNjY2NjY2MiIHRyYW5zZm9ybT0icm90YXRlKDMwIDY0IDY0KSIvPjxwYXRoIGQ9Ik01OS42IDBoOHY0MGgtOFYweiIgZmlsbD0iI2NjY2NjYyIgdHJhbnNmb3JtPSJyb3RhdGUoNjAgNjQgNjQpIi8+PHBhdGggZD0iTTU5LjYgMGg4djQwaC04VjB6IiBmaWxsPSIjY2NjY2NjIiB0cmFuc2Zvcm09InJvdGF0ZSg5MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiNjY2NjY2MiIHRyYW5zZm9ybT0icm90YXRlKDEyMCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiNiMmIyYjIiIHRyYW5zZm9ybT0icm90YXRlKDE1MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM5OTk5OTkiIHRyYW5zZm9ybT0icm90YXRlKDE4MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM3ZjdmN2YiIHRyYW5zZm9ybT0icm90YXRlKDIxMCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM2NjY2NjYiIHRyYW5zZm9ybT0icm90YXRlKDI0MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM0YzRjNGMiIHRyYW5zZm9ybT0icm90YXRlKDI3MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiMzMzMzMzMiIHRyYW5zZm9ybT0icm90YXRlKDMwMCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiMxOTE5MTkiIHRyYW5zZm9ybT0icm90YXRlKDMzMCA2NCA2NCkiLz48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIHR5cGU9InJvdGF0ZSIgdmFsdWVzPSIwIDY0IDY0OzMwIDY0IDY0OzYwIDY0IDY0OzkwIDY0IDY0OzEyMCA2NCA2NDsxNTAgNjQgNjQ7MTgwIDY0IDY0OzIxMCA2NCA2NDsyNDAgNjQgNjQ7MjcwIDY0IDY0OzMwMCA2NCA2NDszMzAgNjQgNjQiIGNhbGNNb2RlPSJkaXNjcmV0ZSIgZHVyPSIxMDgwbXMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIj48L2FuaW1hdGVUcmFuc2Zvcm0+PC9nPjwvc3ZnPg=="; + } + + isLoaded() { + return this.img.classList.contains("nnwLoaded"); + } + + clicked() { + this.showLoadingIndicator(); + if (this.isLoaded()) { + this.showViewer(); + } else { + var callback = () => { + if (this.isLoaded()) { + clearInterval(this.loadingInterval); + this.showViewer(); + } + } + this.loadingInterval = setInterval(callback, 100); + } + } + cancel() { + clearInterval(this.loadingInterval); + this.hideLoadingIndicator(); + } + + showViewer() { + this.hideLoadingIndicator(); + + var canvas = document.createElement("canvas"); + var pixelRatio = window.devicePixelRatio; + do { + canvas.width = this.img.naturalWidth * pixelRatio; + canvas.height = this.img.naturalHeight * pixelRatio; + pixelRatio--; + } while (pixelRatio > 0 && canvas.width * canvas.height > 16777216) + canvas.getContext("2d").drawImage(this.img, 0, 0, canvas.width, canvas.height); + + const rect = this.img.getBoundingClientRect(); + const message = { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + imageTitle: this.img.title, + imageURL: canvas.toDataURL(), + }; + + var jsonMessage = JSON.stringify(message); + window.webkit.messageHandlers.imageWasClicked.postMessage(jsonMessage); + } + + hideImage() { + this.img.style.opacity = 0; + } + + showImage() { + this.img.style.opacity = 1 + } + + showLoadingIndicator() { + var wrapper = document.createElement("div"); + wrapper.classList.add("activityIndicatorWrap"); + this.img.parentNode.insertBefore(wrapper, this.img); + wrapper.appendChild(this.img); + + var activityIndicatorImg = document.createElement("img"); + activityIndicatorImg.classList.add("activityIndicator"); + activityIndicatorImg.style.opacity = 0; + activityIndicatorImg.src = this.activityIndicator; + wrapper.appendChild(activityIndicatorImg); + + activityIndicatorImg.style.opacity = 1; + } + + hideLoadingIndicator() { + var wrapper = this.img.parentNode; + if (wrapper.classList.contains("activityIndicatorWrap")) { + var wrapperParent = wrapper.parentNode; + wrapperParent.insertBefore(this.img, wrapper); + wrapperParent.removeChild(wrapper); + } + } + + static init() { + cancelImageLoad(); + + // keep track of when an image has finished downloading for ImageViewer + document.querySelectorAll("img").forEach(element => { + element.onload = function() { + this.classList.add("nnwLoaded"); + } + }); + + // Add the click listener for images + window.onclick = function(event) { + if (event.target.matches("img") && !event.target.classList.contains("nnw-nozoom")) { + if (activeImageViewer && activeImageViewer.img === event.target) { + cancelImageLoad(); + } else { + cancelImageLoad(); + activeImageViewer = new ImageViewer(event.target); + activeImageViewer.clicked(); + } + } + } + } +} + +function cancelImageLoad() { + if (activeImageViewer) { + activeImageViewer.cancel(); + activeImageViewer = null; + } +} + +function hideClickedImage() { + if (activeImageViewer) { + activeImageViewer.hideImage(); + } +} + +// Used to animate the transition from a fullscreen image +function showClickedImage() { + if (activeImageViewer) { + activeImageViewer.showImage(); + } + window.webkit.messageHandlers.imageWasShown.postMessage(""); +} + +function showFeedInspectorSetup() { + document.getElementById("nnwImageIcon").onclick = function(event) { + window.webkit.messageHandlers.showFeedInspector.postMessage(""); + } +} + +function postRenderProcessing() { + ImageViewer.init(); + showFeedInspectorSetup(); +} + + +function makeHighlightRect({left, top, width, height}, offsetTop=0, offsetLeft=0) { + const overlay = document.createElement('a'); + + Object.assign(overlay.style, { + position: 'absolute', + left: `${Math.floor(left + offsetLeft)}px`, + top: `${Math.floor(top + offsetTop)}px`, + width: `${Math.ceil(width)}px`, + height: `${Math.ceil(height)}px`, + backgroundColor: 'rgba(200, 220, 10, 0.4)', + pointerEvents: 'none' + }); + + return overlay; +} + +function clearHighlightRects() { + let container = document.getElementById('nnw:highlightContainer') + if (container) container.remove(); +} + +function highlightRects(rects, clearOldRects=true, makeHighlightRect=makeHighlightRect) { + const article = document.querySelector('article'); + let container = document.getElementById('nnw:highlightContainer'); + + article.style.position = 'relative'; + + if (container && clearOldRects) + container.remove(); + + container = document.createElement('div'); + container.id = 'nnw:highlightContainer'; + article.appendChild(container); + + const {top, left} = article.getBoundingClientRect(); + return Array.from(rects, rect => + container.appendChild(makeHighlightRect(rect, -top, -left)) + ); +} + +FinderResult = class { + constructor(result) { + Object.assign(this, result); + } + + range() { + const range = document.createRange(); + range.setStart(this.node, this.offset); + range.setEnd(this.node, this.offsetEnd); + return range; + } + + bounds() { + return this.range().getBoundingClientRect(); + } + + rects() { + return this.range().getClientRects(); + } + + highlight({clearOldRects=true, fn=makeHighlightRect} = {}) { + highlightRects(this.rects(), clearOldRects, fn); + } + + scrollTo() { + scrollToRect(this.bounds(), this.node); + } + + toJSON() { + return { + rects: Array.from(this.rects()), + bounds: this.bounds(), + index: this.index, + matchGroups: this.match + }; + } + + toJSONString() { + return JSON.stringify(this.toJSON()); + } +} + +Finder = class { + constructor(pattern, options) { + if (!pattern.global) { + pattern = new RegExp(pattern, 'g'); + } + + this.pattern = pattern; + this.lastResult = null; + this._nodeMatches = []; + this.options = { + rootSelector: '.articleBody', + startNode: null, + startOffset: null, + } + + this.resultIndex = -1 + + Object.assign(this.options, options); + + this.walker = document.createTreeWalker(this.root, NodeFilter.SHOW_TEXT); + } + + get root() { + return document.querySelector(this.options.rootSelector) + } + + get count() { + const node = this.walker.currentNode; + const index = this.resultIndex; + this.reset(); + + let result, count = 0; + while ((result = this.next())) ++count; + + this.resultIndex = index; + this.walker.currentNode = node; + + return count; + } + + reset() { + this.walker.currentNode = this.options.startNode || this.root; + this.resultIndex = -1; + } + + [Symbol.iterator]() { + return this; + } + + next({wrap = false} = {}) { + const { startNode } = this.options; + const { pattern, walker } = this; + + let { node, matchIndex = -1 } = this.lastResult || { node: startNode }; + + while (true) { + if (!node) + node = walker.nextNode(); + + if (!node) { + if (!wrap || this.resultIndex < 0) break; + + this.reset(); + + continue; + } + + let nextIndex = matchIndex + 1; + let matches = this._nodeMatches; + + if (!matches.length) { + matches = Array.from(node.textContent.matchAll(pattern)); + nextIndex = 0; + } + + if (matches[nextIndex]) { + this._nodeMatches = matches; + const m = matches[nextIndex]; + + this.lastResult = new FinderResult({ + node, + offset: m.index, + offsetEnd: m.index + m[0].length, + text: m[0], + match: m, + matchIndex: nextIndex, + index: ++this.resultIndex, + }); + + return { value: this.lastResult, done: false }; + } + + this._nodeMatches = []; + node = null; + } + + return { value: undefined, done: true }; + } + + /// TODO Call when the search text changes + retry() { + if (this.lastResult) { + this.lastResult.offsetEnd = this.lastResult.offset; + } + + } + + toJSON() { + const results = Array.from(this); + } +} + +function scrollParent(node) { + let elt = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; + + while (elt) { + if (elt.scrollHeight > elt.clientHeight) + return elt; + elt = elt.parentElement; + } +} + +function scrollToRect({top, height}, node, pad=20, padBottom=60) { + const scrollToTop = top - pad; + + let scrollBy = scrollToTop; + + if (scrollToTop >= 0) { + const visible = window.visualViewport; + const scrollToBottom = top + height + padBottom - visible.height; + // The top of the rect is already in the viewport + if (scrollToBottom <= 0 || scrollToTop === 0) + // Don't need to scroll up--or can't + return; + + scrollBy = Math.min(scrollToBottom, scrollBy); + } + + scrollParent(node).scrollBy({ top: scrollBy }); +} + +function withEncodedArg(fn) { + return function(encodedData, ...rest) { + const data = encodedData && JSON.parse(atob(encodedData)); + return fn(data, ...rest); + } +} + +function escapeRegex(s) { + return s.replace(/[.?*+^$\\()[\]{}]/g, '\\$&'); +} + +class FindState { + constructor(options) { + let { text, caseSensitive, regex } = options; + + if (!regex) + text = escapeRegex(text); + + const finder = new Finder(new RegExp(text, caseSensitive ? 'g' : 'ig')); + this.results = Array.from(finder); + this.index = -1; + this.options = options; + } + + get selected() { + return this.index > -1 ? this.results[this.index] : null; + } + + toJSON() { + return { + index: this.index > -1 ? this.index : null, + results: this.results, + count: this.results.length + }; + } + + selectNext(step=1) { + const index = this.index + step; + const result = this.results[index]; + if (result) { + this.index = index; + result.highlight(); + result.scrollTo(); + } + return result; + } + + selectPrevious() { + return this.selectNext(-1); + } +} + +CurrentFindState = null; + +const ExcludeKeys = new Set(['top', 'right', 'bottom', 'left']); +updateFind = withEncodedArg(options => { + // TODO Start at the current result position + // TODO Introduce slight delay, cap the number of results, and report results asynchronously + + let newFindState; + if (!options || !options.text) { + clearHighlightRects(); + return + } + + try { + newFindState = new FindState(options); + } catch (err) { + clearHighlightRects(); + throw err; + } + + if (newFindState.results.length) { + let selected = CurrentFindState && CurrentFindState.selected; + let selectIndex = 0; + if (selected) { + let {node: currentNode, offset: currentOffset} = selected; + selectIndex = newFindState.results.findIndex(r => { + if (r.node === currentNode) { + return r.offset >= currentOffset; + } + + let relation = currentNode.compareDocumentPosition(r.node); + return Boolean(relation & Node.DOCUMENT_POSITION_FOLLOWING); + }); + } + + newFindState.selectNext(selectIndex+1); + } else { + clearHighlightRects(); + } + + CurrentFindState = newFindState; + return btoa(JSON.stringify(CurrentFindState, (k, v) => (ExcludeKeys.has(k) ? undefined : v))); +}); + +selectNextResult = withEncodedArg(options => { + if (CurrentFindState) + CurrentFindState.selectNext(); +}); + +selectPreviousResult = withEncodedArg(options => { + if (CurrentFindState) + CurrentFindState.selectPrevious(); +}); + +function endFind() { + clearHighlightRects() + CurrentFindState = null; +} diff --git a/Multiplatform/Shared/Article/page.html b/Multiplatform/Shared/Article/page.html new file mode 100644 index 000000000..0d7d9676d --- /dev/null +++ b/Multiplatform/Shared/Article/page.html @@ -0,0 +1,22 @@ + + + [[title]] + + + + + + + + + + [[body]] + + diff --git a/Multiplatform/Shared/Article/styleSheet.css b/Multiplatform/Shared/Article/styleSheet.css new file mode 100644 index 000000000..98e09e971 --- /dev/null +++ b/Multiplatform/Shared/Article/styleSheet.css @@ -0,0 +1,66 @@ +body { + margin-top: 3px; + margin-bottom: 20px; + padding-left: 20px; + padding-right: 20px; + + word-break: break-word; + -webkit-hyphens: auto; + -webkit-text-size-adjust: none; +} + +:root { + color-scheme: light dark; + font: -apple-system-body; + font-size: [[font-size]]px; + --primary-accent-color: #086AEE; + --secondary-accent-color: #086AEE; + --block-quote-border-color: rgba(8, 106, 238, 0.75); +} + +@media(prefers-color-scheme: dark) { + :root { + --primary-accent-color: #2D80F1; + --secondary-accent-color: #5E9EF4; + --block-quote-border-color: rgba(94, 158, 244, 0.75); + --header-table-border-color: rgba(255, 255, 255, 0.2); + } +} + +body a, body a:link, body a:visited { + color: var(--secondary-accent-color); +} +body .header { + font: -apple-system-body; + font-size: [[font-size]]px; +} +body .header a:link, body .header a:visited { + color: var(--primary-accent-color); +} + +.avatar img { + border-radius: 4px; +} + +pre { + border: 1px solid var(--secondary-accent-color); + padding: 5px; +} + +.nnw-overflow table { + border: 1px solid var(--secondary-accent-color); +} + +.activityIndicatorWrap { + position: relative; +} + +.activityIndicator { + z-index: 1; + width: 64px; + height: 64px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/Multiplatform/Shared/Assets.xcassets/IconBackgroundColor.colorset/Contents.json b/Multiplatform/Shared/Assets.xcassets/IconBackgroundColor.colorset/Contents.json new file mode 100644 index 000000000..49db4ebbe --- /dev/null +++ b/Multiplatform/Shared/Assets.xcassets/IconBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.929", + "green" : "0.922", + "red" : "0.922" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.220", + "green" : "0.220", + "red" : "0.220" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Multiplatform/Shared/SceneModel.swift b/Multiplatform/Shared/SceneModel.swift index 250df5031..67f198928 100644 --- a/Multiplatform/Shared/SceneModel.swift +++ b/Multiplatform/Shared/SceneModel.swift @@ -8,6 +8,7 @@ import Foundation import Account +import Articles final class SceneModel: ObservableObject { @@ -18,11 +19,25 @@ final class SceneModel: ObservableObject { var articleModel: ArticleModel? private var refreshProgressModel: RefreshProgressModel? = nil + #if os(iOS) + private var _webViewProvider: WebViewProvider? = nil + #endif + // MARK: API + func startup() { self.refreshProgressModel = RefreshProgressModel() self.refreshProgressModel!.$state.assign(to: self.$refreshProgressState) + + #if os(iOS) + self._webViewProvider = WebViewProvider(sceneModel: self) + #endif } + + func articleFor(_ articleID: String) -> Article? { + return timelineModel?.articleFor(articleID) + } + } // MARK: SidebarModelDelegate @@ -48,6 +63,29 @@ extension SceneModel: TimelineModelDelegate { // MARK: ArticleModelDelegate extension SceneModel: ArticleModelDelegate { + + #if os(iOS) + var webViewProvider: WebViewProvider? { + return _webViewProvider + } + #endif + func findPrevArticle(_: ArticleModel, article: Article) -> Article? { + return timelineModel?.findPrevArticle(article) + } + + func findNextArticle(_: ArticleModel, article: Article) -> Article? { + return timelineModel?.findNextArticle(article) + } + + func selectArticle(_: ArticleModel, article: Article) { + timelineModel?.selectArticle(article) + } + +} + +// MARK: Private + +private extension SceneModel { } diff --git a/Multiplatform/Shared/Timeline/TimeilneToolbarModifier.swift b/Multiplatform/Shared/Timeline/TimeilneToolbarModifier.swift index e23cdc368..24fb9ae45 100644 --- a/Multiplatform/Shared/Timeline/TimeilneToolbarModifier.swift +++ b/Multiplatform/Shared/Timeline/TimeilneToolbarModifier.swift @@ -6,8 +6,6 @@ // Copyright © 2020 Ranchero Software. All rights reserved. // -import Foundation - import SwiftUI struct TimelineToolbarModifier: ViewModifier { diff --git a/Multiplatform/Shared/Timeline/TimelineContainerView.swift b/Multiplatform/Shared/Timeline/TimelineContainerView.swift index 92b371cbb..8329e14d7 100644 --- a/Multiplatform/Shared/Timeline/TimelineContainerView.swift +++ b/Multiplatform/Shared/Timeline/TimelineContainerView.swift @@ -18,6 +18,7 @@ struct TimelineContainerView: View { @ViewBuilder var body: some View { if let feed = feed { TimelineView() + .modifier(TimelineTitleModifier(title: feed.nameForDisplay)) .modifier(TimelineToolbarModifier()) .environmentObject(timelineModel) .onAppear { diff --git a/Multiplatform/Shared/Timeline/TimelineModel.swift b/Multiplatform/Shared/Timeline/TimelineModel.swift index 307bf0f03..3d448761a 100644 --- a/Multiplatform/Shared/Timeline/TimelineModel.swift +++ b/Multiplatform/Shared/Timeline/TimelineModel.swift @@ -27,8 +27,21 @@ class TimelineModel: ObservableObject { private var exceptionArticleFetcher: ArticleFetcher? private var isReadFiltered = false - private var articles = [Article]() + private var articles = [Article]() { + didSet { + articleDictionaryNeedsUpdate = true + } + } + private var articleDictionaryNeedsUpdate = true + private var _idToArticleDictionary = [String: Article]() + private var idToAticleDictionary: [String: Article] { + if articleDictionaryNeedsUpdate { + rebuildArticleDictionaries() + } + return _idToArticleDictionary + } + private var sortDirection = AppDefaults.shared.timelineSortDirection { didSet { if sortDirection != oldValue { @@ -63,6 +76,28 @@ class TimelineModel: ObservableObject { } } + func articleFor(_ articleID: String) -> Article? { + return idToAticleDictionary[articleID] + } + + func findPrevArticle(_ article: Article) -> Article? { + guard let index = articles.firstIndex(of: article), index > 0 else { + return nil + } + return articles[index - 1] + } + + func findNextArticle(_ article: Article) -> Article? { + guard let index = articles.firstIndex(of: article), index + 1 != articles.count else { + return nil + } + return articles[index + 1] + } + + func selectArticle(_ article: Article) { + // TODO: Implement me! + } + } // MARK: Private @@ -82,6 +117,15 @@ private extension TimelineModel { // restoreSelection(savedSelection) } + func rebuildArticleDictionaries() { + var idDictionary = [String: Article]() + articles.forEach { article in + idDictionary[article.articleID] = article + } + _idToArticleDictionary = idDictionary + articleDictionaryNeedsUpdate = false + } + // MARK: Article Fetching func fetchAndReplaceArticlesAsync() { diff --git a/Multiplatform/Shared/Timeline/TimelineTitleModifier.swift b/Multiplatform/Shared/Timeline/TimelineTitleModifier.swift new file mode 100644 index 000000000..a1956c77a --- /dev/null +++ b/Multiplatform/Shared/Timeline/TimelineTitleModifier.swift @@ -0,0 +1,23 @@ +// +// TimelineTitleModifier.swift +// NetNewsWire +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import SwiftUI + +struct TimelineTitleModifier: ViewModifier { + + var title: String + + func body(content: Content) -> some View { + #if os(macOS) + return content + #endif + #if os(iOS) + return content.navigationBarTitle(Text(verbatim: title), displayMode: .inline) + #endif + } +} diff --git a/Multiplatform/iOS/Article/ArticleExtractorButtonState.swift b/Multiplatform/iOS/Article/ArticleExtractorButtonState.swift new file mode 100644 index 000000000..6c9f6d04a --- /dev/null +++ b/Multiplatform/iOS/Article/ArticleExtractorButtonState.swift @@ -0,0 +1,16 @@ +// +// ArticleExtractorButtonState.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation + +enum ArticleExtractorButtonState { + case error + case animated + case on + case off +} diff --git a/Multiplatform/iOS/Article/ArticleIconSchemeHandler.swift b/Multiplatform/iOS/Article/ArticleIconSchemeHandler.swift new file mode 100644 index 000000000..4e18cccb7 --- /dev/null +++ b/Multiplatform/iOS/Article/ArticleIconSchemeHandler.swift @@ -0,0 +1,60 @@ +// +// ArticleIconSchemeHandler.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import WebKit +import Articles + +class ArticleIconSchemeHandler: NSObject, WKURLSchemeHandler { + + weak var sceneModel: SceneModel? + + init(sceneModel: SceneModel) { + self.sceneModel = sceneModel + } + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + + guard let url = urlSchemeTask.request.url, let sceneModel = sceneModel else { + urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist)) + return + } + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return + } + let articleID = components.path + guard let iconImage = sceneModel.articleFor(articleID)?.iconImage() else { + urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist)) + return + } + + let iconView = IconView(frame: CGRect(x: 0, y: 0, width: 48, height: 48)) + iconView.iconImage = iconImage + let renderedImage = iconView.asImage() + + guard let data = renderedImage.dataRepresentation() else { + urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist)) + return + } + + let headerFields = ["Cache-Control": "no-cache"] + if let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headerFields) { + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + urlSchemeTask.didFailWithError(URLError(.unknown)) + } + +} + diff --git a/Multiplatform/iOS/Article/ArticleView.swift b/Multiplatform/iOS/Article/ArticleView.swift index c9a807d2b..045c29501 100644 --- a/Multiplatform/iOS/Article/ArticleView.swift +++ b/Multiplatform/iOS/Article/ArticleView.swift @@ -7,15 +7,43 @@ // import SwiftUI +import Articles -struct ArticleView: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } +final class ArticleView: UIViewControllerRepresentable { + + var sceneModel: SceneModel + var articleModel: ArticleModel + var article: Article + + init(sceneModel: SceneModel, articleModel: ArticleModel, article: Article) { + self.sceneModel = sceneModel + self.articleModel = articleModel + self.article = article + sceneModel.articleModel = articleModel + articleModel.delegate = sceneModel + } + + func makeUIViewController(context: Context) -> ArticleViewController { + let controller = ArticleViewController() + controller.articleModel = articleModel + controller.article = article + return controller + } + + func updateUIViewController(_ uiViewController: ArticleViewController, context: Context) { + + } + } -struct ArticleView_Previews: PreviewProvider { - static var previews: some View { - ArticleView() - } -} +//struct ArticleView: View { +// +// var sceneModel: SceneModel +// var articleModel: ArticleModel +// var article: Article +// +// var body: some View { +// ArticleViewControllerAdapter(sceneModel: sceneModel, articleModel: articleModel, article: article) +// } +// +//} diff --git a/Multiplatform/iOS/Article/ArticleViewController.swift b/Multiplatform/iOS/Article/ArticleViewController.swift new file mode 100644 index 000000000..4eedc9f5e --- /dev/null +++ b/Multiplatform/iOS/Article/ArticleViewController.swift @@ -0,0 +1,172 @@ +// +// ArticleViewController.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit +import WebKit +import Account +import Articles +import SafariServices + +class ArticleViewController: UIViewController { + + weak var articleModel: ArticleModel? + + private var pageViewController: UIPageViewController! + + private var currentWebViewController: WebViewController? { + return pageViewController?.viewControllers?.first as? WebViewController + } + + var article: Article? { + didSet { + if let controller = currentWebViewController, controller.article != article { + controller.setArticle(article) + DispatchQueue.main.async { + // You have to set the view controller to clear out the UIPageViewController child controller cache. + // You also have to do it in an async call or you will get a strange assertion error. + self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil) + } + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:]) + pageViewController.delegate = self + pageViewController.dataSource = self + + pageViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(pageViewController.view) + addChild(pageViewController!) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: pageViewController.view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: pageViewController.view.trailingAnchor), + view.topAnchor.constraint(equalTo: pageViewController.view.topAnchor), + view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor) + ]) + + let controller = createWebViewController(article, updateView: true) + self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil) + + } + + // MARK: API + + func focus() { + currentWebViewController?.focus() + } + + func canScrollDown() -> Bool { + return currentWebViewController?.canScrollDown() ?? false + } + + func scrollPageDown() { + currentWebViewController?.scrollPageDown() + } + + func stopArticleExtractorIfProcessing() { + currentWebViewController?.stopArticleExtractorIfProcessing() + } + +} + + +// MARK: WebViewControllerDelegate + +extension ArticleViewController: WebViewControllerDelegate { + + func webViewController(_ webViewController: WebViewController, articleExtractorButtonStateDidUpdate buttonState: ArticleExtractorButtonState) { + if webViewController === currentWebViewController { +// articleExtractorButton.buttonState = buttonState + } + } + +} + +// MARK: UIPageViewControllerDataSource + +extension ArticleViewController: UIPageViewControllerDataSource { + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let webViewController = viewController as? WebViewController, + let currentArticle = webViewController.article, + let article = articleModel?.findPrevArticle(currentArticle) else { + return nil + } + return createWebViewController(article) + } + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let webViewController = viewController as? WebViewController, + let currentArticle = webViewController.article, + let article = articleModel?.findNextArticle(currentArticle) else { + return nil + } + return createWebViewController(article) + } + +} + +// MARK: UIPageViewControllerDelegate + +extension ArticleViewController: UIPageViewControllerDelegate { + + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + guard finished, completed else { return } + guard let article = currentWebViewController?.article else { return } + + articleModel?.selectArticle(article) +// articleExtractorButton.buttonState = currentWebViewController?.articleExtractorButtonState ?? .off + + previousViewControllers.compactMap({ $0 as? WebViewController }).forEach({ $0.stopWebViewActivity() }) + } + +} + +// MARK: UIGestureRecognizerDelegate + +extension ArticleViewController: UIGestureRecognizerDelegate { + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + let point = gestureRecognizer.location(in: nil) + if point.x > 40 { + return true + } + return false + } + +} + +// MARK: Private + +private extension ArticleViewController { + + func createWebViewController(_ article: Article?, updateView: Bool = true) -> WebViewController { + let controller = WebViewController() + controller.articleModel = articleModel + controller.delegate = self + controller.setArticle(article, updateView: updateView) + return controller + } + + func resetWebViewController() { + articleModel?.webViewProvider?.flushQueue() + articleModel?.webViewProvider?.replenishQueueIfNeeded() + if let controller = currentWebViewController { + controller.fullReload() + self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil) + } + } + +} diff --git a/Multiplatform/iOS/Article/IconView.swift b/Multiplatform/iOS/Article/IconView.swift new file mode 100644 index 000000000..ebd24e5be --- /dev/null +++ b/Multiplatform/iOS/Article/IconView.swift @@ -0,0 +1,124 @@ +// +// IconView.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit + +final class IconView: UIView { + + var iconImage: IconImage? = nil { + didSet { + if iconImage !== oldValue { + imageView.image = iconImage?.image + + if self.traitCollection.userInterfaceStyle == .dark { + if self.iconImage?.isDark ?? false { + self.isDisconcernable = false + self.setNeedsLayout() + } else { + self.isDisconcernable = true + self.setNeedsLayout() + } + } else { + if self.iconImage?.isBright ?? false { + self.isDisconcernable = false + self.setNeedsLayout() + } else { + self.isDisconcernable = true + self.setNeedsLayout() + } + } + self.setNeedsLayout() + } + } + } + + private var isDisconcernable = true + + private let imageView: UIImageView = { + let imageView = UIImageView(image: AppAssets.faviconTemplateImage) + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 4.0 + return imageView + }() + + private var isVerticalBackgroundExposed: Bool { + return imageView.frame.size.height < bounds.size.height + } + + private var isSymbolImage: Bool { + return imageView.image?.isSymbolImage ?? false + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + convenience init() { + self.init(frame: .zero) + } + + override func didMoveToSuperview() { + setNeedsLayout() + } + + override func layoutSubviews() { + imageView.setFrameIfNotEqual(rectForImageView()) + if (iconImage != nil && isVerticalBackgroundExposed && !isSymbolImage) || !isDisconcernable { + backgroundColor = AppAssets.iconBackgroundColor + } else { + backgroundColor = nil + } + } + +} + +private extension IconView { + + func commonInit() { + layer.cornerRadius = 4 + clipsToBounds = true + addSubview(imageView) + } + + func rectForImageView() -> CGRect { + guard let image = iconImage?.image else { + return CGRect.zero + } + + let imageSize = image.size + let viewSize = bounds.size + if imageSize.height == imageSize.width { + if imageSize.height >= viewSize.height * 0.75 { + // Close enough to viewSize to scale up the image. + return CGRect(x: 0.0, y: 0.0, width: viewSize.width, height: viewSize.height) + } + let offset = floor((viewSize.height - imageSize.height) / 2.0) + return CGRect(x: offset, y: offset, width: imageSize.width, height: imageSize.height) + } + else if imageSize.height > imageSize.width { + let factor = viewSize.height / imageSize.height + let width = imageSize.width * factor + let originX = floor((viewSize.width - width) / 2.0) + return CGRect(x: originX, y: 0.0, width: width, height: viewSize.height) + } + + // Wider than tall: imageSize.width > imageSize.height + let factor = viewSize.width / imageSize.width + let height = imageSize.height * factor + let originY = floor((viewSize.height - height) / 2.0) + return CGRect(x: 0.0, y: originY, width: viewSize.width, height: height) + } + +} diff --git a/Multiplatform/iOS/Article/ImageScrollView.swift b/Multiplatform/iOS/Article/ImageScrollView.swift new file mode 100644 index 000000000..31fe3289c --- /dev/null +++ b/Multiplatform/iOS/Article/ImageScrollView.swift @@ -0,0 +1,361 @@ +// +// ImageScrollView.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit + +@objc public protocol ImageScrollViewDelegate: UIScrollViewDelegate { + func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView) + func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView) +} + +open class ImageScrollView: UIScrollView { + + @objc public enum ScaleMode: Int { + case aspectFill + case aspectFit + case widthFill + case heightFill + } + + @objc public enum Offset: Int { + case begining + case center + } + + static let kZoomInFactorFromMinWhenDoubleTap: CGFloat = 2 + + @objc open var imageContentMode: ScaleMode = .widthFill + @objc open var initialOffset: Offset = .begining + + @objc public private(set) var zoomView: UIImageView? = nil + + @objc open weak var imageScrollViewDelegate: ImageScrollViewDelegate? + + var imageSize: CGSize = CGSize.zero + private var pointToCenterAfterResize: CGPoint = CGPoint.zero + private var scaleToRestoreAfterResize: CGFloat = 1.0 + var maxScaleFromMinScale: CGFloat = 3.0 + + var zoomedFrame: CGRect { + return zoomView?.frame ?? CGRect.zero + } + + override open var frame: CGRect { + willSet { + if frame.equalTo(newValue) == false && newValue.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false { + prepareToResize() + } + } + + didSet { + if frame.equalTo(oldValue) == false && frame.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false { + recoverFromResizing() + } + } + } + + override public init(frame: CGRect) { + super.init(frame: frame) + + initialize() + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + + initialize() + } + + private func initialize() { + showsVerticalScrollIndicator = false + showsHorizontalScrollIndicator = false + bouncesZoom = true + decelerationRate = UIScrollView.DecelerationRate.fast + delegate = self + } + + @objc public func adjustFrameToCenter() { + + guard let unwrappedZoomView = zoomView else { + return + } + + var frameToCenter = unwrappedZoomView.frame + + // center horizontally + if frameToCenter.size.width < bounds.width { + frameToCenter.origin.x = (bounds.width - frameToCenter.size.width) / 2 + } else { + frameToCenter.origin.x = 0 + } + + // center vertically + if frameToCenter.size.height < bounds.height { + frameToCenter.origin.y = (bounds.height - frameToCenter.size.height) / 2 + } else { + frameToCenter.origin.y = 0 + } + + unwrappedZoomView.frame = frameToCenter + } + + private func prepareToResize() { + let boundsCenter = CGPoint(x: bounds.midX, y: bounds.midY) + pointToCenterAfterResize = convert(boundsCenter, to: zoomView) + + scaleToRestoreAfterResize = zoomScale + + // If we're at the minimum zoom scale, preserve that by returning 0, which will be converted to the minimum + // allowable scale when the scale is restored. + if scaleToRestoreAfterResize <= minimumZoomScale + CGFloat(Float.ulpOfOne) { + scaleToRestoreAfterResize = 0 + } + } + + private func recoverFromResizing() { + setMaxMinZoomScalesForCurrentBounds() + + // restore zoom scale, first making sure it is within the allowable range. + let maxZoomScale = max(minimumZoomScale, scaleToRestoreAfterResize) + zoomScale = min(maximumZoomScale, maxZoomScale) + + // restore center point, first making sure it is within the allowable range. + + // convert our desired center point back to our own coordinate space + let boundsCenter = convert(pointToCenterAfterResize, to: zoomView) + + // calculate the content offset that would yield that center point + var offset = CGPoint(x: boundsCenter.x - bounds.size.width/2.0, y: boundsCenter.y - bounds.size.height/2.0) + + // restore offset, adjusted to be within the allowable range + let maxOffset = maximumContentOffset() + let minOffset = minimumContentOffset() + + var realMaxOffset = min(maxOffset.x, offset.x) + offset.x = max(minOffset.x, realMaxOffset) + + realMaxOffset = min(maxOffset.y, offset.y) + offset.y = max(minOffset.y, realMaxOffset) + + contentOffset = offset + } + + private func maximumContentOffset() -> CGPoint { + return CGPoint(x: contentSize.width - bounds.width,y:contentSize.height - bounds.height) + } + + private func minimumContentOffset() -> CGPoint { + return CGPoint.zero + } + + // MARK: - Set up + + open func setup() { + var topSupperView = superview + + while topSupperView?.superview != nil { + topSupperView = topSupperView?.superview + } + + // Make sure views have already layout with precise frame + topSupperView?.layoutIfNeeded() + } + + // MARK: - Display image + + @objc open func display(image: UIImage) { + + if let zoomView = zoomView { + zoomView.removeFromSuperview() + } + + zoomView = UIImageView(image: image) + zoomView!.isUserInteractionEnabled = true + addSubview(zoomView!) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(doubleTapGestureRecognizer(_:))) + tapGesture.numberOfTapsRequired = 2 + zoomView!.addGestureRecognizer(tapGesture) + + let downSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeUpGestureRecognizer(_:))) + downSwipeGesture.direction = .down + zoomView!.addGestureRecognizer(downSwipeGesture) + + let upSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeDownGestureRecognizer(_:))) + upSwipeGesture.direction = .up + zoomView!.addGestureRecognizer(upSwipeGesture) + + configureImageForSize(image.size) + adjustFrameToCenter() + } + + private func configureImageForSize(_ size: CGSize) { + imageSize = size + contentSize = imageSize + setMaxMinZoomScalesForCurrentBounds() + zoomScale = minimumZoomScale + + switch initialOffset { + case .begining: + contentOffset = CGPoint.zero + case .center: + let xOffset = contentSize.width < bounds.width ? 0 : (contentSize.width - bounds.width)/2 + let yOffset = contentSize.height < bounds.height ? 0 : (contentSize.height - bounds.height)/2 + + switch imageContentMode { + case .aspectFit: + contentOffset = CGPoint.zero + case .aspectFill: + contentOffset = CGPoint(x: xOffset, y: yOffset) + case .heightFill: + contentOffset = CGPoint(x: xOffset, y: 0) + case .widthFill: + contentOffset = CGPoint(x: 0, y: yOffset) + } + } + } + + private func setMaxMinZoomScalesForCurrentBounds() { + // calculate min/max zoomscale + let xScale = bounds.width / imageSize.width // the scale needed to perfectly fit the image width-wise + let yScale = bounds.height / imageSize.height // the scale needed to perfectly fit the image height-wise + + var minScale: CGFloat = 1 + + switch imageContentMode { + case .aspectFill: + minScale = max(xScale, yScale) + case .aspectFit: + minScale = min(xScale, yScale) + case .widthFill: + minScale = xScale + case .heightFill: + minScale = yScale + } + + + let maxScale = maxScaleFromMinScale*minScale + + // don't let minScale exceed maxScale. (If the image is smaller than the screen, we don't want to force it to be zoomed.) + if minScale > maxScale { + minScale = maxScale + } + + maximumZoomScale = maxScale + minimumZoomScale = minScale // * 0.999 // the multiply factor to prevent user cannot scroll page while they use this control in UIPageViewController + } + + // MARK: - Gesture + + @objc func doubleTapGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) { + // zoom out if it bigger than middle scale point. Else, zoom in + if zoomScale >= maximumZoomScale / 2.0 { + setZoomScale(minimumZoomScale, animated: true) + } else { + let center = gestureRecognizer.location(in: gestureRecognizer.view) + let zoomRect = zoomRectForScale(ImageScrollView.kZoomInFactorFromMinWhenDoubleTap * minimumZoomScale, center: center) + zoom(to: zoomRect, animated: true) + } + } + + @objc func swipeUpGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) { + if gestureRecognizer.state == .ended { + imageScrollViewDelegate?.imageScrollViewDidGestureSwipeUp(imageScrollView: self) + } + } + + @objc func swipeDownGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) { + if gestureRecognizer.state == .ended { + imageScrollViewDelegate?.imageScrollViewDidGestureSwipeDown(imageScrollView: self) + } + } + + private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect { + var zoomRect = CGRect.zero + + // the zoom rect is in the content view's coordinates. + // at a zoom scale of 1.0, it would be the size of the imageScrollView's bounds. + // as the zoom scale decreases, so more content is visible, the size of the rect grows. + zoomRect.size.height = frame.size.height / scale + zoomRect.size.width = frame.size.width / scale + + // choose an origin so as to get the right center. + zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0) + zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0) + + return zoomRect + } + + open func refresh() { + if let image = zoomView?.image { + display(image: image) + } + } + + open func resize() { + self.configureImageForSize(self.imageSize) + } +} + +extension ImageScrollView: UIScrollViewDelegate { + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewDidScroll?(scrollView) + } + + public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewWillBeginDragging?(scrollView) + } + + public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + imageScrollViewDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset) + } + + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + imageScrollViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) + } + + public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewWillBeginDecelerating?(scrollView) + } + + public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewDidEndDecelerating?(scrollView) + } + + public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewDidEndScrollingAnimation?(scrollView) + } + + public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { + imageScrollViewDelegate?.scrollViewWillBeginZooming?(scrollView, with: view) + } + + public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { + imageScrollViewDelegate?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale) + } + + public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + return false + } + + @available(iOS 11.0, *) + public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewDidChangeAdjustedContentInset?(scrollView) + } + + public func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return zoomView + } + + public func scrollViewDidZoom(_ scrollView: UIScrollView) { + adjustFrameToCenter() + imageScrollViewDelegate?.scrollViewDidZoom?(scrollView) + } + +} diff --git a/Multiplatform/iOS/Article/ImageTransition.swift b/Multiplatform/iOS/Article/ImageTransition.swift new file mode 100644 index 000000000..01f460348 --- /dev/null +++ b/Multiplatform/iOS/Article/ImageTransition.swift @@ -0,0 +1,110 @@ +// +// ImageTransition.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit + +class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning { + + private weak var webViewController: WebViewController? + private let duration = 0.4 + var presenting = true + var originFrame: CGRect! + var maskFrame: CGRect! + var originImage: UIImage! + + init(controller: WebViewController) { + self.webViewController = controller + } + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return duration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + if presenting { + animateTransitionPresenting(using: transitionContext) + } else { + animateTransitionReturning(using: transitionContext) + } + } + + private func animateTransitionPresenting(using transitionContext: UIViewControllerContextTransitioning) { + + let imageView = UIImageView(image: originImage) + imageView.frame = originFrame + + let fromView = transitionContext.view(forKey: .from)! + fromView.removeFromSuperview() + + transitionContext.containerView.backgroundColor = .systemBackground + transitionContext.containerView.addSubview(imageView) + + webViewController?.hideClickedImage() + + UIView.animate( + withDuration: duration, + delay:0.0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.2, + animations: { + let imageController = transitionContext.viewController(forKey: .to) as! ImageViewController + imageView.frame = imageController.zoomedFrame + }, completion: { _ in + imageView.removeFromSuperview() + let toView = transitionContext.view(forKey: .to)! + transitionContext.containerView.addSubview(toView) + transitionContext.completeTransition(true) + }) + } + + private func animateTransitionReturning(using transitionContext: UIViewControllerContextTransitioning) { + let imageController = transitionContext.viewController(forKey: .from) as! ImageViewController + let imageView = UIImageView(image: originImage) + imageView.frame = imageController.zoomedFrame + + let fromView = transitionContext.view(forKey: .from)! + let windowFrame = fromView.window!.frame + fromView.removeFromSuperview() + + let toView = transitionContext.view(forKey: .to)! + transitionContext.containerView.addSubview(toView) + + let maskingView = UIView() + + let fullMaskFrame = CGRect(x: windowFrame.minX, y: maskFrame.minY, width: windowFrame.width, height: maskFrame.height) + let path = UIBezierPath(rect: fullMaskFrame) + let maskLayer = CAShapeLayer() + maskLayer.path = path.cgPath + maskingView.layer.mask = maskLayer + + maskingView.addSubview(imageView) + transitionContext.containerView.addSubview(maskingView) + + UIView.animate( + withDuration: duration, + delay:0.0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.2, + animations: { + imageView.frame = self.originFrame + }, completion: { _ in + if let controller = self.webViewController { + controller.showClickedImage() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + imageView.removeFromSuperview() + transitionContext.completeTransition(true) + } + } + } else { + imageView.removeFromSuperview() + transitionContext.completeTransition(true) + } + }) + } + +} diff --git a/Multiplatform/iOS/Article/ImageViewController.swift b/Multiplatform/iOS/Article/ImageViewController.swift new file mode 100644 index 000000000..4e9ae9466 --- /dev/null +++ b/Multiplatform/iOS/Article/ImageViewController.swift @@ -0,0 +1,92 @@ +// +// ImageViewController.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit + +class ImageViewController: UIViewController { + + @IBOutlet weak var closeButton: UIButton! + @IBOutlet weak var shareButton: UIButton! + @IBOutlet weak var imageScrollView: ImageScrollView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var titleBackground: UIVisualEffectView! + @IBOutlet weak var titleLeading: NSLayoutConstraint! + @IBOutlet weak var titleTrailing: NSLayoutConstraint! + + var image: UIImage! + var imageTitle: String? + var zoomedFrame: CGRect { + return imageScrollView.zoomedFrame + } + + override func viewDidLoad() { + super.viewDidLoad() + + closeButton.imageView?.contentMode = .scaleAspectFit + closeButton.accessibilityLabel = NSLocalizedString("Close", comment: "Close") + shareButton.accessibilityLabel = NSLocalizedString("Share", comment: "Share") + + imageScrollView.setup() + imageScrollView.imageScrollViewDelegate = self + imageScrollView.imageContentMode = .aspectFit + imageScrollView.initialOffset = .center + imageScrollView.display(image: image) + + titleLabel.text = imageTitle ?? "" + layoutTitleLabel() + + guard imageTitle != "" else { + titleBackground.removeFromSuperview() + return + } + titleBackground.layer.cornerRadius = 6 + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate(alongsideTransition: { [weak self] context in + self?.imageScrollView.resize() + }) + } + + @IBAction func share(_ sender: Any) { + guard let image = image else { return } + let activityViewController = UIActivityViewController(activityItems: [image], applicationActivities: nil) + activityViewController.popoverPresentationController?.sourceView = shareButton + activityViewController.popoverPresentationController?.sourceRect = shareButton.bounds + present(activityViewController, animated: true) + } + + @IBAction func done(_ sender: Any) { + dismiss(animated: true) + } + + private func layoutTitleLabel(){ + let width = view.frame.width + let multiplier = UIDevice.current.userInterfaceIdiom == .pad ? CGFloat(0.1) : CGFloat(0.04) + titleLeading.constant += width * multiplier + titleTrailing.constant -= width * multiplier + titleLabel.layoutIfNeeded() + } +} + +// MARK: ImageScrollViewDelegate + +extension ImageViewController: ImageScrollViewDelegate { + + func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView) { + dismiss(animated: true) + } + + func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView) { + dismiss(animated: true) + } + + +} + diff --git a/Multiplatform/iOS/Article/PreloadedWebView.swift b/Multiplatform/iOS/Article/PreloadedWebView.swift new file mode 100644 index 000000000..944e1d578 --- /dev/null +++ b/Multiplatform/iOS/Article/PreloadedWebView.swift @@ -0,0 +1,75 @@ +// +// PreloadedWebView.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import WebKit + +class PreloadedWebView: WKWebView { + + private var isReady: Bool = false + private var readyCompletion: ((PreloadedWebView) -> Void)? + + init(articleIconSchemeHandler: ArticleIconSchemeHandler) { + let preferences = WKPreferences() + preferences.javaScriptCanOpenWindowsAutomatically = false + preferences.javaScriptEnabled = true + + let configuration = WKWebViewConfiguration() + configuration.preferences = preferences + configuration.setValue(true, forKey: "allowUniversalAccessFromFileURLs") + configuration.allowsInlineMediaPlayback = true + configuration.mediaTypesRequiringUserActionForPlayback = .audio + configuration.setURLSchemeHandler(articleIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme) + + super.init(frame: .zero, configuration: configuration) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + func preload() { + navigationDelegate = self + loadFileURL(ArticleRenderer.blank.url, allowingReadAccessTo: ArticleRenderer.blank.baseURL) + } + + func ready(completion: @escaping (PreloadedWebView) -> Void) { + if isReady { + completeRequest(completion: completion) + } else { + readyCompletion = completion + } + } + +} + +// MARK: WKScriptMessageHandler + +extension PreloadedWebView: WKNavigationDelegate { + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + isReady = true + if let completion = readyCompletion { + completeRequest(completion: completion) + readyCompletion = nil + } + } + +} + +// MARK: Private + +private extension PreloadedWebView { + + func completeRequest(completion: @escaping (PreloadedWebView) -> Void) { + isReady = false + navigationDelegate = nil + completion(self) + } + +} diff --git a/Multiplatform/iOS/Article/WebViewController.swift b/Multiplatform/iOS/Article/WebViewController.swift new file mode 100644 index 000000000..794b07418 --- /dev/null +++ b/Multiplatform/iOS/Article/WebViewController.swift @@ -0,0 +1,772 @@ +// +// WebViewController.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit +import WebKit +import RSCore +import Account +import Articles +import SafariServices +import MessageUI + +protocol WebViewControllerDelegate: class { + func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState) +} + +class WebViewController: UIViewController { + + private struct MessageName { + static let imageWasClicked = "imageWasClicked" + static let imageWasShown = "imageWasShown" + static let showFeedInspector = "showFeedInspector" + } + + private var topShowBarsView: UIView! + private var bottomShowBarsView: UIView! + private var topShowBarsViewConstraint: NSLayoutConstraint! + private var bottomShowBarsViewConstraint: NSLayoutConstraint! + + private var webView: PreloadedWebView? { + guard view.subviews.count > 0 else { return nil } + return view.subviews[0] as? PreloadedWebView + } + +// private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self) + private var isFullScreenAvailable: Bool { + return AppDefaults.shared.articleFullscreenAvailable && traitCollection.userInterfaceIdiom == .phone // && coordinator.isRootSplitCollapsed + } + private lazy var transition = ImageTransition(controller: self) + private var clickedImageCompletion: (() -> Void)? + + private var articleExtractor: ArticleExtractor? = nil + var extractedArticle: ExtractedArticle? { + didSet { + windowScrollY = 0 + } + } + var isShowingExtractedArticle = false + + var articleExtractorButtonState: ArticleExtractorButtonState = .off { + didSet { + delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState) + } + } + + var articleModel: ArticleModel? + weak var delegate: WebViewControllerDelegate? + + private(set) var article: Article? + + let scrollPositionQueue = CoalescingQueue(name: "Article Scroll Position", interval: 0.3, maxInterval: 0.3) + var windowScrollY = 0 + + override func viewDidLoad() { + super.viewDidLoad() + + NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) + + // Configure the tap zones +// configureTopShowBarsView() +// configureBottomShowBarsView() + + loadWebView() + + } + + // MARK: Notifications + + @objc func webFeedIconDidBecomeAvailable(_ note: Notification) { + reloadArticleImage() + } + + @objc func avatarDidBecomeAvailable(_ note: Notification) { + reloadArticleImage() + } + + @objc func faviconDidBecomeAvailable(_ note: Notification) { + reloadArticleImage() + } + + // MARK: Actions + +// @objc func showBars(_ sender: Any) { +// showBars() +// } + + // MARK: API + + func setArticle(_ article: Article?, updateView: Bool = true) { + stopArticleExtractor() + + if article != self.article { + self.article = article + if updateView { + if article?.webFeed?.isArticleExtractorAlwaysOn ?? false { + startArticleExtractor() + } + windowScrollY = 0 + loadWebView() + } + } + + } + + func focus() { + webView?.becomeFirstResponder() + } + + func canScrollDown() -> Bool { + guard let webView = webView else { return false } + return webView.scrollView.contentOffset.y < finalScrollPosition() + } + + func scrollPageDown() { + guard let webView = webView else { return } + + let overlap = 2 * UIFont.systemFont(ofSize: UIFont.systemFontSize).lineHeight * UIScreen.main.scale + let scrollToY: CGFloat = { + let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.layoutMarginsGuide.layoutFrame.height - overlap + let final = finalScrollPosition() + return fullScroll < final ? fullScroll : final + }() + + let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView) + let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY) + webView.scrollView.setContentOffset(scrollToPoint, animated: true) + } + + func hideClickedImage() { + webView?.evaluateJavaScript("hideClickedImage();") + } + + func showClickedImage(completion: @escaping () -> Void) { + clickedImageCompletion = completion + webView?.evaluateJavaScript("showClickedImage();") + } + + func fullReload() { + loadWebView(replaceExistingWebView: true) + } + +// func showBars() { +// AppDefaults.shared.articleFullscreenEnabled = false +// coordinator.showStatusBar() +// topShowBarsViewConstraint?.constant = 0 +// bottomShowBarsViewConstraint?.constant = 0 +// navigationController?.setNavigationBarHidden(false, animated: true) +// navigationController?.setToolbarHidden(false, animated: true) +// configureContextMenuInteraction() +// } +// +// func hideBars() { +// if isFullScreenAvailable { +// AppDefaults.shared.articleFullscreenEnabled = true +// coordinator.hideStatusBar() +// topShowBarsViewConstraint?.constant = -44.0 +// bottomShowBarsViewConstraint?.constant = 44.0 +// navigationController?.setNavigationBarHidden(true, animated: true) +// navigationController?.setToolbarHidden(true, animated: true) +// configureContextMenuInteraction() +// } +// } + + func toggleArticleExtractor() { + + guard let article = article else { + return + } + + guard articleExtractor?.state != .processing else { + stopArticleExtractor() + loadWebView() + return + } + + guard !isShowingExtractedArticle else { + isShowingExtractedArticle = false + loadWebView() + articleExtractorButtonState = .off + return + } + + if let articleExtractor = articleExtractor { + if article.preferredLink == articleExtractor.articleLink { + isShowingExtractedArticle = true + loadWebView() + articleExtractorButtonState = .on + } + } else { + startArticleExtractor() + } + + } + + func stopArticleExtractorIfProcessing() { + if articleExtractor?.state == .processing { + stopArticleExtractor() + } + } + + func stopWebViewActivity() { + if let webView = webView { + stopMediaPlayback(webView) + cancelImageLoad(webView) + } + } + +} + +// MARK: ArticleExtractorDelegate + +extension WebViewController: ArticleExtractorDelegate { + + func articleExtractionDidFail(with: Error) { + stopArticleExtractor() + articleExtractorButtonState = .error + loadWebView() + } + + func articleExtractionDidComplete(extractedArticle: ExtractedArticle) { + if articleExtractor?.state != .cancelled { + self.extractedArticle = extractedArticle + isShowingExtractedArticle = true + loadWebView() + articleExtractorButtonState = .on + } + } + +} + +// MARK: UIContextMenuInteractionDelegate + +//extension WebViewController: UIContextMenuInteractionDelegate { +// func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { +// +// return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] suggestedActions in +// guard let self = self else { return nil } +// var actions = [UIAction]() +// +// if let action = self.prevArticleAction() { +// actions.append(action) +// } +// if let action = self.nextArticleAction() { +// actions.append(action) +// } +// if let action = self.toggleReadAction() { +// actions.append(action) +// } +// actions.append(self.toggleStarredAction()) +// if let action = self.nextUnreadArticleAction() { +// actions.append(action) +// } +// actions.append(self.toggleArticleExtractorAction()) +// actions.append(self.shareAction()) +// +// return UIMenu(title: "", children: actions) +// } +// } +// +// func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { +// coordinator.showBrowserForCurrentArticle() +// } +// +//} + +// MARK: WKNavigationDelegate + +extension WebViewController: WKNavigationDelegate { + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + for (index, view) in view.subviews.enumerated() { + if index != 0, let oldWebView = view as? PreloadedWebView { + oldWebView.removeFromSuperview() + } + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + + if navigationAction.navigationType == .linkActivated { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + if components?.scheme == "http" || components?.scheme == "https" { + decisionHandler(.cancel) + + // If the resource cannot be opened with an installed app, present the web view. + UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in + assert(Thread.isMainThread) + guard didOpen == false else { + return + } + let vc = SFSafariViewController(url: url) + self.present(vc, animated: true) + } + } else if components?.scheme == "mailto" { + decisionHandler(.cancel) + + guard let emailAddress = url.emailAddress else { + return + } + + if MFMailComposeViewController.canSendMail() { + let mailComposeViewController = MFMailComposeViewController() + mailComposeViewController.setToRecipients([emailAddress]) + mailComposeViewController.mailComposeDelegate = self + self.present(mailComposeViewController, animated: true, completion: {}) + } else { + let alert = UIAlertController(title: NSLocalizedString("Error", comment: "Error"), message: NSLocalizedString("This device cannot send emails.", comment: "This device cannot send emails."), preferredStyle: .alert) + alert.addAction(.init(title: NSLocalizedString("Dismiss", comment: "Dismiss"), style: .cancel, handler: nil)) + self.present(alert, animated: true, completion: nil) + } + } else if components?.scheme == "tel" { + decisionHandler(.cancel) + + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [.universalLinksOnly : false], completionHandler: nil) + } + + } else { + decisionHandler(.allow) + } + } else { + decisionHandler(.allow) + } + } + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + fullReload() + } + +} + +// MARK: WKUIDelegate + +extension WebViewController: WKUIDelegate { + func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) { + // We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the + // link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get + // the link from the elementInfo above and transition to SFSafariViewController instead of launching + // Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_(ツ)_/¯ + } +} + +// MARK: WKScriptMessageHandler + +extension WebViewController: WKScriptMessageHandler { + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + switch message.name { + case MessageName.imageWasShown: + clickedImageCompletion?() + case MessageName.imageWasClicked: + imageWasClicked(body: message.body as? String) + case MessageName.showFeedInspector: + if let webFeed = article?.webFeed { +// coordinator.showFeedInspector(for: webFeed) + } + default: + return + } + } + +} + +// MARK: UIViewControllerTransitioningDelegate + +extension WebViewController: UIViewControllerTransitioningDelegate { + + func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + transition.presenting = true + return transition + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + transition.presenting = false + return transition + } +} + +// MARK: + +extension WebViewController: UIScrollViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + scrollPositionQueue.add(self, #selector(scrollPositionDidChange)) + } + + @objc func scrollPositionDidChange() { + webView?.evaluateJavaScript("window.scrollY") { (scrollY, error) in + guard error == nil else { return } + let javascriptScrollY = scrollY as? Int ?? 0 + // I don't know why this value gets returned sometimes, but it is in error + guard javascriptScrollY != 33554432 else { return } + self.windowScrollY = javascriptScrollY + } + } + +} + +// MARK: MFMailComposeViewControllerDelegate +extension WebViewController: MFMailComposeViewControllerDelegate { + + func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { + self.dismiss(animated: true, completion: nil) + } + +} + +// MARK: JSON + +private struct ImageClickMessage: Codable { + let x: Float + let y: Float + let width: Float + let height: Float + let imageTitle: String? + let imageURL: String +} + +// MARK: Private + +private extension WebViewController { + + func loadWebView(replaceExistingWebView: Bool = false) { + guard isViewLoaded else { return } + + if !replaceExistingWebView, let webView = webView { + self.renderPage(webView) + return + } + + articleModel?.webViewProvider?.dequeueWebView() { webView in + + // Add the webview + webView.translatesAutoresizingMaskIntoConstraints = false + self.view.insertSubview(webView, at: 0) + NSLayoutConstraint.activate([ + self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor), + self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor), + self.view.topAnchor.constraint(equalTo: webView.topAnchor), + self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor) + ]) + + // UISplitViewController reports the wrong size to WKWebView which can cause horizontal + // rubberbanding on the iPad. This interferes with our UIPageViewController preventing + // us from easily swiping between WKWebViews. This hack fixes that. + webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: -1, bottom: 0, right: 0) + + webView.scrollView.setZoomScale(1.0, animated: false) + + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + + // Configure the webview + webView.navigationDelegate = self + webView.uiDelegate = self + webView.scrollView.delegate = self +// self.configureContextMenuInteraction() + + webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked) + webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown) + webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector) + + self.renderPage(webView) + + } + + } + + func renderPage(_ webView: PreloadedWebView?) { + guard let webView = webView else { return } + + let style = ArticleStylesManager.shared.currentStyle + let rendering: ArticleRenderer.Rendering + + if let articleExtractor = articleExtractor, articleExtractor.state == .processing { + rendering = ArticleRenderer.loadingHTML(style: style) + } else if let articleExtractor = articleExtractor, articleExtractor.state == .failedToParse, let article = article { + rendering = ArticleRenderer.articleHTML(article: article, style: style) + } else if let article = article, let extractedArticle = extractedArticle { + if isShowingExtractedArticle { + rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style) + } else { + rendering = ArticleRenderer.articleHTML(article: article, style: style) + } + } else if let article = article { + rendering = ArticleRenderer.articleHTML(article: article, style: style) + } else { + rendering = ArticleRenderer.noSelectionHTML(style: style) + } + + let substitutions = [ + "title": rendering.title, + "baseURL": rendering.baseURL, + "style": rendering.style, + "body": rendering.html, + "windowScrollY": String(windowScrollY) + ] + + let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions) + webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL) + + } + + func finalScrollPosition() -> CGFloat { + guard let webView = webView else { return 0 } + return webView.scrollView.contentSize.height - webView.scrollView.bounds.height + webView.scrollView.safeAreaInsets.bottom + } + + func startArticleExtractor() { + if let link = article?.preferredLink, let extractor = ArticleExtractor(link) { + extractor.delegate = self + extractor.process() + articleExtractor = extractor + articleExtractorButtonState = .animated + } + } + + func stopArticleExtractor() { + articleExtractor?.cancel() + articleExtractor = nil + isShowingExtractedArticle = false + articleExtractorButtonState = .off + } + + func reloadArticleImage() { + guard let article = article else { return } + + var components = URLComponents() + components.scheme = ArticleRenderer.imageIconScheme + components.path = article.articleID + + if let imageSrc = components.string { + webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")") + } + } + + func imageWasClicked(body: String?) { + guard let webView = webView, + let body = body, + let data = body.data(using: .utf8), + let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data), + let range = clickMessage.imageURL.range(of: ";base64,") + else { return } + + let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound)) + if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) { + + let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top + let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height)) + transition.originFrame = webView.convert(rect, to: nil) + + if navigationController?.navigationBar.isHidden ?? false { + transition.maskFrame = webView.convert(webView.frame, to: nil) + } else { + transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil) + } + + transition.originImage = image + +// coordinator.showFullScreenImage(image: image, imageTitle: clickMessage.imageTitle, transitioningDelegate: self) + } + } + + func stopMediaPlayback(_ webView: WKWebView) { + webView.evaluateJavaScript("stopMediaPlayback();") + } + + func cancelImageLoad(_ webView: WKWebView) { + webView.evaluateJavaScript("cancelImageLoad();") + } + +// func configureTopShowBarsView() { +// topShowBarsView = UIView() +// topShowBarsView.backgroundColor = .clear +// topShowBarsView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(topShowBarsView) +// +// if AppDefaults.shared.articleFullscreenEnabled { +// topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: -44.0) +// } else { +// topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: 0.0) +// } +// +// NSLayoutConstraint.activate([ +// topShowBarsViewConstraint, +// view.leadingAnchor.constraint(equalTo: topShowBarsView.leadingAnchor), +// view.trailingAnchor.constraint(equalTo: topShowBarsView.trailingAnchor), +// topShowBarsView.heightAnchor.constraint(equalToConstant: 44.0) +// ]) +// topShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:)))) +// } +// +// func configureBottomShowBarsView() { +// bottomShowBarsView = UIView() +// topShowBarsView.backgroundColor = .clear +// bottomShowBarsView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(bottomShowBarsView) +// if AppDefaults.shared.articleFullscreenEnabled { +// bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 44.0) +// } else { +// bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 0.0) +// } +// NSLayoutConstraint.activate([ +// bottomShowBarsViewConstraint, +// view.leadingAnchor.constraint(equalTo: bottomShowBarsView.leadingAnchor), +// view.trailingAnchor.constraint(equalTo: bottomShowBarsView.trailingAnchor), +// bottomShowBarsView.heightAnchor.constraint(equalToConstant: 44.0) +// ]) +// bottomShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:)))) +// } + +// func configureContextMenuInteraction() { +// if isFullScreenAvailable { +// if navigationController?.isNavigationBarHidden ?? false { +// webView?.addInteraction(contextMenuInteraction) +// } else { +// webView?.removeInteraction(contextMenuInteraction) +// } +// } +// } +// +// func contextMenuPreviewProvider() -> UIViewController { +// let previewProvider = UIStoryboard.main.instantiateController(ofType: ContextMenuPreviewViewController.self) +// previewProvider.article = article +// return previewProvider +// } +// +// func prevArticleAction() -> UIAction? { +// guard coordinator.isPrevArticleAvailable else { return nil } +// let title = NSLocalizedString("Previous Article", comment: "Previous Article") +// return UIAction(title: title, image: AppAssets.prevArticleImage) { [weak self] action in +// self?.coordinator.selectPrevArticle() +// } +// } +// +// func nextArticleAction() -> UIAction? { +// guard coordinator.isNextArticleAvailable else { return nil } +// let title = NSLocalizedString("Next Article", comment: "Next Article") +// return UIAction(title: title, image: AppAssets.nextArticleImage) { [weak self] action in +// self?.coordinator.selectNextArticle() +// } +// } +// +// func toggleReadAction() -> UIAction? { +// guard let article = article, !article.status.read || article.isAvailableToMarkUnread else { return nil } +// +// let title = article.status.read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read") +// let readImage = article.status.read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage +// return UIAction(title: title, image: readImage) { [weak self] action in +// self?.coordinator.toggleReadForCurrentArticle() +// } +// } +// +// func toggleStarredAction() -> UIAction { +// let starred = article?.status.starred ?? false +// let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred") +// let starredImage = starred ? AppAssets.starOpenImage : AppAssets.starClosedImage +// return UIAction(title: title, image: starredImage) { [weak self] action in +// self?.coordinator.toggleStarredForCurrentArticle() +// } +// } +// +// func nextUnreadArticleAction() -> UIAction? { +// guard coordinator.isAnyUnreadAvailable else { return nil } +// let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article") +// return UIAction(title: title, image: AppAssets.nextUnreadArticleImage) { [weak self] action in +// self?.coordinator.selectNextUnread() +// } +// } +// +// func toggleArticleExtractorAction() -> UIAction { +// let extracted = articleExtractorButtonState == .on +// let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View") +// let extractorImage = extracted ? AppAssets.articleExtractorOffSF : AppAssets.articleExtractorOnSF +// return UIAction(title: title, image: extractorImage) { [weak self] action in +// self?.toggleArticleExtractor() +// } +// } +// +// func shareAction() -> UIAction { +// let title = NSLocalizedString("Share", comment: "Share") +// return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in +// self?.showActivityDialog() +// } +// } + +} + +// MARK: Find in Article + +private struct FindInArticleOptions: Codable { + var text: String + var caseSensitive = false + var regex = false +} + +internal struct FindInArticleState: Codable { + struct WebViewClientRect: Codable { + let x: Double + let y: Double + let width: Double + let height: Double + } + + struct FindInArticleResult: Codable { + let rects: [WebViewClientRect] + let bounds: WebViewClientRect + let index: UInt + let matchGroups: [String] + } + + let index: UInt? + let results: [FindInArticleResult] + let count: UInt +} + +extension WebViewController { + + func searchText(_ searchText: String, completionHandler: @escaping (FindInArticleState) -> Void) { + guard let json = try? JSONEncoder().encode(FindInArticleOptions(text: searchText)) else { + return + } + let encoded = json.base64EncodedString() + + webView?.evaluateJavaScript("updateFind(\"\(encoded)\")") { + (result, error) in + guard error == nil, + let b64 = result as? String, + let rawData = Data(base64Encoded: b64), + let findState = try? JSONDecoder().decode(FindInArticleState.self, from: rawData) else { + return + } + + completionHandler(findState) + } + } + + func endSearch() { + webView?.evaluateJavaScript("endFind()") + } + + func selectNextSearchResult() { + webView?.evaluateJavaScript("selectNextResult()") + } + + func selectPreviousSearchResult() { + webView?.evaluateJavaScript("selectPreviousResult()") + } + +} + diff --git a/Multiplatform/iOS/Article/WebViewProvider.swift b/Multiplatform/iOS/Article/WebViewProvider.swift new file mode 100644 index 000000000..dea43b7ff --- /dev/null +++ b/Multiplatform/iOS/Article/WebViewProvider.swift @@ -0,0 +1,133 @@ +// +// WebViewProvider.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit +import RSCore +import WebKit + +/// WKWebView has an awful behavior of a flash to white on first load when in dark mode. +/// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle. +class WebViewProvider: NSObject { + + private let articleIconSchemeHandler: ArticleIconSchemeHandler + private let operationQueue = MainThreadOperationQueue() + private var queue = NSMutableArray() + + init(sceneModel: SceneModel) { + articleIconSchemeHandler = ArticleIconSchemeHandler(sceneModel: sceneModel) + super.init() + replenishQueueIfNeeded() + } + + func flushQueue() { + operationQueue.add(WebViewProviderFlushQueueOperation(queue: queue)) + } + + func replenishQueueIfNeeded() { + operationQueue.add(WebViewProviderReplenishQueueOperation(queue: queue, articleIconSchemeHandler: articleIconSchemeHandler)) + } + + func dequeueWebView(completion: @escaping (PreloadedWebView) -> ()) { + operationQueue.add(WebViewProviderDequeueOperation(queue: queue, articleIconSchemeHandler: articleIconSchemeHandler, completion: completion)) + operationQueue.add(WebViewProviderReplenishQueueOperation(queue: queue, articleIconSchemeHandler: articleIconSchemeHandler)) + } + +} + +class WebViewProviderFlushQueueOperation: MainThreadOperation { + + // MainThreadOperation + public var isCanceled = false + public var id: Int? + public weak var operationDelegate: MainThreadOperationDelegate? + public var name: String? = "WebViewProviderFlushQueueOperation" + public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? + + private var queue: NSMutableArray + + init(queue: NSMutableArray) { + self.queue = queue + } + + func run() { + queue.removeAllObjects() + self.operationDelegate?.operationDidComplete(self) + } + +} + +class WebViewProviderReplenishQueueOperation: MainThreadOperation { + + // MainThreadOperation + public var isCanceled = false + public var id: Int? + public weak var operationDelegate: MainThreadOperationDelegate? + public var name: String? = "WebViewProviderReplenishQueueOperation" + public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? + + private let minimumQueueDepth = 3 + + private var queue: NSMutableArray + private var articleIconSchemeHandler: ArticleIconSchemeHandler + + init(queue: NSMutableArray, articleIconSchemeHandler: ArticleIconSchemeHandler) { + self.queue = queue + self.articleIconSchemeHandler = articleIconSchemeHandler + } + + func run() { + while queue.count < minimumQueueDepth { + let webView = PreloadedWebView(articleIconSchemeHandler: articleIconSchemeHandler) + queue.insert(webView, at: 0) + webView.preload() + } + self.operationDelegate?.operationDidComplete(self) + } + +} + +class WebViewProviderDequeueOperation: MainThreadOperation { + + // MainThreadOperation + public var isCanceled = false + public var id: Int? + public weak var operationDelegate: MainThreadOperationDelegate? + public var name: String? = "WebViewProviderFlushQueueOperation" + public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? + + private var queue: NSMutableArray + private var articleIconSchemeHandler: ArticleIconSchemeHandler + private var completion: (PreloadedWebView) -> () + + init(queue: NSMutableArray, articleIconSchemeHandler: ArticleIconSchemeHandler, completion: @escaping (PreloadedWebView) -> ()) { + self.queue = queue + self.articleIconSchemeHandler = articleIconSchemeHandler + self.completion = completion + } + + func run() { + if let webView = queue.lastObject as? PreloadedWebView { + queue.removeLastObject() + webView.ready { preloadedWebView in + self.completion(preloadedWebView) + self.operationDelegate?.operationDidComplete(self) + } + return + } + + assertionFailure("Creating PreloadedWebView in \(#function); queue has run dry.") + + let webView = PreloadedWebView(articleIconSchemeHandler: articleIconSchemeHandler) + webView.preload() + webView.ready { preloadedWebView in + self.completion(preloadedWebView) + self.operationDelegate?.operationDidComplete(self) + } + } + +} diff --git a/Multiplatform/iOS/Article/WrapperScriptMessageHandler.swift b/Multiplatform/iOS/Article/WrapperScriptMessageHandler.swift new file mode 100644 index 000000000..e3a58e19d --- /dev/null +++ b/Multiplatform/iOS/Article/WrapperScriptMessageHandler.swift @@ -0,0 +1,25 @@ +// +// WrapperScriptMessageHandler.swift +// Multiplatform iOS +// +// Created by Maurice Parker on 7/6/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import WebKit + +class WrapperScriptMessageHandler: NSObject, WKScriptMessageHandler { + + // We need to wrap a message handler to prevent a circlular reference + private weak var handler: WKScriptMessageHandler? + + init(_ handler: WKScriptMessageHandler) { + self.handler = handler + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + handler?.userContentController(userContentController, didReceive: message) + } + +} diff --git a/Multiplatform/macOS/Article/ArticleView.swift b/Multiplatform/macOS/Article/ArticleView.swift index 309551225..dbf33d14c 100644 --- a/Multiplatform/macOS/Article/ArticleView.swift +++ b/Multiplatform/macOS/Article/ArticleView.swift @@ -7,15 +7,15 @@ // import SwiftUI +import Articles struct ArticleView: View { - var body: some View { + + var sceneModel: SceneModel + var articleModel: ArticleModel + var article: Article + + var body: some View { Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) } } - -struct ArticleView_Previews: PreviewProvider { - static var previews: some View { - ArticleView() - } -} diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index ffc5f39e2..a8be3d0c1 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -219,6 +219,16 @@ 5177470924B2F87600EB0F74 /* SidebarListStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470824B2F87600EB0F74 /* SidebarListStyleModifier.swift */; }; 5177470A24B2F87600EB0F74 /* SidebarListStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470824B2F87600EB0F74 /* SidebarListStyleModifier.swift */; }; 5177470E24B2FF6F00EB0F74 /* ArticleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470D24B2FF6F00EB0F74 /* ArticleView.swift */; }; + 5177471024B3029400EB0F74 /* ArticleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470F24B3029400EB0F74 /* ArticleViewController.swift */; }; + 5177471224B37C5400EB0F74 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471124B37C5400EB0F74 /* WebViewController.swift */; }; + 5177471424B37D4000EB0F74 /* PreloadedWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471324B37D4000EB0F74 /* PreloadedWebView.swift */; }; + 5177471624B37D9700EB0F74 /* ArticleIconSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471524B37D9700EB0F74 /* ArticleIconSchemeHandler.swift */; }; + 5177471824B3812200EB0F74 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471724B3812200EB0F74 /* IconView.swift */; }; + 5177471A24B3863000EB0F74 /* WebViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471924B3863000EB0F74 /* WebViewProvider.swift */; }; + 5177471C24B387AC00EB0F74 /* ImageScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471B24B387AC00EB0F74 /* ImageScrollView.swift */; }; + 5177471E24B387E100EB0F74 /* ImageTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471D24B387E100EB0F74 /* ImageTransition.swift */; }; + 5177472024B3882600EB0F74 /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471F24B3882600EB0F74 /* ImageViewController.swift */; }; + 5177472224B38CAE00EB0F74 /* ArticleExtractorButtonState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177472124B38CAE00EB0F74 /* ArticleExtractorButtonState.swift */; }; 5177475C24B39AD500EB0F74 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 5177475824B39AD400EB0F74 /* Credits.rtf */; }; 5177475D24B39AD500EB0F74 /* Dedication.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 5177475924B39AD400EB0F74 /* Dedication.rtf */; }; 5177475E24B39AD500EB0F74 /* Thanks.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 5177475A24B39AD500EB0F74 /* Thanks.rtf */; }; @@ -233,6 +243,17 @@ 517A757A24451C0700B553B9 /* OAuthSwift.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 517A755524451BD500B553B9 /* OAuthSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 517A757B24451C1500B553B9 /* OAuthSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 517A755324451BD500B553B9 /* OAuthSwift.framework */; }; 517A757C24451C1500B553B9 /* OAuthSwift.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 517A755324451BD500B553B9 /* OAuthSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 517B2EBC24B3E62A001AC46C /* WrapperScriptMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517B2EBB24B3E62A001AC46C /* WrapperScriptMessageHandler.swift */; }; + 517B2EE224B3E8FE001AC46C /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EDE24B3E8FE001AC46C /* page.html */; }; + 517B2EE324B3E8FE001AC46C /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EDE24B3E8FE001AC46C /* page.html */; }; + 517B2EE424B3E8FE001AC46C /* blank.html in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EDF24B3E8FE001AC46C /* blank.html */; }; + 517B2EE524B3E8FE001AC46C /* blank.html in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EDF24B3E8FE001AC46C /* blank.html */; }; + 517B2EE624B3E8FE001AC46C /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EE024B3E8FE001AC46C /* styleSheet.css */; }; + 517B2EE724B3E8FE001AC46C /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EE024B3E8FE001AC46C /* styleSheet.css */; }; + 517B2EE824B3E8FE001AC46C /* main_multiplatform.js in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EE124B3E8FE001AC46C /* main_multiplatform.js */; }; + 517B2EE924B3E8FE001AC46C /* main_multiplatform.js in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EE124B3E8FE001AC46C /* main_multiplatform.js */; }; + 517B2EEB24B40E09001AC46C /* TimelineTitleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517B2EEA24B40E09001AC46C /* TimelineTitleModifier.swift */; }; + 517B2EEC24B40E09001AC46C /* TimelineTitleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517B2EEA24B40E09001AC46C /* TimelineTitleModifier.swift */; }; 5181C5AD24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5181C5AC24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift */; }; 5181C5AE24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5181C5AC24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift */; }; 5181C66224B0C326002E0F70 /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5181C66124B0C326002E0F70 /* SettingsModel.swift */; }; @@ -1872,6 +1893,16 @@ 5177470524B2910300EB0F74 /* ArticleToolbarModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleToolbarModifier.swift; sourceTree = ""; }; 5177470824B2F87600EB0F74 /* SidebarListStyleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListStyleModifier.swift; sourceTree = ""; }; 5177470D24B2FF6F00EB0F74 /* ArticleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleView.swift; sourceTree = ""; }; + 5177470F24B3029400EB0F74 /* ArticleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleViewController.swift; sourceTree = ""; }; + 5177471124B37C5400EB0F74 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; + 5177471324B37D4000EB0F74 /* PreloadedWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreloadedWebView.swift; sourceTree = ""; }; + 5177471524B37D9700EB0F74 /* ArticleIconSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleIconSchemeHandler.swift; sourceTree = ""; }; + 5177471724B3812200EB0F74 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; + 5177471924B3863000EB0F74 /* WebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewProvider.swift; sourceTree = ""; }; + 5177471B24B387AC00EB0F74 /* ImageScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScrollView.swift; sourceTree = ""; }; + 5177471D24B387E100EB0F74 /* ImageTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTransition.swift; sourceTree = ""; }; + 5177471F24B3882600EB0F74 /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; + 5177472124B38CAE00EB0F74 /* ArticleExtractorButtonState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleExtractorButtonState.swift; sourceTree = ""; }; 5177475824B39AD400EB0F74 /* Credits.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; 5177475924B39AD400EB0F74 /* Dedication.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Dedication.rtf; sourceTree = ""; }; 5177475A24B39AD500EB0F74 /* Thanks.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Thanks.rtf; sourceTree = ""; }; @@ -1881,6 +1912,12 @@ 5177476624B3BE3400EB0F74 /* SettingsAboutModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAboutModel.swift; sourceTree = ""; }; 517A745A2443665000B553B9 /* UIPageViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIPageViewController-Extensions.swift"; sourceTree = ""; }; 517A754424451BD500B553B9 /* OAuthSwift.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = OAuthSwift.xcodeproj; path = submodules/OAuthSwift/OAuthSwift.xcodeproj; sourceTree = ""; }; + 517B2EBB24B3E62A001AC46C /* WrapperScriptMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrapperScriptMessageHandler.swift; sourceTree = ""; }; + 517B2EDE24B3E8FE001AC46C /* page.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; + 517B2EDF24B3E8FE001AC46C /* blank.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = blank.html; sourceTree = ""; }; + 517B2EE024B3E8FE001AC46C /* styleSheet.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = styleSheet.css; sourceTree = ""; }; + 517B2EE124B3E8FE001AC46C /* main_multiplatform.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = main_multiplatform.js; sourceTree = ""; }; + 517B2EEA24B40E09001AC46C /* TimelineTitleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTitleModifier.swift; sourceTree = ""; }; 5181C5AC24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredColorSchemeModifier.swift; sourceTree = ""; }; 5181C66124B0C326002E0F70 /* SettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModel.swift; sourceTree = ""; }; 5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicLabel.swift; sourceTree = ""; }; @@ -2424,9 +2461,7 @@ 172199EB24AB228E00A31D04 /* Settings */ = { isa = PBXGroup; children = ( - 65CBAD5924AE03C20006DD91 /* ColorPaletteContainerView.swift */, 65C2E40024B05D8A000AFDF6 /* FeedsSettingsModel.swift */, - 17B223DB24AC24D2001E4592 /* TimelineLayoutView.swift */, 65CBAD5924AE03C20006DD91 /* ColorPaletteContainerView.swift */, 5181C66124B0C326002E0F70 /* SettingsModel.swift */, 172199C824AB228900A31D04 /* SettingsView.swift */, @@ -2663,7 +2698,18 @@ 5177470B24B2FF2C00EB0F74 /* Article */ = { isa = PBXGroup; children = ( + 5177472124B38CAE00EB0F74 /* ArticleExtractorButtonState.swift */, + 5177471524B37D9700EB0F74 /* ArticleIconSchemeHandler.swift */, 5177470D24B2FF6F00EB0F74 /* ArticleView.swift */, + 5177470F24B3029400EB0F74 /* ArticleViewController.swift */, + 5177471724B3812200EB0F74 /* IconView.swift */, + 5177471B24B387AC00EB0F74 /* ImageScrollView.swift */, + 5177471D24B387E100EB0F74 /* ImageTransition.swift */, + 5177471F24B3882600EB0F74 /* ImageViewController.swift */, + 5177471324B37D4000EB0F74 /* PreloadedWebView.swift */, + 5177471124B37C5400EB0F74 /* WebViewController.swift */, + 5177471924B3863000EB0F74 /* WebViewProvider.swift */, + 517B2EBB24B3E62A001AC46C /* WrapperScriptMessageHandler.swift */, ); path = Article; sourceTree = ""; @@ -2762,6 +2808,7 @@ 514E6C0124AD29A300AC6F6E /* TimelineItemStatusView.swift */, 514E6BD924ACEA0400AC6F6E /* TimelineItemView.swift */, 51919FF024AB864A00541E64 /* TimelineModel.swift */, + 517B2EEA24B40E09001AC46C /* TimelineTitleModifier.swift */, 51919FF624AB8B7700541E64 /* TimelineView.swift */, ); path = Timeline; @@ -2779,6 +2826,10 @@ 51A576B924AE617B00078888 /* Article */ = { isa = PBXGroup; children = ( + 517B2EE024B3E8FE001AC46C /* styleSheet.css */, + 517B2EDF24B3E8FE001AC46C /* blank.html */, + 517B2EDE24B3E8FE001AC46C /* page.html */, + 517B2EE124B3E8FE001AC46C /* main_multiplatform.js */, 51A5769524AE617200078888 /* ArticleContainerView.swift */, 51A576BA24AE621800078888 /* ArticleModel.swift */, 5177470524B2910300EB0F74 /* ArticleToolbarModifier.swift */, @@ -4534,14 +4585,18 @@ buildActionMask = 2147483647; files = ( 5177475E24B39AD500EB0F74 /* Thanks.rtf in Resources */, + 517B2EE224B3E8FE001AC46C /* page.html in Resources */, 51E4995F24A875F300B667CB /* shared.css in Resources */, + 517B2EE824B3E8FE001AC46C /* main_multiplatform.js in Resources */, 51E4997324A8784300B667CB /* DefaultFeeds.opml in Resources */, 51C0516224A77DF800194D5E /* Assets.xcassets in Resources */, 5177475F24B39AD500EB0F74 /* About.rtf in Resources */, 51E4996024A875F300B667CB /* template.html in Resources */, 5177475D24B39AD500EB0F74 /* Dedication.rtf in Resources */, 51E4995E24A875F300B667CB /* newsfoot.js in Resources */, + 517B2EE424B3E8FE001AC46C /* blank.html in Resources */, 5177475C24B39AD500EB0F74 /* Credits.rtf in Resources */, + 517B2EE624B3E8FE001AC46C /* styleSheet.css in Resources */, 51E4995D24A875F300B667CB /* main.js in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4550,12 +4605,16 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 517B2EE324B3E8FE001AC46C /* page.html in Resources */, 51E4996424A875F400B667CB /* shared.css in Resources */, 51E4997524A8784400B667CB /* DefaultFeeds.opml in Resources */, 51C0516324A77DF800194D5E /* Assets.xcassets in Resources */, 51E4996524A875F400B667CB /* template.html in Resources */, + 517B2EE924B3E8FE001AC46C /* main_multiplatform.js in Resources */, + 517B2EE524B3E8FE001AC46C /* blank.html in Resources */, 51E4996324A875F400B667CB /* newsfoot.js in Resources */, 51E4996224A875F400B667CB /* main.js in Resources */, + 517B2EE724B3E8FE001AC46C /* styleSheet.css in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4880,6 +4939,7 @@ 51E4995924A873F900B667CB /* ErrorHandler.swift in Sources */, 51392D1B24AC19A000BE0D35 /* SidebarExpandedContainers.swift in Sources */, 51E4992F24A8676400B667CB /* ArticleArray.swift in Sources */, + 5177471C24B387AC00EB0F74 /* ImageScrollView.swift in Sources */, 51E498F824A8085D00B667CB /* UnreadFeed.swift in Sources */, FF64D0E724AF53EE0084080A /* RefreshProgressModel.swift in Sources */, 51E4996A24A8762D00B667CB /* ExtractedArticle.swift in Sources */, @@ -4893,11 +4953,13 @@ 5177470624B2910300EB0F74 /* ArticleToolbarModifier.swift in Sources */, 51919FAF24AA8EFA00541E64 /* SidebarItemView.swift in Sources */, 514E6BDA24ACEA0400AC6F6E /* TimelineItemView.swift in Sources */, + 5177471624B37D9700EB0F74 /* ArticleIconSchemeHandler.swift in Sources */, FA80C11724B0728000974098 /* AddFolderView.swift in Sources */, 51E4990D24A808C500B667CB /* RSHTMLMetadata+Extension.swift in Sources */, 51919FF424AB869C00541E64 /* TimelineItem.swift in Sources */, 514E6C0224AD29A300AC6F6E /* TimelineItemStatusView.swift in Sources */, 51E49A0024A91FC100B667CB /* SidebarContainerView.swift in Sources */, + 5177471824B3812200EB0F74 /* IconView.swift in Sources */, 51E4995C24A875F300B667CB /* ArticleRenderer.swift in Sources */, 51E4992324A8095700B667CB /* URL-Extensions.swift in Sources */, 51E4993624A867E800B667CB /* UserInfoKey.swift in Sources */, @@ -4920,6 +4982,7 @@ 51E4991B24A8091000B667CB /* IconImage.swift in Sources */, 51E4995424A8734D00B667CB /* ExtensionPointIdentifer.swift in Sources */, 51E4996924A8760C00B667CB /* ArticleStylesManager.swift in Sources */, + 5177471E24B387E100EB0F74 /* ImageTransition.swift in Sources */, 51E498F324A8085D00B667CB /* PseudoFeed.swift in Sources */, 51A5769624AE617200078888 /* ArticleContainerView.swift in Sources */, 5181C5AD24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift in Sources */, @@ -4929,8 +4992,10 @@ 51E4991D24A8092100B667CB /* NSAttributedString+NetNewsWire.swift in Sources */, FF64D0E924AF53EE0084080A /* RefreshProgressView.swift in Sources */, 51E499FD24A9137600B667CB /* SidebarModel.swift in Sources */, + 517B2EEB24B40E09001AC46C /* TimelineTitleModifier.swift in Sources */, 5181C66224B0C326002E0F70 /* SettingsModel.swift in Sources */, 51E4995324A8734D00B667CB /* RedditFeedProvider-Extensions.swift in Sources */, + 5177471024B3029400EB0F74 /* ArticleViewController.swift in Sources */, 172199C924AB228900A31D04 /* SettingsView.swift in Sources */, 17D232A824AFF10A0005F075 /* AddWebFeedModel.swift in Sources */, 51E4994224A8713C00B667CB /* ArticleStatusSyncTimer.swift in Sources */, @@ -4939,6 +5004,7 @@ 51919FB624AABCA100541E64 /* IconImageView.swift in Sources */, 51919FA624AA64B000541E64 /* SidebarView.swift in Sources */, 51E4997024A8764C00B667CB /* ActivityManager.swift in Sources */, + 5177471224B37C5400EB0F74 /* WebViewController.swift in Sources */, 51E4990F24A808CC00B667CB /* HTMLMetadataDownloader.swift in Sources */, 5177476524B3BDAE00EB0F74 /* AttributedStringView.swift in Sources */, 51E4993124A8676400B667CB /* FetchRequestOperation.swift in Sources */, @@ -4952,7 +5018,10 @@ 51E4991E24A8094300B667CB /* RSImage-AppIcons.swift in Sources */, 51E499D824A912C200B667CB /* SceneModel.swift in Sources */, 5177470E24B2FF6F00EB0F74 /* ArticleView.swift in Sources */, + 5177471424B37D4000EB0F74 /* PreloadedWebView.swift in Sources */, + 517B2EBC24B3E62A001AC46C /* WrapperScriptMessageHandler.swift in Sources */, 51919FB324AAB97900541E64 /* FeedIconImageLoader.swift in Sources */, + 5177472024B3882600EB0F74 /* ImageViewController.swift in Sources */, 51919FB324AAB97900541E64 /* FeedIconImageLoader.swift in Sources */, 51E4991324A808FB00B667CB /* AddWebFeedDefaultContainer.swift in Sources */, 51E4993C24A8709900B667CB /* AppDelegate.swift in Sources */, @@ -4961,6 +5030,8 @@ 17930ED424AF10EE00A9BA52 /* AddWebFeedView.swift in Sources */, 51E4995124A8734D00B667CB /* ExtensionPointManager.swift in Sources */, 51E4990C24A808C500B667CB /* AuthorAvatarDownloader.swift in Sources */, + 5177472224B38CAE00EB0F74 /* ArticleExtractorButtonState.swift in Sources */, + 5177471A24B3863000EB0F74 /* WebViewProvider.swift in Sources */, 51E4992124A8095000B667CB /* RSImage-Extensions.swift in Sources */, 51E4990324A808BB00B667CB /* FaviconDownloader.swift in Sources */, 172199ED24AB2E0100A31D04 /* SafariView.swift in Sources */, @@ -5030,6 +5101,7 @@ 51E4995B24A875D500B667CB /* ArticlePasteboardWriter.swift in Sources */, 51E4993424A867E700B667CB /* UserInfoKey.swift in Sources */, 1776E88F24AC5F8A00E78166 /* AppDefaults.swift in Sources */, + 517B2EEC24B40E09001AC46C /* TimelineTitleModifier.swift in Sources */, 1729529724AA1CD000D65E66 /* MacPreferencesView.swift in Sources */, 51E4994C24A8734C00B667CB /* RedditFeedProvider-Extensions.swift in Sources */, 1729529324AA1CAA00D65E66 /* AccountsPreferencesView.swift in Sources */, diff --git a/iOS/Article/WebViewProvider.swift b/iOS/Article/WebViewProvider.swift index 41c0e550c..0a00d667f 100644 --- a/iOS/Article/WebViewProvider.swift +++ b/iOS/Article/WebViewProvider.swift @@ -12,7 +12,7 @@ import WebKit /// WKWebView has an awful behavior of a flash to white on first load when in dark mode. /// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle. -class WebViewProvider: NSObject { +class : NSObject { private let articleIconSchemeHandler: ArticleIconSchemeHandler private let operationQueue = MainThreadOperationQueue()