mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge commit 'd7a2659fc4ae83fd71944dc2d71e79bbe9c6e069' into OMN-SB
This commit is contained in:
commit
5fad4002e3
32 changed files with 637 additions and 229 deletions
|
|
@ -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: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,12 +32,8 @@ export interface SearchBody {
|
|||
nested: {
|
||||
path: 'labels'
|
||||
query: {
|
||||
bool: {
|
||||
filter: {
|
||||
terms: {
|
||||
'labels.name': string[]
|
||||
}
|
||||
}[]
|
||||
terms: {
|
||||
'labels.name': string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
packages/api/src/entity/subscription.ts
Normal file
48
packages/api/src/entity/subscription.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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!]!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
54
packages/api/src/resolvers/subscriptions/index.ts
Normal file
54
packages/api/src/resolvers/subscriptions/index.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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!
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
81
packages/api/test/resolvers/subscriptions.test.ts
Normal file
81
packages/api/test/resolvers/subscriptions.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
27
packages/db/migrations/0080.do.add_subscriptions_table.sql
Executable file
27
packages/db/migrations/0080.do.add_subscriptions_table.sql
Executable 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;
|
||||
10
packages/db/migrations/0080.undo.add_subscriptions_table.sql
Executable file
10
packages/db/migrations/0080.undo.add_subscriptions_table.sql
Executable 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;
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue