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/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 4ee67a777..ed61a4341 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 @@ -43,7 +43,7 @@ final class HomeFeedViewModel: ObservableObject { // 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) + Task { await loadItems(dataService: dataService, isRefresh: false) } } } @@ -61,12 +61,9 @@ final class HomeFeedViewModel: ObservableObject { isLoading = true // Cache the viewer + if dataService.currentViewer == nil { - dataService.viewerPublisher().sink( - receiveCompletion: { _ in }, - receiveValue: { _ in } - ) - .store(in: &subscriptions) + Task { _ = 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 ) 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/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/ViewerFetcher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift new file mode 100644 index 000000000..509ff01a7 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift @@ -0,0 +1,85 @@ +import Combine +import Foundation +import Models +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")) + } + } + } + } +} + +extension DataService { + @available(*, deprecated, message: "use async version instead") + func internalViewerPublisher() -> AnyPublisher { + 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 Deferred { + Future { [weak self] promise in + send(query, to: path, headers: headers) { result in + switch result { + case let .success(payload): + self?.currentViewer = payload.data + promise(.success(payload.data)) + case .failure: + promise(.failure(.message(messageText: "http error"))) + } + } + } + } + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerPublisher.swift deleted file mode 100644 index 7b91e2c50..000000000 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerPublisher.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Combine -import Foundation -import Models -import SwiftGraphQL -import Utils - -public extension DataService { - 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 { - func internalViewerPublisher() -> AnyPublisher { - 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 Deferred { - Future { [weak self] promise in - send(query, to: path, headers: headers) { result in - switch result { - case let .success(payload): - self?.currentViewer = payload.data - promise(.success(payload.data)) - case .failure: - promise(.failure(.message(messageText: "http error"))) - } - } - } - } - .eraseToAnyPublisher() - } -} 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[] } } } 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/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) 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; diff --git a/packages/web/components/elements/LabelChip.tsx b/packages/web/components/elements/LabelChip.tsx index 86dd3c3ba..412915a00 100644 --- a/packages/web/components/elements/LabelChip.tsx +++ b/packages/web/components/elements/LabelChip.tsx @@ -1,6 +1,6 @@ -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 +8,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 +18,12 @@ export function LabelChip(props: LabelChipProps): JSX.Element { return [r, g, b] } const color = hexToRgb(props.color) + return ( - + ) } diff --git a/packages/web/components/patterns/LibraryCards/GridLinkedItemCard.tsx b/packages/web/components/patterns/LibraryCards/GridLinkedItemCard.tsx index 319bc6fdb..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) => ( ))} - */} + ) } 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.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..2e682da08 100644 --- a/packages/web/pages/[username]/[slug]/index.tsx +++ b/packages/web/pages/[username]/[slug]/index.tsx @@ -164,12 +164,14 @@ export default function Home(): JSX.Element { headerToolbarControl={ } + alwaysDisplayToolbar={article.contentReader == 'PDF'} pageMetaDataProps={{ title: article.title, path: router.pathname, @@ -190,7 +192,7 @@ export default function Home(): JSX.Element { top: '-120px', left: 8, height: '100%', - width: '48px', + width: '35px', '@lgDown': { display: 'none', }, @@ -199,9 +201,10 @@ export default function Home(): JSX.Element { {article.contentReader !== 'PDF' ? ( ) : null} @@ -209,6 +212,8 @@ export default function Home(): JSX.Element { {article.contentReader == 'PDF' ? ( ) : ( 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