add local webreader for apple apps

This commit is contained in:
Satindar Dhillon
2022-03-18 16:35:25 -07:00
parent af0d732ab6
commit 5d58cd28bf
18 changed files with 593 additions and 1 deletions

View File

@ -145,7 +145,9 @@ struct LinkItemDetailView: View {
var body: some View {
#if os(iOS)
if viewModel.item.isPDF {
if FeatureFlag.useLocalWebView {
WebReaderContainerView(item: viewModel.item)
} else if viewModel.item.isPDF {
fixedNavBarReader
} else {
hidingNavBarReader

View File

@ -0,0 +1,72 @@
import Combine
import Models
import Services
import SwiftUI
import UIKit
import Utils
import WebKit
final class WebReaderViewModel: ObservableObject {
@Published var isLoading = false
@Published var htmlContent: String?
var subscriptions = Set<AnyCancellable>()
func loadContent(dataService: DataService, slug: String) {
isLoading = true
guard let viewer = dataService.currentViewer else { return }
dataService.articleContentPublisher(username: viewer.username, slug: slug).sink(
receiveCompletion: { [weak self] completion in
guard case .failure = completion else { return }
self?.isLoading = false
},
receiveValue: { [weak self] htmlContent in
self?.htmlContent = htmlContent
}
)
.store(in: &subscriptions)
}
}
struct WebReaderContainerView: View {
let item: FeedItem
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = WebReaderViewModel()
var body: some View {
Group {
if let htmlContent = viewModel.htmlContent {
WebReader(htmlContent: htmlContent, item: item)
} else {
Color.clear
.contentShape(Rectangle())
.onAppear {
if !viewModel.isLoading {
viewModel.loadContent(dataService: dataService, slug: item.slug)
}
}
}
}
}
}
struct WebReader: UIViewRepresentable {
let htmlContent: String
let item: FeedItem
func makeUIView(context _: Context) -> WKWebView {
print(WebReaderResources.bundleURL)
let webView = WKWebView()
webView.loadHTMLString(
WebReaderContent(htmlContent: htmlContent, item: item).styledContent,
baseURL: WebReaderResources.bundleURL
)
// webView.configuration.userContentController.addUserScript(WebReaderResources.cssScript)
return webView
}
func updateUIView(_: WKWebView, context _: UIViewRepresentableContext<WebReader>) {}
}

View File

@ -0,0 +1,75 @@
import Foundation
import Models
import Utils
struct WebReaderContent {
let textFontSize: String
let fontColor: String
let fontColorTransparent: String
let tableHeaderColor: String
let headerColor: String
let margin: String
let content: String
let item: FeedItem
init(
htmlContent: String,
item: FeedItem,
isDark: Bool = false,
fontSize: String = "16px",
margin: String = "24px"
) {
self.textFontSize = fontSize
self.fontColor = isDark ? "#B9B9B9" : "#3D3D3D"
self.fontColorTransparent = isDark ? "rgba(185,185,185,0.65)" : "rgba(185,185,185,0.65)"
self.tableHeaderColor = "#FFFFFF"
self.headerColor = isDark ? "#B9B9B9" : "#3D3D3D"
self.margin = margin
self.content = htmlContent
self.item = item
}
var styleString: String {
// swiftlint:disable line_length
"--text-font-size:\(textFontSize);--font-color:\(fontColor);--font-color-transparent\(fontColorTransparent);--table-header-color:\(tableHeaderColor);--headers-color:\(headerColor);--app-margin:\(margin);"
}
var styledContent: String {
"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
</head>
<body>
<div id="root" style="\(styleString)">
<div>React App\(WebReaderResources.bundleURL)</div>
<script type="text/javascript">
function loadArticle() {
window.omnivoreArticle = {
id: "test",
linkId: "test",
slug: "test-slug",
createdAt: new Date().toISOString(),
savedAt: new Date().toISOString(),
url: "https://example.com",
title: `\(item.title)`,
content: `\(content)`,
originalArticleUrl: "https://example.com",
contentReader: "WEB",
readingProgressPercent: \(item.readingProgress),
readingProgressAnchorIndex: \(item.readingProgressAnchor),
highlights: [],
}
}
loadArticle()
</script>
</div>
<script src="bundle.js"></script>
</body>
</html>
"""
}
}

View File

@ -0,0 +1,55 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<String, ServerError> {
enum QueryResult {
case success(result: String)
case error(error: String)
}
let articleSelection = Selection.Article {
try $0.content()
}
let selection = Selection<QueryResult, Unions.ArticleResult> {
try $0.on(
articleSuccess: .init {
QueryResult.success(result: try $0.article(selection: articleSelection))
},
articleError: .init {
QueryResult.error(error: try $0.errorCodes().description)
}
)
}
let query = Selection.Query {
try $0.article(username: username, slug: slug, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(query, to: path, headers: headers) { result in
switch result {
case let .success(payload):
switch payload.data {
case let .success(result: result):
promise(.success(result))
case .error:
promise(.failure(.unknown))
}
case .failure:
promise(.failure(.unknown))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View File

@ -15,4 +15,5 @@ public enum FeatureFlag {
public static let enableShareButton = false
public static let enableSnooze = false
public static let showFeedItemTags = false
public static let useLocalWebView = true
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,355 @@
:root {
color-scheme: light dark;
--text-font-family: inter;
--line-height: 150%;
--blockquote-padding: 0.5em 1em;
--blockquote-icon-font-size: 1.3rem;
--figure-margin: 1.6rem auto;
--hr-margin: 1em;
}
@media screen and (min-width: 576px) {
:root {
--blockquote-padding: 1em 2em;
--blockquote-icon-font-size: 1.7rem;
--figure-margin: 2.6875rem auto;
--hr-margin: 2em;
margin: 30px calc(var(--app-margin) / 2);
}
}
@media screen and (min-width: 768px) {
:root {
max-width: 92%;
}
}
@media screen and (min-width: 992px) {
:root {
margin: 30px 0;
width: auto;
max-width: calc(1024px - var(--app-margin));
}
}
.page * {
padding: 16px;
max-width: 94%;
}
.highlight {
color: var(--colors-highlightText);
background-color: var(--colors-highlightBackground);
cursor: pointer;
}
.highlight_with_note {
color: var(--colors-highlightText);
border-bottom: 2px var(--colors-highlightBackground) solid;
border-radius: 2px;
cursor: pointer;
}
.page .highlight_with_note .highlight_note_button {
display: unset !important;
margin: 0px !important;
max-width: unset !important;
height: unset !important;
padding: 0px 8px;
cursor: pointer;
}
.page h1,
.page h2,
.page h3,
.page h4,
.page h5,
.page h6 {
margin-block-start: 0.83em;
margin-block-end: 0.83em;
margin-inline-start: 0px;
margin-inline-end: 0px;
line-height: var(--line-height);
font-size: var(--text-font-size);
color: var(--headers-color);
font-weight: bold;
}
.page h1 {
font-size: 1.5em !important;
line-height: 1.4em !important;
}
.page h2 {
font-size: 1.43em !important;
}
.page h3 {
font-size: 1.25em !important;
}
.page h4,
.page h5,
.page h6 {
font-size: 1em !important;
margin: 1em 0 !important;
}
.page .scrollable {
overflow: auto;
}
.page div {
line-height: var(--line-height);
/* font-size: var(--text-font-size); */
color: var(--font-color);
}
.page p {
font-family: var(--text-font-family);
font-style: normal;
font-weight: normal;
color: var(--font-color);
display: block;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
> img {
display: block;
margin: 0.5em auto !important;
max-width: 100% !important;
}
> iframe {
width: 100%;
height: 350px;
}
}
.page section {
line-height: 1.65em;
font-size: var(--text-font-size);
}
.page blockquote {
display: block;
border-left: 1px solid var(--font-color-transparent);
padding-left: 16px;
font-style: italic;
margin-inline-start: 0px;
> * {
font-style: italic;
}
p:last-of-type {
margin-bottom: 0;
}
}
.page a {
color: var(--font-color-readerFont);
}
.page .highlight a {
color: var(--colors-highlightText);
}
.page figure {
* {
color: var(--font-color-transparent);
}
margin: 30px 0;
font-size: 0.75em;
line-height: 1.5em;
figcaption {
color: var(--font-color);
opacity: 0.7;
margin: 10px 20px 10px 0;
}
figcaption * {
color: var(--font-color);
opacity: 0.7;
}
figcaption a {
color: var(--font-color);
/* margin: 10px 20px 10px 0; */
opacity: 0.6;
}
> div {
max-width: 100%;
}
}
.page figure[aria-label='media'] {
margin: var(--figure-margin);
display: flex;
flex-direction: column;
align-items: center;
}
.page hr {
margin-bottom: var(--hr-margin);
border: none;
}
.page table {
display: block;
word-break: normal;
white-space: nowrap;
border: 1px solid rgb(216, 216, 216);
border-spacing: 0;
border-collapse: collapse;
font-size: 0.8rem;
margin: auto;
line-height: 1.5em;
font-size: 0.9em;
max-width: -moz-fit-content;
max-width: fit-content;
margin: 0 auto;
overflow-x: auto;
caption {
margin: 0.5em 0;
}
th {
border: 1px solid rgb(216, 216, 216);
background-color: var(--table-header-color);
padding: 5px;
}
td {
border: 1px solid rgb(216, 216, 216);
padding: 0.5em;
padding: 5px;
}
p {
margin: 0;
padding: 0;
}
img {
display: block;
margin: 0.5em auto !important;
max-width: 100% !important;
}
}
.page ul,
.page ol {
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 40px;
margin-top: 18px;
font-family: var(--text-font-family);
font-style: normal;
font-weight: normal;
line-height: var(--line-height);
font-size: var(--text-font-size);
color: var(--font-color);
}
.page li {
word-break: break-word;
ol,
ul {
margin: 0;
}
}
.page sup,
.page sub {
position: relative;
a {
color: inherit;
pointer-events: none;
}
}
.page sup {
top: -0.3em;
}
.page sub {
bottom: 0.3em;
}
.page cite {
font-style: normal;
}
.page .page {
width: 100%;
}
/* Collapse excess whitespace. */
.page p > p:empty,
.page div > p:empty,
.page p > div:empty,
.page div > div:empty,
.page p + br,
.page p > br:only-child,
.page div > br:only-child,
.page img + br {
display: none;
}
.page video {
max-width: 100%;
}
.page dl {
display: block;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
}
.page dd {
display: block;
margin-inline-start: 40px;
}
.page pre,
.page code {
vertical-align: bottom;
word-wrap: initial;
font-family: 'SF Mono', monospace !important;
white-space: pre;
direction: ltr;
unicode-bidi: embed;
color: var(--font-color);
max-width: -moz-fit-content;
margin: 0;
overflow-x: auto;
word-wrap: normal;
border-radius: 4px;
}
.page img {
display: block;
margin: 0.5em auto !important;
max-width: 100% !important;
height: auto;
}
.page .page {
text-align: start;
word-wrap: break-word;
font-size: var(--text-font-size);
line-height: var(--line-height);
}
.page .omnivore-instagram-embed {
img {
margin: 0 !important;
}
}

View File

@ -0,0 +1,30 @@
import Foundation
import WebKit
public enum WebReaderResources {
public static var cssScript: WKUserScript {
WKUserScript(source: css(), injectionTime: .atDocumentEnd, forMainFrameOnly: false)
}
public static var bundleURL: URL {
Bundle.module.bundleURL
}
}
private func css() -> String {
guard let path = Bundle.module.path(forResource: "reader", ofType: "css") else { return "" }
let cssString = (try? String(contentsOfFile: path, encoding: .utf8)) ?? ""
return """
javascript:(function() {
var parent = document.getElementsByTagName('head').item(0);
var style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = window.atob('\(encodeStringTo64(fromString: cssString))');
parent.appendChild(style)})()
"""
}
private func encodeStringTo64(fromString: String) -> String {
let plainData = fromString.data(using: .utf8)
return plainData?.base64EncodedString(options: []) ?? ""
}