Merge commit '42e2beb53d89b333611f215e63d366034905bba6' into OMN-189

This commit is contained in:
gitstart-omnivore 2022-03-22 22:06:44 +00:00
commit 2593596833
29 changed files with 604 additions and 462 deletions

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

@ -148,7 +148,7 @@ struct LinkItemDetailView: View {
if viewModel.item.isPDF {
fixedNavBarReader
} else if FeatureFlag.useLocalWebView {
WebReaderContainerView(item: viewModel.item)
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

@ -1,250 +1,11 @@
import Combine
import Models
import Services
import SwiftUI
import UIKit
import Utils
import Views
import WebKit
struct SafariWebLink: Identifiable {
let id: UUID
let url: URL
}
final class WebReaderViewModel: ObservableObject {
@Published var isLoading = false
@Published var htmlContent: String?
var subscriptions = Set<AnyCancellable>()
func loadContent(dataService: DataService, slug: String) {
isLoading = true
guard let viewer = dataService.currentViewer else { return }
dataService.articleContentPublisher(username: viewer.username, slug: slug).sink(
receiveCompletion: { [weak self] completion in
guard case .failure = completion else { return }
self?.isLoading = false
},
receiveValue: { [weak self] htmlContent in
self?.htmlContent = htmlContent
}
)
.store(in: &subscriptions)
}
}
struct WebReaderContainerView: View {
let item: FeedItem
@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?
@EnvironmentObject var dataService: DataService
@EnvironmentObject var authenticator: Authenticator
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@StateObject var viewModel = WebReaderViewModel()
var fontAdjustmentPopoverView: some View {
FontSizeAdjustmentPopoverView(
increaseFontAction: { increaseFontActionID = UUID() },
decreaseFontAction: { decreaseFontActionID = UUID() }
)
}
var navBariOS14: some View {
HStack(alignment: .center) {
Button(
action: { self.presentationMode.wrappedValue.dismiss() },
label: {
Image(systemName: "chevron.backward")
.font(.appTitleTwo)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal)
}
)
.scaleEffect(navBarVisibilityRatio)
Spacer()
Button(
action: { showFontSizePopover.toggle() },
label: {
Image(systemName: "textformat.size")
.font(.appTitleTwo)
}
)
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
}
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
.opacity(navBarVisibilityRatio)
.background(Color.systemBackground)
.onTapGesture {
showFontSizePopover = false
}
}
@available(macOS 12.0, *)
@available(iOS 15.0, *)
var navBar: some View {
HStack(alignment: .center) {
Button(
action: { self.presentationMode.wrappedValue.dismiss() },
label: {
Image(systemName: "chevron.backward")
.font(.appTitleTwo)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal)
}
)
.scaleEffect(navBarVisibilityRatio)
Spacer()
Button(
action: { showFontSizePopover.toggle() },
label: {
Image(systemName: "textformat.size")
.font(.appTitleTwo)
}
)
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
Menu(
content: {
Group {
Button(
action: {}, // ,viewModel.handleArchiveAction(dataService: dataService) },
label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
}
)
Button(
action: { showDeleteConfirmation = true },
label: { Label("Delete", systemImage: "trash") }
)
}
},
label: {
Image.profile
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
}
)
}
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
.opacity(navBarVisibilityRatio)
.background(Color.systemBackground)
.onTapGesture {
showFontSizePopover = false
}
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
Button("Remove Link", role: .destructive) {
// viewModel.handleDeleteAction(dataService: dataService)
}
Button("Cancel", role: .cancel, action: {})
}
}
var body: some View {
ZStack {
if let htmlContent = viewModel.htmlContent {
WebReader(
htmlContent: htmlContent,
item: item,
openLinkAction: { url in print(url) },
webViewActionHandler: { _ in },
navBarVisibilityRatioUpdater: {
if $0 < 1 {
showFontSizePopover = false
}
navBarVisibilityRatio = $0
},
authToken: authenticator.authToken ?? "",
appEnv: dataService.appEnvironment,
increaseFontActionID: $increaseFontActionID,
decreaseFontActionID: $decreaseFontActionID,
annotationSaveTransactionID: nil
)
.overlay(
Group {
if showOverlay {
Color.systemBackground
.transition(.opacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
withAnimation(.linear(duration: 0.2)) {
showOverlay = 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)
}
}
// TODO: implement things WebAppWrapperView does
struct WebReader: UIViewRepresentable {
let htmlContent: String
let articleContent: ArticleContent
let item: FeedItem
let openLinkAction: (URL) -> Void
let webViewActionHandler: (WKScriptMessage) -> Void
@ -254,9 +15,8 @@ struct WebReader: UIViewRepresentable {
@Binding var increaseFontActionID: UUID?
@Binding var decreaseFontActionID: UUID?
@State var annotationSaveTransactionID: UUID?
@State private var annotation = String()
@Binding var annotationSaveTransactionID: UUID?
@Binding var annotation: String
func makeCoordinator() -> WebReaderCoordinator {
WebReaderCoordinator()
@ -268,12 +28,12 @@ struct WebReader: UIViewRepresentable {
}
func makeUIView(context: Context) -> WKWebView {
let webView = WebViewManager.create()
let webView = WebViewManager.shared()
let contentController = WKUserContentController()
webView.loadHTMLString(
WebReaderContent(
htmlContent: htmlContent,
articleContent: articleContent,
item: item,
authToken: authToken,
isDark: UITraitCollection.current.userInterfaceStyle == .dark,
@ -292,23 +52,14 @@ struct WebReader: UIViewRepresentable {
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.configureForOmnivoreAppEmbed(
// config: WebViewConfig(
// url: dataService.appEnvironment.webAppBaseURL,
// themeId: UITraitCollection.current.userInterfaceStyle == .dark ? "Gray" : "LightGray",
// margin: 0,
// fontSize: fontSize(),
// fontFamily: "inter",
// rawAuthCookie: rawAuthCookie
// )
// )
context.coordinator.linkHandler = openLinkAction
context.coordinator.webViewActionHandler = webViewActionHandler
context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater

View file

@ -0,0 +1,298 @@
import Combine
import Models
import Services
import SwiftUI
import Views
import WebKit
struct SafariWebLink: Identifiable {
let id: UUID
let url: URL
}
// TODO: load highlights
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)
}
}
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
@EnvironmentObject var authenticator: Authenticator
@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) {
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
},
authToken: authenticator.authToken ?? "",
appEnv: dataService.appEnvironment,
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

@ -4,14 +4,14 @@ import Utils
struct WebReaderContent {
let textFontSize: Int
let content: String
let articleContent: ArticleContent
let item: FeedItem
let themeKey: String
let authToken: String
let appEnv: AppEnvironment
init(
htmlContent: String,
articleContent: ArticleContent,
item: FeedItem,
authToken: String,
isDark: Bool,
@ -19,7 +19,7 @@ struct WebReaderContent {
appEnv: AppEnvironment
) {
self.textFontSize = fontSize
self.content = htmlContent
self.articleContent = articleContent
self.item = item
self.themeKey = isDark ? "Gray" : "LightGray"
self.authToken = authToken
@ -36,36 +36,35 @@ struct WebReaderContent {
<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">
window.omnivoreEnv = {
"NEXT_PUBLIC_APP_ENV": "\(appEnv.rawValue)",
"NEXT_PUBLIC_\(appEnv.rawValue.uppercased())_BASE_URL": "\(appEnv.webAppBaseURL.absoluteString)",
"NEXT_PUBLIC_\(appEnv.rawValue.uppercased())_SERVER_BASE_URL": "\(appEnv.serverBaseURL.absoluteString)",
"NEXT_PUBLIC_\(appEnv.rawValue.uppercased())_HIGHLIGHTS_BASE_URL": "\(appEnv.highlightsServerBaseURL.absoluteString)"
}
<div id="root" />
<script type="text/javascript">
window.omnivoreEnv = {
"NEXT_PUBLIC_APP_ENV": "\(appEnv.rawValue)",
"NEXT_PUBLIC_\(appEnv.rawValue.uppercased())_BASE_URL": "\(appEnv.webAppBaseURL.absoluteString)",
"NEXT_PUBLIC_\(appEnv.rawValue.uppercased())_SERVER_BASE_URL": "\(appEnv.serverBaseURL.absoluteString)",
"NEXT_PUBLIC_\(appEnv.rawValue.uppercased())_HIGHLIGHTS_BASE_URL": "\(appEnv.highlightsServerBaseURL.absoluteString)"
}
window.omnivoreArticle = {
id: "test",
linkId: "test",
slug: "test-slug",
createdAt: new Date().toISOString(),
savedAt: new Date().toISOString(),
url: "https://example.com",
title: `\(item.title)`,
content: `\(content)`,
originalArticleUrl: "https://example.com",
contentReader: "WEB",
readingProgressPercent: \(item.readingProgress),
readingProgressAnchorIndex: \(item.readingProgressAnchor),
highlights: [],
}
window.omnivoreArticle = {
id: "test",
linkId: "test",
slug: "test-slug",
createdAt: new Date().toISOString(),
savedAt: new Date().toISOString(),
url: "https://example.com",
title: `\(item.title)`,
content: `\(articleContent.htmlContent)`,
originalArticleUrl: "https://example.com",
contentReader: "WEB",
readingProgressPercent: \(item.readingProgress),
readingProgressAnchorIndex: \(item.readingProgressAnchor),
highlights: \(articleContent.highlightsJSONString),
}
window.fontSize = \(textFontSize)
window.localStorage.setItem("authToken", "\(authToken)")
window.localStorage.setItem("theme", "\(themeKey)")
</script>
</div>
window.fontSize = \(textFontSize)
window.localStorage.setItem("authToken", "\(authToken)")
window.localStorage.setItem("theme", "\(themeKey)")
</script>
<script src="bundle.js"></script>
</body>
</html>

View file

@ -114,31 +114,3 @@ extension WebReaderCoordinator: WKNavigationDelegate {
}
}
#endif
struct WebViewConfig {
let url: URL
let themeId: String
let margin: Int
let fontSize: Int
let fontFamily: String
let rawAuthCookie: String?
}
extension WKWebView {
func configureForOmnivoreAppEmbed(config: WebViewConfig) {
// Set cookies to pass article preferences to web view
injectCookie(cookieString: "theme=\(config.themeId); Max-Age=31536000;", url: config.url)
injectCookie(cookieString: "margin=\(config.margin); Max-Age=31536000;", url: config.url)
injectCookie(cookieString: "fontSize=\(config.fontSize); Max-Age=31536000;", url: config.url)
injectCookie(cookieString: "fontFamily=\(config.fontFamily); Max-Age=31536000;", url: config.url)
injectCookie(cookieString: config.rawAuthCookie, url: config.url)
}
func injectCookie(cookieString: String?, url: URL) {
if let cookieString = cookieString {
for cookie in HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": cookieString], for: url) {
configuration.websiteDataStore.httpCookieStore.setCookie(cookie) {}
}
}
}
}

View file

@ -0,0 +1,22 @@
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
print(highlightsJSONString)
}
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

@ -4,14 +4,30 @@ import Models
import SwiftGraphQL
public extension DataService {
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<String, ServerError> {
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<ArticleContent, ServerError> {
enum QueryResult {
case success(result: String)
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 {
try $0.content()
ArticleContent(
htmlContent: try $0.content(),
highlights: try $0.highlights(selection: highlightSelection.list)
)
}
let selection = Selection<QueryResult, Unions.ArticleResult> {

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

@ -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

@ -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
}
}

File diff suppressed because one or more lines are too long

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

@ -352,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
}
}
@ -474,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,

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

@ -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,26 +0,0 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json"
},
"plugins": ["@typescript-eslint", "functional"],
"extends": [
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"prettier",
"plugin:functional/no-object-orientation"
],
"root": true,
"env": {
"es6": true,
"browser": true,
"jest": true,
"node": true
},
"ignorePatterns": ["next.config.js", "jest.config.js"],
"rules": {
"functional/no-mixed-type": 0,
"react/react-in-jsx-scope": 0
}
}

View file

@ -1,9 +0,0 @@
export {}
declare global {
interface Window {
omnivoreArticle?: any
omnivoreEnv?: any
fontSize?: number
}
}

View file

@ -2,18 +2,20 @@
<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' />
<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">
<!-- {{INJECT_SWIFT_STRING_HERE!!}} -->
<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"
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',
}
}
@ -23,19 +25,19 @@
<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",
id: 'test',
linkId: 'test',
slug: 'test-slug',
createdAt: new Date().toISOString(),
savedAt: new Date().toISOString(),
url: "https://example.com",
title: "Test Article",
url: 'https://example.com',
title: 'Test Article',
content: CONTENT,
originalArticleUrl: "https://example.com",
contentReader: "WEB",
originalArticleUrl: 'https://example.com',
contentReader: 'WEB',
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
highlights: [],
@ -48,4 +50,4 @@
</div>
<script src="bundle.js"></script>
</body>
</html>
</html>

View file

@ -2,7 +2,6 @@ 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 { ArticleAttributes } from '@omnivore/web/lib/networking/queries/useGetArticleQuery'
import { applyStoredTheme } from '@omnivore/web/lib/themeUpdater'
import '@omnivore/web/styles/globals.css'
import '@omnivore/web/styles/articleInnerStyling.css'
@ -26,7 +25,7 @@ const App = () => {
>
<ArticleContainer
viewerUsername="test"
article={window.omnivoreArticle as ArticleAttributes}
article={window.omnivoreArticle}
scrollElementRef={React.createRef()}
isAppleAppEmbed={true}
highlightBarDisabled={true}

View file

@ -1,17 +0,0 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": ["additional.d.ts", "src"]
}

View file

@ -1,19 +1,13 @@
import path from "path"
import glob from 'glob'
import path from 'path'
import { DefinePlugin } from 'webpack'
import { Configuration } from "webpack"
import { Configuration } from 'webpack'
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'
const analyze = process.env.ANALYZE
const config: Configuration = {
entry: {
bundle: './src/index.tsx',
fonts: [
...glob.sync('../web/public/static/fonts/Inter/*'),
...glob.sync('../web/public/static/fonts/SFMono/*')
],
bundle: './src/index.jsx',
},
module: {
rules: [
@ -21,40 +15,37 @@ const config: Configuration = {
test: /\.(ts|js)x?$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
loader: 'babel-loader',
options: {
presets: [
"@babel/preset-env",
["@babel/preset-react", {"runtime": "automatic"}],
"@babel/preset-typescript",
'@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',
use: [
'style-loader',
{
loader: 'css-loader',
options: { url: false },
},
}],
},
{
test: /.(ttf|otf|woff(2)?)(\?[a-z0-9]+)?$/,
type: 'asset/resource',
generator: {
filename: '[path][name][ext]'
}
{
// 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',
},
},
],
},
],
},
@ -65,15 +56,15 @@ const config: Configuration = {
new BundleAnalyzerPlugin({
openAnalyzer: !!analyze,
analyzerMode: 'static',
})
}),
],
resolve: {
extensions: [".tsx", ".ts", ".js"],
extensions: ['.tsx', '.ts', '.js'],
},
output: {
path: path.resolve(__dirname, "build"),
path: path.resolve(__dirname, 'build'),
filename: '[name].js',
},
};
}
export default config;
export default config