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()