diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index bb53c910c..abd162beb 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -22,6 +22,7 @@ import Views ZStack { if let linkRequest = viewModel.linkRequest { NavigationLink( + // TODO: add alt for macOS destination: WebReaderLoadingContainer(requestID: linkRequest.serverID), tag: linkRequest, selection: $viewModel.linkRequest diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift index cde19d366..8ba1a95b1 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift @@ -8,7 +8,6 @@ import Views @MainActor final class LinkItemDetailViewModel: ObservableObject { let pdfItem: PDFItem? let item: LinkedItem? - @Published var webAppWrapperViewModel: WebAppWrapperViewModel? init(linkedItemObjectID: NSManagedObjectID, dataService: DataService) { if let linkedItem = dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem { @@ -42,32 +41,6 @@ import Views ) } - func loadWebAppWrapper(dataService: DataService, rawAuthCookie: String?) async { - let viewer: Viewer? = await { - if let currentViewer = dataService.currentViewer { - return currentViewer - } - - guard let viewerObjectID = try? await dataService.fetchViewer() else { return nil } - - var result: Viewer? - - await dataService.viewContext.perform { - result = dataService.viewContext.object(with: viewerObjectID) as? Viewer - } - - return result - }() - - if let viewer = viewer { - createWebAppWrapperViewModel( - username: viewer.unwrappedUsername, - dataService: dataService, - rawAuthCookie: rawAuthCookie - ) - } - } - func trackReadEvent() { guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return } guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return } @@ -89,23 +62,6 @@ import Views var isItemArchived: Bool { item?.isArchived ?? pdfItem?.isArchived ?? false } - - private func createWebAppWrapperViewModel(username: String, dataService: DataService, rawAuthCookie: String?) { - guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return } - let baseURL = dataService.appEnvironment.webAppBaseURL - - let urlRequest = URLRequest.webRequest( - baseURL: dataService.appEnvironment.webAppBaseURL, - urlPath: "/app/\(username)/\(slug)", - queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"] - ) - - webAppWrapperViewModel = WebAppWrapperViewModel( - webViewURLRequest: urlRequest, - baseURL: baseURL, - rawAuthCookie: rawAuthCookie - ) - } } struct LinkItemDetailView: View { @@ -144,13 +100,6 @@ struct LinkItemDetailView: View { ) } - var fontAdjustmentPopoverView: some View { - FontSizeAdjustmentPopoverView( - increaseFontAction: { viewModel.webAppWrapperViewModel?.sendIncreaseFontSignal = true }, - decreaseFontAction: { viewModel.webAppWrapperViewModel?.sendDecreaseFontSignal = true } - ) - } - // We always want this hidden but setting it to false initially // fixes a bug where SwiftUI searchable will always show the nav bar // if the search field is active when pushing. @@ -174,8 +123,11 @@ struct LinkItemDetailView: View { } } #else - fixedNavBarReader - .task { viewModel.trackReadEvent() } + // TODO: make pdf item work for macos + if let item = viewModel.item { + WebReaderContainerView(item: item) + .task { viewModel.trackReadEvent() } + } #endif } @@ -249,61 +201,6 @@ struct LinkItemDetailView: View { } } - #if os(iOS) - @ViewBuilder private var hidingNavBarReader: some View { - if let webAppWrapperViewModel = viewModel.webAppWrapperViewModel { - ZStack { - WebAppWrapperView( - viewModel: webAppWrapperViewModel, - navBarVisibilityRatioUpdater: { - if $0 < 1 { - showFontSizePopover = false - } - navBarVisibilityRatio = $0 - } - ) - 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 - } - ) - } - VStack(spacing: 0) { - navBar - Spacer() - } - } - } else { - VStack(spacing: 0) { - navBar - Spacer() - } - .task { - await viewModel.loadWebAppWrapper( - dataService: dataService, - rawAuthCookie: authenticator.omnivoreAuthCookieString - ) - } - } - } - #endif - @ViewBuilder private var fixedNavBarReader: some View { if let pdfItem = viewModel.pdfItem, let pdfURL = pdfItem.pdfURL { #if os(iOS) @@ -312,39 +209,12 @@ struct LinkItemDetailView: View { #elseif os(macOS) PDFWrapperView(pdfURL: pdfURL) #endif - } else if let webAppWrapperViewModel = viewModel.webAppWrapperViewModel { - WebAppWrapperView(viewModel: webAppWrapperViewModel) - .toolbar { - ToolbarItem(placement: .automatic) { - Button( - action: { showFontSizePopover = true }, - label: { - Image(systemName: "textformat.size") - } - ) - #if os(iOS) - .fittedPopover(isPresented: $showFontSizePopover) { - fontAdjustmentPopoverView - } - #else - .popover(isPresented: $showFontSizePopover) { - fontAdjustmentPopoverView - } - #endif - } - } } else { HStack(alignment: .center) { Spacer() Text("Loading...") Spacer() } - .task { - await viewModel.loadWebAppWrapper( - dataService: dataService, - rawAuthCookie: authenticator.omnivoreAuthCookieString - ) - } } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift index b598fa014..9acf9a501 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift @@ -32,22 +32,6 @@ public final class RootViewModel: ObservableObject { #endif } - func webAppWrapperViewModel(webLinkPath: String) -> WebAppWrapperViewModel { - let baseURL = services.dataService.appEnvironment.webAppBaseURL - - let urlRequest = URLRequest.webRequest( - baseURL: services.dataService.appEnvironment.webAppBaseURL, - urlPath: webLinkPath, - queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"] - ) - - return WebAppWrapperViewModel( - webViewURLRequest: urlRequest, - baseURL: baseURL, - rawAuthCookie: services.authenticator.omnivoreAuthCookieString - ) - } - func triggerPushNotificationRequestIfNeeded() { guard FeatureFlag.enablePushNotifications else { return } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift index bafbaf6d1..1a7468497 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift @@ -167,4 +167,164 @@ import WebKit ) } } +#else + struct WebReader: NSViewRepresentable { + let item: LinkedItem + let articleContent: ArticleContent + let openLinkAction: (URL) -> Void + let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void + let navBarVisibilityRatioUpdater: (Double) -> Void + + @Binding var updateFontFamilyActionID: UUID? + @Binding var updateFontActionID: UUID? + @Binding var updateTextContrastActionID: UUID? + @Binding var updateMaxWidthActionID: UUID? + @Binding var updateLineHeightActionID: UUID? + @Binding var annotationSaveTransactionID: UUID? + @Binding var showNavBarActionID: UUID? + @Binding var shareActionID: UUID? + @Binding var annotation: String + + func makeCoordinator() -> WebReaderCoordinator { + WebReaderCoordinator() + } + + func fontSize() -> Int { + let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebFontSize.rawValue) + return storedSize <= 1 ? Int(NSFont.userFont(ofSize: 16)?.pointSize ?? 16) : storedSize + } + + func lineHeight() -> Int { + let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebLineSpacing.rawValue) + return storedSize <= 1 ? 150 : storedSize + } + + func maxWidthPercentage() -> Int { + let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebMaxWidthPercentage.rawValue) + return storedSize <= 1 ? 100 : storedSize + } + + func makeNSView(context: Context) -> WKWebView { + let webView = WebViewManager.shared() + let contentController = WKUserContentController() + + webView.navigationDelegate = context.coordinator + webView.configuration.userContentController = contentController + webView.setValue(false, forKey: "drawsBackground") + + for action in WebViewAction.allCases { + webView.configuration.userContentController.add(context.coordinator, name: action.rawValue) + } + + webView.configuration.userContentController.removeAllScriptMessageHandlers() + +// webView.configuration.userContentController.add(webView, name: "viewerAction") + + webView.configuration.userContentController.addScriptMessageHandler( + context.coordinator, contentWorld: .page, name: "articleAction" + ) + + context.coordinator.linkHandler = openLinkAction + context.coordinator.webViewActionHandler = webViewActionHandler + context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater + loadContent(webView: webView) + + return webView + } + + // swiftlint:disable:next cyclomatic_complexity + func updateNSView(_ webView: WKWebView, context: Context) { + if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID { + context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID + (webView as? OmnivoreWebView)?.dispatchEvent(.saveAnnotation(annotation: annotation)) + } + + if updateFontFamilyActionID != context.coordinator.previousUpdateFontFamilyActionID { + context.coordinator.previousUpdateFontFamilyActionID = updateFontFamilyActionID + (webView as? OmnivoreWebView)?.updateFontFamily() + } + + if updateFontActionID != context.coordinator.previousUpdateFontActionID { + context.coordinator.previousUpdateFontActionID = updateFontActionID + (webView as? OmnivoreWebView)?.updateFontSize() + } + + if updateTextContrastActionID != context.coordinator.previousUpdateTextContrastActionID { + context.coordinator.previousUpdateTextContrastActionID = updateTextContrastActionID + (webView as? OmnivoreWebView)?.updateTextContrast() + } + + if updateMaxWidthActionID != context.coordinator.previousUpdateMaxWidthActionID { + context.coordinator.previousUpdateMaxWidthActionID = updateMaxWidthActionID + (webView as? OmnivoreWebView)?.updateMaxWidthPercentage() + } + + if updateLineHeightActionID != context.coordinator.previousUpdateLineHeightActionID { + context.coordinator.previousUpdateLineHeightActionID = updateLineHeightActionID + (webView as? OmnivoreWebView)?.updateLineHeight() + } + + if showNavBarActionID != context.coordinator.previousShowNavBarActionID { + context.coordinator.previousShowNavBarActionID = showNavBarActionID + context.coordinator.showNavBar() + } + + if shareActionID != context.coordinator.previousShareActionID { + context.coordinator.previousShareActionID = shareActionID + (webView as? OmnivoreWebView)?.shareOriginalItem() + } + + // If the webview had been terminated `needsReload` will have been set to true + if context.coordinator.needsReload { + loadContent(webView: webView) + context.coordinator.needsReload = false + return + } + + if webView.isLoading { return } + + // If the root element is not detected then `WKWebView` may have unloaded the content + // so we need to load it again. + webView.evaluateJavaScript("document.getElementById('root') ? true : false") { hasRootElement, _ in + guard let hasRootElement = hasRootElement as? Bool else { return } + + if !hasRootElement { + DispatchQueue.main.async { + loadContent(webView: webView) + } + } + } + } + + func loadContent(webView: WKWebView) { + let fontFamilyValue = UserDefaults.standard.string(forKey: UserDefaultKey.preferredWebFont.rawValue) + + let prefersHighContrastText: Bool = { + let key = UserDefaultKey.prefersHighContrastWebFont.rawValue + if UserDefaults.standard.object(forKey: key) != nil { + return UserDefaults.standard.bool(forKey: key) + } else { + return true + } + }() + + let fontFamily = fontFamilyValue.flatMap { WebFont(rawValue: $0) } ?? .inter + + webView.loadHTMLString( + WebReaderContent( + item: item, + articleContent: articleContent, + isDark: NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua, + fontSize: fontSize(), + lineHeight: lineHeight(), + maxWidthPercentage: maxWidthPercentage(), + fontFamily: fontFamily, + prefersHighContrastText: prefersHighContrastText + ) + .styledContent, + baseURL: ViewsPackage.bundleURL + ) + } + } + #endif diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 5bc1db9ef..54019c938 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -4,209 +4,213 @@ import SwiftUI import Views import WebKit -#if os(iOS) - struct WebReaderContainerView: View { - let item: LinkedItem +struct WebReaderContainerView: View { + let item: LinkedItem - @State private var showPreferencesPopover = false - @State private var showLabelsModal = false - @State private var showTitleEdit = false - @State var showHighlightAnnotationModal = false - @State var safariWebLink: SafariWebLink? - @State private var navBarVisibilityRatio = 1.0 - @State private var showDeleteConfirmation = false - @State private var progressViewOpacity = 0.0 - @State var updateFontFamilyActionID: UUID? - @State var updateFontActionID: UUID? - @State var updateTextContrastActionID: UUID? - @State var updateMaxWidthActionID: UUID? - @State var updateLineHeightActionID: UUID? - @State var annotationSaveTransactionID: UUID? - @State var showNavBarActionID: UUID? - @State var shareActionID: UUID? - @State var annotation = String() + @State private var showPreferencesPopover = false + @State private var showLabelsModal = false + @State private var showTitleEdit = false + @State var showHighlightAnnotationModal = false + @State var safariWebLink: SafariWebLink? + @State private var navBarVisibilityRatio = 1.0 + @State private var showDeleteConfirmation = false + @State private var progressViewOpacity = 0.0 + @State var updateFontFamilyActionID: UUID? + @State var updateFontActionID: UUID? + @State var updateTextContrastActionID: UUID? + @State var updateMaxWidthActionID: UUID? + @State var updateLineHeightActionID: UUID? + @State var annotationSaveTransactionID: UUID? + @State var showNavBarActionID: UUID? + @State var shareActionID: UUID? + @State var annotation = String() - @EnvironmentObject var dataService: DataService - @Environment(\.presentationMode) var presentationMode: Binding - @StateObject var viewModel = WebReaderViewModel() + @EnvironmentObject var dataService: DataService + @Environment(\.presentationMode) var presentationMode: Binding + @StateObject var viewModel = WebReaderViewModel() - func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) { - if let replyHandler = replyHandler { - viewModel.webViewActionWithReplyHandler( - message: message, - replyHandler: replyHandler, - dataService: dataService - ) - return - } - - if message.name == WebViewAction.highlightAction.rawValue { - handleHighlightAction(message: message) - } + func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) { + if let replyHandler = replyHandler { + viewModel.webViewActionWithReplyHandler( + message: message, + replyHandler: replyHandler, + dataService: dataService + ) + return } - private func handleHighlightAction(message: WKScriptMessage) { - guard let messageBody = message.body as? [String: String] else { return } - guard let actionID = messageBody["actionID"] else { return } - - switch actionID { - case "annotate": - annotation = messageBody["annotation"] ?? "" - showHighlightAnnotationModal = true - default: - break - } + if message.name == WebViewAction.highlightAction.rawValue { + handleHighlightAction(message: message) } + } - 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: { showPreferencesPopover.toggle() }, - label: { - Image(systemName: "textformat.size") - .font(.appTitleTwo) - } - ) - .padding(.horizontal) - .scaleEffect(navBarVisibilityRatio) - Menu( - content: { - Group { - Button( - action: { showTitleEdit = true }, - label: { Label("Edit Title/Description", systemImage: "textbox") } - ) - Button( - action: { showLabelsModal = true }, - label: { Label("Edit Labels", systemImage: "tag") } - ) - Button( - action: { - dataService.archiveLink(objectID: item.objectID, archived: !item.isArchived) - presentationMode.wrappedValue.dismiss() - Snackbar.show(message: !item.isArchived ? "Link archived" : "Link moved to Inbox") - }, - label: { - Label( - item.isArchived ? "Unarchive" : "Archive", - systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox" - ) - } - ) - Button( - action: { shareActionID = UUID() }, - label: { Label("Share Original", systemImage: "square.and.arrow.up") } - ) - 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) - .alert("Are you sure?", isPresented: $showDeleteConfirmation) { - Button("Remove Link", role: .destructive) { - Snackbar.show(message: "Link removed") - dataService.removeLink(objectID: item.objectID) - presentationMode.wrappedValue.dismiss() + private func handleHighlightAction(message: WKScriptMessage) { + guard let messageBody = message.body as? [String: String] else { return } + guard let actionID = messageBody["actionID"] else { return } + + switch actionID { + case "annotate": + annotation = messageBody["annotation"] ?? "" + showHighlightAnnotationModal = true + default: + break + } + } + + var navBar: some View { + HStack(alignment: .center) { + Button( + action: { self.presentationMode.wrappedValue.dismiss() }, + label: { + Image(systemName: "chevron.backward") + .font(.appTitleTwo) + .foregroundColor(.appGrayTextContrast) + .padding(.horizontal) } - Button("Cancel", role: .cancel, action: {}) - } - .sheet(isPresented: $showLabelsModal) { - ApplyLabelsView(mode: .item(item), onSave: { _ in showLabelsModal = false }) - } - .sheet(isPresented: $showTitleEdit) { - LinkedItemTitleEditView(item: item) - } - } - - var body: some View { - ZStack { - if let articleContent = viewModel.articleContent { - WebReader( - item: item, - articleContent: articleContent, - openLinkAction: { - #if os(macOS) - NSWorkspace.shared.open($0) - #elseif os(iOS) - safariWebLink = SafariWebLink(id: UUID(), url: $0) - #endif - }, - webViewActionHandler: webViewActionHandler, - navBarVisibilityRatioUpdater: { - navBarVisibilityRatio = $0 - }, - updateFontFamilyActionID: $updateFontFamilyActionID, - updateFontActionID: $updateFontActionID, - updateTextContrastActionID: $updateTextContrastActionID, - updateMaxWidthActionID: $updateMaxWidthActionID, - updateLineHeightActionID: $updateLineHeightActionID, - annotationSaveTransactionID: $annotationSaveTransactionID, - showNavBarActionID: $showNavBarActionID, - shareActionID: $shareActionID, - annotation: $annotation - ) - .onTapGesture { - withAnimation { - navBarVisibilityRatio = 1 - showNavBarActionID = UUID() - } + ) + .scaleEffect(navBarVisibilityRatio) + Spacer() + Button( + action: { showPreferencesPopover.toggle() }, + label: { + Image(systemName: "textformat.size") + .font(.appTitleTwo) + } + ) + .padding(.horizontal) + .scaleEffect(navBarVisibilityRatio) + Menu( + content: { + Group { + Button( + action: { showTitleEdit = true }, + label: { Label("Edit Title/Description", systemImage: "textbox") } + ) + Button( + action: { showLabelsModal = true }, + label: { Label("Edit Labels", systemImage: "tag") } + ) + Button( + action: { + dataService.archiveLink(objectID: item.objectID, archived: !item.isArchived) + presentationMode.wrappedValue.dismiss() + Snackbar.show(message: !item.isArchived ? "Link archived" : "Link moved to Inbox") + }, + label: { + Label( + item.isArchived ? "Unarchive" : "Archive", + systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox" + ) + } + ) + Button( + action: { shareActionID = UUID() }, + label: { Label("Share Original", systemImage: "square.and.arrow.up") } + ) + 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) + .alert("Are you sure?", isPresented: $showDeleteConfirmation) { + Button("Remove Link", role: .destructive) { + Snackbar.show(message: "Link removed") + dataService.removeLink(objectID: item.objectID) + presentationMode.wrappedValue.dismiss() + } + Button("Cancel", role: .cancel, action: {}) + } + .sheet(isPresented: $showLabelsModal) { + ApplyLabelsView(mode: .item(item), onSave: { _ in showLabelsModal = false }) + } + .sheet(isPresented: $showTitleEdit) { + LinkedItemTitleEditView(item: item) + } + } + + var body: some View { + ZStack { + if let articleContent = viewModel.articleContent { + WebReader( + item: item, + articleContent: articleContent, + openLinkAction: { + #if os(macOS) + NSWorkspace.shared.open($0) + #elseif os(iOS) + safariWebLink = SafariWebLink(id: UUID(), url: $0) + #endif + }, + webViewActionHandler: webViewActionHandler, + navBarVisibilityRatioUpdater: { + navBarVisibilityRatio = $0 + }, + updateFontFamilyActionID: $updateFontFamilyActionID, + updateFontActionID: $updateFontActionID, + updateTextContrastActionID: $updateTextContrastActionID, + updateMaxWidthActionID: $updateMaxWidthActionID, + updateLineHeightActionID: $updateLineHeightActionID, + annotationSaveTransactionID: $annotationSaveTransactionID, + showNavBarActionID: $showNavBarActionID, + shareActionID: $shareActionID, + annotation: $annotation + ) + .onTapGesture { + withAnimation { + navBarVisibilityRatio = 1 + showNavBarActionID = UUID() + } + } + #if os(iOS) + // TODO: fix for mac .fullScreenCover(item: $safariWebLink) { SafariView(url: $0.url) } - .sheet(isPresented: $showHighlightAnnotationModal) { - HighlightAnnotationSheet( - annotation: $annotation, - onSave: { - annotationSaveTransactionID = UUID() - showHighlightAnnotationModal = false - }, - onCancel: { - showHighlightAnnotationModal = false - } - ) + #endif + .sheet(isPresented: $showHighlightAnnotationModal) { + HighlightAnnotationSheet( + annotation: $annotation, + onSave: { + annotationSaveTransactionID = UUID() + showHighlightAnnotationModal = false + }, + onCancel: { + showHighlightAnnotationModal = false + } + ) + } + } else if let errorMessage = viewModel.errorMessage { + Text(errorMessage).padding() + } else { + ProgressView() + .opacity(progressViewOpacity) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) { + progressViewOpacity = 1 + } + } + .task { + await viewModel.loadContent(dataService: dataService, itemID: item.unwrappedID) } - } else if let errorMessage = viewModel.errorMessage { - Text(errorMessage).padding() - } else { - ProgressView() - .opacity(progressViewOpacity) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) { - progressViewOpacity = 1 - } - } - .task { - await viewModel.loadContent(dataService: dataService, itemID: item.unwrappedID) - } - } - VStack(spacing: 0) { - navBar - Spacer() - } } + VStack(spacing: 0) { + navBar + Spacer() + } + } + #if os(iOS) + // TODO: implement for mac .formSheet(isPresented: $showPreferencesPopover, useSmallDetent: false) { WebPreferencesPopoverView( updateFontFamilyAction: { updateFontFamilyActionID = UUID() }, @@ -217,10 +221,10 @@ import WebKit dismissAction: { showPreferencesPopover = false } ) } - .onDisappear { - // Clear the shared webview content when exiting - WebViewManager.shared().loadHTMLString("", baseURL: nil) - } + #endif + .onDisappear { + // Clear the shared webview content when exiting + WebViewManager.shared().loadHTMLString("", baseURL: nil) } } -#endif +} diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift index 94265cf37..a89b39bef 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift @@ -4,56 +4,60 @@ import Services import SwiftUI import Utils -#if os(iOS) - @MainActor final class WebReaderLoadingContainerViewModel: ObservableObject { - @Published var item: LinkedItem? - @Published var errorMessage: String? +@MainActor final class WebReaderLoadingContainerViewModel: ObservableObject { + @Published var item: LinkedItem? + @Published var errorMessage: String? - func loadItem(dataService: DataService, requestID: String) async { - guard let objectID = try? await dataService.loadItemContentUsingRequestID(requestID: requestID) else { return } - item = dataService.viewContext.object(with: objectID) as? LinkedItem - } - - func trackReadEvent() { - guard let item = item else { return } - - EventTracker.track( - .linkRead( - linkID: item.unwrappedID, - slug: item.unwrappedSlug, - originalArticleURL: item.unwrappedPageURLString - ) - ) - } + func loadItem(dataService: DataService, requestID: String) async { + guard let objectID = try? await dataService.loadItemContentUsingRequestID(requestID: requestID) else { return } + item = dataService.viewContext.object(with: objectID) as? LinkedItem } - public struct WebReaderLoadingContainer: View { - let requestID: String + func trackReadEvent() { + guard let item = item else { return } - @EnvironmentObject var dataService: DataService - @StateObject var viewModel = WebReaderLoadingContainerViewModel() + EventTracker.track( + .linkRead( + linkID: item.unwrappedID, + slug: item.unwrappedSlug, + originalArticleURL: item.unwrappedPageURLString + ) + ) + } +} - public var body: some View { - if let item = viewModel.item { - if let pdfItem = PDFItem.make(item: item) { +public struct WebReaderLoadingContainer: View { + let requestID: String + + @EnvironmentObject var dataService: DataService + @StateObject var viewModel = WebReaderLoadingContainerViewModel() + + public var body: some View { + if let item = viewModel.item { + if let pdfItem = PDFItem.make(item: item) { + #if os(iOS) PDFViewer(viewModel: PDFViewerViewModel(pdfItem: pdfItem)) .navigationBarHidden(true) .navigationViewStyle(.stack) .accentColor(.appGrayTextContrast) .task { viewModel.trackReadEvent() } - } else { - WebReaderContainerView(item: item) - .navigationBarHidden(true) - .navigationViewStyle(.stack) - .accentColor(.appGrayTextContrast) - .task { viewModel.trackReadEvent() } - } - } else if let errorMessage = viewModel.errorMessage { - Text(errorMessage) + #else + // TODO: implement pdf view for macOS + #endif } else { - ProgressView() - .task { await viewModel.loadItem(dataService: dataService, requestID: requestID) } + WebReaderContainerView(item: item) + #if os(iOS) + .navigationBarHidden(true) + .navigationViewStyle(.stack) + #endif + .accentColor(.appGrayTextContrast) + .task { viewModel.trackReadEvent() } } + } else if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + } else { + ProgressView() + .task { await viewModel.loadItem(dataService: dataService, requestID: requestID) } } } -#endif +} diff --git a/apple/OmnivoreKit/Sources/Views/Article/SafariView.swift b/apple/OmnivoreKit/Sources/Views/Article/SafariView.swift new file mode 100644 index 000000000..2f7bc16e4 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Article/SafariView.swift @@ -0,0 +1,35 @@ +import SafariServices +import SwiftUI +import WebKit + +#if os(iOS) + public struct SafariView: UIViewControllerRepresentable { + let url: URL + + public init(url: URL) { + self.url = url + } + + public func makeUIViewController( + context _: UIViewControllerRepresentableContext + ) -> SFSafariViewController { + SFSafariViewController(url: url) + } + + // swiftlint:disable:next line_length + public func updateUIViewController(_: SFSafariViewController, context _: UIViewControllerRepresentableContext) {} + } + +#elseif os(macOS) + public struct SafariView: View { + let url: URL + + public init(url: URL) { + self.url = url + } + + public var body: some View { + Color.clear + } + } +#endif diff --git a/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift b/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift deleted file mode 100644 index bb390ac1b..000000000 --- a/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift +++ /dev/null @@ -1,187 +0,0 @@ -import Models -import SwiftUI -import Utils -import WebKit - -public let readerViewNavBarHeight = 50.0 - -enum WebViewConfigurationManager { - private static let processPool = WKProcessPool() - static func create() -> WKWebViewConfiguration { - let config = WKWebViewConfiguration() - config.processPool = processPool - #if os(iOS) - config.allowsInlineMediaPlayback = true - #endif - config.mediaTypesRequiringUserActionForPlayback = .audio - return config - } -} - -public enum WebViewManager { - public static let sharedView = create() - public static func shared() -> OmnivoreWebView { - sharedView - } - - public static func create() -> OmnivoreWebView { - OmnivoreWebView(frame: CGRect.zero, configuration: WebViewConfigurationManager.create()) - } -} - -#if os(iOS) - struct WebAppView: UIViewRepresentable { - let request: URLRequest - let baseURL: URL - let rawAuthCookie: String? - let openLinkAction: (URL) -> Void - let webViewActionHandler: (WKScriptMessage) -> Void - let navBarVisibilityRatioUpdater: (Double) -> Void - @Binding var annotation: String - @Binding var annotationSaveTransactionID: UUID? - @Binding var sendIncreaseFontSignal: Bool - @Binding var sendDecreaseFontSignal: Bool - - func makeCoordinator() -> WebAppViewCoordinator { - WebAppViewCoordinator() - } - - func fontSize() -> Int { - let storedSize = UserDefaults.standard.integer(forKey: "preferredWebFontSize") - return storedSize <= 1 ? UITraitCollection.current.preferredWebFontSize : storedSize - } - - func makeUIView(context: Context) -> WKWebView { - let webView = WebViewManager.create() - let contentController = WKUserContentController() - - 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: baseURL, - 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(_ webView: WKWebView, context: Context) { - if context.coordinator.needsReload { - webView.load(request) - context.coordinator.needsReload = false - } - - if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID { - context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID - (webView as? OmnivoreWebView)?.dispatchEvent(.saveAnnotation(annotation: annotation)) - } - - if sendIncreaseFontSignal { - sendIncreaseFontSignal = false - (webView as? OmnivoreWebView)?.updateFontSize() - } - - if sendDecreaseFontSignal { - sendDecreaseFontSignal = false - (webView as? OmnivoreWebView)?.updateFontSize() - } - } - } -#endif - -#if os(macOS) - struct WebAppView: NSViewRepresentable { - let request: URLRequest - let baseURL: URL - let rawAuthCookie: String? - let openLinkAction: (URL) -> Void - let webViewActionHandler: (WKScriptMessage) -> Void - let navBarVisibilityRatioUpdater: (Double) -> Void - @Binding var annotation: String - @Binding var annotationSaveTransactionID: UUID? - @Binding var sendIncreaseFontSignal: Bool - @Binding var sendDecreaseFontSignal: Bool - - func makeCoordinator() -> WebAppViewCoordinator { - WebAppViewCoordinator() - } - - func fontSize() -> Int { - let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebFontSize.rawValue) - return storedSize <= 1 ? Int(NSFont.userFont(ofSize: 16)?.pointSize ?? 16) : storedSize - } - - func makeNSView(context: Context) -> WKWebView { - let contentController = WKUserContentController() - let webView = OmnivoreWebView(frame: CGRect.zero) - - webView.navigationDelegate = context.coordinator - webView.configuration.userContentController = contentController - webView.setValue(false, forKey: "drawsBackground") - - for action in WebViewAction.allCases { - webView.configuration.userContentController.add(context.coordinator, name: action.rawValue) - } - - webView.configureForOmnivoreAppEmbed( - config: WebViewConfig( - url: baseURL, - themeId: NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua ? "Gray" : "LightGray", - margin: 0, - fontSize: fontSize(), - fontFamily: "inter", - rawAuthCookie: rawAuthCookie - ) - ) - - context.coordinator.linkHandler = openLinkAction - context.coordinator.webViewActionHandler = webViewActionHandler - - return webView - } - - func updateNSView(_ webView: WKWebView, context: Context) { - if context.coordinator.needsReload { - webView.load(request) - context.coordinator.needsReload = false - } - - if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID { - context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID - (webView as? OmnivoreWebView)?.dispatchEvent(.saveAnnotation(annotation: annotation)) - } - - if sendIncreaseFontSignal { - sendIncreaseFontSignal = false - (webView as? OmnivoreWebView)?.updateFontSize() - } - - if sendDecreaseFontSignal { - sendDecreaseFontSignal = false - (webView as? OmnivoreWebView)?.updateFontSize() - } - } - } -#endif diff --git a/apple/OmnivoreKit/Sources/Views/Article/WebAppViewCoordinator.swift b/apple/OmnivoreKit/Sources/Views/Article/WebAppViewCoordinator.swift deleted file mode 100644 index 430d17591..000000000 --- a/apple/OmnivoreKit/Sources/Views/Article/WebAppViewCoordinator.swift +++ /dev/null @@ -1,153 +0,0 @@ -import SwiftUI -import WebKit - -final class WebAppViewCoordinator: NSObject { - var webViewActionHandler: (WKScriptMessage) -> Void = { _ in } - var linkHandler: (URL) -> Void = { _ in } - var needsReload = true - var lastSavedAnnotationID: 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 WebAppViewCoordinator: WKScriptMessageHandler { - func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { - webViewActionHandler(message) - } -} - -extension WebAppViewCoordinator: 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 WebAppViewCoordinator: 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) - - let authToken = extractAuthToken(rawAuthCookie: config.rawAuthCookie, url: config.url) - - if let authToken = authToken { - injectCookie(cookieString: "authToken=\(authToken); Max-Age=31536000;", url: config.url) - } else { - 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) {} - } - } - } -} - -// Temp utility until we swap out the web build used in prod. -// Once we do that we can just pass in the authToken directly -// and remove the rawAuthCookie param. -// This util just makes it easier to feature flag the web build used -private func extractAuthToken(rawAuthCookie: String?, url: URL) -> String? { - guard let rawAuthCookie = rawAuthCookie else { return nil } - let cookies = HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": rawAuthCookie], for: url) - return cookies.first?.value -} diff --git a/apple/OmnivoreKit/Sources/Views/Article/WebAppWrapperView.swift b/apple/OmnivoreKit/Sources/Views/Article/WebAppWrapperView.swift deleted file mode 100644 index 29b3f2f8f..000000000 --- a/apple/OmnivoreKit/Sources/Views/Article/WebAppWrapperView.swift +++ /dev/null @@ -1,127 +0,0 @@ -import SafariServices -import SwiftUI -import WebKit - -public final class WebAppWrapperViewModel: ObservableObject { - public enum Action { - case shareHighlight(highlightID: String) - } - - let webViewURLRequest: URLRequest - let baseURL: URL - let rawAuthCookie: String? - - @Published public var sendIncreaseFontSignal: Bool = false - @Published public var sendDecreaseFontSignal: Bool = false - - public init(webViewURLRequest: URLRequest, baseURL: URL, rawAuthCookie: String?) { - self.webViewURLRequest = webViewURLRequest - self.rawAuthCookie = rawAuthCookie - self.baseURL = baseURL - } -} - -public struct WebAppWrapperView: View { - struct SafariWebLink: Identifiable { - let id: UUID - let url: URL - } - - @ObservedObject private var viewModel: WebAppWrapperViewModel - @State var showHighlightAnnotationModal = false - @State private var annotation = String() - @State var annotationSaveTransactionID: UUID? - @State var safariWebLink: SafariWebLink? - let navBarVisibilityRatioUpdater: (Double) -> Void - - public init(viewModel: WebAppWrapperViewModel, navBarVisibilityRatioUpdater: ((Double) -> Void)? = nil) { - self.viewModel = viewModel - self.navBarVisibilityRatioUpdater = navBarVisibilityRatioUpdater ?? { _ in } - } - - public var body: some View { - VStack { - WebAppView( - request: viewModel.webViewURLRequest, - baseURL: viewModel.baseURL, - rawAuthCookie: viewModel.rawAuthCookie, - openLinkAction: { - #if os(macOS) - NSWorkspace.shared.open($0) - #elseif os(iOS) - safariWebLink = SafariWebLink(id: UUID(), url: $0) - #endif - }, - webViewActionHandler: webViewActionHandler, - navBarVisibilityRatioUpdater: navBarVisibilityRatioUpdater, - annotation: $annotation, - annotationSaveTransactionID: $annotationSaveTransactionID, - sendIncreaseFontSignal: $viewModel.sendIncreaseFontSignal, - sendDecreaseFontSignal: $viewModel.sendDecreaseFontSignal - ) - } - .sheet(item: $safariWebLink) { - SafariView(url: $0.url) - } - .sheet(isPresented: $showHighlightAnnotationModal) { - HighlightAnnotationSheet( - annotation: $annotation, - onSave: { - annotationSaveTransactionID = UUID() - showHighlightAnnotationModal = false - }, - onCancel: { - showHighlightAnnotationModal = false - } - ) - } - } - - func webViewActionHandler(message: WKScriptMessage) { - if message.name == WebViewAction.highlightAction.rawValue { - handleHighlightAction(message: message) - } - } - - private func handleHighlightAction(message: WKScriptMessage) { - guard let messageBody = message.body as? [String: String] else { return } - guard let actionID = messageBody["actionID"] else { return } - - if actionID == "annotate" { - annotation = messageBody["annotation"] ?? "" - showHighlightAnnotationModal = true - } - } -} - -#if os(iOS) - public struct SafariView: UIViewControllerRepresentable { - let url: URL - - public init(url: URL) { - self.url = url - } - - public func makeUIViewController( - context _: UIViewControllerRepresentableContext - ) -> SFSafariViewController { - SFSafariViewController(url: url) - } - - // swiftlint:disable:next line_length - public func updateUIViewController(_: SFSafariViewController, context _: UIViewControllerRepresentableContext) {} - } - -#elseif os(macOS) - public struct SafariView: View { - let url: URL - - public init(url: URL) { - self.url = url - } - - public var body: some View { - Color.clear - } - } -#endif diff --git a/apple/OmnivoreKit/Sources/Views/Article/WebViewManager.swift b/apple/OmnivoreKit/Sources/Views/Article/WebViewManager.swift new file mode 100644 index 000000000..022b1bce1 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Article/WebViewManager.swift @@ -0,0 +1,30 @@ +import Models +import SwiftUI +import Utils +import WebKit + +public let readerViewNavBarHeight = 50.0 + +enum WebViewConfigurationManager { + private static let processPool = WKProcessPool() + static func create() -> WKWebViewConfiguration { + let config = WKWebViewConfiguration() + config.processPool = processPool + #if os(iOS) + config.allowsInlineMediaPlayback = true + #endif + config.mediaTypesRequiringUserActionForPlayback = .audio + return config + } +} + +public enum WebViewManager { + public static let sharedView = create() + public static func shared() -> OmnivoreWebView { + sharedView + } + + public static func create() -> OmnivoreWebView { + OmnivoreWebView(frame: CGRect.zero, configuration: WebViewConfigurationManager.create()) + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Web/BasicWebAppView.swift b/apple/OmnivoreKit/Sources/Views/Web/BasicWebAppView.swift index 1baaf13d0..7d9df8561 100644 --- a/apple/OmnivoreKit/Sources/Views/Web/BasicWebAppView.swift +++ b/apple/OmnivoreKit/Sources/Views/Web/BasicWebAppView.swift @@ -73,3 +73,13 @@ public final class BasicWebAppViewCoordinator: NSObject { super.init() } } + +extension WKWebView { + 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) {} + } + } + } +}