Merge commit '134264940f208a47dde8489932e6dd6831110f81' into OMN-134

This commit is contained in:
gitstart-omnivore 2022-03-24 23:09:44 +00:00
commit 1acaf40a4d
108 changed files with 9281 additions and 670 deletions

View file

@ -0,0 +1,10 @@
---
name: Blank Template
about: Should be used for most issues
title: ''
labels: ''
assignees: ''
---

View file

@ -0,0 +1,28 @@
---
name: QA Run Template
about: Use this template when doing QA
title: ''
labels: QA
assignees: ''
---
There are three QA focus areas in Omnivore:
1. Saving
2. Library Management
3. Reader Controls
## Saving
Omnivore has three APIs involved in saving that should all be tested. URLs, Files, and Pages (captured content from the browser or the mobile) app can all be saved
## Library Management
The main library in `/home` has functionality for searching and organizing content.
## Reader functionality
The reader has functionality for highlighting, annotating, and saving reading progress

View file

@ -6,3 +6,7 @@ apple_graphql_gen:
apple_extension_gen:
$(MAKE) -C apple extension_gen
apple_webview_gen:
yarn workspace @omnivore/appreader build
cp packages/appreader/build/bundle.js apple/OmnivoreKit/Sources/Views/Resources/bundle.js

View file

@ -1463,7 +1463,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.4.1;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1495,7 +1495,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.4.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@ -1534,7 +1534,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.4.1;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"-framework",
@ -1696,7 +1696,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.4.1;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
PRODUCT_NAME = ShareExtension;
SDKROOT = iphoneos;
@ -1750,7 +1750,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.4.1;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1778,7 +1778,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.4.1;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
PRODUCT_NAME = ShareExtension;
SDKROOT = iphoneos;

View file

@ -57,7 +57,8 @@ public final class PDFViewerViewModel: ObservableObject {
prefix: nil,
suffix: nil,
patch: patch,
annotation: nil
annotation: nil,
createdByMe: true
)
)
@ -94,7 +95,8 @@ public final class PDFViewerViewModel: ObservableObject {
prefix: nil,
suffix: nil,
patch: patch,
annotation: nil
annotation: nil,
createdByMe: true
)
)

View file

@ -14,19 +14,21 @@ struct FeedCardNavigationLink: View {
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
NavigationLink(
destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)),
tag: item,
selection: $selectedLinkItem
) {
EmptyView()
ZStack {
NavigationLink(
destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)),
tag: item,
selection: $selectedLinkItem
) {
EmptyView()
}
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService)
}
FeedCard(item: item)
}
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService)
}
FeedCard(item: item)
}
}

View file

@ -147,6 +147,8 @@ struct LinkItemDetailView: View {
#if os(iOS)
if viewModel.item.isPDF {
fixedNavBarReader
} else if FeatureFlag.useLocalWebView {
WebReaderContainerView(item: viewModel.item, homeFeedViewModel: viewModel.homeFeedViewModel)
} else {
hidingNavBarReader
}

View file

@ -11,6 +11,14 @@ final class ProfileContainerViewModel: ObservableObject {
var subscriptions = Set<AnyCancellable>()
var appVersionString: String {
if let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
return "Omnivore Version \(appVersion)"
} else {
return ""
}
}
func loadProfileData(dataService: DataService) {
dataService.viewerPublisher().sink(
receiveCompletion: { _ in },
@ -81,7 +89,7 @@ struct ProfileView: View {
#endif
}
Section {
Section(footer: Text(viewModel.appVersionString)) {
if FeatureFlag.showAccountDeletion {
NavigationLink(
destination: ManageAccountView(handleAccountDeletion: {

View file

@ -0,0 +1,84 @@
import Models
import SwiftUI
import Utils
import Views
import WebKit
struct WebReader: UIViewRepresentable {
let articleContent: ArticleContent
let item: FeedItem
let openLinkAction: (URL) -> Void
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
let navBarVisibilityRatioUpdater: (Double) -> Void
@Binding var increaseFontActionID: UUID?
@Binding var decreaseFontActionID: UUID?
@Binding var annotationSaveTransactionID: UUID?
@Binding 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.shared()
let contentController = WKUserContentController()
webView.loadHTMLString(
WebReaderContent(
articleContent: articleContent,
item: item,
isDark: UITraitCollection.current.userInterfaceStyle == .dark,
fontSize: fontSize()
)
.styledContent,
baseURL: ViewsPackage.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
webView.configuration.userContentController.removeAllScriptMessageHandlers()
for action in WebViewAction.allCases {
webView.configuration.userContentController.add(context.coordinator, name: action.rawValue)
}
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
return webView
}
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()
}
}
}

View file

@ -0,0 +1,282 @@
import Combine
import Models
import Services
import SwiftUI
import Views
import WebKit
struct WebReaderContainerView: View {
let item: FeedItem
let homeFeedViewModel: HomeFeedViewModel
@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 private var showOverlay = true
@State var increaseFontActionID: UUID?
@State var decreaseFontActionID: UUID?
@State var annotationSaveTransactionID: UUID?
@State var annotation = String()
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@StateObject var viewModel = WebReaderViewModel()
var fontAdjustmentPopoverView: some View {
FontSizeAdjustmentPopoverView(
increaseFontAction: { increaseFontActionID = UUID() },
decreaseFontAction: { decreaseFontActionID = UUID() }
)
}
func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) {
if message.name == WebViewAction.readingProgressUpdate.rawValue {
let messageBody = message.body as? [String: Double]
if let messageBody = messageBody, let progress = messageBody["progress"] {
homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress))
}
}
if let replyHandler = replyHandler {
viewModel.webViewActionWithReplyHandler(
message: message,
replyHandler: replyHandler,
dataService: dataService
)
return
}
if message.name == WebViewAction.highlightAction.rawValue {
handleHighlightAction(message: message)
}
if message.name == WebViewAction.readingProgressUpdate.rawValue {
guard let messageBody = message.body as? [String: Double] else { return }
guard let progress = messageBody["progress"] else { return }
homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress))
}
}
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 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: {
homeFeedViewModel.setLinkArchived(
dataService: dataService,
linkId: item.id,
archived: !item.isArchived
)
},
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) {
homeFeedViewModel.removeLink(dataService: dataService, linkId: item.id)
}
Button("Cancel", role: .cancel, action: {})
}
}
var body: some View {
ZStack {
if let articleContent = viewModel.articleContent {
WebReader(
articleContent: articleContent,
item: item,
openLinkAction: {
#if os(macOS)
NSWorkspace.shared.open($0)
#elseif os(iOS)
safariWebLink = SafariWebLink(id: UUID(), url: $0)
#endif
},
webViewActionHandler: webViewActionHandler,
navBarVisibilityRatioUpdater: {
if $0 < 1 {
showFontSizePopover = false
}
navBarVisibilityRatio = $0
},
increaseFontActionID: $increaseFontActionID,
decreaseFontActionID: $decreaseFontActionID,
annotationSaveTransactionID: $annotationSaveTransactionID,
annotation: $annotation
)
.overlay(
Group {
if showOverlay {
Color.systemBackground
.transition(.opacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
withAnimation(.linear(duration: 0.2)) {
showOverlay = false
}
}
}
}
}
)
.sheet(item: $safariWebLink) {
SafariView(url: $0.url)
}
.sheet(isPresented: $showHighlightAnnotationModal) {
HighlightAnnotationSheet(
annotation: $annotation,
onSave: {
annotationSaveTransactionID = UUID()
showHighlightAnnotationModal = false
},
onCancel: {
showHighlightAnnotationModal = false
}
)
}
} else {
Color.clear
.contentShape(Rectangle())
.onAppear {
if !viewModel.isLoading {
viewModel.loadContent(dataService: dataService, slug: item.slug)
}
}
}
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("<html></html>", baseURL: nil)
}
.navigationBarHidden(true)
}
}

View file

@ -0,0 +1,77 @@
import Foundation
import Models
import Utils
struct WebReaderContent {
let textFontSize: Int
let articleContent: ArticleContent
let item: FeedItem
let themeKey: String
init(
articleContent: ArticleContent,
item: FeedItem,
isDark: Bool,
fontSize: Int
) {
self.textFontSize = fontSize
self.articleContent = articleContent
self.item = item
self.themeKey = isDark ? "Gray" : "LightGray"
}
// swiftlint:disable line_length
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' />
<style>
@import url("highlight\(themeKey == "Gray" ? "-dark" : "").css");
</style>
</head>
<body>
<div id="root" />
<div id='_omnivore-htmlContent' style="display: none;">
\(articleContent.htmlContent)
</div>
<div id='_omnivore-title' style="display: none;">
\(item.title)
</div>
<script type="text/javascript">
window.omnivoreEnv = {
"NEXT_PUBLIC_APP_ENV": "prod",
"NEXT_PUBLIC_BASE_URL": "unset",
"NEXT_PUBLIC_SERVER_BASE_URL": "unset",
"NEXT_PUBLIC_HIGHLIGHTS_BASE_URL": "unset"
}
window.omnivoreArticle = {
id: "\(item.id)",
linkId: "\(item.id)",
slug: "\(item.slug)",
createdAt: new Date().toISOString(),
savedAt: new Date().toISOString(),
url: `\(item.pageURLString)`,
title: document.getElementById('_omnivore-title').innerHTML,
content: document.getElementById('_omnivore-htmlContent').innerHTML,
originalArticleUrl: "\(item.pageURLString)",
contentReader: "WEB",
readingProgressPercent: \(item.readingProgress),
readingProgressAnchorIndex: \(item.readingProgressAnchor),
highlights: \(articleContent.highlightsJSONString),
}
window.fontSize = \(textFontSize)
window.localStorage.setItem("theme", "\(themeKey)")
</script>
<script src="bundle.js"></script>
<script src="mathJaxConfiguration.js" id="MathJax-script"></script>
<script src="mathjax.js" id="MathJax-script"></script>
</body>
</html>
"""
}
}

View file

@ -0,0 +1,128 @@
import Combine
import Models
import Services
import SwiftUI
import UIKit
import Utils
import Views
import WebKit
typealias WKScriptMessageReplyHandler = (Any?, String?) -> Void
final class WebReaderCoordinator: NSObject {
var webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> 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, nil)
}
}
extension WebReaderCoordinator: WKScriptMessageHandlerWithReply {
func userContentController(
_: WKUserContentController,
didReceive message: WKScriptMessage,
replyHandler: @escaping (Any?, String?) -> Void
) {
webViewActionHandler(message, replyHandler)
}
}
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

View file

@ -0,0 +1,165 @@
import Combine
import Models
import Services
import SwiftUI
import WebKit
struct SafariWebLink: Identifiable {
let id: UUID
let url: URL
}
final class WebReaderViewModel: ObservableObject {
@Published var isLoading = false
@Published var articleContent: ArticleContent?
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] articleContent in
self?.articleContent = articleContent
}
)
.store(in: &subscriptions)
}
func createHighlight(
messageBody: [String: Any],
replyHandler: @escaping WKScriptMessageReplyHandler,
dataService: DataService
) {
dataService.createHighlightPublisher(
shortId: messageBody["shortId"] as? String ?? "",
highlightID: messageBody["id"] as? String ?? "",
quote: messageBody["quote"] as? String ?? "",
patch: messageBody["patch"] as? String ?? "",
articleId: messageBody["articleId"] as? String ?? ""
)
.sink { completion in
guard case .failure = completion else { return }
replyHandler(["result": false], nil)
} receiveValue: { _ in
replyHandler(["result": true], nil)
}
.store(in: &subscriptions)
}
func deleteHighlight(
messageBody: [String: Any],
replyHandler: @escaping WKScriptMessageReplyHandler,
dataService: DataService
) {
dataService.deleteHighlightPublisher(
highlightId: messageBody["highlightId"] as? String ?? ""
)
.sink { completion in
guard case .failure = completion else { return }
replyHandler(["result": false], nil)
} receiveValue: { _ in
replyHandler(["result": true], nil)
}
.store(in: &subscriptions)
}
func mergeHighlight(
messageBody: [String: Any],
replyHandler: @escaping WKScriptMessageReplyHandler,
dataService: DataService
) {
dataService.mergeHighlightPublisher(
shortId: messageBody["shortId"] as? String ?? "",
highlightID: messageBody["id"] as? String ?? "",
quote: messageBody["quote"] as? String ?? "",
patch: messageBody["patch"] as? String ?? "",
articleId: messageBody["articleId"] as? String ?? "",
overlapHighlightIdList: messageBody["overlapHighlightIdList"] as? [String] ?? []
)
.sink { completion in
guard case .failure = completion else { return }
replyHandler(["result": false], nil)
} receiveValue: { _ in
replyHandler(["result": true], nil)
}
.store(in: &subscriptions)
}
func updateHighlight(
messageBody: [String: Any],
replyHandler: @escaping WKScriptMessageReplyHandler,
dataService: DataService
) {
dataService.updateHighlightAttributesPublisher(
highlightID: messageBody["highlightId"] as? String ?? "",
annotation: messageBody["annotation"] as? String ?? "",
sharedAt: nil
)
.sink { completion in
guard case .failure = completion else { return }
replyHandler(["result": false], nil)
} receiveValue: { _ in
replyHandler(["result": true], nil)
}
.store(in: &subscriptions)
}
func updateReadingProgress(
messageBody: [String: Any],
replyHandler: @escaping WKScriptMessageReplyHandler,
dataService: DataService
) {
let itemID = messageBody["id"] as? String
let readingProgress = messageBody["readingProgressPercent"] as? Double
let anchorIndex = messageBody["readingProgressAnchorIndex"] as? Int
guard let itemID = itemID, let readingProgress = readingProgress, let anchorIndex = anchorIndex else {
replyHandler(["result": false], nil)
return
}
dataService.updateArticleReadingProgressPublisher(
itemID: itemID,
readingProgress: readingProgress,
anchorIndex: anchorIndex
)
.sink { completion in
guard case .failure = completion else { return }
replyHandler(["result": false], nil)
} receiveValue: { _ in
replyHandler(["result": true], nil)
}
.store(in: &subscriptions)
}
func webViewActionWithReplyHandler(
message: WKScriptMessage,
replyHandler: @escaping WKScriptMessageReplyHandler,
dataService: DataService
) {
guard let messageBody = message.body as? [String: Any] else { return }
guard let actionID = messageBody["actionID"] as? String else { return }
switch actionID {
case "deleteHighlight":
deleteHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "createHighlight":
createHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "mergeHighlight":
mergeHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "updateHighlight":
updateHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "articleReadingProgress":
updateReadingProgress(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
default:
replyHandler(nil, "Unknown actionID: \(actionID)")
}
}
}

View file

@ -27,7 +27,11 @@ private let prodBaseURL = "https://api-prod.omnivore.app"
private let devWebURL = "https://web-dev.omnivore.app"
private let demoWebURL = "https://demo.omnivore.app"
private let prodWebURL = "https://web-prod.omnivore.app"
private let prodWebURL = "https://omnivore.app"
private let devHighlightsServerURL = "https://highlights-dev.omnivore.app"
private let demoHighlightsServerURL = "https://highlights-demo.omnivore.app"
private let prodHighlightsServerURL = "https://highlights.omnivore.app"
public extension AppEnvironment {
var graphqlPath: String {
@ -59,4 +63,17 @@ public extension AppEnvironment {
return URL(string: "http://localhost:3000")!
}
}
var highlightsServerBaseURL: URL {
switch self {
case .dev:
return URL(string: devHighlightsServerURL)!
case .demo:
return URL(string: demoHighlightsServerURL)!
case .prod:
return URL(string: prodHighlightsServerURL)!
case .test, .local:
return URL(string: "http://localhost:8080")!
}
}
}

View file

@ -0,0 +1,20 @@
import Foundation
public struct ArticleContent {
public let htmlContent: String
public let highlights: [Highlight]
public init(
htmlContent: String,
highlights: [Highlight]
) {
self.htmlContent = htmlContent
self.highlights = highlights
}
public var highlightsJSONString: String {
let jsonData = try? JSONEncoder().encode(highlights)
guard let jsonData = jsonData else { return "[]" }
return String(data: jsonData, encoding: .utf8) ?? "[]"
}
}

View file

@ -1,6 +1,6 @@
import Foundation
public struct Highlight: Identifiable, Hashable {
public struct Highlight: Identifiable, Hashable, Codable {
public let id: String
public let shortId: String
public let quote: String
@ -8,6 +8,9 @@ public struct Highlight: Identifiable, Hashable {
public let suffix: String?
public let patch: String
public let annotation: String?
public let createdAt: Date?
public let updatedAt: Date?
public let createdByMe: Bool
public init(
id: String,
@ -16,7 +19,10 @@ public struct Highlight: Identifiable, Hashable {
prefix: String?,
suffix: String?,
patch: String,
annotation: String?
annotation: String?,
createdByMe: Bool,
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
self.id = id
self.shortId = shortId
@ -25,5 +31,8 @@ public struct Highlight: Identifiable, Hashable {
self.suffix = suffix
self.patch = patch
self.annotation = annotation
self.createdAt = createdAt
self.updatedAt = updatedAt
self.createdByMe = createdByMe
}
}

View file

@ -9,7 +9,8 @@ public extension DataService {
highlightID: String,
quote: String,
patch: String,
articleId: String
articleId: String,
annotation: String? = nil
) -> AnyPublisher<String, BasicError> {
enum MutationResult {
case saved(id: String)
@ -28,7 +29,12 @@ public extension DataService {
let mutation = Selection.Mutation {
try $0.createHighlight(
input: InputObjects.CreateHighlightInput(
id: highlightID, shortId: shortId, articleId: articleId, patch: patch, quote: quote
id: highlightID,
shortId: shortId,
articleId: articleId,
patch: patch,
quote: quote,
annotation: OptionalArgument(annotation)
),
selection: selection
)

View file

@ -0,0 +1,72 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
// swiftlint:disable:next function_body_length
public extension DataService {
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<ArticleContent, ServerError> {
enum QueryResult {
case success(result: ArticleContent)
case error(error: String)
}
let highlightSelection = Selection.Highlight {
Highlight(
id: try $0.id(),
shortId: try $0.shortId(),
quote: try $0.quote(),
prefix: try $0.prefix(),
suffix: try $0.suffix(),
patch: try $0.patch(),
annotation: try $0.annotation(),
createdByMe: try $0.createdByMe()
)
}
let articleSelection = Selection.Article {
ArticleContent(
htmlContent: try $0.content(),
highlights: try $0.highlights(selection: highlightSelection.list)
)
}
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

@ -18,7 +18,8 @@ public extension DataService {
prefix: try $0.prefix(),
suffix: try $0.suffix(),
patch: try $0.patch(),
annotation: try $0.annotation()
annotation: try $0.annotation(),
createdByMe: try $0.createdByMe()
)
}

View file

@ -12,3 +12,10 @@ public extension Bundle {
#endif
}
}
// Convenience for locating package resources externally
public enum UtilsPackage {
public static var bundleURL: URL {
Bundle.module.bundleURL
}
}

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
}

View file

@ -1,13 +1,13 @@
import Introspect
import SwiftUI
struct HighlightAnnotationSheet: View {
public struct HighlightAnnotationSheet: View {
@Binding var annotation: String
let onSave: () -> Void
let onCancel: () -> Void
init(
public init(
annotation: Binding<String>,
onSave: @escaping () -> Void,
onCancel: @escaping () -> Void
@ -17,7 +17,7 @@ struct HighlightAnnotationSheet: View {
self.onCancel = onCancel
}
var body: some View {
public var body: some View {
VStack {
HStack {
Button("Cancel", action: onCancel)

View file

@ -5,6 +5,27 @@ import WebKit
public let readerViewNavBarHeight = 50.0
enum WebViewConfigurationManager {
private static let processPool = WKProcessPool()
static func create() -> WKWebViewConfiguration {
let config = WKWebViewConfiguration()
config.processPool = processPool
// config.limitsNavigationsToAppBoundDomains = true
return config
}
}
public enum WebViewManager {
public static let sharedView = create()
public static func shared() -> WebView {
sharedView
}
public static func create() -> WebView {
WebView(frame: CGRect.zero, configuration: WebViewConfigurationManager.create())
}
}
#if os(iOS)
struct WebAppView: UIViewRepresentable {
let request: URLRequest
@ -28,7 +49,7 @@ public let readerViewNavBarHeight = 50.0
}
func makeUIView(context: Context) -> WKWebView {
let webView = WebView(frame: CGRect.zero)
let webView = WebViewManager.create()
let contentController = WKUserContentController()
webView.navigationDelegate = context.coordinator

View file

@ -114,21 +114,32 @@ public struct WebAppWrapperView: View {
}
#if os(iOS)
struct SafariView: UIViewControllerRepresentable {
public struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context _: UIViewControllerRepresentableContext<SafariView>) -> SFSafariViewController {
public init(url: URL) {
self.url = url
}
public func makeUIViewController(
context _: UIViewControllerRepresentableContext<SafariView>
) -> SFSafariViewController {
SFSafariViewController(url: url)
}
// swiftlint:disable:next line_length
func updateUIViewController(_: SFSafariViewController, context _: UIViewControllerRepresentableContext<SafariView>) {}
public func updateUIViewController(_: SFSafariViewController, context _: UIViewControllerRepresentableContext<SafariView>) {}
}
#elseif os(macOS)
struct SafariView: View {
public struct SafariView: View {
let url: URL
var body: some View {
public init(url: URL) {
self.url = url
}
public var body: some View {
Color.clear
}
}

View file

@ -2,12 +2,12 @@ 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
}
final class WebView: WKWebView {
public final class WebView: WKWebView {
#if os(iOS)
private var panGestureRecognizer: UIPanGestureRecognizer?
private var tapGestureRecognizer: UITapGestureRecognizer?
@ -26,11 +26,11 @@ 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")
}
@ -43,7 +43,7 @@ final class WebView: WKWebView {
}
#if os(iOS)
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
@ -81,7 +81,7 @@ final class WebView: WKWebView {
setDefaultMenu()
}
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
public func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard let messageBody = message.body as? [String: Any] else { return }
guard let actionID = messageBody["actionID"] as? String else { return }
@ -115,7 +115,7 @@ final class WebView: WKWebView {
UIMenuController.shared.menuItems = [remove, /* share, */ annotate]
}
override var canBecomeFirstResponder: Bool {
override public var canBecomeFirstResponder: Bool {
true
}
@ -123,11 +123,12 @@ final class WebView: WKWebView {
setDefaultMenu()
}
func gestureRecognizer(_: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool {
// swiftlint:disable:next line_length
public func gestureRecognizer(_: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool {
true
}
override func canPerformAction(_ action: Selector, withSender _: Any?) -> Bool {
override public func canPerformAction(_ action: Selector, withSender _: Any?) -> Bool {
switch action {
case #selector(annotateSelection): return true
case #selector(highlightSelection): return true
@ -163,7 +164,7 @@ final class WebView: WKWebView {
hideMenu()
}
@objc override func copy(_ sender: Any?) {
@objc override public func copy(_ sender: Any?) {
super.copy(sender)
dispatchEvent("copyHighlight")
hideMenu()
@ -209,7 +210,7 @@ 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

View file

@ -0,0 +1,8 @@
import Foundation
// Convenience for locating package resources externally
public enum ViewsPackage {
public static var bundleURL: URL {
Bundle.module.bundleURL
}
}

View file

@ -3,15 +3,16 @@ import SwiftUI
@discardableResult
public func registerFonts() -> Bool {
[
registerFont(bundle: .module, fontName: "Inter-Black", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-ExtraBold", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-Bold", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-SemiBold", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-Medium", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-Regular", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-Light", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-ExtraLight", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-Thin", fontExtension: "ttf")
registerFont(bundle: .module, fontName: "Inter-Black-900", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-ExtraBold-800", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-Bold-700", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-SemiBold-600", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-Medium-500", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-Regular-400", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-Light-300", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-ExtraLight-200", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Inter-Thin", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "SFMonoRegular", fontExtension: "otf")
]
.allSatisfy { $0 }
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#ddd;background:#303030}.hljs-keyword,.hljs-link,.hljs-literal,.hljs-section,.hljs-selector-tag{color:#fff}.hljs-addition,.hljs-attribute,.hljs-built_in,.hljs-bullet,.hljs-name,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable,.hljs-title,.hljs-type,.hljs-variable{color:#d88}.hljs-comment,.hljs-deletion,.hljs-meta,.hljs-quote{color:#979797}.hljs-doctag,.hljs-keyword,.hljs-literal,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-strong,.hljs-title,.hljs-type{font-weight:700}.hljs-emphasis{font-style:italic}

View file

@ -0,0 +1 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#f3f3f3;color:#444}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}

View file

@ -0,0 +1,8 @@
MathJax = {
tex: {
inlineMath: [
['$latex', '$'],
['\\(', '\\)'],
],
},
}

File diff suppressed because one or more lines are too long

View file

@ -83,6 +83,7 @@ services:
- PG_DB=omnivore
- PG_PORT=5432
- PG_POOL_MAX=20
- ELASTIC_URL=http://elastic:9200
- IMAGE_PROXY_URL=http://localhost:9999
- IMAGE_PROXY_SECRET=some-secret
- JWT_SECRET=some_secret

View file

@ -26,3 +26,4 @@ TWITTER_BEARER_TOKEN=
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
SEGMENT_WRITE_KEY='test'
REMINDER_TASK_HANDLER_URL=http://localhost:4000/svc/reminders/trigger
PUBSUB_VERIFICATION_TOKEN='123456'

View file

@ -16,6 +16,18 @@ All operations on the database must be wrapped in Knex transaction on a resolver
Because we make use of Row Level Security in the database, - all operations typically begin with assuming the role for which policies exist via `omnivore.set_claims` database function.
## ElasticSearch
We use ElasticSearch to store page data in a distributed manner. This is a great way to store data that is not easily searchable.
All the page data is stored in a single index `pages`. This index is then queried by the app to display the data.
You need to make sure you have an elasticsearch instance running locally (or just use docker-compose).
ES url is specified by `ES_URL` environment variable (username `ES_USERNAME` and password `ES_PASSWORD` can be random strings in local environment).
When you're running elastic for the very first time, you need to create indices and ingest existing data. This can be done by running `python elastic_migrate.py`.
This operation is idempotent, so you can always run `python elastic_migrate.py` again to re-ingest all the data.
You can run ElasticSearch separately by using `docker-compose -f docker-compose.yml up -d elastic`.
## Image Proxy (optional for local dev)
Backend API server returns article image links using image proxy

View file

@ -1,4 +1,5 @@
#!/usr/bin/python
from datetime import datetime
import os
import json
import psycopg2
@ -21,7 +22,7 @@ INDEX_SETTINGS = os.getenv('INDEX_SETTINGS', 'index_settings.json')
DATETIME_FORMAT = 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
QUERY = f'''
SELECT
p.id,
l.id,
title,
description,
to_char(l.created_at, '{DATETIME_FORMAT}') as "createdAt",
@ -42,28 +43,63 @@ QUERY = f'''
to_char(saved_at, '{DATETIME_FORMAT}') as "savedAt",
slug,
to_char(archived_at, '{DATETIME_FORMAT}') as "archivedAt"
FROM omnivore.pages p
LEFT JOIN omnivore.links l ON p.id = l.article_id
WHERE p.updated_at > '{UPDATE_TIME}'
FROM omnivore.links l
INNER JOIN omnivore.pages p ON p.id = l.article_id
WHERE l.updated_at > '{UPDATE_TIME}'
'''
UPDATE_ARTICLE_SAVING_REQUEST_SQL = f'''
UPDATE omnivore.article_saving_request
SET elastic_page_id = article_id
WHERE elastic_page_id is NULL
AND article_id is NOT NULL
AND updated_at > '{UPDATE_TIME}';
UPDATE
omnivore.article_saving_request a
SET
elastic_page_id = l.id
FROM
omnivore.links l
WHERE
a.article_id = l.article_id
AND a.user_id = l.user_id
AND a.updated_at > '{UPDATE_TIME}'
'''
UPDATE_HIGHLIGHT_SQL = f'''
UPDATE omnivore.highlight
SET elastic_page_id = article_id
WHERE elastic_page_id is NULL
AND article_id is NOT NULL
AND updated_at > '{UPDATE_TIME}';
UPDATE
omnivore.highlight h
SET
elastic_page_id = l.id
FROM
omnivore.links l
WHERE
h.article_id = l.article_id
AND h.user_id = l.user_id
AND h.updated_at > '{UPDATE_TIME}'
'''
def assertData(conn, client):
# get all users from postgres
try:
cursor = conn.cursor(cursor_factory=RealDictCursor)
cursor.execute('''SELECT id FROM omnivore.user''')
result = cursor.fetchall()
for row in result:
userId = row['id']
cursor.execute(
f'SELECT COUNT(*) FROM omnivore.links WHERE user_id = \'{userId}\'''')
countInPostgres = cursor.fetchone()['count']
countInElastic = client.count(
index='pages', body={'query': {'term': {'userId': userId}}})['count']
if countInPostgres == countInElastic:
print(f'User {userId} OK')
else:
print(
f'User {userId} ERROR: postgres: {countInPostgres}, elastic: {countInElastic}')
cursor.close()
except Exception as err:
print('Assert data ERROR:', err)
exit(1)
def create_index(client):
print('Creating index')
try:
@ -153,6 +189,7 @@ def import_data_to_es(client, docs) -> int:
doc_list = []
for doc in docs:
doc['publishedAt'] = validated_date(doc['publishedAt'])
# convert the string to a dict object
dict_doc = {
'_index': 'pages',
@ -166,6 +203,22 @@ def import_data_to_es(client, docs) -> int:
return count
def validated_date(date):
try:
if date is None:
return None
datetime_object = datetime.strptime(date, '%Y-%m-%dT%H:%M:%S.%fZ')
# Make sure the date year is not greater than 9999
if datetime_object.year > 9999:
return None
return date
except Exception as err:
print('error validating date', err)
return None
print('Starting migration')
# test elastic client
@ -193,6 +246,8 @@ update_postgres_data(conn, UPDATE_ARTICLE_SAVING_REQUEST_SQL,
'article_saving_request')
update_postgres_data(conn, UPDATE_HIGHLIGHT_SQL, 'highlight')
assertData(conn, client)
client.close()
conn.close()

View file

@ -33,7 +33,7 @@
"@opentelemetry/semantic-conventions": "^0.24.0",
"@opentelemetry/tracing": "^0.24.0",
"@sendgrid/mail": "^7.6.0",
"@sentry/integrations": "^5.29.0",
"@sentry/integrations": "^6.19.1",
"@sentry/node": "^5.26.0",
"@sentry/tracing": "^5.26.0",
"@types/analytics-node": "^3.1.7",
@ -75,6 +75,7 @@
"graphql-middleware": "^6.0.10",
"graphql-shield": "^7.5.0",
"highlightjs": "^9.16.2",
"html-entities": "^2.3.2",
"intercom-client": "^3.1.4",
"jsdom": "^16.4.0",
"jsonwebtoken": "^8.5.1",

View file

@ -201,6 +201,7 @@ export const updatePage = async (
},
},
refresh: ctx.refresh,
retry_on_conflict: 3,
})
if (body.result !== 'updated') return false
@ -237,6 +238,7 @@ export const addLabelInPage = async (
},
},
refresh: ctx.refresh,
retry_on_conflict: 3,
})
return body.result === 'updated'
@ -350,7 +352,7 @@ export const getPageByParam = async <K extends keyof ParamSet>(
id: body.hits.hits[0]._id,
} as Page
} catch (e) {
console.log('failed to search pages in elastic', e)
console.error('failed to search pages in elastic', e)
return undefined
}
}
@ -472,6 +474,7 @@ export const searchPages = async (
return [
response.body.hits.hits.map((hit: { _source: Page; _id: string }) => ({
...hit._source,
content: '',
id: hit._id,
})),
response.body.hits.total.value,

View file

@ -349,7 +349,13 @@ export const createArticleResolver = authorized<
articleSavingRequest
)
}
log.info('page created in elastic', articleToSave)
log.info(
'page created in elastic',
pageId,
articleToSave.url,
articleToSave.slug,
articleToSave.title
)
articleToSave.id = pageId
}
@ -408,12 +414,7 @@ export const getArticleResolver: ResolverFn<
})
await createIntercomEvent('get-article', claims.uid)
console.log('start to get article', Date.now())
const page = await getPageByParam({ userId: claims.uid, slug })
console.log('get article from elastic', Date.now())
if (!page) {
return { errorCodes: [ArticleErrorCode.NotFound] }
}
@ -440,8 +441,6 @@ export const getArticlesResolver = authorized<
const startCursor = params.after || ''
const first = params.first || 10
console.log('getArticlesResolver starts', Date.now())
// Perform basic sanitization. Right now we just allow alphanumeric, space and quote
// so queries can contain phrases like "human race";
// We can also split out terms like "label:unread".
@ -461,8 +460,6 @@ export const getArticlesResolver = authorized<
},
})
console.log('parsed search query', Date.now())
await createIntercomEvent('search', claims.uid)
const [pages, totalCount] = (await searchPages(
@ -485,8 +482,6 @@ export const getArticlesResolver = authorized<
const hasNextPage = pages.length > first
const endCursor = String(start + pages.length - (hasNextPage ? 1 : 0))
console.log('get search result', Date.now())
console.log(
'start',
start,
@ -707,9 +702,9 @@ export const saveArticleReadingProgressResolver = authorized<
{ input: { id, readingProgressPercent, readingProgressAnchorIndex } },
{ claims: { uid }, pubsub }
) => {
const userArticleRecord = await getPageByParam({ userId: uid, _id: id })
const page = await getPageByParam({ userId: uid, _id: id })
if (!userArticleRecord) {
if (!page) {
return { errorCodes: [SaveArticleReadingProgressErrorCode.NotFound] }
}
@ -725,26 +720,25 @@ export const saveArticleReadingProgressResolver = authorized<
// be greater than the current reading progress.
const shouldUpdate =
readingProgressPercent === 0 ||
(userArticleRecord.readingProgressPercent || 0) <
readingProgressPercent ||
(userArticleRecord.readingProgressAnchorIndex || 0) <
readingProgressAnchorIndex
page.readingProgressPercent < readingProgressPercent ||
page.readingProgressAnchorIndex < readingProgressAnchorIndex
const updatedArticle = Object.assign(userArticleRecord, {
const updatedPart = {
readingProgressPercent: shouldUpdate
? readingProgressPercent
: userArticleRecord.readingProgressPercent,
: page.readingProgressPercent,
readingProgressAnchorIndex: shouldUpdate
? readingProgressAnchorIndex
: userArticleRecord.readingProgressAnchorIndex,
})
: page.readingProgressAnchorIndex,
}
shouldUpdate && (await updatePage(id, updatedArticle, { pubsub, uid }))
shouldUpdate && (await updatePage(id, updatedPart, { pubsub, uid }))
return {
updatedArticle: {
...updatedArticle,
isArchived: !!updatedArticle.archivedAt,
...page,
...updatedPart,
isArchived: !!page.archivedAt,
},
}
}

View file

@ -131,6 +131,7 @@ export const mergeHighlightResolver = authorized<
await models.highlight.deleteMany(overlapHighlightIdList, tx)
return await models.highlight.create({
...newHighlightInput,
articleId: undefined,
annotation: mergedAnnotation ? mergedAnnotation.join('\n') : null,
userId: claims.uid,
elasticPageId: newHighlightInput.articleId,

View file

@ -7,6 +7,7 @@ import { generateUploadSignedUrl, uploadToSignedUrl } from '../../utils/uploads'
import { v4 as uuidv4 } from 'uuid'
import { env } from '../../env'
import { Page } from '../../elastic/types'
import { DateTime } from 'luxon'
export function pageServiceRouter() {
const router = express.Router()
@ -36,19 +37,25 @@ export function pageServiceRouter() {
const contentType = 'application/json'
const bucketName = env.fileUpload.gcsUploadPrivateBucket
console.log('generate upload url')
const uploadUrl = await generateUploadSignedUrl(
`${req.params.folder}/${
data.userId
}/${new Date().toDateString()}/${uuidv4()}.json`,
`${req.params.folder}/${data.userId}/${DateTime.now().toFormat(
'yyyy-LL-dd'
)}/${uuidv4()}.json`,
contentType,
bucketName
)
console.log('start uploading', uploadUrl)
await uploadToSignedUrl(
uploadUrl,
Buffer.from(msgStr, 'utf8'),
contentType
)
res.status(200)
res.status(200).send('OK')
} catch (err) {
console.log('upload page data failed', err)
res.status(500).send(err)

View file

@ -122,6 +122,7 @@ export const createApp = (): {
}
const main = async (): Promise<void> => {
console.log('starting with log levels', config.syslog.levels)
// If creating the DB entities fails, we want this to throw
// so the container will be restarted and not come online
// as healthy.

View file

@ -14,6 +14,7 @@ import { AxiosHandler } from './axios-handler'
import { BloombergHandler } from './bloomberg-handler'
import { GolangHandler } from './golang-handler'
import * as hljs from 'highlightjs'
import { decode } from 'html-entities'
const logger = buildLogger('utils.parse')
@ -333,10 +334,10 @@ const getJSONLdLinkMetadata = async (
const jsonLd =
(await axios.get(jsonLdLink.href, { timeout: 5000 })).data || {}
result.byline = jsonLd['author_name']
result.previewImage = jsonLd['thumbnail_url']
result.siteName = jsonLd['provider_name']
result.title = jsonLd['title']
result.byline = decode(jsonLd['author_name'])
result.previewImage = decode(jsonLd['thumbnail_url'])
result.siteName = decode(jsonLd['provider_name'])
result.title = decode(jsonLd['title'])
return result
} catch (error) {

View file

@ -2,7 +2,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { env } from '../env'
import { GetSignedUrlConfig, Storage } from '@google-cloud/storage'
import axios, { AxiosResponse } from 'axios'
import axios from 'axios'
/* On GAE/Prod, we shall rely on default app engine service account credentials.
* Two changes needed: 1) add default service account to our uploads GCS Bucket
@ -102,12 +102,17 @@ export const uploadToSignedUrl = async (
uploadUrl: string,
data: Buffer,
contentType: string
): Promise<AxiosResponse> => {
return axios.put(uploadUrl, data, {
): Promise<void> => {
if (env.dev.isLocal) {
return
}
await axios.put(uploadUrl, data, {
headers: {
'Content-Type': contentType,
},
maxBodyLength: 1000000000,
maxContentLength: 100000000,
timeout: 30000,
})
}

View file

@ -52,6 +52,43 @@ const createHighlightQuery = (
`
}
const mergeHighlightQuery = (
pageId: string,
highlightId: string,
shortHighlightId: string,
overlapHighlightIdList: string[],
prefix = '_prefix',
suffix = '_suffix',
quote = '_quote',
patch = '_patch'
) => {
return `
mutation {
mergeHighlight(
input: {
prefix: "${prefix}",
suffix: "${suffix}",
quote: "${quote}",
id: "${highlightId}",
shortId: "${shortHighlightId}",
patch: "${patch}",
articleId: "${pageId}",
overlapHighlightIdList: "${overlapHighlightIdList}"
}
) {
... on MergeHighlightSuccess {
highlight {
id
}
}
... on MergeHighlightError {
errorCodes
}
}
}
`
}
describe('Highlights API', () => {
const username = 'fakeUser'
let authToken: string
@ -93,4 +130,35 @@ describe('Highlights API', () => {
expect(res.body.data.createHighlight.highlight.id).to.eq(highlightId)
})
})
context('mergeHighlightMutation', () => {
let highlightId: string
before(async () => {
// create test highlight
highlightId = generateFakeUuid()
const shortHighlightId = '_short_id'
const query = createHighlightQuery(
authToken,
pageId,
highlightId,
shortHighlightId
)
await graphqlRequest(query, authToken).expect(200)
})
it('should not fail', async () => {
const newHighlightId = generateFakeUuid()
const newShortHighlightId = '_short_id_1'
const query = mergeHighlightQuery(
pageId,
newHighlightId,
newShortHighlightId,
[highlightId]
)
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.mergeHighlight.highlight.id).to.eq(newHighlightId)
})
})
})

View file

@ -0,0 +1,23 @@
import { request } from '../util'
import 'mocha'
describe('Pages Router', () => {
const token = process.env.PUBSUB_VERIFICATION_TOKEN || ''
describe('upload', () => {
it('upload data to GCS', async () => {
const data = {
message: {
data: Buffer.from(JSON.stringify({ userId: 'userId' })).toString(
'base64'
),
publishTime: new Date().toISOString(),
},
}
await request
.post(`/svc/pubsub/pages/upload/createdPage?token=${token}`)
.send(data)
.expect(200)
})
})
})

View file

@ -1,9 +1,9 @@
import 'mocha'
import { expect } from 'chai'
import 'chai/register-should'
import { JSDOM } from 'jsdom'
import fs from 'fs'
import { findNewsletterUrl, isProbablyNewsletter, parsePageMetadata, parsePreparedContent } from '../../src/utils/parser'
import nock from 'nock'
const load = (path: string): string => {
return fs.readFileSync(path, 'utf8')
@ -70,4 +70,31 @@ describe('parsePreparedContent', async () => {
)
expect(result.parsedContent?.publishedDate?.getTime()).to.equal(new Date('2016-04-05T15:27:51+00:00').getTime())
})
})
})
describe('parsePreparedContent', async () => {
nock('https://oembeddata').get('/').reply(200, {
"version":"1.0",
"provider_name":"Hippocratic Adventures",
"provider_url":"https:\/\/www.hippocraticadventures.com",
"title":"The Ultimate Guide to Practicing Medicine in Singapore &#8211; Part 2"
})
it('gets metadata from external JSONLD if available', async () => {
const html = `<html>
<head>
<link rel="alternate" type="application/json+oembed" href="https://oembeddata">
</link
</head>
<body>body</body>
</html>`
const result = await parsePreparedContent(
'https://example.com/',
{
document: html,
pageInfo: { }
},
)
expect(result.parsedContent?.title).to.equal('The Ultimate Guide to Practicing Medicine in Singapore Part 2')
})
})

View file

@ -0,0 +1,16 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"regenerator": true
}
]
]
}

View file

@ -0,0 +1,3 @@
**/node_modules/*
**/out/*
**/.next/*

View file

@ -0,0 +1,53 @@
<!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">
<script type="text/javascript">
function loadEnv() {
window.omnivoreEnv = {
NEXT_PUBLIC_APP_ENV: 'local',
NEXT_PUBLIC_LOCAL_BASE_URL: 'http://localhost:3000',
NEXT_PUBLIC_LOCAL_SERVER_BASE_URL: 'http://localhost:4000',
NEXT_PUBLIC_LOCAL_HIGHLIGHTS_BASE_URL: 'http://localhost:4000',
}
}
function loadArticle() {
const CONTENT = `
<div class="page" id="readability-page-1" data-omnivore-anchor-idx="1"><div data-omnivore-anchor-idx="2">
<p data-omnivore-anchor-idx="3">I didn't have time to write a short email so I scheduled a long meeting.</p><p data-omnivore-anchor-idx="4">
<a href="https://twitter.com/jacksonh" data-omnivore-anchor-idx="5">jacksonh</a> Jackson Harper <a href="https://twitter.com/jacksonh/status/1069798258141159425" data-omnivore-anchor-idx="6">December 4, 2018, 3:39 AM UTC</a>
</p></div></div>
`
window.omnivoreArticle = {
id: 'test',
linkId: 'test',
slug: 'test-slug',
createdAt: new Date().toISOString(),
savedAt: new Date().toISOString(),
url: 'https://example.com',
title: 'Test Article',
content: CONTENT,
originalArticleUrl: 'https://example.com',
contentReader: 'WEB',
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
highlights: [],
}
}
loadEnv()
loadArticle()
</script>
</div>
<script src="bundle.js"></script>
</body>
</html>

View file

@ -0,0 +1,37 @@
{
"name": "@omnivore/appreader",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "webpack --mode production"
},
"dependencies": {
"@omnivore/web": "1.0.0"
},
"devDependencies": {
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2",
"@babel/core": "^7.17.7",
"@babel/plugin-transform-runtime": "^7.17.0",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
"@babel/runtime": "^7.17.7",
"@types/string-replace-loader": "^2.3.2",
"@types/webpack-bundle-analyzer": "^4.4.1",
"@types/webpack-dev-server": "^4.7.2",
"babel-loader": "^8.2.3",
"css-loader": "^6.7.1",
"eslint-config-next": "12.0.7",
"eslint-plugin-functional": "^4.0.2",
"eslint-plugin-react": "^7.28.0",
"string-replace-loader": "^3.1.0",
"style-loader": "^3.3.1",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-dev-server": "^4.7.4"
},
"volta": {
"node": "14.18.0",
"yarn": "1.22.10"
}
}

View file

@ -0,0 +1,58 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Box, VStack } from '@omnivore/web/components/elements/LayoutPrimitives'
import { ArticleContainer } from '@omnivore/web/components/templates/article/ArticleContainer'
import { applyStoredTheme } from '@omnivore/web/lib/themeUpdater'
import '@omnivore/web/styles/globals.css'
import '@omnivore/web/styles/articleInnerStyling.css'
const mutation = async (name, input) => {
const result = await window?.webkit?.messageHandlers.articleAction?.postMessage({
actionID: name,
...input
})
console.log('action result', result, result.result)
return result.result
}
const App = () => {
applyStoredTheme(false)
return (
<>
<Box
css={{
overflowY: 'auto',
height: '100%',
width: '100vw',
}}
>
<VStack
alignment="center"
distribution="center"
className="disable-webkit-callout"
>
<ArticleContainer
viewerUsername="test"
article={window.omnivoreArticle}
scrollElementRef={React.createRef()}
isAppleAppEmbed={true}
highlightBarDisabled={true}
highlightsBaseURL="https://example.com"
fontSize={window.fontSize ?? 18}
margin={0}
articleMutations={{
createHighlightMutation: (input) => mutation('createHighlight', input),
deleteHighlightMutation: (highlightId) => mutation('deleteHighlight', { highlightId }),
mergeHighlightMutation: (input) => mutation('mergeHighlight', input),
updateHighlightMutation: (input) => mutation('updateHighlight', input),
articleReadingProgressMutation: (input) => mutation('articleReadingProgress', input),
}}
/>
</VStack>
</Box>
</>
)
}
ReactDOM.render(<App />, document.getElementById('root'))

View file

@ -0,0 +1,70 @@
import path from 'path'
import { DefinePlugin } from 'webpack'
import { Configuration } from 'webpack'
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'
const analyze = process.env.ANALYZE
const config: Configuration = {
entry: {
bundle: './src/index.jsx',
},
module: {
rules: [
{
test: /\.(ts|js)x?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript',
],
},
},
},
{
test: /\.css$/i,
use: [
'style-loader',
{
loader: 'css-loader',
options: { url: false },
},
{
// We want paths like `/static/fonts/Inter/Inter.woff2 to become
// `Inter.woff2` which is how they will be bundled on iOS.
loader: 'string-replace-loader',
options: {
search: "('/static/fonts/.*/(.*)')",
replace(_s: string, _p: string, group: string) {
return group
},
flags: 'g',
},
},
],
},
],
},
plugins: [
new DefinePlugin({
'process.env': 'window.omnivoreEnv',
}),
new BundleAnalyzerPlugin({
openAnalyzer: !!analyze,
analyzerMode: 'static',
}),
],
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].js',
},
}
export default config

View file

@ -25,7 +25,7 @@
},
"dependencies": {
"@fast-csv/parse": "^4.3.6",
"@google-cloud/functions-framework": "1.9.0",
"@google-cloud/functions-framework": "3.0.0",
"@google-cloud/pubsub": "^2.16.3",
"@google-cloud/storage": "^5.18.1",
"@types/express": "^4.17.13",

View file

@ -26,7 +26,7 @@
"eslint-plugin-prettier": "^4.0.0"
},
"dependencies": {
"@google-cloud/functions-framework": "1.9.0",
"@google-cloud/functions-framework": "3.0.0",
"@google-cloud/pubsub": "^2.18.4",
"@sendgrid/client": "^7.6.0",
"@sentry/serverless": "^6.16.1",

View file

@ -23,7 +23,7 @@
"@types/node": "^14.11.2"
},
"dependencies": {
"@google-cloud/functions-framework": "1.9.0",
"@google-cloud/functions-framework": "3.0.0",
"@google-cloud/pubsub": "^2.16.3",
"@google-cloud/storage": "^5.18.1",
"axios": "^0.26.0",

View file

@ -17,7 +17,7 @@
"winston": "^3.3.3"
},
"devDependencies": {
"@google-cloud/functions-framework": "^1.7.1"
"@google-cloud/functions-framework": "^3.0.0"
},
"scripts": {
"start": "npx functions-framework --port=9090 --target=puppeteer",

View file

@ -167,9 +167,9 @@ Readability.prototype = {
lazyLoadingElements: /\S*loading\S*/i,
// NOTE: These two regular expressions are duplicated in
// Readability-readerable.js. Please keep both copies in sync.
articleNegativeLookBehindCandidates: /breadcrumbs|breadcrumb|utils/i,
articleNegativeLookAheadCandidates: /outstream(.?)_|sub(.?)_/i,
unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|breadcrumb|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager(?!ow)|popup|yom-remote|copyright|keywords|outline|infinite-list|beta|recirculation|site-index|hide-for-print|post-end-share-cta|post-end-cta-full|post-footer|main-navigation|programtic-ads|outstream_article|hfeed|comment-holder/i,
articleNegativeLookBehindCandidates: /breadcrumbs|breadcrumb|utils|trilist/i,
articleNegativeLookAheadCandidates: /outstream(.?)_|sub(.?)_|m_/i,
unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|breadcrumb|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager(?!ow)|popup|yom-remote|copyright|keywords|outline|infinite-list|beta|recirculation|site-index|hide-for-print|post-end-share-cta|post-end-cta-full|post-footer|main-navigation|programtic-ads|outstream_article|hfeed|comment-holder|back-to-top|show-up-next/i,
// okMaybeItsACandidate: /and|article(?!-breadcrumb)|body|column|content|main|shadow|post-header/i,
get okMaybeItsACandidate() {
return new RegExp(`and|(?<!${this.articleNegativeLookAheadCandidates.source})article(?!-(${this.articleNegativeLookBehindCandidates.source}))|body|column|content|^(?!main-navigation)main|shadow|post-header|hfeed site|blog-posts hfeed`, 'i')
@ -2785,7 +2785,7 @@ Readability.prototype = {
_isProbablyVisible: function(node) {
// Have to null-check node.style and node.className.indexOf to deal with SVG and MathML nodes.
return (!node.style || node.style.display !== "none")
&& node.style.visibility !== 'hidden'
&& (node.style && node.style.visibility !== 'hidden')
&& !node.hasAttribute("hidden")
//check for "fallback-image" so that wikimedia math images are displayed
&& (!node.hasAttribute("aria-hidden")

View file

@ -207,26 +207,17 @@ function onResponseReceived(error, source, destRoot) {
function runReadability(source, destPath, metadataDestPath) {
var uri = "http://fakehost/test/page.html";
var doc = new JSDOM(source, {
url: uri,
}).window.document;
var myReader, result, readerable;
try {
// We pass `caption` as a class to check that passing in extra classes works,
// given that it appears in some of the test documents.
myReader = new Readability(doc, { classesToPreserve: ["caption"] });
result = myReader.parse();
} catch (ex) {
console.error(ex);
ex.stack.forEach(console.log.bind(console));
}
// Use jsdom for isProbablyReaderable because it supports querySelectorAll
try {
var jsdomDoc = new JSDOM(source, {
// Use jsdom for isProbablyReaderable because it supports querySelectorAll
var jsdom = new JSDOM(source, {
url: uri,
}).window.document;
myReader = new Readability(jsdomDoc);
readerable = isProbablyReaderable(jsdomDoc);
// We pass `caption` as a class to check that passing in extra classes works,
// given that it appears in some of the test documents.
myReader = new Readability(jsdom, { classesToPreserve: ["caption"]});
result = myReader.parse();
readerable = isProbablyReaderable(jsdom);
} catch (ex) {
console.error(ex);
ex.stack.forEach(console.log.bind(console));

View file

@ -0,0 +1,10 @@
{
"title": "Will Western Sanctions Accelerate Russian Realignment? | City Journal",
"byline": "Samo Burja is the president of Bismarck Analysis, a political risk consultancy. Twitter: @samoburja",
"dir": null,
"excerpt": "As the Russian economy falters, it may have to look east.",
"siteName": "City Journal",
"previewImage": "https://media4.manhattan-institute.org/sites/cj/files/will-western-sanctions-accelerate-russian-realignment.jpg",
"publishedDate": "2022-03-17T15:14:12.000Z",
"readerable": true
}

View file

@ -0,0 +1,27 @@
<div id="readability-page-1" class="page">
<div>
<div>
<h2> The Great Eurasian Economic Realignment </h2>
</div>
<div id="wallit-pop">
<!-- Part of the l_ipage-container-module -->
<!-- actual article chapter content -->
<p> Since Vladimir Putin ordered the Russian Armed Forces to <a href="https://www.city-journal.org/putins-bet" target="_blank">launch a full-scale invasion</a> of Ukraine on February 24, Western countries and their close allies have hit Russia with a raft of sweeping and unprecedented <a href="https://www.city-journal.org/under-heavy-sanctions-time-is-not-on-russias-side" target="_blank">sanctions</a>. Several major Russian banks were cut off from the Belgium-based SWIFT international financial messaging system. Transactions with Russias central bank were banned by the United States, United Kingdom, and European Union, effectively preventing Russia from utilizing much of its $640 billion of foreign exchange reserves. Targeted asset freezes and seizures were carried out against high-level members of Russias political and economic elite, Russian planes were denied access to essentially all European and North American airspace, and an unofficial boycott of Russia has seen <a href="https://www.dw.com/en/which-companies-have-pulled-out-of-russia-over-the-ukraine-invasion/a-61078955" target="_blank">over 300</a> international businesses suspend operations in or with the country, including major brands such as Apple, Disney, Zara, Visa, IKEA, and Coca-Cola. The Russian ruble has <a href="https://www.nytimes.com/2022/03/09/business/russia-ruble-central-bank.html" target="_blank">lost</a> about 40 percent of its value against the U.S. dollar in a matter of weeks. </p>
<p> This kind of economic warfare is designed to isolate Putins government and bring an end to the invasion. But Russias vast exports of oil, coal, and natural gas to Europe have been notably exempt from the sanctions so far. Europe heavily relies on Russia to meet its energy needs: Russia alone <a href="https://edition.cnn.com/2022/03/08/energy/gas-russia-europe/index.html" target="_blank">supplies</a> 40 percent of the European Unions natural gas imports, 46 percent of EU coal imports, and 27 percent of EU oil imports. Russia is also the worlds largest <a href="https://www.aljazeera.com/news/2022/2/17/infographic-russia-ukraine-and-the-global-wheat-supply-interactive" target="_blank">wheat</a> exporter, a leading producer of fertilizer, and possesses vast mineral reserves. The sanctions and boycotts against Russia are thus perhaps better termed financial warfare. Despite the belief that financial pain for the Russian populace will translate into political change, the nature of the Russian government remains the same. And while such bureaucratic and accounting moves can certainly cause significant pain in the short and medium term, they cannot change the fact that Russia possesses valuable and useful natural resources. </p>
<p> But as the <a href="https://www.forbes.com/sites/rrapier/2021/11/14/is-the-us-energy-independent/" target="_blank">energy-independent U.S.</a> pushes for energy sanctions, Germany in particular finds itself in an increasingly difficult position. For decades, Germanys political and business elites have been <a href="https://brief.bismarckanalysis.com/p/the-german-retreat-from-nuclear-power" target="_blank">implementing a wide-ranging transition</a> from reliance on fossil fuels and nuclear energy to wind, solar, and biofuels. This energy transition, called the <i>Energiewende</i> in German, was always a precariously ambitious strategy for Europes premier industrial and manufacturing power. Heavy industry requires consistent and abundant energy—something that <a href="https://brief.bismarckanalysis.com/p/photovoltaics-give-credibility-to" target="_blank">photovoltaic solar panels could provide in theory</a> but not in dim and cloudy Germany. With its nuclear plants now almost entirely shuttered, Germanys energy transition has instead resulted in a reliance on Russian energy significantly higher than the EU average, with <a href="https://www.bp.com/content/dam/bp/business-sites/en/global/corporate/pdfs/energy-economics/statistical-review/bp-stats-review-2021-natural-gas.pdf" target="_blank">55 percent of natural gas imports</a> coming from Russia in 2020. </p>
<p> Will that change? In the wake of Russias invasion of Ukraine, the German government briefly floated the possibility of delaying shutdowns of its few remaining nuclear plants, before ultimately <a href="https://www.reuters.com/world/europe/german-ministries-say-cannot-recommend-extending-nuclear-plants-lifetime-2022-03-08/" target="_blank">deciding against it</a> and instead emphasizing plans to increase liquefied natural gas (LNG) imports and development of renewables. While the U.S. has announced plans to ban Russian energy imports immediately, new European <a href="https://edition.cnn.com/2022/03/08/energy/gas-russia-europe/index.html" target="_blank">plans</a> to reduce energy imports from Russia are less ambitious on paper but could prove harder to achieve. </p>
<p> Political considerations may ultimately outweigh economic ones in Europe. All that is stopping European countries from eventually reducing energy imports from Russia to zero is their willingness to pay the costs of higher energy import prices, such as by importing LNG from the United States, the Middle East, or Africa, or by continuing to subsidize the construction and operation of renewable-energy installations. Much as Westerners overrate the effects of economic hardship on political outcomes in Russia, Iran, or North Korea, it would be a mistake for Russia to overrate the effects of economic hardship on political outcomes in Europe. For while Russia is Europes largest energy supplier, Europe is Russias largest trade partner. The complete loss of this trade relationship would have far harsher impacts on Russias economy and state coffers than even the current round of financial sanctions. </p>
<p> What has not been widely considered, however, is the possibility that Russia welcomes this outcome. If Russia is betting on economic divorce from Europe, including in energy, then sanctions and boycotts counterintuitively support, rather than frustrate, Russian strategy. Indeed, Putins government is already planning to retaliate with economic warfare of its own: it has prepared its own list of “unfriendly” countries to which to <a href="https://www.bloomberg.com/news/articles/2022-03-08/russia-to-restrict-some-raw-material-exports-but-omits-details" target="_blank">ban exports</a> of natural resources, and has <a href="https://www.bloomberg.com/news/articles/2022-03-07/russia-threatens-to-cut-gas-flows-to-europe-via-nord-stream-1" target="_blank">threatened</a> to shut down gas to Germany via the Nord Stream 1 pipeline in retaliation for Germany halting the certification of Nord Stream 2. </p>
<p> Perhaps these moves can be written off as symbolic reactions, but they may fit a broader strategy of replacing Europe with Asia as Russias key customer for natural resources. Russia has been heavily investing in development and infrastructure <a href="https://brief.bismarckanalysis.com/p/russias-long-term-bet-on-the-arctic" target="_blank">in its Arctic and subarctic regions</a>, which produce an overwhelming majority of its fossil fuels and a substantial portion of its valuable minerals. The Russian government has gone so far as to plan a “<a href="https://en.wikipedia.org/wiki/Northern_Sea_Route" target="_blank">Northern Sea Route</a>” to Asia across Russias vast Arctic coastline, normally considered far too icy and dangerous for regular maritime shipping. Such a route could prove geopolitically vital, even if uneconomical, in case Russian ships are denied access to Western ports, as the United Kingdom <a href="https://www.reuters.com/world/uk/uk-bans-russian-ships-docking-its-ports-2022-02-28/" target="_blank">has already done</a>. To this end, Russia has also revived its fleet of nuclear-powered icebreakers and built the worlds first floating nuclear power station to supply electricity to remote regions of Siberia, with more of each on the way. </p>
<p> Russia already depends on natural resource exports. That wont change. But over the coming decades, it will not be the stagnant economies of Europe that will have the greatest demand for energy and minerals. Rather, the maturing industrial economy of China, in addition to the still-industrializing economies of India and other Asian and African countries, will be the primary growth markets for Russias natural resources. Neither China, India, Indonesia, the Gulf States, nor any of the many countries in Africa and Latin America chose to <a href="https://en.wikipedia.org/wiki/International_sanctions_during_the_Russo-Ukrainian_War" target="_blank">sanction</a> Russia over the invasion of Ukraine. Todays economic disassociation may well have unintended consequences for Europe. </p>
<!-- Photo Attribution -->
<p>
<strong>Photo by ALEXEY DRUZHININ/SPUTNIK/AFP via Getty Images</strong>
</p>
<div>
<p><span><em>City Journal</em> is a publication of the Manhattan Institute for Policy Research (MI), a leading free-market think tank. Are you interested in supporting the magazine? As a 501(c)(3) nonprofit, donations in support of MI and <em>City Journal</em> are fully tax-deductible as provided by law (EIN #13-2912529).</span> <a href="http://fakehost/donate?p=will-western-sanctions-accelerate-russian-realignment">DONATE</a>
</p>
</div>
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
https://www.city-journal.org/will-western-sanctions-accelerate-russian-realignment?wallit_nosession=1

View file

@ -0,0 +1,9 @@
{
"title": "Flow Network based Generative Models for Non-Iterative Diverse Candidate Generation",
"byline": null,
"dir": null,
"excerpt": "What follows is a high-level overview of this work, for more details refer to our paper. Given a reward and a deterministic episodic environment where episodes end with a ``generate '' action, how do we generate diverse and high-reward s?\n We propose to use Flow Networks to model discrete from which we can sample sequentially (like episodic RL, rather than iteratively as MCMC methods would). We show that our method, GFlowNet, is very useful on a combinatorial domain, drug molecule synthesis, because unlike RL methods it generates diverse s by design.",
"siteName": null,
"publishedDate": null,
"readerable": true
}

View file

@ -0,0 +1,54 @@
<div id="readability-page-1" class="page">
<div>
<center>
<a href="http://folinoid.com/">[Home]</a>
</center>
<center>
<b><a href="https://folinoid.com/">Emmanuel Bengio</a>, <a href="https://mj10.github.io/">Moksh Jain</a>, <a href="https://scholar.google.com/citations?user=TpuvCSwAAAAJ&amp;hl=en">Maksym Korablyov</a>, <a href="https://www.cs.mcgill.ca/~dprecup/">Doina Precup</a>, <a href="https://yoshuabengio.org/">Yoshua Bengio</a></b>
</center><br>
<center>
<b><a href="https://arxiv.org/abs/2106.04399">arXiv preprint</a>, <a href="https://github.com/bengioe/gflownet">code</a></b><br> also see the <b><a href="https://arxiv.org/abs/2111.09266">GFlowNet Foundations</a></b> paper<br> and a more recent (and thorough) <a href="https://tinyurl.com/gflownet-tutorial">tutorial on the framework</a>.
</center>
<p><i>What follows is a high-level overview of this work, for more details refer to our paper.</i> Given a reward <span><span></span></span> and a deterministic episodic environment where episodes end with a ``generate <span><span></span></span>'' action, how do we generate diverse and high-reward <span><span></span></span>s?<br> We propose to use <i>Flow Networks</i> to model discrete <span><span></span></span> from which we can sample sequentially (like episodic RL, rather than iteratively as MCMC methods would). We show that our method, <b>GFlowNet</b>, is very useful on a combinatorial domain, drug molecule synthesis, because unlike RL methods it generates diverse <span><span></span></span>s by design.<br>
<a name="s2" id="s2"></a>
</p>
<h3> Flow Networks </h3>
<p>A flow network is a directed graph with <i>sources</i> and <i>sinks</i>, and edges carrying some amount of flow between them through intermediate nodes -- think of pipes of water. For our purposes, we define a flow network with a single source, the root or <span><span></span></span>; the sinks of the network correspond to the terminal states. We'll assign to each sink <span><span></span></span> an ``out-flow'' <span><span></span></span>.</p>
<center>
</center>
<p>Given the graph structure and the out-flow of the sinks, we wish to calculate a valid <i>flow</i> between nodes, e.g. how much water each pipe is carrying. Generally there can be infinite solutions, but this is not a problem here -- any valid solution will do. For example above, there is almost no flow between <span><span></span></span> and <span><span></span></span> that goes through <span><span></span></span>, it all goes through <span><span></span></span>, but the reverse solution would also be a valid flow.<br> Why is this useful? Such a construction corresponds to a generative model. If we follow the flow, we'll end up in a terminal state, a sink, with probability <span><span></span></span>. On top of that, we'll have the property that the in-flow of <span><span></span></span>--the flow of the unique source--is <span><span></span></span>, the partition function. If we assign to each intermediate node a <i>state</i> and to each edge an <i>action</i>, we recover a useful MDP.<br> Let <span><span></span></span> be the flow between <span><span></span></span> and <span><span></span></span>, where <span><span></span></span>, i.e. <span><span></span></span> is the (deterministic) state transitioned to from state <span><span></span></span> and action <span><span></span></span>. Let <span><span><span></span></span></span> then following policy <span><span></span></span>, starting from <span><span></span></span>, leads to terminal state <span><span></span></span> with probability <span><span></span></span> (see the paper for proofs and more rigorous explanations).<br>
<a name="s3" id="s3"></a>
</p>
<h3> Approximating Flow Networks </h3>
<p>As you may suspect, there are only few scenarios in which we can build the above graph explicitly. For drug-like molecules, it would have around <span><span></span></span> nodes!<br> Instead, we resort to function approximation, just like deep RL resorts to it when computing the (action-)value functions of MDPs.<br> Our goal here is to approximate the flow <span><span></span></span>. Earlier we called a <i>valid</i> flow one that correctly routed all the flow from the source to the sinks through the intermediary nodes. Let's be more precise. For some node <span><span></span></span>, let the in-flow <span><span></span></span> be the sum of incoming flows: <span><span><span></span></span></span> Here the set <span><span></span></span> is the set of state-action pairs that lead to <span><span></span></span>. Now, let the out-flow be the sum of outgoing flows--or the reward if <span><span></span></span> is terminal: <span><span><span></span></span></span> Note that we reused <span><span></span></span>. This is because for a valid flow, the in-flow is equal to the out-flow, i.e. the flow through <span><span></span></span>, <span><span></span></span>. Here <span><span></span></span> is the set of valid actions in state <span><span></span></span>, which is the empty set when <span><span></span></span> is a sink. <span><span></span></span> is 0 unless <span><span></span></span> is a sink, in which case <span><span></span></span>.<br> We can thus call the set of these equalities for all states <span><span></span></span> the <i>flow consistency equations</i>: <span><span><span></span></span></span></p>
<center>
</center>
<p>Here the set of parents <span><span></span></span> is <span><span></span></span>, and <span><span></span></span>.<br> By now our RL senses should be tingling. We've defined a value function recursively, with two quantities that need to match.<br>
<a name="s4" id="s4"></a>
</p>
<h4> A TD-Like Objective </h4>
<p>Just like one can cast the Bellman equations into TD objectives, so do we cast the flow consistency equations into an objective. We want <span><span></span></span> that minimizes the square difference between the two sides of the equations, but we add a few bells and whistles: <span><span><span></span></span></span> First, we match the <span><span></span></span> of each side, which is important since as intermediate nodes get closer to the root, their flow will become exponentially bigger (remember that <span><span></span></span>), but we care equally about all nodes. Second, we predict <span><span></span></span> for the same reasons. Finally, we add an <span><span></span></span> value inside the <span><span></span></span>; this doesn't change the minima of the objective, but gives more gradient weight to large values and less to small values.<br> We show in the paper that a minimizer of this objective achieves our desiderata, which is to have <span><span></span></span> when sampling from <span><span></span></span> as defined above.<br>
<a name="s5" id="s5"></a>
</p>
<h3> GFlowNet as Amortized Sampling with an OOD Potential </h3>
<p>It is interesting to compare GFlowNet with Monte-Carlo Markov Chain (MCMC) methods. MCMC methods can be used to sample from a distribution for which there is no analytical sampling formula but an energy function or unnormalized probability function is available. In our context, this unnormalized probability function is our reward function <span><span></span></span>.<br> Like MCMC methods, GFlowNet can turn a given energy function into samples but it does it in an amortized way, converting the cost a lot of very expensive MCMC trajectories (to obtain each sample) into the cost training a generative model (in our case a generative policy which sequentially builds up <span><span></span></span>). Sampling from the generative model is then very cheap (e.g. adding one component at a time to a molecule) compared to an MCMC. But the most important gain may not be just computational, but in terms of the ability to discover new modes of the reward function.<br> MCMC methods are iterative, making many small noisy steps, which can converge in the neighborhood of a mode, and with some probability jump from one mode to a nearby one. However, if two modes are far from each other, MCMC can require <i>exponential</i> time to mix between the two. If in addition the modes occupy a tiny volume of the state space, the chances of initializing a chain near one of the unknown modes is also tiny, and the MCMC approach becomes unsatisfactory. Whereas such a situation seems hopeless with MCMC, GFlowNet has the potential to discover modes and jump there directly, if there is structure that relates the modes that it already knows, and if its inductive biases and training procedure make it possible to generalize there.<br> GFlowNet does not need to perfectly know where the modes are: it is sufficient to make guesses which occasionally work well. Like for MCMC methods, once a point in the region of new mode is discovered, further training of GFlowNet will sculpt that mode and zoom in on its peak.<br> Note that we can put <span><span></span></span> to some power <span><span></span></span>, a coefficient which acts like a temperature, and <span><span></span></span>, making it possible to focus more or less on the highest modes (versus spreading probability mass more uniformly).<br>
<a name="s6" id="s6"></a>
</p>
<h3> Generating molecule graphs </h3>
<p>The motivation for this work is to be able to generate diverse molecules from a proxy reward <span><span></span></span> that is imprecise because it comes from biochemical simulations that have a high uncertainty. As such, we do not care about the maximizer as RL methods would, but rather about a set of ``good enough'' candidates to send to a true biochemical assay.<br> Another motivation is to have diversity: by fitting the distribution of rewards rather than trying to maximize the expected reward, we're likely to find more modes than if we were being greedy after having found a good enough mode, which again and again we've found RL methods such as PPO to do.<br> Here we generate molecule graphs via a sequence of additive edits, i.e. we progressively build the graph by adding new leaf nodes to it. We also create molecules block-by-block rather than atom-by-atom.<br> We find experimentally that we get both good molecules, and diverse ones. We compare ourselves to PPO and MARS (an MCMC-based method).<br> Figure 3 shows that we're fitting a distribution that makes sense. If we change the reward by exponentiating it as <span><span></span></span> with <span><span></span></span>, this shifts the reward distribution to the right.<br> Figure 4 shows the top-<span><span></span></span> found as a function of the number of episodes.</p>
<center>
<img src="http://fakehost/test/gfn_fig34.png" width="650px">
</center>
<p> Finally, Figure 5 shows that using a biochemical measure of diversity to estimate the number of distinct modes found, GFlowNet finds much more varied candidates.</p>
<center>
<img src="http://fakehost/test/gfn_fig5.png" width="650px">
</center><br>
<h4> Active Learning experiments </h4>
<p>The above experiments assume access to a reward <span><span></span></span> that is cheap to evaluate. In fact it uses a neural network <i>proxy</i> trained from a large dataset of molecules. This setup isn't quite what we would get when interacting with biochemical assays, where we'd have access to much fewer data. To emulate such a setting, we consider our oracle to be a <i>docking simulation</i> (which is relatively expensive to run, ~30 cpu seconds).<br> In this setting, there is a limited budget for calls to the true oracle <span><span></span></span>. We use a proxy <span><span></span></span> initialized by training on a limited dataset of <span><span></span></span> pairs <span><span></span></span>, where <span><span></span></span> is the true reward from the oracle. The generative model (<span><span></span></span>) is then trained to fit <span><span></span></span> but as predicted by the proxy <span><span></span></span>. We then sample a batch <span><span></span></span> where <span><span></span></span>, which is evaluated with the oracle <span><span></span></span>. The proxy <span><span></span></span> is updated with this newly acquired and labeled batch, and the process is repeated for <span><span></span></span> iterations.<br> By doing this on the molecule setting we again find that we can generate better molecules. This showcases the importance of having these diverse candidates.</p>
<center>
<img src="http://fakehost/test/gfn_fig7.png" width="325px">
</center>
<p> For more figures, experiments and explanations, check out <a href="https://arxiv.org/abs/2106.04399">the paper</a>, or reach out to us!<br>
</p>
</div>
</div>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
http://folinoid.com/w/gflownet/

View file

@ -29,12 +29,18 @@ const StyledAvatar = styled(Root, {
verticalAlign: 'middle',
overflow: 'hidden',
userSelect: 'none',
border: '1px solid $grayBorder',
})
const StyledImage = styled(Image, {
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: '48%',
'&:hover': {
opacity: '100%',
},
})
const StyledFallback = styled(Fallback, {
@ -43,5 +49,8 @@ const StyledFallback = styled(Fallback, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'dodgerblue',
fontSize: '$2',
fontWeight: 700,
backgroundColor: '$avatarBg',
color: '$avatarFont',
})

View file

@ -12,7 +12,7 @@ export function AvatarDropdown(props: AvatarDropdownProps): JSX.Element {
<HStack alignment="center" css={{ gap: '6px' }}>
<Avatar
imageURL={props.profileImageURL}
height='30px'
height='32px'
fallbackText={props.userInitials}
/>
</HStack>

View file

@ -63,10 +63,17 @@ const StyledLabel = styled(Label, {
cursor: 'default',
})
export type DropdownAlignment =
| 'start'
| 'end'
| 'center'
type DropdownProps = {
labelText?: string
triggerElement: React.ReactNode
children: React.ReactNode
styledArrow?: boolean
align?: DropdownAlignment
}
export const DropdownSeparator = styled(Separator, {
@ -102,10 +109,11 @@ export function Dropdown(props: DropdownProps): JSX.Element {
// remove focus from dropdown
;(document.activeElement as HTMLElement).blur()
}}
align={props.align ? props.align : 'center'}
>
{props.labelText && <StyledLabel>{props.labelText}</StyledLabel>}
{props.children}
<StyledArrow offset={20} />
{props.styledArrow && <StyledArrow offset={20} />}
</DropdownContent>
</Root>
)

View file

@ -0,0 +1,77 @@
import React, { FC } from 'react';
import { styled, keyframes } from '@stitches/react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
const slideUpAndFade = keyframes({
'0%': { opacity: 0, transform: 'translateY(2px)' },
'100%': { opacity: 1, transform: 'translateY(0)' },
});
const slideRightAndFade = keyframes({
'0%': { opacity: 0, transform: 'translateX(-2px)' },
'100%': { opacity: 1, transform: 'translateX(0)' },
});
const slideDownAndFade = keyframes({
'0%': { opacity: 0, transform: 'translateY(-2px)' },
'100%': { opacity: 1, transform: 'translateY(0)' },
});
const slideLeftAndFade = keyframes({
'0%': { opacity: 0, transform: 'translateX(2px)' },
'100%': { opacity: 1, transform: 'translateX(0)' },
});
const StyledContent = styled(TooltipPrimitive.Content, {
borderRadius: 4,
padding: '8px 13px',
fontSize: 12,
color: '#FFFFFF',
backgroundColor: '#1C1C1E',
'@media (prefers-reduced-motion: no-preference)': {
animationDuration: '400ms',
animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)',
animationFillMode: 'forwards',
willChange: 'transform, opacity',
'&[data-state="delayed-open"]': {
'&[data-side="top"]': { animationName: slideDownAndFade },
'&[data-side="right"]': { animationName: slideLeftAndFade },
'&[data-side="bottom"]': { animationName: slideUpAndFade },
'&[data-side="left"]': { animationName: slideRightAndFade },
},
},
});
const StyledArrow = styled(TooltipPrimitive.Arrow, {
fill: '#1C1C1E',
});
export const TooltipProvider = TooltipPrimitive.Provider;
export const Tooltip = TooltipPrimitive.Root;
export const TooltipTrigger = TooltipPrimitive.Trigger;
export const TooltipContent = StyledContent;
export const TooltipArrow = StyledArrow;
type TooltipWrappedProps = {
tooltipContent: string,
tooltipSide ?: TooltipPrimitive.TooltipContentProps['side'],
align?: TooltipPrimitive.TooltipContentProps['align'],
alignOffset?: TooltipPrimitive.TooltipContentProps['alignOffset']
arrowStyles?: TooltipPrimitive.TooltipArrowProps['style']
style?: TooltipPrimitive.TooltipContentProps['style']
}
export const TooltipWrapped: FC<TooltipWrappedProps> = ({children, tooltipContent, tooltipSide, ...props}) => {
return (
<Tooltip>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipContent sideOffset={5} side={tooltipSide} {...props} >
{tooltipContent}
<TooltipArrow style={props.arrowStyles} />
</TooltipContent>
</Tooltip>
)
}

View file

@ -1,21 +1,22 @@
import { Box } from './../../../components/elements/LayoutPrimitives'
import { Box } from '../../elements/LayoutPrimitives'
import { useReadingProgressAnchor } from '../../../lib/hooks/useReadingProgressAnchor'
import {
useScrollWatcher,
ScrollOffsetChangeset,
useScrollWatcher,
} from '../../../lib/hooks/useScrollWatcher'
import {
useRef,
useState,
useEffect,
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation'
import { Tweet } from 'react-twitter-widgets'
import { render } from 'react-dom'
import { isDarkTheme } from '../../../lib/themeUpdater'
import useDebounce from '../../../lib/hooks/useDebounce'
import { debounce } from 'lodash'
import { ArticleMutations } from '../../../lib/articleActions'
export type ArticleProps = {
articleId: string
@ -23,12 +24,15 @@ export type ArticleProps = {
initialAnchorIndex: number
initialReadingProgress?: number
scrollElementRef: MutableRefObject<HTMLDivElement | null>
articleMutations: ArticleMutations
}
export function Article(props: ArticleProps): JSX.Element {
const highlightTheme = isDarkTheme() ? 'dark' : 'default'
const [readingProgress, setReadingProgress] = useState(props.initialReadingProgress)
const [readingProgress, setReadingProgress] = useState(
props.initialReadingProgress
)
const [readingAnchorIndex, setReadingAnchorIndex] = useState(
props.initialAnchorIndex
@ -41,14 +45,30 @@ export function Article(props: ArticleProps): JSX.Element {
useReadingProgressAnchor(articleContentRef, setReadingAnchorIndex)
const debouncedReadingProgress = useDebounce(readingProgress, 1000);
const debouncedSetReadingProgress = useMemo(
() =>
debounce((readingProgress: number) => {
console.log('setReadingProgress', readingProgress)
setReadingProgress(readingProgress)
}, 2000),
[]
)
// Stop the invocation of the debounced function
// after unmounting
useEffect(() => {
return () => {
debouncedSetReadingProgress.cancel()
}
}, [])
useEffect(() => {
;(async () => {
if (!debouncedReadingProgress) return
await articleReadingProgressMutation({
if (!readingProgress) return
await props.articleMutations.articleReadingProgressMutation({
id: props.articleId,
readingProgressPercent: debouncedReadingProgress,
// round reading progress to 100% if more than that
readingProgressPercent: readingProgress > 100 ? 100 : readingProgress,
readingProgressAnchorIndex: readingAnchorIndex,
})
})()
@ -56,20 +76,16 @@ export function Article(props: ArticleProps): JSX.Element {
// We don't react to changes to readingAnchorIndex we
// only care about the progress (scroll position) changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
props.articleId,
debouncedReadingProgress,
readingAnchorIndex,
])
}, [props.articleId, readingProgress])
// Post message to webkit so apple app embeds get progress updates
useEffect(() => {
if (typeof window?.webkit != 'undefined') {
window.webkit.messageHandlers.readingProgressUpdate?.postMessage({
progress: debouncedReadingProgress,
progress: readingProgress,
})
}
}, [readingProgress, debouncedReadingProgress])
}, [readingProgress])
const setScrollWatchedElement = useScrollWatcher(
(changeset: ScrollOffsetChangeset) => {
@ -79,13 +95,13 @@ export function Article(props: ArticleProps): JSX.Element {
(changeset.current.y + scrollContainer.clientHeight) /
scrollContainer.scrollHeight
setReadingProgress(newReadingProgress * 100)
debouncedSetReadingProgress(newReadingProgress * 100)
} else if (window && window.document.scrollingElement) {
const newReadingProgress =
window.scrollY / window.document.scrollingElement.scrollHeight
const adjustedReadingProgress =
newReadingProgress > 0.92 ? 1 : newReadingProgress
setReadingProgress(adjustedReadingProgress * 100)
debouncedSetReadingProgress(adjustedReadingProgress * 100)
}
},
1000
@ -152,9 +168,15 @@ export function Article(props: ArticleProps): JSX.Element {
}
if (props.scrollElementRef.current) {
props.scrollElementRef.current?.scroll(0, calculateOffset(anchorElement))
props.scrollElementRef.current?.scroll(
0,
calculateOffset(anchorElement)
)
} else {
window.document.documentElement.scroll(0, calculateOffset(anchorElement))
window.document.documentElement.scroll(
0,
calculateOffset(anchorElement)
)
}
}
}, [

View file

@ -19,10 +19,12 @@ import { updateThemeLocally } from '../../../lib/themeUpdater'
import { EditLabelsModal } from './EditLabelsModal'
import Script from 'next/script'
import { useRouter } from 'next/router'
import { ArticleMutations } from '../../../lib/articleActions'
type ArticleContainerProps = {
viewerUsername: string
article: ArticleAttributes
articleMutations: ArticleMutations
scrollElementRef: MutableRefObject<HTMLDivElement | null>
isAppleAppEmbed: boolean
highlightBarDisabled: boolean
@ -121,12 +123,16 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
return (
<>
<Script async src="/static/scripts/mathJaxConfiguration.js" />
<Script
async
id="MathJax-script"
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
/>
{!props.isAppleAppEmbed && (
<>
<Script async src="/static/scripts/mathJaxConfiguration.js" />
<Script
async
id="MathJax-script"
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
/>
</>
)}
<Box
id="article-container"
css={{
@ -187,6 +193,7 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
content={props.article.content}
initialAnchorIndex={props.article.readingProgressAnchorIndex}
scrollElementRef={props.scrollElementRef}
articleMutations={props.articleMutations}
/>
<Button
style="ghost"
@ -216,6 +223,7 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
showNotesSidebar={showNotesSidebar}
highlightsBaseURL={props.highlightsBaseURL}
setShowNotesSidebar={setShowNotesSidebar}
articleMutations={props.articleMutations}
/>
{showReportIssuesModal ? (
<ReportIssuesModal

View file

@ -3,7 +3,6 @@ import { makeHighlightStartEndOffset } from '../../../lib/highlights/highlightGe
import type { HighlightLocation } from '../../../lib/highlights/highlightGenerator'
import { useSelection } from '../../../lib/highlights/useSelection'
import type { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
import { shareHighlightToFeedMutation } from '../../../lib/networking/mutations/shareHighlightToFeedMutation'
import { shareHighlightCommentMutation } from '../../../lib/networking/mutations/updateShareHighlightCommentMutation'
import {
@ -18,9 +17,9 @@ import { HighlightNoteModal } from './HighlightNoteModal'
import { ShareHighlightModal } from './ShareHighlightModal'
import { HighlightPostToFeedModal } from './HighlightPostToFeedModal'
import { HighlightsModal } from './HighlightsModal'
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
import { useCanShareNative } from '../../../lib/hooks/useCanShareNative'
import toast from 'react-hot-toast'
import { ArticleMutations } from '../../../lib/articleActions'
type HighlightsLayerProps = {
viewerUsername: string
@ -33,6 +32,7 @@ type HighlightsLayerProps = {
showNotesSidebar: boolean
highlightsBaseURL: string
setShowNotesSidebar: React.Dispatch<React.SetStateAction<boolean>>
articleMutations: ArticleMutations
}
type HighlightModalAction = 'none' | 'addComment' | 'postToFeed' | 'share'
@ -88,7 +88,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
const highlightId = id || focusedHighlight?.id
if (!highlightId) return
const didDeleteHighlight = await deleteHighlightMutation(highlightId)
const didDeleteHighlight = await props.articleMutations.deleteHighlightMutation(highlightId)
if (didDeleteHighlight) {
removeHighlights(
@ -191,7 +191,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
existingHighlights: highlights,
highlightStartEndOffsets: highlightLocations,
annotation: note,
})
}, props.articleMutations)
if (!result.highlights || result.highlights.length == 0) {
// TODO: show an error message
@ -407,7 +407,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
if (focusedHighlight) {
const annotation = event.annotation ?? ''
const result = await updateHighlightMutation({
const result = await props.articleMutations.updateHighlightMutation({
highlightId: focusedHighlight.id,
annotation: event.annotation ?? '',
})

View file

@ -125,7 +125,7 @@ export default function PdfArticleContainer(
instance.setSelectedAnnotation(null)
},
}
return [copy, remove, share]
return [copy, remove]
}
instance = await PSPDFKit.load({

View file

@ -207,6 +207,12 @@ export function HomeFeedContainer(props: HomeFeedContainerProps): JSX.Element {
if (activeCardId && !alreadyScrolled.current) {
scrollToActiveCard(activeCardId)
alreadyScrolled.current = true
if (activeItem) {
console.log('refreshing')
// refresh items on home feed
performActionOnItem('refresh', activeItem)
}
}
}, [activeCardId, scrollToActiveCard])

View file

@ -158,6 +158,10 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
readerFontTransparent: 'rgba(61,61,61,0.65)',
readerHeader: '3D3D3D',
readerTableHeader: '#FFFFFF',
// Avatar Fallback color
avatarBg: '#FFFFFF',
avatarFont: '#0A0806',
},
},
media: {
@ -168,10 +172,10 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
md: '(min-width: 768px)',
lg: '(min-width: 992px)',
xl: '(min-width: 1200px)',
}
},
})
export const darkTheme = createTheme(ThemeId.Dark, {
const darkThemeSpec = {
colors: {
...yellowDark, // Brand
...orangeDark, //Accent
@ -204,91 +208,26 @@ export const darkTheme = createTheme(ThemeId.Dark, {
readerFontTransparent: 'rgba(185,185,185,0.65)',
readerHeader: '#b9b9b9',
readerTableHeader: '#FFFFFF',
// Avatar Fallback color
avatarBg: '#000000',
avatarFont: 'rgba(255, 255, 255, 0.8)',
},
})
}
export const darkerTheme = createTheme(ThemeId.Darker, {
colors: {
...yellowDark, // Brand
...orangeDark, //Accent
// Dark and Darker theme now match each other.
// Use the darkThemeSpec object to make updates.
export const darkTheme = createTheme(ThemeId.Dark, darkThemeSpec)
export const darkerTheme = createTheme(ThemeId.Darker, darkThemeSpec)
// Grayscale
grayBase: grayDark.gray1,
grayBgSubtle: grayDark.gray2,
grayBg: grayDark.gray3,
grayBgHover: grayDark.gray4,
grayBgActive: grayDark.gray5,
grayLine: grayDark.gray6,
grayBorder: grayDark.gray7,
grayBorderHover: grayDark.gray8,
graySolid: grayDark.gray9,
graySolidHover: grayDark.gray10,
grayText: grayDark.gray11,
grayTextContrast: grayDark.gray12,
// Semantic Colors
overlay: blackA.blackA9,
highlightBackground: 'rgb(255,234,159)',
highlight: '#FFD234',
highlightText: 'rgba(255, 210, 52)',
error: '#FA5E4A',
// Brand Colors
loginBg: '#FFD492',
// Reader Colors
readerBg: '#000000',
readerFont: '#888888',
readerFontTransparent: 'rgba(136,136,136,0.65)',
readerHeader: '#888888',
readerTableHeader: '',
},
})
export const lighterTheme = createTheme(ThemeId.Lighter, {
colors: {
...yellow, // Brand
...orange, //Accent
// Grayscale
grayBase: '#F8F8F8',
grayBgActive: '#FFFFFF',
grayBorder: 'rgba(0, 0, 0, 0.06)',
grayTextContrast: '#3A3939',
// Grayscale
grayBgSubtle: gray.gray2,
grayBg: gray.gray3,
grayBgHover: gray.gray4,
grayLine: gray.gray6,
grayBorderHover: gray.gray8,
graySolid: gray.gray9,
graySolidHover: gray.gray10,
grayText: gray.gray11,
// Semantic Colors
overlay: blackA.blackA9,
highlightBackground: 'rgb(255,234,159)',
highlight: '#FFD234',
error: '#FA5E4A',
// Brand Colors
loginBg: '#FFD492',
// Reader Colors
readerBg: '#E5DDD5',
readerFont: '#3D3D3D',
readerFontTransparent: 'rgba(61,61,61,0.65)',
readerHeader: '',
readerTableHeader: '',
},
})
// Lighter theme now matches the default theme.
// This only exists for users that might still have a lighter theme set
export const lighterTheme = createTheme(ThemeId.Lighter, {})
// Apply global styles in here
export const globalStyles = globalCss({
'*': {
'&:focus': {
outline: 'none',
},
'&:focus-visible': {
@ -299,6 +238,6 @@ export const globalStyles = globalCss({
'.article-inner-css': {
'::selection': {
background: '$highlightBackground',
}
}
},
},
})

View file

@ -0,0 +1,14 @@
import { Highlight } from "./networking/fragments/highlightFragment"
import { ArticleReadingProgressMutationInput } from "./networking/mutations/articleReadingProgressMutation"
import { CreateHighlightInput } from "./networking/mutations/createHighlightMutation"
import { MergeHighlightInput, MergeHighlightOutput } from "./networking/mutations/mergeHighlightMutation"
import { UpdateHighlightInput } from "./networking/mutations/updateHighlightMutation"
export type ArticleMutations = {
createHighlightMutation: (input: CreateHighlightInput) => Promise<Highlight | undefined>
deleteHighlightMutation: (highlightId: string) => Promise<boolean>
mergeHighlightMutation: (input: MergeHighlightInput) => Promise<MergeHighlightOutput | undefined>
updateHighlightMutation: (input: UpdateHighlightInput) => Promise<string | undefined>
articleReadingProgressMutation: (input: ArticleReadingProgressMutationInput) => Promise<boolean>
}

View file

@ -9,9 +9,8 @@ import {
import type { HighlightLocation } from './highlightGenerator'
import { extendRangeToWordBoundaries } from './normalizeHighlightRange'
import type { Highlight } from '../networking/fragments/highlightFragment'
import { createHighlightMutation } from '../networking/mutations/createHighlightMutation'
import { removeHighlights } from './deleteHighlight'
import { mergeHighlightMutation } from '../networking/mutations/mergeHighlightMutation'
import { ArticleMutations } from '../articleActions'
type CreateHighlightInput = {
selection: SelectionAttributes
@ -28,7 +27,8 @@ type CreateHighlightOutput = {
}
export async function createHighlight(
input: CreateHighlightInput
input: CreateHighlightInput,
articleMutations: ArticleMutations
): Promise<CreateHighlightOutput> {
if (!input.selection.selection) {
@ -89,7 +89,7 @@ export async function createHighlight(
let keptHighlights = input.existingHighlights
if (shouldMerge) {
const result = await mergeHighlightMutation({
const result = await articleMutations.mergeHighlightMutation({
...newHighlightAttributes,
overlapHighlightIdList: input.selection.overlapHighlights,
})
@ -99,7 +99,7 @@ export async function createHighlight(
($0) => !input.selection.overlapHighlights.includes($0.id)
)
} else {
highlight = await createHighlightMutation(newHighlightAttributes)
highlight = await articleMutations.createHighlightMutation(newHighlightAttributes)
}
if (highlight) {

View file

@ -2,7 +2,11 @@ import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch'
import { RefObject } from 'react'
import type { Highlight } from '../networking/fragments/highlightFragment'
import { interpolationSearch } from './interpolationSearch'
import { highlightIdAttribute, highlightNoteIdAttribute } from './highlightHelpers'
import {
highlightIdAttribute,
highlightNoteIdAttribute,
noteImage,
} from './highlightHelpers'
const highlightTag = 'omnivore_highlight'
const highlightClassname = 'highlight'
@ -10,7 +14,8 @@ const highlightWithNoteClassName = 'highlight_with_note'
const articleContainerId = 'article-container'
export const maxHighlightLength = 2000
const nonParagraphTagsRegEx = /^(a|b|basefont|bdo|big|em|font|i|s|small|span|strike|strong|su[bp]|tt|u|code|mark)$/i
const nonParagraphTagsRegEx =
/^(a|b|basefont|bdo|big|em|font|i|s|small|span|strike|strong|su[bp]|tt|u|code|mark)$/i
const highlightContentRegex = new RegExp(
`<${highlightTag}>([\\s\\S]*)<\\/${highlightTag}>`,
'i'
@ -47,10 +52,8 @@ export type HighlightNodeAttributes = {
export function makeHighlightStartEndOffset(
highlight: Highlight
): HighlightLocation {
const {
startLocation: highlightTextStart,
endLocation: highlightTextEnd,
} = nodeAttributesFromHighlight(highlight)
const { startLocation: highlightTextStart, endLocation: highlightTextEnd } =
nodeAttributesFromHighlight(highlight)
return {
id: highlight.id,
start: highlightTextStart,
@ -129,7 +132,9 @@ export function makeHighlightNodeAttributes(
}
const newHighlightSpan = document.createElement('span')
newHighlightSpan.className = withNote ? highlightWithNoteClassName : highlightClassname
newHighlightSpan.className = withNote
? highlightWithNoteClassName
: highlightClassname
newHighlightSpan.setAttribute(highlightIdAttribute, id)
customColor &&
newHighlightSpan.setAttribute(
@ -146,13 +151,18 @@ export function makeHighlightNodeAttributes(
}
if (withNote && lastElement) {
lastElement.classList.add('last_element')
const button = document.createElement('img')
button.className = 'highlight_note_button'
button.src = '/static/icons/highlight-note-icon.svg'
button.alt = 'Add note'
button.setAttribute(highlightNoteIdAttribute, id)
lastElement.appendChild(button)
const svg = noteImage()
svg.setAttribute(highlightNoteIdAttribute, id)
const ctr = document.createElement('div')
ctr.className = 'highlight_note_button'
ctr.appendChild(svg)
ctr.setAttribute(highlightNoteIdAttribute, id)
ctr.setAttribute('width', '14px')
ctr.setAttribute('height', '14px')
lastElement.appendChild(ctr)
}
return {
@ -199,9 +209,8 @@ export function generateDiffPatch(range: Range): string {
export function wrapHighlightTagAroundRange(range: Range): [number, number] {
const patch = generateDiffPatch(range)
const { highlightTextStart, highlightTextEnd } = selectionOffsetsFromPatch(
patch
)
const { highlightTextStart, highlightTextEnd } =
selectionOffsetsFromPatch(patch)
return [highlightTextStart, highlightTextEnd]
}
@ -290,11 +299,7 @@ const selectionOffsetsFromPatch = (
}
}
export function getPrefixAndSuffix({
patch,
}: {
patch: string
}): {
export function getPrefixAndSuffix({ patch }: { patch: string }): {
prefix: string
suffix: string
highlightTextStart: number
@ -305,9 +310,8 @@ export function getPrefixAndSuffix({
if (!patch) throw new Error('Invalid patch')
const { textNodes } = getArticleTextNodes()
const { highlightTextStart, highlightTextEnd } = selectionOffsetsFromPatch(
patch
)
const { highlightTextStart, highlightTextEnd } =
selectionOffsetsFromPatch(patch)
// Searching for the starting text node using interpolation search algorithm
const textNodeIndex = interpolationSearch(
textNodes.map(({ startIndex: startIndex }) => startIndex),
@ -360,9 +364,11 @@ const fillHighlight = ({
highlightTextStart: number
highlightTextEnd: number
}): FillNodeResponse => {
const { node, startIndex: startIndex, startsParagraph } = textNodes[
startingTextNodeIndex
]
const {
node,
startIndex: startIndex,
startsParagraph,
} = textNodes[startingTextNodeIndex]
const text = node.nodeValue || ''
const textBeforeHighlightLenght = highlightTextStart - startIndex

View file

@ -24,3 +24,23 @@ export function getHighlightNoteButton(highlightId: string): Element[] {
document.querySelectorAll(`[${highlightNoteIdAttribute}='${highlightId}']`)
)
}
export function noteImage(): SVGSVGElement {
const svgURI = 'http://www.w3.org/2000/svg'
const svg = document.createElementNS(svgURI, 'svg')
svg.setAttribute('viewBox', '0 0 14 14')
svg.setAttribute('width', '14')
svg.setAttribute('height', '14')
svg.setAttribute('fill', 'none')
const path = document.createElementNS(svgURI, 'path')
path.setAttribute(
'd',
'M1 5.66602C1 3.7804 1 2.83759 1.58579 2.2518C2.17157 1.66602 3.11438 1.66602 5 1.66602H9C10.8856 1.66602 11.8284 1.66602 12.4142 2.2518C13 2.83759 13 3.7804 13 5.66602V7.66601C13 9.55163 13 10.4944 12.4142 11.0802C11.8284 11.666 10.8856 11.666 9 11.666H4.63014C4.49742 11.666 4.43106 11.666 4.36715 11.6701C3.92582 11.6984 3.50632 11.8722 3.17425 12.1642C3.12616 12.2065 3.07924 12.2534 2.98539 12.3473V12.3473C2.75446 12.5782 2.639 12.6937 2.55914 12.7475C1.96522 13.1481 1.15512 12.8125 1.01838 12.1093C1 12.0148 1 11.8515 1 11.5249V5.66602Z'
)
path.setAttribute('stroke', 'rgba(255, 210, 52, 0.8)')
path.setAttribute('stroke-width', '1.8')
path.setAttribute('stroke-linejoin', 'round')
svg.appendChild(path)
return svg
}

View file

@ -136,7 +136,7 @@ export function useSelection(
document.removeEventListener('touchend', handleFinishTouch)
document.removeEventListener('contextmenu', handleFinishTouch)
}
}, [JSON.stringify(highlightLocations), handleFinishTouch, disabled])
}, [highlightLocations, handleFinishTouch, disabled])
return [selectionAttributes, setSelectionAttributes]
}

View file

@ -1,7 +1,7 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
type ArticleReadingProgressMutationInput = {
export type ArticleReadingProgressMutationInput = {
id: string
readingProgressPercent: number
readingProgressAnchorIndex: number

View file

@ -2,7 +2,7 @@ import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { Highlight } from './../fragments/highlightFragment'
type CreateHighlightInput = {
export type CreateHighlightInput = {
prefix: string
suffix: string
quote: string

View file

@ -0,0 +1,31 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export async function deleteNewsletterEmailMutation(
newsletterEmailId: string
): Promise<string | undefined> {
const mutation = gql`
mutation DeleteNewsletterEmailMutation($newsletterEmailId: ID!) {
deleteNewsletterEmail(newsletterEmailId: $newsletterEmailId) {
... on DeleteNewsletterEmailSuccess {
newsletterEmail {
id
address
}
}
... on DeleteNewsletterEmailError {
errorCodes
}
}
}
`
try {
const data = await gqlFetcher(mutation, { newsletterEmailId })
console.log('delete email', data)
return 'data'
} catch (error) {
console.log('deleteNewsletterEmailMutation error', error)
return undefined
}
}

View file

@ -2,7 +2,7 @@ import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { Highlight } from './../fragments/highlightFragment'
type MergeHighlightInput = {
export type MergeHighlightInput = {
id: string
shortId: string
articleId: string
@ -14,7 +14,7 @@ type MergeHighlightInput = {
overlapHighlightIdList: string[]
}
type MergeHighlightOutput = {
export type MergeHighlightOutput = {
mergeHighlight: InnerMergeHighlightOutput
}

View file

@ -1,7 +1,7 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
type UpdateHighlightInput = {
export type UpdateHighlightInput = {
highlightId: string
annotation?: string
sharedAt?: string

View file

@ -6,7 +6,6 @@ import { articleFragment } from '../fragments/articleFragment'
import { setLinkArchivedMutation } from '../mutations/setLinkArchivedMutation'
import { deleteLinkMutation } from '../mutations/deleteLinkMutation'
import { articleReadingProgressMutation } from '../mutations/articleReadingProgressMutation'
import { labelFragment } from '../fragments/labelFragment'
import { Label } from './useGetLabelsQuery'
export type LibraryItemsQueryInput = {
@ -34,6 +33,7 @@ type LibraryItemAction =
| 'delete'
| 'mark-read'
| 'mark-unread'
| 'refresh'
export type LibraryItemsData = {
articles: LibraryItems
@ -259,6 +259,8 @@ export function useGetLibraryItemsQuery({
readingProgressAnchorIndex: 0,
})
break
case 'refresh':
await mutate()
}
}

View file

@ -133,6 +133,12 @@ const moduleExports = {
'https://apps.apple.com/us/app/omnivore-read-highlight-share/id1564031042',
permanent: true,
},
{
source: '/install/apple',
destination:
'https://apps.apple.com/us/app/omnivore-read-highlight-share/id1564031042',
permanent: true,
},
]
},
}

View file

@ -23,6 +23,7 @@
"@radix-ui/react-id": "^0.1.1",
"@radix-ui/react-popover": "^0.1.1",
"@radix-ui/react-separator": "^0.1.0",
"@radix-ui/react-tooltip": "^0.1.7",
"@segment/analytics-next": "^1.33.5",
"@sentry/nextjs": "^6.16.1",
"@stitches/react": "^1.2.5",
@ -50,6 +51,7 @@
"@types/cookie": "^0.4.1",
"@types/diff-match-patch": "^1.0.32",
"@types/jest": "^27.0.2",
"@types/lodash.debounce": "^4.0.6",
"@types/react": "17.0.2",
"@types/react-dom": "^17.0.2",
"@types/segment-analytics": "^0.0.34",

View file

@ -13,6 +13,11 @@ import dynamic from 'next/dynamic'
import { useGetUserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
import { webBaseURL } from '../../../lib/appConfig'
import { Toaster } from 'react-hot-toast'
import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation'
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeHighlightMutation'
import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation'
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
const PdfArticleContainerNoSSR = dynamic<PdfArticleContainerProps>(
() => import('./../../../components/templates/article/PdfArticleContainer'),
@ -70,6 +75,13 @@ export default function Home(): JSX.Element {
viewerUsername={viewerData.me?.profile?.username}
highlightsBaseURL={`${webBaseURL}/${viewerData.me?.profile?.username}/${slug}/highlights`}
fontSize={preferencesData?.fontSize}
articleMutations={{
createHighlightMutation,
deleteHighlightMutation,
mergeHighlightMutation,
updateHighlightMutation,
articleReadingProgressMutation,
}}
/>
</VStack>
)}

Some files were not shown because too many files have changed in this diff Show more