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 ) } 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[] } } }