From ea2ba678c09b0c96d204eb206d1e2e6059a911c6 Mon Sep 17 00:00:00 2001
From: Satindar Dhillon
Date: Thu, 14 Apr 2022 13:35:28 -0700
Subject: [PATCH 01/20] add fetchViewer async func
---
.../App/Views/Profile/ProfileView.swift | 26 +++++-------
.../DataService/Queries/ViewerPublisher.swift | 41 +++++++++++++++++++
2 files changed, 52 insertions(+), 15 deletions(-)
diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift
index 80d6eef94..aab2f65ab 100644
--- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift
+++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift
@@ -5,12 +5,10 @@ import SwiftUI
import Utils
import Views
-final class ProfileContainerViewModel: ObservableObject {
+@MainActor final class ProfileContainerViewModel: ObservableObject {
@Published var isLoading = false
@Published var profileCardData = ProfileCardData()
- var subscriptions = Set()
-
var appVersionString: String {
if let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
return "Omnivore Version \(appVersion)"
@@ -19,18 +17,14 @@ final class ProfileContainerViewModel: ObservableObject {
}
}
- func loadProfileData(dataService: DataService) {
- dataService.viewerPublisher().sink(
- receiveCompletion: { _ in },
- receiveValue: { [weak self] viewer in
- self?.profileCardData = ProfileCardData(
- name: viewer.name,
- username: viewer.username,
- imageURL: viewer.profileImageURL.flatMap { URL(string: $0) }
- )
- }
+ func loadProfileData(dataService: DataService) async {
+ guard let viewer = try? await dataService.fetchViewer() else { return }
+
+ profileCardData = ProfileCardData(
+ name: viewer.name,
+ username: viewer.username,
+ imageURL: viewer.profileImageURL.flatMap { URL(string: $0) }
)
- .store(in: &subscriptions)
}
}
@@ -59,7 +53,9 @@ struct ProfileView: View {
Group {
Section {
ProfileCard(data: viewModel.profileCardData)
- .onAppear { viewModel.loadProfileData(dataService: dataService) }
+ .task {
+ await viewModel.loadProfileData(dataService: dataService)
+ }
}
Section {
diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerPublisher.swift
index 7b91e2c50..39961b7cb 100644
--- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerPublisher.swift
+++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerPublisher.swift
@@ -5,6 +5,47 @@ import SwiftGraphQL
import Utils
public extension DataService {
+ func fetchViewer() async throws -> Viewer {
+ let selection = Selection {
+ Viewer(
+ username: try $0.profile(
+ selection: .init { try $0.username() }
+ ),
+ name: try $0.name(),
+ profileImageURL: try $0.profile(
+ selection: .init { try $0.pictureUrl() }
+ ),
+ userID: try $0.id()
+ )
+ }
+
+ let query = Selection.Query {
+ try $0.me(selection: selection.nonNullOrFail)
+ }
+
+ let path = appEnvironment.graphqlPath
+ let headers = networker.defaultHeaders
+
+ return try await withCheckedThrowingContinuation { continuation in
+ send(query, to: path, headers: headers) { [weak self] result in
+ switch result {
+ case let .success(payload):
+ self?.currentViewer = payload.data
+ if UserDefaults.standard.string(forKey: Keys.userIdKey) == nil {
+ UserDefaults.standard.setValue(payload.data.userID, forKey: Keys.userIdKey)
+ DataService.registerIntercomUser?(payload.data.userID)
+ }
+ continuation.resume(returning: payload.data)
+ case .failure:
+ continuation.resume(throwing: BasicError.message(messageText: "http error"))
+ }
+ }
+ }
+ }
+}
+
+public extension DataService {
+ @available(*, deprecated, message: "use async version instead")
func viewerPublisher() -> AnyPublisher {
internalViewerPublisher()
.handleEvents(receiveOutput: {
From 5dff87a8e9e1732f42ee5a10eed9acc74911144a Mon Sep 17 00:00:00 2001
From: Satindar Dhillon
Date: Thu, 14 Apr 2022 13:55:46 -0700
Subject: [PATCH 02/20] replace uses of viewerPublisher with fetchViewer
---
.../Components/FeedCardNavigationLink.swift | 4 +-
.../App/Views/Home/HomeFeedViewIOS.swift | 14 +++----
.../App/Views/Home/HomeFeedViewModel.swift | 14 +++----
.../App/Views/LinkItemDetailView.swift | 41 ++++++++-----------
4 files changed, 30 insertions(+), 43 deletions(-)
diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift
index dbf0084ca..93fd2670b 100644
--- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift
+++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift
@@ -22,7 +22,7 @@ struct FeedCardNavigationLink: View {
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
- viewModel.itemAppeared(item: item, dataService: dataService)
+ Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
}
FeedCard(item: item)
}
@@ -60,7 +60,7 @@ struct GridCardNavigationLink: View {
}
})
.onAppear {
- viewModel.itemAppeared(item: item, dataService: dataService)
+ Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
}
}
.aspectRatio(1.8, contentMode: .fill)
diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift
index 46720c206..f5462772b 100644
--- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift
+++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift
@@ -19,7 +19,7 @@ import Views
viewModel: viewModel
)
.refreshable {
- viewModel.loadItems(dataService: dataService, isRefresh: true)
+ Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
}
.searchable(
text: $viewModel.searchTerm,
@@ -35,13 +35,13 @@ import Views
.onChange(of: viewModel.searchTerm) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
- viewModel.loadItems(dataService: dataService, isRefresh: true)
+ Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
}
.onChange(of: viewModel.selectedLabels) { _ in
- viewModel.loadItems(dataService: dataService, isRefresh: true)
+ Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
}
.onSubmit(of: .search) {
- viewModel.loadItems(dataService: dataService, isRefresh: true)
+ Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(mode: .item(item)) { labels in
@@ -54,7 +54,7 @@ import Views
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
// Don't refresh the list if the user is currently reading an article
if viewModel.selectedLinkItem == nil {
- viewModel.loadItems(dataService: dataService, isRefresh: true)
+ Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
}
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushFeedItem"))) { notification in
@@ -75,7 +75,7 @@ import Views
}
.onAppear {
if viewModel.items.isEmpty {
- viewModel.loadItems(dataService: dataService, isRefresh: true)
+ Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
}
}
.onChange(of: viewModel.selectedLinkItem) { _ in
@@ -322,7 +322,7 @@ import Views
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
DispatchQueue.main.async {
if !viewModel.isLoading, offset > 240 {
- viewModel.loadItems(dataService: dataService, isRefresh: true)
+ Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
}
}
}
diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift
index 4ee67a777..e3bfffbc8 100644
--- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift
+++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift
@@ -5,7 +5,7 @@ import SwiftUI
import Utils
import Views
-final class HomeFeedViewModel: ObservableObject {
+@MainActor final class HomeFeedViewModel: ObservableObject {
var currentDetailViewModel: LinkItemDetailViewModel?
/// Track progress updates to be committed when user navigates back to grid view
@@ -36,14 +36,14 @@ final class HomeFeedViewModel: ObservableObject {
init() {}
- func itemAppeared(item: FeedItem, dataService: DataService) {
+ func itemAppeared(item: FeedItem, dataService: DataService) async {
if isLoading { return }
let itemIndex = items.firstIndex(where: { $0.id == item.id })
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
// Check if user has scrolled to the last five items in the list
if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 {
- loadItems(dataService: dataService, isRefresh: false)
+ await loadItems(dataService: dataService, isRefresh: false)
}
}
@@ -51,7 +51,7 @@ final class HomeFeedViewModel: ObservableObject {
items.insert(item, at: 0)
}
- func loadItems(dataService: DataService, isRefresh: Bool) {
+ func loadItems(dataService: DataService, isRefresh: Bool) async {
// Clear offline highlights since we'll be populating new FeedItems with the correct highlights set
dataService.clearHighlights()
@@ -62,11 +62,7 @@ final class HomeFeedViewModel: ObservableObject {
// Cache the viewer
if dataService.currentViewer == nil {
- dataService.viewerPublisher().sink(
- receiveCompletion: { _ in },
- receiveValue: { _ in }
- )
- .store(in: &subscriptions)
+ _ = try? await dataService.fetchViewer()
}
dataService.libraryItemsPublisher(
diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift
index d267389e3..6ae0f7ad9 100644
--- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift
+++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift
@@ -9,7 +9,7 @@ enum PDFProvider {
static var pdfViewerProvider: ((URL, FeedItem) -> AnyView)?
}
-final class LinkItemDetailViewModel: ObservableObject {
+@MainActor final class LinkItemDetailViewModel: ObservableObject {
let homeFeedViewModel: HomeFeedViewModel
@Published var item: FeedItem
@Published var webAppWrapperViewModel: WebAppWrapperViewModel?
@@ -45,31 +45,22 @@ final class LinkItemDetailViewModel: ObservableObject {
.store(in: &subscriptions)
}
- func loadWebAppWrapper(dataService: DataService, rawAuthCookie: String?) {
- // Attempt to get `Viewer` from DataService
- if let currentViewer = dataService.currentViewer {
+ func loadWebAppWrapper(dataService: DataService, rawAuthCookie: String?) async {
+ let viewer: Viewer? = await {
+ if let currentViewer = dataService.currentViewer {
+ return currentViewer
+ }
+
+ return try? await dataService.fetchViewer()
+ }()
+
+ if let viewer = viewer {
createWebAppWrapperViewModel(
- username: currentViewer.username,
+ username: viewer.username,
dataService: dataService,
rawAuthCookie: rawAuthCookie
)
- return
}
-
- dataService.viewerPublisher().sink(
- receiveCompletion: { completion in
- guard case let .failure(error) = completion else { return }
- print(error)
- },
- receiveValue: { [weak self] viewer in
- self?.createWebAppWrapperViewModel(
- username: viewer.username,
- dataService: dataService,
- rawAuthCookie: rawAuthCookie
- )
- }
- )
- .store(in: &subscriptions)
}
private func createWebAppWrapperViewModel(username: String, dataService: DataService, rawAuthCookie: String?) {
@@ -265,8 +256,8 @@ struct LinkItemDetailView: View {
navBar
Spacer()
}
- .onAppear {
- viewModel.loadWebAppWrapper(
+ .task {
+ await viewModel.loadWebAppWrapper(
dataService: dataService,
rawAuthCookie: authenticator.omnivoreAuthCookieString
)
@@ -311,8 +302,8 @@ struct LinkItemDetailView: View {
Text("Loading...")
Spacer()
}
- .onAppear {
- viewModel.loadWebAppWrapper(
+ .task {
+ await viewModel.loadWebAppWrapper(
dataService: dataService,
rawAuthCookie: authenticator.omnivoreAuthCookieString
)
From 70c4b1c690a5ca46f57e5091c98b3f471d1ca6c5 Mon Sep 17 00:00:00 2001
From: Satindar Dhillon
Date: Thu, 14 Apr 2022 14:57:48 -0700
Subject: [PATCH 03/20] convery additional uses of viewerPublisher to
fetchViewer
---
.../Share/ShareExtensionScene.swift | 15 ++++++++-------
.../Sources/App/Views/RootView/RootView.swift | 4 ++--
.../App/Views/RootView/RootViewModel.swift | 19 +++++--------------
...werPublisher.swift => ViewerFetcher.swift} | 17 +----------------
4 files changed, 16 insertions(+), 39 deletions(-)
rename apple/OmnivoreKit/Sources/Services/DataService/Queries/{ViewerPublisher.swift => ViewerFetcher.swift} (81%)
diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift
index 42f2f3225..acc01f73a 100644
--- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift
+++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift
@@ -80,13 +80,14 @@ final class ShareExtensionViewModel: ObservableObject {
.store(in: &subscriptions)
// Using viewerPublisher to get fast feedback for auth/network errors
- services.dataService.viewerPublisher()
- .sink { [weak self] completion in
- guard case let .failure(error) = completion else { return }
- self?.debugText = "saveArticleError: \(error)"
- self?.status = .failed(error: .unknown(description: ""))
- } receiveValue: { _ in }
- .store(in: &subscriptions)
+ Task {
+ do {
+ _ = try await services.dataService.fetchViewer()
+ } catch {
+ debugText = "saveArticleError: \(error)"
+ status = .failed(error: .unknown(description: ""))
+ }
+ }
}
}
diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift
index 0d4af5f7c..135be02c1 100644
--- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift
+++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift
@@ -91,10 +91,10 @@ struct InnerRootView: View {
if viewModel.webLinkPath != nil {
viewModel.webLinkPath = nil
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
- viewModel.onOpenURL(url: url)
+ Task { await viewModel.onOpenURL(url: url) }
}
} else {
- viewModel.onOpenURL(url: url)
+ Task { await viewModel.onOpenURL(url: url) }
}
}
}
diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift
index fa8762ec0..a8afdf799 100644
--- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift
+++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift
@@ -20,8 +20,6 @@ public final class RootViewModel: ObservableObject {
@Published var snackbarMessage: String?
@Published var showSnackbar = false
- public var subscriptions = Set()
-
public init() {
registerFonts()
@@ -57,7 +55,7 @@ public final class RootViewModel: ObservableObject {
)
}
- func onOpenURL(url: URL) {
+ @MainActor func onOpenURL(url: URL) async {
guard let linkRequestID = DeepLink.make(from: url)?.linkRequestID else { return }
if let username = services.dataService.currentViewer?.username {
@@ -66,17 +64,10 @@ public final class RootViewModel: ObservableObject {
return
}
- services.dataService.viewerPublisher().sink(
- receiveCompletion: { completion in
- guard case let .failure(error) = completion else { return }
- print(error)
- },
- receiveValue: { [weak self] viewer in
- let path = self?.linkRequestPath(username: viewer.username, requestID: linkRequestID) ?? ""
- self?.webLinkPath = SafariWebLinkPath(id: UUID(), path: path)
- }
- )
- .store(in: &subscriptions)
+ if let viewer = try? await services.dataService.fetchViewer() {
+ let path = linkRequestPath(username: viewer.username, requestID: linkRequestID)
+ webLinkPath = SafariWebLinkPath(id: UUID(), path: path)
+ }
}
func triggerPushNotificationRequestIfNeeded() {
diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift
similarity index 81%
rename from apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerPublisher.swift
rename to apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift
index 39961b7cb..509ff01a7 100644
--- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerPublisher.swift
+++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift
@@ -44,23 +44,8 @@ public extension DataService {
}
}
-public extension DataService {
- @available(*, deprecated, message: "use async version instead")
- func viewerPublisher() -> AnyPublisher {
- internalViewerPublisher()
- .handleEvents(receiveOutput: {
- // Persist ID so AppDelegate can use it to register Intercom users at launch time
- if UserDefaults.standard.string(forKey: Keys.userIdKey) == nil {
- UserDefaults.standard.setValue($0.userID, forKey: Keys.userIdKey)
- DataService.registerIntercomUser?($0.userID)
- }
- })
- .receive(on: DispatchQueue.main)
- .eraseToAnyPublisher()
- }
-}
-
extension DataService {
+ @available(*, deprecated, message: "use async version instead")
func internalViewerPublisher() -> AnyPublisher {
let selection = Selection {
Viewer(
From 98080719f37e58f2e746f5ceefa5fbc65a2a2a80 Mon Sep 17 00:00:00 2001
From: Satindar Dhillon
Date: Thu, 14 Apr 2022 15:06:50 -0700
Subject: [PATCH 04/20] wrap viewModel async call in Task
---
.../Home/Components/FeedCardNavigationLink.swift | 4 ++--
.../Sources/App/Views/Home/HomeFeedViewIOS.swift | 14 +++++++-------
.../Sources/App/Views/Home/HomeFeedViewModel.swift | 9 +++++----
3 files changed, 14 insertions(+), 13 deletions(-)
diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift
index 93fd2670b..dbf0084ca 100644
--- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift
+++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift
@@ -22,7 +22,7 @@ struct FeedCardNavigationLink: View {
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
- Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
+ viewModel.itemAppeared(item: item, dataService: dataService)
}
FeedCard(item: item)
}
@@ -60,7 +60,7 @@ struct GridCardNavigationLink: View {
}
})
.onAppear {
- Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
+ viewModel.itemAppeared(item: item, dataService: dataService)
}
}
.aspectRatio(1.8, contentMode: .fill)
diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift
index f5462772b..46720c206 100644
--- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift
+++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift
@@ -19,7 +19,7 @@ import Views
viewModel: viewModel
)
.refreshable {
- Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
+ viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.searchable(
text: $viewModel.searchTerm,
@@ -35,13 +35,13 @@ import Views
.onChange(of: viewModel.searchTerm) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
- Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
+ viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.onChange(of: viewModel.selectedLabels) { _ in
- Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
+ viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.onSubmit(of: .search) {
- Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
+ viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(mode: .item(item)) { labels in
@@ -54,7 +54,7 @@ import Views
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
// Don't refresh the list if the user is currently reading an article
if viewModel.selectedLinkItem == nil {
- Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
+ viewModel.loadItems(dataService: dataService, isRefresh: true)
}
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushFeedItem"))) { notification in
@@ -75,7 +75,7 @@ import Views
}
.onAppear {
if viewModel.items.isEmpty {
- Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
+ viewModel.loadItems(dataService: dataService, isRefresh: true)
}
}
.onChange(of: viewModel.selectedLinkItem) { _ in
@@ -322,7 +322,7 @@ import Views
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
DispatchQueue.main.async {
if !viewModel.isLoading, offset > 240 {
- Task { await viewModel.loadItems(dataService: dataService, isRefresh: true) }
+ viewModel.loadItems(dataService: dataService, isRefresh: true)
}
}
}
diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift
index e3bfffbc8..ed61a4341 100644
--- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift
+++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift
@@ -36,14 +36,14 @@ import Views
init() {}
- func itemAppeared(item: FeedItem, dataService: DataService) async {
+ func itemAppeared(item: FeedItem, dataService: DataService) {
if isLoading { return }
let itemIndex = items.firstIndex(where: { $0.id == item.id })
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
// Check if user has scrolled to the last five items in the list
if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 {
- await loadItems(dataService: dataService, isRefresh: false)
+ Task { await loadItems(dataService: dataService, isRefresh: false) }
}
}
@@ -51,7 +51,7 @@ import Views
items.insert(item, at: 0)
}
- func loadItems(dataService: DataService, isRefresh: Bool) async {
+ func loadItems(dataService: DataService, isRefresh: Bool) {
// Clear offline highlights since we'll be populating new FeedItems with the correct highlights set
dataService.clearHighlights()
@@ -61,8 +61,9 @@ import Views
isLoading = true
// Cache the viewer
+
if dataService.currentViewer == nil {
- _ = try? await dataService.fetchViewer()
+ Task { _ = try? await dataService.fetchViewer() }
}
dataService.libraryItemsPublisher(
From 0715dafd4b17ac7236f91f8596d86e8ff874d4e4 Mon Sep 17 00:00:00 2001
From: Jackson Harper
Date: Thu, 14 Apr 2022 15:06:27 -0700
Subject: [PATCH 05/20] Display the all highlights modal from PDFs via the
article actions button
---
.../templates/article/ArticleContainer.tsx | 1 -
.../templates/article/HighlightsModal.tsx | 20 ++++++++++++-------
.../templates/article/PdfArticleContainer.tsx | 9 +++++++++
.../web/pages/[username]/[slug]/index.tsx | 2 ++
4 files changed, 24 insertions(+), 8 deletions(-)
diff --git a/packages/web/components/templates/article/ArticleContainer.tsx b/packages/web/components/templates/article/ArticleContainer.tsx
index 46a846637..62e4a7b8d 100644
--- a/packages/web/components/templates/article/ArticleContainer.tsx
+++ b/packages/web/components/templates/article/ArticleContainer.tsx
@@ -35,7 +35,6 @@ type ArticleContainerProps = {
export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
const [showShareModal, setShowShareModal] = useState(false)
const [showReportIssuesModal, setShowReportIssuesModal] = useState(false)
- const [showHighlightsModal, setShowHighlightsModal] = useState(props.showHighlightsModal)
const [fontSize, setFontSize] = useState(props.fontSize ?? 20)
const updateFontSize = async (newFontSize: number) => {
diff --git a/packages/web/components/templates/article/HighlightsModal.tsx b/packages/web/components/templates/article/HighlightsModal.tsx
index 92358b057..d31557c4b 100644
--- a/packages/web/components/templates/article/HighlightsModal.tsx
+++ b/packages/web/components/templates/article/HighlightsModal.tsx
@@ -19,7 +19,7 @@ import { Pen, Trash } from 'phosphor-react'
type HighlightsModalProps = {
highlights: Highlight[]
- deleteHighlightAction: (highlightId: string) => void
+ deleteHighlightAction?: (highlightId: string) => void
onOpenChange: (open: boolean) => void
}
@@ -59,9 +59,12 @@ export function HighlightsModal(props: HighlightsModalProps): JSX.Element {
- props.deleteHighlightAction(highlight.id)
- }
+ showDelete={!!props.deleteHighlightAction}
+ deleteHighlightAction={() => {
+ if (props.deleteHighlightAction) {
+ props.deleteHighlightAction(highlight.id)
+ }
+ }}
/>
))}
{props.highlights.length === 0 && (
@@ -78,6 +81,7 @@ export function HighlightsModal(props: HighlightsModalProps): JSX.Element {
type ModalHighlightViewProps = {
highlight: Highlight
+ showDelete: boolean
deleteHighlightAction: () => void
}
@@ -112,9 +116,11 @@ function ModalHighlightView(props: ModalHighlightViewProps): JSX.Element {
/>
)}
*/}
-
+ {props.showDelete && (
+
+ )}
)
diff --git a/packages/web/components/templates/article/PdfArticleContainer.tsx b/packages/web/components/templates/article/PdfArticleContainer.tsx
index 77659e737..06cb94286 100644
--- a/packages/web/components/templates/article/PdfArticleContainer.tsx
+++ b/packages/web/components/templates/article/PdfArticleContainer.tsx
@@ -15,10 +15,13 @@ import { ShareHighlightModal } from './ShareHighlightModal'
import { useCanShareNative } from '../../../lib/hooks/useCanShareNative'
import { webBaseURL } from '../../../lib/appConfig'
import { pspdfKitKey } from '../../../lib/appConfig'
+import { HighlightsModal } from './HighlightsModal'
export type PdfArticleContainerProps = {
viewerUsername: string
article: ArticleAttributes
+ showHighlightsModal: boolean
+ setShowHighlightsModal: React.Dispatch>
}
export default function PdfArticleContainer(
@@ -348,6 +351,12 @@ export default function PdfArticleContainer(
}}
/>
)}
+ {props.showHighlightsModal && (
+ props.setShowHighlightsModal(false)}
+ />
+ )}
)
}
diff --git a/packages/web/pages/[username]/[slug]/index.tsx b/packages/web/pages/[username]/[slug]/index.tsx
index 282bbf0d4..18bd8a00c 100644
--- a/packages/web/pages/[username]/[slug]/index.tsx
+++ b/packages/web/pages/[username]/[slug]/index.tsx
@@ -209,6 +209,8 @@ export default function Home(): JSX.Element {
{article.contentReader == 'PDF' ? (
) : (
From 0842aba57cf565c64a68e9eb0e0e89bc00a12c03 Mon Sep 17 00:00:00 2001
From: Jackson Harper
Date: Thu, 14 Apr 2022 15:53:51 -0700
Subject: [PATCH 06/20] Always display the article actions toolbar on top for
PDFs
Because we dont have margin space on PDFs we want to always
display this on top.
---
.../web/components/patterns/PrimaryHeader.tsx | 5 +-
.../components/templates/PrimaryLayout.tsx | 4 +-
.../templates/article/ArticleActionsMenu.tsx | 66 ++++++++++---------
.../web/pages/[username]/[slug]/index.tsx | 7 +-
4 files changed, 46 insertions(+), 36 deletions(-)
diff --git a/packages/web/components/patterns/PrimaryHeader.tsx b/packages/web/components/patterns/PrimaryHeader.tsx
index 79294d02f..12c7e4754 100644
--- a/packages/web/components/patterns/PrimaryHeader.tsx
+++ b/packages/web/components/patterns/PrimaryHeader.tsx
@@ -26,6 +26,7 @@ type HeaderProps = {
isFixedPosition: boolean
scrollElementRef?: React.RefObject
toolbarControl?: JSX.Element
+ alwaysDisplayToolbar?: boolean
setShowLogoutConfirmation: (showShareModal: boolean) => void
setShowKeyboardCommandsModal: (showShareModal: boolean) => void
}
@@ -128,6 +129,7 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
isVisible={true}
isFixedPosition={true}
toolbarControl={props.toolbarControl}
+ alwaysDisplayToolbar={props.alwaysDisplayToolbar}
/>
>
)
@@ -143,6 +145,7 @@ type NavHeaderProps = {
isVisible?: boolean
isFixedPosition: boolean
toolbarControl?: JSX.Element
+ alwaysDisplayToolbar?: boolean
}
function NavHeader(props: NavHeaderProps): JSX.Element {
@@ -187,7 +190,7 @@ function NavHeader(props: NavHeaderProps): JSX.Element {
headerToolbarControl?: JSX.Element
+ alwaysDisplayToolbar?: boolean
}
export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
@@ -75,8 +76,9 @@ export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
userInitials={viewerData?.me?.name.charAt(0) ?? ''}
profileImageURL={viewerData?.me?.profile.pictureUrl}
isFixedPosition={true}
- toolbarControl={props.headerToolbarControl}
scrollElementRef={props.scrollElementRef}
+ toolbarControl={props.headerToolbarControl}
+ alwaysDisplayToolbar={props.alwaysDisplayToolbar}
setShowLogoutConfirmation={setShowLogoutConfirmation}
setShowKeyboardCommandsModal={setShowKeyboardCommandsModal}
/>
diff --git a/packages/web/components/templates/article/ArticleActionsMenu.tsx b/packages/web/components/templates/article/ArticleActionsMenu.tsx
index 880cfea9a..9b82691f0 100644
--- a/packages/web/components/templates/article/ArticleActionsMenu.tsx
+++ b/packages/web/components/templates/article/ArticleActionsMenu.tsx
@@ -1,7 +1,6 @@
import { Separator } from "@radix-ui/react-separator"
import { ArchiveBox, DotsThree, HighlighterCircle, TagSimple, TextAa } from "phosphor-react"
import { ArticleAttributes } from "../../../lib/networking/queries/useGetArticleQuery"
-import { useGetUserPreferences } from "../../../lib/networking/queries/useGetUserPreferences"
import { Button } from "../../elements/Button"
import { Dropdown } from "../../elements/DropdownElements"
import { Box, SpanBox } from "../../elements/LayoutPrimitives"
@@ -9,15 +8,15 @@ import { TooltipWrapped } from "../../elements/Tooltip"
import { styled, theme } from "../../tokens/stitches.config"
import { SetLabelsControl } from "./SetLabelsControl"
import { ReaderSettingsControl } from "./ReaderSettingsControl"
-import { usePersistedState } from "../../../lib/hooks/usePersistedState"
-export type ArticleActionsMenuLayout = 'horizontal' | 'vertical'
+export type ArticleActionsMenuLayout = 'top' | 'side'
type ArticleActionsMenuProps = {
article: ArticleAttributes
layout: ArticleActionsMenuLayout
lineHeight: number
marginWidth: number
+ showReaderDisplaySettings?: boolean
articleActionHandler: (action: string, arg?: unknown) => void
}
@@ -32,7 +31,7 @@ const MenuSeparator = (props: MenuSeparatorProps): JSX.Element => {
borderBottom: `1px solid ${theme.colors.grayLine.toString()}`,
my: '8px',
})
- return (props.layout == 'vertical' ? : <>>)
+ return (props.layout == 'side' ? : <>>)
}
type ActionDropdownProps = {
@@ -45,10 +44,10 @@ const ActionDropdown = (props: ActionDropdownProps): JSX.Element => {
return
{props.children}
@@ -62,32 +61,35 @@ export function ArticleActionsMenu(props: ArticleActionsMenuProps): JSX.Element
css={{
display: 'flex',
alignItems: 'center',
- flexDirection: props.layout == 'vertical' ? 'column' : 'row',
- justifyContent: props.layout == 'vertical' ? 'center' : 'flex-end',
- gap: props.layout == 'vertical' ? '8px' : '24px',
+ flexDirection: props.layout == 'side' ? 'column' : 'row',
+ justifyContent: props.layout == 'side' ? 'center' : 'flex-end',
+ gap: props.layout == 'side' ? '8px' : '24px',
paddingTop: '6px',
}}
>
-
-
+
+
+
+ }
>
-
-
- }
- >
-
-
+
+
-
+
+ >
+ )}
@@ -127,7 +129,7 @@ export function ArticleActionsMenu(props: ArticleActionsMenuProps): JSX.Element
- {/*
+
{props.item.labels?.map(({ name, color }, index) => (
))}
- */}
+
)
}
From 958e67a1cbe8c3c09fbd6e473b5b4962b92241f2 Mon Sep 17 00:00:00 2001
From: Jackson Harper
Date: Thu, 14 Apr 2022 20:21:58 -0700
Subject: [PATCH 10/20] Use block display for label chips so they wrap to new
lines on overflow
---
.../components/patterns/LibraryCards/GridLinkedItemCard.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/web/components/patterns/LibraryCards/GridLinkedItemCard.tsx b/packages/web/components/patterns/LibraryCards/GridLinkedItemCard.tsx
index 5d61e35b4..f01e7260c 100644
--- a/packages/web/components/patterns/LibraryCards/GridLinkedItemCard.tsx
+++ b/packages/web/components/patterns/LibraryCards/GridLinkedItemCard.tsx
@@ -154,11 +154,11 @@ export function GridLinkedItemCard(props: LinkedItemCardProps): JSX.Element {
/>
)}
-
+
{props.item.labels?.map(({ name, color }, index) => (
))}
-
+
)
}
From 944784f25a5091e80f1679c7bb60c6289fd0aebd Mon Sep 17 00:00:00 2001
From: Jackson Harper
Date: Thu, 14 Apr 2022 20:40:25 -0700
Subject: [PATCH 11/20] Use Button+router for label chips instead of next/link
This allows us to use preventDefault on click, which means labels
can be inside of grid cards, and clicking a label wont navigate
to the card's page.
---
packages/web/components/elements/LabelChip.tsx | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/packages/web/components/elements/LabelChip.tsx b/packages/web/components/elements/LabelChip.tsx
index 86dd3c3ba..5a7a92bc8 100644
--- a/packages/web/components/elements/LabelChip.tsx
+++ b/packages/web/components/elements/LabelChip.tsx
@@ -1,6 +1,7 @@
-import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { Button } from './Button'
import { SpanBox } from './LayoutPrimitives'
-import { StyledText } from './StyledText'
+
type LabelChipProps = {
text: string
@@ -8,6 +9,7 @@ type LabelChipProps = {
}
export function LabelChip(props: LabelChipProps): JSX.Element {
+ const router = useRouter()
const hexToRgb = (hex: string) => {
const bigint = parseInt(hex.substring(1), 16)
const r = (bigint >> 16) & 255
@@ -17,8 +19,12 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
return [r, g, b]
}
const color = hexToRgb(props.color)
+
return (
-
+ {
+ router.push(`/home?q=label:"${props.text}"`)
+ e.stopPropagation()
+ }}>
{props.text}
-
+
)
}
From d56e3147df83e78e9a3de135ae701685786cc85b Mon Sep 17 00:00:00 2001
From: Jackson Harper
Date: Thu, 14 Apr 2022 21:05:02 -0700
Subject: [PATCH 12/20] Remove whitespace
---
packages/web/components/elements/LabelChip.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/web/components/elements/LabelChip.tsx b/packages/web/components/elements/LabelChip.tsx
index 5a7a92bc8..dcf1cbf3a 100644
--- a/packages/web/components/elements/LabelChip.tsx
+++ b/packages/web/components/elements/LabelChip.tsx
@@ -2,7 +2,6 @@ import { useRouter } from 'next/router'
import { Button } from './Button'
import { SpanBox } from './LayoutPrimitives'
-
type LabelChipProps = {
text: string
color: string // expected to be a RGB hex color string
From 0e50ef1b562af3f07943035c25f8d1e1fe924b5f Mon Sep 17 00:00:00 2001
From: Jackson Harper
Date: Thu, 14 Apr 2022 21:15:53 -0700
Subject: [PATCH 13/20] Tighten padding on label chips
---
packages/web/components/elements/LabelChip.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/web/components/elements/LabelChip.tsx b/packages/web/components/elements/LabelChip.tsx
index dcf1cbf3a..d38b232fd 100644
--- a/packages/web/components/elements/LabelChip.tsx
+++ b/packages/web/components/elements/LabelChip.tsx
@@ -32,7 +32,7 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
color: props.color,
fontSize: '12px',
fontWeight: 'bold',
- padding: '4px 8px 4px 8px',
+ padding: '1px 7px 1px 7px',
whiteSpace: 'nowrap',
cursor: 'pointer',
backgroundClip: 'padding-box',
From 0db6bff308577ee15e80055c5b3c212f4ab35a06 Mon Sep 17 00:00:00 2001
From: Jackson Harper
Date: Thu, 14 Apr 2022 21:20:17 -0700
Subject: [PATCH 14/20] label padding
---
packages/web/components/elements/LabelChip.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/web/components/elements/LabelChip.tsx b/packages/web/components/elements/LabelChip.tsx
index d38b232fd..131f1acae 100644
--- a/packages/web/components/elements/LabelChip.tsx
+++ b/packages/web/components/elements/LabelChip.tsx
@@ -32,7 +32,7 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
color: props.color,
fontSize: '12px',
fontWeight: 'bold',
- padding: '1px 7px 1px 7px',
+ padding: '2px 7px 2px 7px',
whiteSpace: 'nowrap',
cursor: 'pointer',
backgroundClip: 'padding-box',
From c004f20ee7e8c7207f743e801af9be6d832f9f78 Mon Sep 17 00:00:00 2001
From: Jackson Harper
Date: Thu, 14 Apr 2022 21:38:48 -0700
Subject: [PATCH 15/20] More label padding tweaks
---
packages/web/components/elements/LabelChip.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/web/components/elements/LabelChip.tsx b/packages/web/components/elements/LabelChip.tsx
index 131f1acae..412915a00 100644
--- a/packages/web/components/elements/LabelChip.tsx
+++ b/packages/web/components/elements/LabelChip.tsx
@@ -32,7 +32,7 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
color: props.color,
fontSize: '12px',
fontWeight: 'bold',
- padding: '2px 7px 2px 7px',
+ padding: '2px 5px 2px 5px',
whiteSpace: 'nowrap',
cursor: 'pointer',
backgroundClip: 'padding-box',
From 0b2b93384f636c242ae40506f2ccfade638e8a26 Mon Sep 17 00:00:00 2001
From: Hongbo Wu
Date: Mon, 18 Apr 2022 20:45:06 +0800
Subject: [PATCH 16/20] fix return undefined as updateLabelResult
---
packages/api/src/resolvers/labels/index.ts | 25 ++++++++++------------
1 file changed, 11 insertions(+), 14 deletions(-)
diff --git a/packages/api/src/resolvers/labels/index.ts b/packages/api/src/resolvers/labels/index.ts
index b86a1ae42..a61ba82c8 100644
--- a/packages/api/src/resolvers/labels/index.ts
+++ b/packages/api/src/resolvers/labels/index.ts
@@ -288,9 +288,16 @@ export const updateLabelResolver = authorized<
}
}
+ log.info('Updating a label', {
+ labels: {
+ source: 'resolver',
+ resolver: 'updateLabelResolver',
+ },
+ })
+
const result = await AppDataSource.transaction(async (t) => {
await setClaims(t, uid)
- return await t.getRepository(Label).update(
+ return t.getRepository(Label).update(
{ id: labelId },
{
name: name,
@@ -300,23 +307,13 @@ export const updateLabelResolver = authorized<
)
})
- log.info('Updating a label', {
- result,
- labels: {
- source: 'resolver',
- resolver: 'updateLabelResolver',
- },
- })
-
- if (!result) {
- log.info('failed to update')
+ if (!result.affected) {
+ log.error('failed to update')
return {
- errorCodes: [UpdateLabelErrorCode.BadRequest],
+ errorCodes: [UpdateLabelErrorCode.NotFound],
}
}
- log.info('updated successfully')
-
return { label: label }
} catch (error) {
log.error('error updating label', error)
From 3ba67a9e389b4d7b660a26226124c8f9ad534311 Mon Sep 17 00:00:00 2001
From: Jackson Harper
Date: Mon, 18 Apr 2022 09:34:14 -0700
Subject: [PATCH 17/20] Adjust article actions width to center under logo
---
packages/web/pages/[username]/[slug]/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/web/pages/[username]/[slug]/index.tsx b/packages/web/pages/[username]/[slug]/index.tsx
index 9973d45b3..2e682da08 100644
--- a/packages/web/pages/[username]/[slug]/index.tsx
+++ b/packages/web/pages/[username]/[slug]/index.tsx
@@ -192,7 +192,7 @@ export default function Home(): JSX.Element {
top: '-120px',
left: 8,
height: '100%',
- width: '48px',
+ width: '35px',
'@lgDown': {
display: 'none',
},
From 6f9abc198fcf224d50431c161555996cd6769dc8 Mon Sep 17 00:00:00 2001
From: Jackson Harper
Date: Mon, 18 Apr 2022 13:17:52 -0700
Subject: [PATCH 18/20] Fix typo in labels docs
---
packages/web/pages/help/labels.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/web/pages/help/labels.tsx b/packages/web/pages/help/labels.tsx
index b14496ec7..23f86b29a 100644
--- a/packages/web/pages/help/labels.tsx
+++ b/packages/web/pages/help/labels.tsx
@@ -70,7 +70,7 @@ export default function Labels(): JSX.Element {
Some examples:
- -label:Newsletter finds all pages that do not have the label Newsletter
+ -label:Newsletter finds all pages that have the label Newsletter
label:Cooking,Fitness finds all your pages with either the Cooking or Fitness labels
label:Newsletter label:Surfing finds all pages with both the Newsletter and Surfing labels
label:Coding -label:Newsletter finds all pages with the Coding label that do not have the Newsletter label
From 1117a0c575a331882fcc8ec174330c402f39b4ea Mon Sep 17 00:00:00 2001
From: Hongbo Wu
Date: Tue, 19 Apr 2022 11:08:43 +0800
Subject: [PATCH 19/20] Feature/subscription list resolver (#432)
* add subscriptions table
* add listSubscriptions schema
* add listSubscriptions resolver
---
packages/api/src/entity/subscription.ts | 48 +++++++++++
packages/api/src/entity/user.ts | 4 +
packages/api/src/generated/graphql.ts | 78 ++++++++++++++++++
packages/api/src/generated/schema.graphql | 34 ++++++++
.../api/src/resolvers/function_resolvers.ts | 5 +-
.../api/src/resolvers/subscriptions/index.ts | 54 +++++++++++++
packages/api/src/schema.ts | 34 ++++++++
packages/api/test/db.ts | 39 ++++++---
.../api/test/resolvers/subscriptions.test.ts | 81 +++++++++++++++++++
.../0080.do.add_subscriptions_table.sql | 27 +++++++
.../0080.undo.add_subscriptions_table.sql | 10 +++
11 files changed, 400 insertions(+), 14 deletions(-)
create mode 100644 packages/api/src/entity/subscription.ts
create mode 100644 packages/api/src/resolvers/subscriptions/index.ts
create mode 100644 packages/api/test/resolvers/subscriptions.test.ts
create mode 100755 packages/db/migrations/0080.do.add_subscriptions_table.sql
create mode 100755 packages/db/migrations/0080.undo.add_subscriptions_table.sql
diff --git a/packages/api/src/entity/subscription.ts b/packages/api/src/entity/subscription.ts
new file mode 100644
index 000000000..d00d19eba
--- /dev/null
+++ b/packages/api/src/entity/subscription.ts
@@ -0,0 +1,48 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ JoinColumn,
+ ManyToOne,
+ PrimaryGeneratedColumn,
+ UpdateDateColumn,
+} from 'typeorm'
+import { User } from './user'
+import { SubscriptionStatus } from '../generated/graphql'
+
+@Entity({ name: 'subscriptions' })
+export class Subscription {
+ @PrimaryGeneratedColumn('uuid')
+ id!: string
+
+ @ManyToOne(() => User)
+ @JoinColumn({ name: 'user_id' })
+ user!: User
+
+ @Column('text')
+ name!: string
+
+ @Column('enum', {
+ enum: SubscriptionStatus,
+ default: SubscriptionStatus.Active,
+ })
+ status!: SubscriptionStatus
+
+ @Column('text', { nullable: true })
+ description?: string
+
+ @Column('text', { nullable: true })
+ url?: string
+
+ @Column('text', { nullable: true })
+ unsubscribeMailTo?: string
+
+ @Column('text', { nullable: true })
+ unsubscribeHttpUrl?: string
+
+ @CreateDateColumn()
+ createdAt!: Date
+
+ @UpdateDateColumn()
+ updatedAt!: Date
+}
diff --git a/packages/api/src/entity/user.ts b/packages/api/src/entity/user.ts
index 1b7a55da3..320d26885 100644
--- a/packages/api/src/entity/user.ts
+++ b/packages/api/src/entity/user.ts
@@ -11,6 +11,7 @@ import { MembershipTier, RegistrationType } from '../datalayer/user/model'
import { NewsletterEmail } from './newsletter_email'
import { Profile } from './profile'
import { Label } from './label'
+import { Subscription } from './subscription'
@Entity()
export class User {
@@ -49,4 +50,7 @@ export class User {
@OneToMany(() => Label, (label) => label.user)
labels?: Label[]
+
+ @OneToMany(() => Subscription, (subscription) => subscription.user)
+ subscriptions?: Subscription[]
}
diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts
index d1e733acc..7b11aece0 100644
--- a/packages/api/src/generated/graphql.ts
+++ b/packages/api/src/generated/graphql.ts
@@ -1122,6 +1122,7 @@ export type Query = {
reminder: ReminderResult;
search: SearchResult;
sharedArticle: SharedArticleResult;
+ subscriptions: SubscriptionsResult;
user: UserResult;
users: UsersResult;
validateUsername: Scalars['Boolean'];
@@ -1614,6 +1615,42 @@ export type SortParams = {
order?: InputMaybe;
};
+export type Subscription = {
+ __typename?: 'Subscription';
+ createdAt: Scalars['Date'];
+ description?: Maybe;
+ id: Scalars['ID'];
+ name: Scalars['String'];
+ status: SubscriptionStatus;
+ unsubscribeHttpUrl?: Maybe;
+ unsubscribeMailTo?: Maybe;
+ updatedAt: Scalars['Date'];
+ url?: Maybe;
+};
+
+export type SubscriptionsError = {
+ __typename?: 'SubscriptionsError';
+ errorCodes: Array;
+};
+
+export enum SubscriptionsErrorCode {
+ BadRequest = 'BAD_REQUEST',
+ Unauthorized = 'UNAUTHORIZED'
+}
+
+export type SubscriptionsResult = SubscriptionsError | SubscriptionsSuccess;
+
+export type SubscriptionsSuccess = {
+ __typename?: 'SubscriptionsSuccess';
+ subscriptions: Array;
+};
+
+export enum SubscriptionStatus {
+ Active = 'ACTIVE',
+ Deleted = 'DELETED',
+ Unsubscribed = 'UNSUBSCRIBED'
+}
+
export type UpdateHighlightError = {
__typename?: 'UpdateHighlightError';
errorCodes: Array;
@@ -2206,6 +2243,12 @@ export type ResolversTypes = {
SortOrder: SortOrder;
SortParams: SortParams;
String: ResolverTypeWrapper;
+ Subscription: ResolverTypeWrapper<{}>;
+ SubscriptionsError: ResolverTypeWrapper;
+ SubscriptionsErrorCode: SubscriptionsErrorCode;
+ SubscriptionsResult: ResolversTypes['SubscriptionsError'] | ResolversTypes['SubscriptionsSuccess'];
+ SubscriptionsSuccess: ResolverTypeWrapper;
+ SubscriptionStatus: SubscriptionStatus;
UpdateHighlightError: ResolverTypeWrapper;
UpdateHighlightErrorCode: UpdateHighlightErrorCode;
UpdateHighlightInput: UpdateHighlightInput;
@@ -2452,6 +2495,10 @@ export type ResolversParentTypes = {
SignupSuccess: SignupSuccess;
SortParams: SortParams;
String: Scalars['String'];
+ Subscription: {};
+ SubscriptionsError: SubscriptionsError;
+ SubscriptionsResult: ResolversParentTypes['SubscriptionsError'] | ResolversParentTypes['SubscriptionsSuccess'];
+ SubscriptionsSuccess: SubscriptionsSuccess;
UpdateHighlightError: UpdateHighlightError;
UpdateHighlightInput: UpdateHighlightInput;
UpdateHighlightReplyError: UpdateHighlightReplyError;
@@ -3170,6 +3217,7 @@ export type QueryResolvers>;
search?: Resolver>;
sharedArticle?: Resolver>;
+ subscriptions?: Resolver;
user?: Resolver>;
users?: Resolver;
validateUsername?: Resolver>;
@@ -3431,6 +3479,32 @@ export type SignupSuccessResolvers;
};
+export type SubscriptionResolvers = {
+ createdAt?: SubscriptionResolver;
+ description?: SubscriptionResolver, "description", ParentType, ContextType>;
+ id?: SubscriptionResolver;
+ name?: SubscriptionResolver;
+ status?: SubscriptionResolver;
+ unsubscribeHttpUrl?: SubscriptionResolver, "unsubscribeHttpUrl", ParentType, ContextType>;
+ unsubscribeMailTo?: SubscriptionResolver, "unsubscribeMailTo", ParentType, ContextType>;
+ updatedAt?: SubscriptionResolver;
+ url?: SubscriptionResolver, "url", ParentType, ContextType>;
+};
+
+export type SubscriptionsErrorResolvers = {
+ errorCodes?: Resolver, ParentType, ContextType>;
+ __isTypeOf?: IsTypeOfResolverFn;
+};
+
+export type SubscriptionsResultResolvers = {
+ __resolveType: TypeResolveFn<'SubscriptionsError' | 'SubscriptionsSuccess', ParentType, ContextType>;
+};
+
+export type SubscriptionsSuccessResolvers = {
+ subscriptions?: Resolver, ParentType, ContextType>;
+ __isTypeOf?: IsTypeOfResolverFn;
+};
+
export type UpdateHighlightErrorResolvers = {
errorCodes?: Resolver, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn;
@@ -3769,6 +3843,10 @@ export type Resolvers = {
SignupError?: SignupErrorResolvers;
SignupResult?: SignupResultResolvers;
SignupSuccess?: SignupSuccessResolvers;
+ Subscription?: SubscriptionResolvers;
+ SubscriptionsError?: SubscriptionsErrorResolvers;
+ SubscriptionsResult?: SubscriptionsResultResolvers;
+ SubscriptionsSuccess?: SubscriptionsSuccessResolvers;
UpdateHighlightError?: UpdateHighlightErrorResolvers;
UpdateHighlightReplyError?: UpdateHighlightReplyErrorResolvers;
UpdateHighlightReplyResult?: UpdateHighlightReplyResultResolvers;
diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql
index 2f77183e1..12e6d0fce 100644
--- a/packages/api/src/generated/schema.graphql
+++ b/packages/api/src/generated/schema.graphql
@@ -821,6 +821,7 @@ type Query {
reminder(linkId: ID!): ReminderResult!
search(after: String, first: Int, query: String): SearchResult!
sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult!
+ subscriptions: SubscriptionsResult!
user(userId: ID, username: String): UserResult!
users: UsersResult!
validateUsername(username: String!): Boolean!
@@ -1212,6 +1213,39 @@ input SortParams {
order: SortOrder
}
+type Subscription {
+ createdAt: Date!
+ description: String
+ id: ID!
+ name: String!
+ status: SubscriptionStatus!
+ unsubscribeHttpUrl: String
+ unsubscribeMailTo: String
+ updatedAt: Date!
+ url: String
+}
+
+type SubscriptionsError {
+ errorCodes: [SubscriptionsErrorCode!]!
+}
+
+enum SubscriptionsErrorCode {
+ BAD_REQUEST
+ UNAUTHORIZED
+}
+
+union SubscriptionsResult = SubscriptionsError | SubscriptionsSuccess
+
+type SubscriptionsSuccess {
+ subscriptions: [Subscription!]!
+}
+
+enum SubscriptionStatus {
+ ACTIVE
+ DELETED
+ UNSUBSCRIBED
+}
+
type UpdateHighlightError {
errorCodes: [UpdateHighlightErrorCode!]!
}
diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts
index 7a99e06c0..1897ea782 100644
--- a/packages/api/src/resolvers/function_resolvers.ts
+++ b/packages/api/src/resolvers/function_resolvers.ts
@@ -66,6 +66,7 @@ import {
setUserPersonalizationResolver,
signupResolver,
updateHighlightResolver,
+ updateLabelResolver,
updateLinkShareInfoResolver,
updateReminderResolver,
updateSharedCommentResolver,
@@ -73,7 +74,6 @@ import {
updateUserResolver,
uploadFileRequestResolver,
validateUsernameResolver,
- updateLabelResolver,
} from './index'
import { getShareInfoForArticle } from '../datalayer/links/share_info'
import {
@@ -82,6 +82,7 @@ import {
} from '../utils/uploads'
import { getPageByParam } from '../elastic/pages'
import { generateApiKeyResolver } from './api_key'
+import { subscriptionsResolver } from './subscriptions'
/* eslint-disable @typescript-eslint/naming-convention */
type ResultResolveType = {
@@ -160,6 +161,7 @@ export const functionResolvers = {
reminder: reminderResolver,
labels: labelsResolver,
search: searchResolver,
+ subscriptions: subscriptionsResolver,
},
User: {
async sharedArticles(
@@ -547,4 +549,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('SetLabels'),
...resultResolveTypeResolver('GenerateApiKey'),
...resultResolveTypeResolver('Search'),
+ ...resultResolveTypeResolver('Subscriptions'),
}
diff --git a/packages/api/src/resolvers/subscriptions/index.ts b/packages/api/src/resolvers/subscriptions/index.ts
new file mode 100644
index 000000000..a8153e48f
--- /dev/null
+++ b/packages/api/src/resolvers/subscriptions/index.ts
@@ -0,0 +1,54 @@
+import { authorized } from '../../utils/helpers'
+import {
+ SubscriptionsError,
+ SubscriptionsErrorCode,
+ SubscriptionsSuccess,
+ SubscriptionStatus,
+} from '../../generated/graphql'
+import { analytics } from '../../utils/analytics'
+import { env } from '../../env'
+import { getRepository } from '../../entity/utils'
+import { User } from '../../entity/user'
+
+export const subscriptionsResolver = authorized<
+ SubscriptionsSuccess,
+ SubscriptionsError
+>(async (_obj, _params, { claims: { uid }, log }) => {
+ log.info('subscriptionsResolver')
+
+ analytics.track({
+ userId: uid,
+ event: 'subscriptions',
+ properties: {
+ env: env.server.apiEnv,
+ },
+ })
+
+ try {
+ const user = await getRepository(User).findOne({
+ where: { id: uid, subscriptions: { status: SubscriptionStatus.Active } },
+ relations: {
+ subscriptions: true,
+ },
+ order: {
+ subscriptions: {
+ createdAt: 'DESC',
+ },
+ },
+ })
+ if (!user) {
+ return {
+ errorCodes: [SubscriptionsErrorCode.Unauthorized],
+ }
+ }
+
+ return {
+ subscriptions: user.subscriptions || [],
+ }
+ } catch (error) {
+ log.error(error)
+ return {
+ errorCodes: [SubscriptionsErrorCode.BadRequest],
+ }
+ }
+})
diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts
index 08def9c86..c8286b7a6 100755
--- a/packages/api/src/schema.ts
+++ b/packages/api/src/schema.ts
@@ -1444,6 +1444,39 @@ const schema = gql`
errorCodes: [SearchErrorCode!]!
}
+ union SubscriptionsResult = SubscriptionsSuccess | SubscriptionsError
+
+ type SubscriptionsSuccess {
+ subscriptions: [Subscription!]!
+ }
+
+ type Subscription {
+ id: ID!
+ name: String!
+ url: String
+ description: String
+ status: SubscriptionStatus!
+ unsubscribeMailTo: String
+ unsubscribeHttpUrl: String
+ createdAt: Date!
+ updatedAt: Date!
+ }
+
+ enum SubscriptionStatus {
+ ACTIVE
+ UNSUBSCRIBED
+ DELETED
+ }
+
+ type SubscriptionsError {
+ errorCodes: [SubscriptionsErrorCode!]!
+ }
+
+ enum SubscriptionsErrorCode {
+ UNAUTHORIZED
+ BAD_REQUEST
+ }
+
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@@ -1542,6 +1575,7 @@ const schema = gql`
reminder(linkId: ID!): ReminderResult!
labels: LabelsResult!
search(after: String, first: Int, query: String): SearchResult!
+ subscriptions: SubscriptionsResult!
}
`
diff --git a/packages/api/test/db.ts b/packages/api/test/db.ts
index d7b75c4c4..944535721 100644
--- a/packages/api/test/db.ts
+++ b/packages/api/test/db.ts
@@ -1,16 +1,18 @@
-import Postgrator from "postgrator";
-import { User } from "../src/entity/user";
-import { Profile } from "../src/entity/profile";
-import { Page } from "../src/entity/page";
-import { Link } from "../src/entity/link";
-import { Reminder } from "../src/entity/reminder";
-import { NewsletterEmail } from "../src/entity/newsletter_email";
-import { UserDeviceToken } from "../src/entity/user_device_tokens";
-import { Label } from "../src/entity/label";
-import { AppDataSource } from "../src/server";
-import { getRepository } from "../src/entity/utils";
-import { createUser } from "../src/services/create_user";
-import { SnakeNamingStrategy } from "typeorm-naming-strategies";
+import Postgrator from 'postgrator'
+import { User } from '../src/entity/user'
+import { Profile } from '../src/entity/profile'
+import { Page } from '../src/entity/page'
+import { Link } from '../src/entity/link'
+import { Reminder } from '../src/entity/reminder'
+import { NewsletterEmail } from '../src/entity/newsletter_email'
+import { UserDeviceToken } from '../src/entity/user_device_tokens'
+import { Label } from '../src/entity/label'
+import { Subscription } from '../src/entity/subscription'
+import { AppDataSource } from '../src/server'
+import { getRepository } from '../src/entity/utils'
+import { createUser } from '../src/services/create_user'
+import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
+import { SubscriptionStatus } from '../src/generated/graphql'
const runMigrations = async () => {
const migrationDirectory = __dirname + '/../../db/migrations'
@@ -187,3 +189,14 @@ export const createTestLabel = async (
color: color,
})
}
+
+export const createTestSubscription = async (
+ user: User,
+ name: string
+): Promise => {
+ return getRepository(Subscription).save({
+ user,
+ name,
+ status: SubscriptionStatus.Active,
+ })
+}
diff --git a/packages/api/test/resolvers/subscriptions.test.ts b/packages/api/test/resolvers/subscriptions.test.ts
new file mode 100644
index 000000000..a79fb0678
--- /dev/null
+++ b/packages/api/test/resolvers/subscriptions.test.ts
@@ -0,0 +1,81 @@
+import { createTestSubscription, createTestUser, deleteTestUser } from '../db'
+import { graphqlRequest, request } from '../util'
+import { Subscription } from '../../src/entity/subscription'
+import { expect } from 'chai'
+import 'mocha'
+import { User } from '../../src/entity/user'
+
+describe('Subscriptions API', () => {
+ const username = 'fakeUser'
+
+ let user: User
+ let authToken: string
+ let subscriptions: Subscription[]
+
+ before(async () => {
+ // create test user and login
+ user = await createTestUser(username)
+ const res = await request
+ .post('/local/debug/fake-user-login')
+ .send({ fakeEmail: user.email })
+
+ authToken = res.body.authToken
+
+ // create testing subscriptions
+ const sub1 = await createTestSubscription(user, 'sub_1')
+ const sub2 = await createTestSubscription(user, 'sub_2')
+ subscriptions = [sub2, sub1]
+ })
+
+ after(async () => {
+ // clean up
+ await deleteTestUser(username)
+ })
+
+ describe('GET subscriptions', () => {
+ let query: string
+
+ beforeEach(() => {
+ query = `
+ query {
+ subscriptions {
+ ... on SubscriptionsSuccess {
+ subscriptions {
+ id
+ name
+ }
+ }
+ ... on SubscriptionsError {
+ errorCodes
+ }
+ }
+ }
+ `
+ })
+
+ it('should return subscriptions', async () => {
+ const res = await graphqlRequest(query, authToken).expect(200)
+
+ expect(res.body.data.subscriptions.subscriptions).to.eql(
+ subscriptions.map((sub) => ({
+ id: sub.id,
+ name: sub.name,
+ }))
+ )
+ })
+
+ it('responds status code 400 when invalid query', async () => {
+ const invalidQuery = `
+ query {
+ subscriptions {}
+ }
+ `
+ return graphqlRequest(invalidQuery, authToken).expect(400)
+ })
+
+ it('responds status code 500 when invalid user', async () => {
+ const invalidAuthToken = 'Fake token'
+ return graphqlRequest(query, invalidAuthToken).expect(500)
+ })
+ })
+})
diff --git a/packages/db/migrations/0080.do.add_subscriptions_table.sql b/packages/db/migrations/0080.do.add_subscriptions_table.sql
new file mode 100755
index 000000000..faa3a7028
--- /dev/null
+++ b/packages/db/migrations/0080.do.add_subscriptions_table.sql
@@ -0,0 +1,27 @@
+-- Type: DO
+-- Name: add_subscriptions_table
+-- Description: Add subscriptions table
+
+BEGIN;
+
+CREATE TYPE subscription_status_type AS ENUM ('ACTIVE', 'UNSUBSCRIBED', 'DELETED');
+
+CREATE TABLE omnivore.subscriptions (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(),
+ user_id uuid NOT NULL REFERENCES omnivore.user (id) ON DELETE CASCADE,
+ name text NOT NULL,
+ description text,
+ url text,
+ status subscription_status_type NOT NULL,
+ unsubscribe_mail_to text,
+ unsubscribe_http_url text,
+ created_at timestamptz NOT NULL DEFAULT current_timestamp,
+ updated_at timestamptz NOT NULL DEFAULT current_timestamp
+);
+
+CREATE TRIGGER update_subscription_modtime BEFORE UPDATE ON omnivore.subscriptions
+ FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
+
+GRANT SELECT, INSERT, UPDATE ON omnivore.subscriptions TO omnivore_user;
+
+COMMIT;
diff --git a/packages/db/migrations/0080.undo.add_subscriptions_table.sql b/packages/db/migrations/0080.undo.add_subscriptions_table.sql
new file mode 100755
index 000000000..a434c103c
--- /dev/null
+++ b/packages/db/migrations/0080.undo.add_subscriptions_table.sql
@@ -0,0 +1,10 @@
+-- Type: UNDO
+-- Name: add_subscriptions_table
+-- Description: Add subscriptions table
+
+BEGIN;
+
+DROP TABLE omnivore.subscriptions;
+DROP TYPE subscription_status_type;
+
+COMMIT;
From d7a2659fc4ae83fd71944dc2d71e79bbe9c6e069 Mon Sep 17 00:00:00 2001
From: Hongbo Wu
Date: Tue, 19 Apr 2022 12:13:47 +0800
Subject: [PATCH 20/20] fix searching for multiple labels (#444)
---
packages/api/src/elastic/pages.ts | 22 +++++++++-------------
packages/api/src/elastic/types.ts | 8 ++------
2 files changed, 11 insertions(+), 19 deletions(-)
diff --git a/packages/api/src/elastic/pages.ts b/packages/api/src/elastic/pages.ts
index 658abffc7..119c3c1f4 100644
--- a/packages/api/src/elastic/pages.ts
+++ b/packages/api/src/elastic/pages.ts
@@ -128,21 +128,17 @@ const appendIncludeLabelFilter = (
body: SearchBody,
filters: LabelFilter[]
): void => {
- body.query.bool.filter.push({
- nested: {
- path: 'labels',
- query: {
- bool: {
- filter: filters.map((filter) => {
- return {
- terms: {
- 'labels.name': filter.labels,
- },
- }
- }),
+ filters.forEach((filter) => {
+ body.query.bool.filter.push({
+ nested: {
+ path: 'labels',
+ query: {
+ terms: {
+ 'labels.name': filter.labels,
+ },
},
},
- },
+ })
})
}
diff --git a/packages/api/src/elastic/types.ts b/packages/api/src/elastic/types.ts
index 9d9e5f74a..c550367a8 100644
--- a/packages/api/src/elastic/types.ts
+++ b/packages/api/src/elastic/types.ts
@@ -32,12 +32,8 @@ export interface SearchBody {
nested: {
path: 'labels'
query: {
- bool: {
- filter: {
- terms: {
- 'labels.name': string[]
- }
- }[]
+ terms: {
+ 'labels.name': string[]
}
}
}