diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift index 343973d99..941f5fbbf 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift @@ -7,6 +7,11 @@ import Utils import Views import WebKit +struct SafariWebLink: Identifiable { + let id: UUID + let url: URL +} + final class WebReaderViewModel: ObservableObject { @Published var isLoading = false @Published var htmlContent: String? @@ -33,13 +38,140 @@ final class WebReaderViewModel: ObservableObject { struct WebReaderContainerView: View { let item: FeedItem + + @State private var showFontSizePopover = false + @State var showHighlightAnnotationModal = false + @State var safariWebLink: SafariWebLink? + @State private var navBarVisibilityRatio = 1.0 + @State private var showDeleteConfirmation = false + @State var increaseFontActionID: UUID? + @State var decreaseFontActionID: UUID? + @EnvironmentObject var dataService: DataService + @EnvironmentObject var authenticator: Authenticator + @Environment(\.presentationMode) var presentationMode: Binding @StateObject var viewModel = WebReaderViewModel() + var fontAdjustmentPopoverView: some View { + FontSizeAdjustmentPopoverView( + increaseFontAction: { increaseFontActionID = UUID() }, + decreaseFontAction: { decreaseFontActionID = UUID() } + ) + } + + var navBariOS14: some View { + HStack(alignment: .center) { + Button( + action: { self.presentationMode.wrappedValue.dismiss() }, + label: { + Image(systemName: "chevron.backward") + .font(.appTitleTwo) + .foregroundColor(.appGrayTextContrast) + .padding(.horizontal) + } + ) + .scaleEffect(navBarVisibilityRatio) + Spacer() + Button( + action: { showFontSizePopover.toggle() }, + label: { + Image(systemName: "textformat.size") + .font(.appTitleTwo) + } + ) + .padding(.horizontal) + .scaleEffect(navBarVisibilityRatio) + } + .frame(height: readerViewNavBarHeight * navBarVisibilityRatio) + .opacity(navBarVisibilityRatio) + .background(Color.systemBackground) + .onTapGesture { + showFontSizePopover = false + } + } + + @available(macOS 12.0, *) + @available(iOS 15.0, *) + var navBar: some View { + HStack(alignment: .center) { + Button( + action: { self.presentationMode.wrappedValue.dismiss() }, + label: { + Image(systemName: "chevron.backward") + .font(.appTitleTwo) + .foregroundColor(.appGrayTextContrast) + .padding(.horizontal) + } + ) + .scaleEffect(navBarVisibilityRatio) + Spacer() + Button( + action: { showFontSizePopover.toggle() }, + label: { + Image(systemName: "textformat.size") + .font(.appTitleTwo) + } + ) + .padding(.horizontal) + .scaleEffect(navBarVisibilityRatio) + Menu( + content: { + Group { + Button( + action: {}, // ,viewModel.handleArchiveAction(dataService: dataService) }, + label: { + Label( + item.isArchived ? "Unarchive" : "Archive", + systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox" + ) + } + ) + Button( + action: { showDeleteConfirmation = true }, + label: { Label("Delete", systemImage: "trash") } + ) + } + }, + label: { + Image.profile + .padding(.horizontal) + .scaleEffect(navBarVisibilityRatio) + } + ) + } + .frame(height: readerViewNavBarHeight * navBarVisibilityRatio) + .opacity(navBarVisibilityRatio) + .background(Color.systemBackground) + .onTapGesture { + showFontSizePopover = false + } + .alert("Are you sure?", isPresented: $showDeleteConfirmation) { + Button("Remove Link", role: .destructive) { +// viewModel.handleDeleteAction(dataService: dataService) + } + Button("Cancel", role: .cancel, action: {}) + } + } + var body: some View { - Group { + ZStack { if let htmlContent = viewModel.htmlContent { - WebReader(htmlContent: htmlContent, item: item) + WebReader( + htmlContent: htmlContent, + item: item, + openLinkAction: { url in print(url) }, + webViewActionHandler: { _ in }, + navBarVisibilityRatioUpdater: { + if $0 < 1 { + showFontSizePopover = false + } + navBarVisibilityRatio = $0 + }, + authToken: authenticator.authToken ?? "", + increaseFontActionID: $increaseFontActionID, + decreaseFontActionID: $decreaseFontActionID, + annotationSaveTransactionID: nil + ) } else { Color.clear .contentShape(Rectangle()) @@ -49,26 +181,137 @@ struct WebReaderContainerView: View { } } } + if showFontSizePopover { + VStack { + Color.clear + .contentShape(Rectangle()) + .frame(height: LinkItemDetailView.navBarHeight) + HStack { + Spacer() + fontAdjustmentPopoverView + .background(Color.appButtonBackground) + .cornerRadius(8) + .padding(.trailing, 44) + } + Spacer() + } + .background( + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + showFontSizePopover = false + } + ) + } + if #available(iOS 15.0, *) { + VStack(spacing: 0) { + navBar + Spacer() + } + .navigationBarHidden(true) + } else { + VStack(spacing: 0) { + navBariOS14 + Spacer() + } + .navigationBarHidden(true) + } + }.onDisappear { // Clear the shared webview content when exiting WebViewManager.shared().loadHTMLString("", baseURL: nil) } + .navigationBarHidden(true) } } +// TODO: implement things WebAppWrapperView does struct WebReader: UIViewRepresentable { let htmlContent: String let item: FeedItem + let openLinkAction: (URL) -> Void + let webViewActionHandler: (WKScriptMessage) -> Void + let navBarVisibilityRatioUpdater: (Double) -> Void + let authToken: String - func makeUIView(context _: Context) -> WKWebView { + @Binding var increaseFontActionID: UUID? + @Binding var decreaseFontActionID: UUID? + + @State var annotationSaveTransactionID: UUID? + @State private var annotation = String() + + func makeCoordinator() -> WebReaderCoordinator { + WebReaderCoordinator() + } + + func fontSize() -> Int { + let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebFontSize.rawValue) + return storedSize <= 1 ? UITraitCollection.current.preferredWebFontSize : storedSize + } + + func makeUIView(context: Context) -> WKWebView { let webView = WebViewManager.create() + let contentController = WKUserContentController() + webView.loadHTMLString( - WebReaderContent(htmlContent: htmlContent, item: item).styledContent, + WebReaderContent( + htmlContent: htmlContent, + item: item, + authToken: authToken, + isDark: UITraitCollection.current.userInterfaceStyle == .dark, + fontSize: "\(fontSize())px", + margin: "0" + ) + .styledContent, baseURL: UtilsPackage.bundleURL ) + webView.navigationDelegate = context.coordinator + webView.isOpaque = false + webView.backgroundColor = .clear + webView.configuration.userContentController = contentController + webView.scrollView.delegate = context.coordinator + webView.scrollView.contentInset.top = readerViewNavBarHeight + webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight + + for action in WebViewAction.allCases { + webView.configuration.userContentController.add(context.coordinator, name: action.rawValue) + } + + webView.configuration.userContentController.add(webView, name: "viewerAction") + +// webView.configureForOmnivoreAppEmbed( +// config: WebViewConfig( +// url: dataService.appEnvironment.webAppBaseURL, +// themeId: UITraitCollection.current.userInterfaceStyle == .dark ? "Gray" : "LightGray", +// margin: 0, +// fontSize: fontSize(), +// fontFamily: "inter", +// rawAuthCookie: rawAuthCookie +// ) +// ) + + context.coordinator.linkHandler = openLinkAction + context.coordinator.webViewActionHandler = webViewActionHandler + context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater + return webView } - func updateUIView(_: WKWebView, context _: UIViewRepresentableContext) {} + func updateUIView(_ webView: WKWebView, context: Context) { + if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID { + context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID + (webView as? WebView)?.saveAnnotation(annotation: annotation) + } + + if increaseFontActionID != context.coordinator.previousIncreaseFontActionID { + context.coordinator.previousIncreaseFontActionID = increaseFontActionID + (webView as? WebView)?.increaseFontSize() + } + + if decreaseFontActionID != context.coordinator.previousDecreaseFontActionID { + context.coordinator.previousDecreaseFontActionID = decreaseFontActionID + (webView as? WebView)?.decreaseFontSize() + } + } } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift index 90478d832..ef51fc49c 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift @@ -11,10 +11,13 @@ struct WebReaderContent { let margin: String let content: String let item: FeedItem + let themeKey: String + let authToken: String init( htmlContent: String, item: FeedItem, + authToken: String, isDark: Bool = false, fontSize: String = "16px", margin: String = "24px" @@ -27,6 +30,8 @@ struct WebReaderContent { self.margin = margin self.content = htmlContent self.item = item + self.themeKey = isDark ? "Gray" : "LightGray" + self.authToken = authToken } var styleString: String { @@ -34,6 +39,8 @@ struct WebReaderContent { "--text-font-size:\(textFontSize);--font-color:\(fontColor);--font-color-transparent\(fontColorTransparent);--table-header-color:\(tableHeaderColor);--headers-color:\(headerColor);--app-margin:\(margin);" } + // TODO: pass in fontSize and theme + var styledContent: String { """ @@ -64,6 +71,7 @@ struct WebReaderContent { } loadArticle() + window.localStorage.setItem("authToken", "\(authToken)") diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift new file mode 100644 index 000000000..f069e59c3 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift @@ -0,0 +1,144 @@ +import Combine +import Models +import Services +import SwiftUI +import UIKit +import Utils +import Views +import WebKit + +final class WebReaderCoordinator: NSObject { + var webViewActionHandler: (WKScriptMessage) -> Void = { _ in } + var linkHandler: (URL) -> Void = { _ in } + var needsReload = true + var lastSavedAnnotationID: UUID? + var previousIncreaseFontActionID: UUID? + var previousDecreaseFontActionID: UUID? + var updateNavBarVisibilityRatio: (Double) -> Void = { _ in } + private var yOffsetAtStartOfDrag: Double? + private var lastYOffset: Double = 0 + private var hasDragged = false + private var isNavBarHidden = false + + override init() { + super.init() + } + + var navBarVisibilityRatio: Double = 1.0 { + didSet { + isNavBarHidden = navBarVisibilityRatio == 0 + updateNavBarVisibilityRatio(navBarVisibilityRatio) + } + } +} + +extension WebReaderCoordinator: WKScriptMessageHandler { + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + webViewActionHandler(message) + } +} + +extension WebReaderCoordinator: WKNavigationDelegate { + // swiftlint:disable:next line_length + func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if navigationAction.navigationType == .linkActivated { + if let linkURL = navigationAction.request.url { + linkHandler(linkURL) + } + decisionHandler(.cancel) + } else { + decisionHandler(.allow) + } + } + + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + #if os(iOS) + webView.isOpaque = true + webView.backgroundColor = .systemBackground + #endif + } +} + +#if os(iOS) + extension WebReaderCoordinator: UIScrollViewDelegate { + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + hasDragged = true + yOffsetAtStartOfDrag = scrollView.contentOffset.y + scrollView.contentInset.top + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard hasDragged else { return } + + let yOffset = scrollView.contentOffset.y + + if yOffset == 0 { + scrollView.contentInset.top = readerViewNavBarHeight + navBarVisibilityRatio = 1 + return + } + + if yOffset < 0 { + navBarVisibilityRatio = 1 + scrollView.contentInset.top = readerViewNavBarHeight + return + } + + if yOffset < readerViewNavBarHeight { + let isScrollingUp = yOffsetAtStartOfDrag ?? 0 > yOffset + navBarVisibilityRatio = isScrollingUp || yOffset < 0 ? 1 : min(1, 1 - (yOffset / readerViewNavBarHeight)) + scrollView.contentInset.top = navBarVisibilityRatio * readerViewNavBarHeight + return + } + + guard let yOffsetAtStartOfDrag = yOffsetAtStartOfDrag else { return } + + if yOffset > yOffsetAtStartOfDrag, !isNavBarHidden { + let translation = yOffset - yOffsetAtStartOfDrag + let ratio = translation < readerViewNavBarHeight ? 1 - (translation / readerViewNavBarHeight) : 0 + navBarVisibilityRatio = min(ratio, 1) + scrollView.contentInset.top = navBarVisibilityRatio * readerViewNavBarHeight + } + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if decelerate, scrollView.contentOffset.y + scrollView.contentInset.top < (yOffsetAtStartOfDrag ?? 0) { + scrollView.contentInset.top = readerViewNavBarHeight + navBarVisibilityRatio = 1 + } + } + + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + scrollView.contentInset.top = readerViewNavBarHeight + navBarVisibilityRatio = 1 + return false + } + } +#endif + +struct WebViewConfig { + let url: URL + let themeId: String + let margin: Int + let fontSize: Int + let fontFamily: String + let rawAuthCookie: String? +} + +extension WKWebView { + func configureForOmnivoreAppEmbed(config: WebViewConfig) { + // Set cookies to pass article preferences to web view + injectCookie(cookieString: "theme=\(config.themeId); Max-Age=31536000;", url: config.url) + injectCookie(cookieString: "margin=\(config.margin); Max-Age=31536000;", url: config.url) + injectCookie(cookieString: "fontSize=\(config.fontSize); Max-Age=31536000;", url: config.url) + injectCookie(cookieString: "fontFamily=\(config.fontFamily); Max-Age=31536000;", url: config.url) + injectCookie(cookieString: config.rawAuthCookie, url: config.url) + } + + func injectCookie(cookieString: String?, url: URL) { + if let cookieString = cookieString { + for cookie in HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": cookieString], for: url) { + configuration.websiteDataStore.httpCookieStore.setCookie(cookie) {} + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift index 3c1597265..a06f1d9e8 100644 --- a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift +++ b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift @@ -15,5 +15,5 @@ public enum FeatureFlag { public static let enableShareButton = false public static let enableSnooze = false public static let showFeedItemTags = false - public static let useLocalWebView = false + public static let useLocalWebView = true } diff --git a/apple/OmnivoreKit/Sources/Views/Article/WebView.swift b/apple/OmnivoreKit/Sources/Views/Article/WebView.swift index 595df0a89..9744d2f9a 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/WebView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/WebView.swift @@ -2,7 +2,7 @@ import WebKit /// Describes actions that can be sent from the WebView back to native views. /// The names on the javascript side must match for an action to be handled. -enum WebViewAction: String, CaseIterable { +public enum WebViewAction: String, CaseIterable { case highlightAction case readingProgressUpdate } @@ -26,11 +26,11 @@ public final class WebView: WKWebView { fatalError("init(coder:) has not been implemented") } - func increaseFontSize() { + public func increaseFontSize() { dispatchEvent("increaseFontSize") } - func decreaseFontSize() { + public func decreaseFontSize() { dispatchEvent("decreaseFontSize") } @@ -123,6 +123,7 @@ public final class WebView: WKWebView { setDefaultMenu() } + // swiftlint:disable:next line_length public func gestureRecognizer(_: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool { true } @@ -209,7 +210,7 @@ public final class WebView: WKWebView { UIMenuController.shared.showMenu(from: self, rect: rect) } - func saveAnnotation(annotation: String) { + public func saveAnnotation(annotation: String) { // swiftlint:disable:next line_length let dispatch = "var event = new Event('saveAnnotation');event.annotation = '\(annotation)';document.dispatchEvent(event);" evaluateJavaScript(dispatch) { obj, err in