Merge commit 'd7a2659fc4ae83fd71944dc2d71e79bbe9c6e069' into OMN-SB

This commit is contained in:
gitstart-omnivore 2022-04-19 09:36:23 +00:00
commit 5fad4002e3
32 changed files with 637 additions and 229 deletions

View file

@ -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: ""))
}
}
}
}

View file

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

View file

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

View file

@ -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<AnyCancellable>()
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 {

View file

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

View file

@ -20,8 +20,6 @@ public final class RootViewModel: ObservableObject {
@Published var snackbarMessage: String?
@Published var showSnackbar = false
public var subscriptions = Set<AnyCancellable>()
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() {

View file

@ -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, Objects.User> {
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<Viewer, BasicError> {
let selection = Selection<Viewer, Objects.User> {
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()
}
}

View file

@ -1,59 +0,0 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
import Utils
public extension DataService {
func viewerPublisher() -> AnyPublisher<Viewer, BasicError> {
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<Viewer, BasicError> {
let selection = Selection<Viewer, Objects.User> {
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()
}
}

View file

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

View file

@ -32,12 +32,8 @@ export interface SearchBody {
nested: {
path: 'labels'
query: {
bool: {
filter: {
terms: {
'labels.name': string[]
}
}[]
terms: {
'labels.name': string[]
}
}
}

View file

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

View file

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

View file

@ -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<SortOrder>;
};
export type Subscription = {
__typename?: 'Subscription';
createdAt: Scalars['Date'];
description?: Maybe<Scalars['String']>;
id: Scalars['ID'];
name: Scalars['String'];
status: SubscriptionStatus;
unsubscribeHttpUrl?: Maybe<Scalars['String']>;
unsubscribeMailTo?: Maybe<Scalars['String']>;
updatedAt: Scalars['Date'];
url?: Maybe<Scalars['String']>;
};
export type SubscriptionsError = {
__typename?: 'SubscriptionsError';
errorCodes: Array<SubscriptionsErrorCode>;
};
export enum SubscriptionsErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type SubscriptionsResult = SubscriptionsError | SubscriptionsSuccess;
export type SubscriptionsSuccess = {
__typename?: 'SubscriptionsSuccess';
subscriptions: Array<Subscription>;
};
export enum SubscriptionStatus {
Active = 'ACTIVE',
Deleted = 'DELETED',
Unsubscribed = 'UNSUBSCRIBED'
}
export type UpdateHighlightError = {
__typename?: 'UpdateHighlightError';
errorCodes: Array<UpdateHighlightErrorCode>;
@ -2206,6 +2243,12 @@ export type ResolversTypes = {
SortOrder: SortOrder;
SortParams: SortParams;
String: ResolverTypeWrapper<Scalars['String']>;
Subscription: ResolverTypeWrapper<{}>;
SubscriptionsError: ResolverTypeWrapper<SubscriptionsError>;
SubscriptionsErrorCode: SubscriptionsErrorCode;
SubscriptionsResult: ResolversTypes['SubscriptionsError'] | ResolversTypes['SubscriptionsSuccess'];
SubscriptionsSuccess: ResolverTypeWrapper<SubscriptionsSuccess>;
SubscriptionStatus: SubscriptionStatus;
UpdateHighlightError: ResolverTypeWrapper<UpdateHighlightError>;
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<ContextType = ResolverContext, ParentType extends Res
reminder?: Resolver<ResolversTypes['ReminderResult'], ParentType, ContextType, RequireFields<QueryReminderArgs, 'linkId'>>;
search?: Resolver<ResolversTypes['SearchResult'], ParentType, ContextType, Partial<QuerySearchArgs>>;
sharedArticle?: Resolver<ResolversTypes['SharedArticleResult'], ParentType, ContextType, RequireFields<QuerySharedArticleArgs, 'slug' | 'username'>>;
subscriptions?: Resolver<ResolversTypes['SubscriptionsResult'], ParentType, ContextType>;
user?: Resolver<ResolversTypes['UserResult'], ParentType, ContextType, Partial<QueryUserArgs>>;
users?: Resolver<ResolversTypes['UsersResult'], ParentType, ContextType>;
validateUsername?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<QueryValidateUsernameArgs, 'username'>>;
@ -3431,6 +3479,32 @@ export type SignupSuccessResolvers<ContextType = ResolverContext, ParentType ext
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SubscriptionResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Subscription'] = ResolversParentTypes['Subscription']> = {
createdAt?: SubscriptionResolver<ResolversTypes['Date'], "createdAt", ParentType, ContextType>;
description?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "description", ParentType, ContextType>;
id?: SubscriptionResolver<ResolversTypes['ID'], "id", ParentType, ContextType>;
name?: SubscriptionResolver<ResolversTypes['String'], "name", ParentType, ContextType>;
status?: SubscriptionResolver<ResolversTypes['SubscriptionStatus'], "status", ParentType, ContextType>;
unsubscribeHttpUrl?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "unsubscribeHttpUrl", ParentType, ContextType>;
unsubscribeMailTo?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "unsubscribeMailTo", ParentType, ContextType>;
updatedAt?: SubscriptionResolver<ResolversTypes['Date'], "updatedAt", ParentType, ContextType>;
url?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "url", ParentType, ContextType>;
};
export type SubscriptionsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscriptionsError'] = ResolversParentTypes['SubscriptionsError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SubscriptionsErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SubscriptionsResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscriptionsResult'] = ResolversParentTypes['SubscriptionsResult']> = {
__resolveType: TypeResolveFn<'SubscriptionsError' | 'SubscriptionsSuccess', ParentType, ContextType>;
};
export type SubscriptionsSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscriptionsSuccess'] = ResolversParentTypes['SubscriptionsSuccess']> = {
subscriptions?: Resolver<Array<ResolversTypes['Subscription']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateHighlightErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateHighlightError'] = ResolversParentTypes['UpdateHighlightError']> = {
errorCodes?: Resolver<Array<ResolversTypes['UpdateHighlightErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3769,6 +3843,10 @@ export type Resolvers<ContextType = ResolverContext> = {
SignupError?: SignupErrorResolvers<ContextType>;
SignupResult?: SignupResultResolvers<ContextType>;
SignupSuccess?: SignupSuccessResolvers<ContextType>;
Subscription?: SubscriptionResolvers<ContextType>;
SubscriptionsError?: SubscriptionsErrorResolvers<ContextType>;
SubscriptionsResult?: SubscriptionsResultResolvers<ContextType>;
SubscriptionsSuccess?: SubscriptionsSuccessResolvers<ContextType>;
UpdateHighlightError?: UpdateHighlightErrorResolvers<ContextType>;
UpdateHighlightReplyError?: UpdateHighlightReplyErrorResolvers<ContextType>;
UpdateHighlightReplyResult?: UpdateHighlightReplyResultResolvers<ContextType>;

View file

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

View file

@ -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'),
}

View file

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

View file

@ -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],
}
}
})

View file

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

View file

@ -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<Subscription> => {
return getRepository(Subscription).save({
user,
name,
status: SubscriptionStatus.Active,
})
}

View file

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

View file

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

View file

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

View file

@ -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 (
<Link href={`/home?q=label:"${props.text}"`}>
<Button style="plainIcon" onClick={(e) => {
router.push(`/home?q=label:"${props.text}"`)
e.stopPropagation()
}}>
<SpanBox
css={{
display: 'inline-table',
@ -27,7 +32,7 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
color: props.color,
fontSize: '12px',
fontWeight: 'bold',
padding: '4px 8px 4px 8px',
padding: '2px 5px 2px 5px',
whiteSpace: 'nowrap',
cursor: 'pointer',
backgroundClip: 'padding-box',
@ -37,6 +42,6 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
>
{props.text}
</SpanBox>
</Link>
</Button>
)
}

View file

@ -154,11 +154,11 @@ export function GridLinkedItemCard(props: LinkedItemCardProps): JSX.Element {
/>
)}
</HStack>
{/* <HStack css={{ mt: '8px' }}>
<Box css={{ display: 'block', mt: '8px' }}>
{props.item.labels?.map(({ name, color }, index) => (
<LabelChip key={index} text={name || ''} color={color} />
))}
</HStack> */}
</Box>
</VStack>
)
}

View file

@ -26,6 +26,7 @@ type HeaderProps = {
isFixedPosition: boolean
scrollElementRef?: React.RefObject<HTMLDivElement>
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 {
<HStack distribution="end" alignment="center" css={{
height: '100%', width: '100%',
mr: '16px',
display: 'none',
display: props.alwaysDisplayToolbar ? 'flex' : 'none',
'@lgDown': {
display: 'flex',
},

View file

@ -23,6 +23,7 @@ type PrimaryLayoutProps = {
pageMetaDataProps?: PageMetaDataProps
scrollElementRef?: MutableRefObject<HTMLDivElement | null>
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}
/>

View file

@ -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' ? <LineSeparator /> : <></>)
return (props.layout == 'side' ? <LineSeparator /> : <></>)
}
type ActionDropdownProps = {
@ -45,10 +44,10 @@ const ActionDropdown = (props: ActionDropdownProps): JSX.Element => {
return <Dropdown
showArrow={true}
css={{ m: '0px', p: '0px', overflow: 'hidden', width: '265px', maxWidth: '265px', '@smDown': { width: '230px' } }}
side={props.layout == 'vertical' ? 'right' : 'bottom'}
sideOffset={props.layout == 'vertical' ? 8 : 0}
align={props.layout == 'vertical' ? 'start' : 'center'}
alignOffset={props.layout == 'vertical' ? -18 : undefined}
side={props.layout == 'side' ? 'right' : 'bottom'}
sideOffset={props.layout == 'side' ? 8 : 0}
align={props.layout == 'side' ? 'start' : 'center'}
alignOffset={props.layout == 'side' ? -18 : undefined}
triggerElement={props.triggerElement}
>
{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',
}}
>
<ActionDropdown
layout={props.layout}
triggerElement={
<TooltipWrapped
tooltipContent="Adjust Display Settings"
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
{props.showReaderDisplaySettings && (
<>
<ActionDropdown
layout={props.layout}
triggerElement={
<TooltipWrapped
tooltipContent="Adjust Display Settings"
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<TextAa size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>
}
>
<TextAa size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>
}
>
<ReaderSettingsControl
lineHeight={props.lineHeight}
marginWidth={props.marginWidth}
articleActionHandler={props.articleActionHandler}
/>
</ActionDropdown>
<ReaderSettingsControl
lineHeight={props.lineHeight}
marginWidth={props.marginWidth}
articleActionHandler={props.articleActionHandler}
/>
</ActionDropdown>
<MenuSeparator layout={props.layout} />
<MenuSeparator layout={props.layout} />
</>
)}
<SpanBox css={{
'display': 'flex',
@ -100,7 +102,7 @@ export function ArticleActionsMenu(props: ArticleActionsMenuProps): JSX.Element
triggerElement={
<TooltipWrapped
tooltipContent="Edit labels"
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<TagSimple size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>
@ -127,7 +129,7 @@ export function ArticleActionsMenu(props: ArticleActionsMenuProps): JSX.Element
<Button style='articleActionIcon' onClick={() => props.articleActionHandler('showHighlights')}>
<TooltipWrapped
tooltipContent="View Highlights"
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<HighlighterCircle size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>
@ -138,7 +140,7 @@ export function ArticleActionsMenu(props: ArticleActionsMenuProps): JSX.Element
<Button style='articleActionIcon' onClick={() => props.articleActionHandler('archive')}>
<TooltipWrapped
tooltipContent="Archive"
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
tooltipSide={props.layout == 'side' ? 'right' : 'bottom'}
>
<ArchiveBox size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>

View file

@ -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) => {

View file

@ -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 {
<ModalHighlightView
key={highlight.id}
highlight={highlight}
deleteHighlightAction={() =>
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 {
/>
)}
</Button> */}
<Button style="ghost" onClick={() => setShowDeleteConfirmation(true)}>
<Trash width={18} height={18} color={theme.colors.grayText.toString()} />
</Button>
{props.showDelete && (
<Button style="ghost" onClick={() => setShowDeleteConfirmation(true)}>
<Trash width={18} height={18} color={theme.colors.grayText.toString()} />
</Button>
)}
</HStack>
)

View file

@ -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<React.SetStateAction<boolean>>
}
export default function PdfArticleContainer(
@ -348,6 +351,12 @@ export default function PdfArticleContainer(
}}
/>
)}
{props.showHighlightsModal && (
<HighlightsModal
highlights={props.article.highlights}
onOpenChange={() => props.setShowHighlightsModal(false)}
/>
)}
</Box>
)
}

View file

@ -164,12 +164,14 @@ export default function Home(): JSX.Element {
headerToolbarControl={
<ArticleActionsMenu
article={article}
layout='horizontal'
layout='top'
lineHeight={lineHeight}
marginWidth={marginWidth}
showReaderDisplaySettings={article.contentReader != 'PDF'}
articleActionHandler={actionHandler}
/>
}
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' ? (
<ArticleActionsMenu
article={article}
layout='vertical'
layout='side'
lineHeight={lineHeight}
marginWidth={marginWidth}
showReaderDisplaySettings={true}
articleActionHandler={actionHandler}
/>
) : null}
@ -209,6 +212,8 @@ export default function Home(): JSX.Element {
{article.contentReader == 'PDF' ? (
<PdfArticleContainerNoSSR
article={article}
showHighlightsModal={showHighlightsModal}
setShowHighlightsModal={setShowHighlightsModal}
viewerUsername={viewerData.me?.profile?.username}
/>
) : (

View file

@ -70,7 +70,7 @@ export default function Labels(): JSX.Element {
</p>
<p>Some examples:</p>
<ul>
<li><code>-label:Newsletter</code> finds all pages that do not have the label <code>Newsletter</code></li>
<li><code>-label:Newsletter</code> finds all pages that have the label <code>Newsletter</code></li>
<li><code>label:Cooking,Fitness</code> finds all your pages with either the <code>Cooking</code> or <code>Fitness</code> labels</li>
<li><code>label:Newsletter label:Surfing</code> finds all pages with both the <code>Newsletter</code> and <code>Surfing</code> labels</li>
<li><code>label:Coding -label:Newsletter</code> finds all pages with the <code>Coding</code> label that do not have the <code>Newsletter</code> label</li>