Merge pull request #3659 from omnivore-app/discover-no-ts-changes

Discover no ts changes
This commit is contained in:
Jackson Harper 2024-03-20 10:02:19 +08:00 committed by GitHub
commit caceb72714
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 10384 additions and 16 deletions

View file

@ -15,6 +15,8 @@ services:
retries: 3
expose:
- 5432
ports:
- "5432:5432"
migrate:
build:

View file

@ -78,6 +78,7 @@
"image-size": "^1.0.2",
"intercom-client": "^3.1.4",
"ioredis": "^5.3.2",
"redis": "^4.6.13",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^2.0.3",
"langchain": "^0.1.21",
@ -167,4 +168,4 @@
"volta": {
"extends": "../../package.json"
}
}
}

View file

@ -17,6 +17,29 @@ export type Scalars = {
JSON: any;
};
export type AddDiscoverFeedError = {
__typename?: 'AddDiscoverFeedError';
errorCodes: Array<AddDiscoverFeedErrorCode>;
};
export enum AddDiscoverFeedErrorCode {
BadRequest = 'BAD_REQUEST',
Conflict = 'CONFLICT',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type AddDiscoverFeedInput = {
url: Scalars['String'];
};
export type AddDiscoverFeedResult = AddDiscoverFeedError | AddDiscoverFeedSuccess;
export type AddDiscoverFeedSuccess = {
__typename?: 'AddDiscoverFeedSuccess';
feed: DiscoverFeed;
};
export type AddPopularReadError = {
__typename?: 'AddPopularReadError';
errorCodes: Array<AddPopularReadErrorCode>;
@ -525,6 +548,51 @@ export type DeleteAccountSuccess = {
userID: Scalars['ID'];
};
export type DeleteDiscoverArticleError = {
__typename?: 'DeleteDiscoverArticleError';
errorCodes: Array<DeleteDiscoverArticleErrorCode>;
};
export enum DeleteDiscoverArticleErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type DeleteDiscoverArticleInput = {
discoverArticleId: Scalars['ID'];
};
export type DeleteDiscoverArticleResult = DeleteDiscoverArticleError | DeleteDiscoverArticleSuccess;
export type DeleteDiscoverArticleSuccess = {
__typename?: 'DeleteDiscoverArticleSuccess';
id: Scalars['ID'];
};
export type DeleteDiscoverFeedError = {
__typename?: 'DeleteDiscoverFeedError';
errorCodes: Array<DeleteDiscoverFeedErrorCode>;
};
export enum DeleteDiscoverFeedErrorCode {
BadRequest = 'BAD_REQUEST',
Conflict = 'CONFLICT',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type DeleteDiscoverFeedInput = {
feedId: Scalars['ID'];
};
export type DeleteDiscoverFeedResult = DeleteDiscoverFeedError | DeleteDiscoverFeedSuccess;
export type DeleteDiscoverFeedSuccess = {
__typename?: 'DeleteDiscoverFeedSuccess';
id: Scalars['String'];
};
export type DeleteFilterError = {
__typename?: 'DeleteFilterError';
errorCodes: Array<DeleteFilterErrorCode>;
@ -735,6 +803,79 @@ export enum DirectionalityType {
Rtl = 'RTL'
}
export type DiscoverFeed = {
__typename?: 'DiscoverFeed';
description?: Maybe<Scalars['String']>;
id: Scalars['ID'];
image?: Maybe<Scalars['String']>;
link: Scalars['String'];
title: Scalars['String'];
type: Scalars['String'];
visibleName?: Maybe<Scalars['String']>;
};
export type DiscoverFeedArticle = {
__typename?: 'DiscoverFeedArticle';
author?: Maybe<Scalars['String']>;
description: Scalars['String'];
feed: Scalars['String'];
id: Scalars['ID'];
image?: Maybe<Scalars['String']>;
publishedDate?: Maybe<Scalars['Date']>;
savedId?: Maybe<Scalars['String']>;
savedLinkUrl?: Maybe<Scalars['String']>;
siteName?: Maybe<Scalars['String']>;
slug: Scalars['String'];
title: Scalars['String'];
url: Scalars['String'];
};
export type DiscoverFeedError = {
__typename?: 'DiscoverFeedError';
errorCodes: Array<DiscoverFeedErrorCode>;
};
export enum DiscoverFeedErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type DiscoverFeedResult = DiscoverFeedError | DiscoverFeedSuccess;
export type DiscoverFeedSuccess = {
__typename?: 'DiscoverFeedSuccess';
feeds: Array<Maybe<DiscoverFeed>>;
};
export type DiscoverTopic = {
__typename?: 'DiscoverTopic';
description: Scalars['String'];
name: Scalars['String'];
};
export type EditDiscoverFeedError = {
__typename?: 'EditDiscoverFeedError';
errorCodes: Array<EditDiscoverFeedErrorCode>;
};
export enum EditDiscoverFeedErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type EditDiscoverFeedInput = {
feedId: Scalars['ID'];
name: Scalars['String'];
};
export type EditDiscoverFeedResult = EditDiscoverFeedError | EditDiscoverFeedSuccess;
export type EditDiscoverFeedSuccess = {
__typename?: 'EditDiscoverFeedSuccess';
id: Scalars['ID'];
};
export type EmptyTrashError = {
__typename?: 'EmptyTrashError';
errorCodes: Array<EmptyTrashErrorCode>;
@ -923,6 +1064,41 @@ export type GenerateApiKeySuccess = {
apiKey: ApiKey;
};
export type GetDiscoverFeedArticleError = {
__typename?: 'GetDiscoverFeedArticleError';
errorCodes: Array<GetDiscoverFeedArticleErrorCode>;
};
export enum GetDiscoverFeedArticleErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type GetDiscoverFeedArticleResults = GetDiscoverFeedArticleError | GetDiscoverFeedArticleSuccess;
export type GetDiscoverFeedArticleSuccess = {
__typename?: 'GetDiscoverFeedArticleSuccess';
discoverArticles?: Maybe<Array<Maybe<DiscoverFeedArticle>>>;
pageInfo: PageInfo;
};
export type GetDiscoverTopicError = {
__typename?: 'GetDiscoverTopicError';
errorCodes: Array<GetDiscoverTopicErrorCode>;
};
export enum GetDiscoverTopicErrorCode {
Unauthorized = 'UNAUTHORIZED'
}
export type GetDiscoverTopicResults = GetDiscoverTopicError | GetDiscoverTopicSuccess;
export type GetDiscoverTopicSuccess = {
__typename?: 'GetDiscoverTopicSuccess';
discoverTopics?: Maybe<Array<DiscoverTopic>>;
};
export type GetFollowersError = {
__typename?: 'GetFollowersError';
errorCodes: Array<GetFollowersErrorCode>;
@ -1369,6 +1545,7 @@ export type MoveToFolderSuccess = {
export type Mutation = {
__typename?: 'Mutation';
addDiscoverFeed: AddDiscoverFeedResult;
addPopularRead: AddPopularReadResult;
bulkAction: BulkActionResult;
createArticle: CreateArticleResult;
@ -1378,6 +1555,8 @@ export type Mutation = {
createLabel: CreateLabelResult;
createNewsletterEmail: CreateNewsletterEmailResult;
deleteAccount: DeleteAccountResult;
deleteDiscoverArticle: DeleteDiscoverArticleResult;
deleteDiscoverFeed: DeleteDiscoverFeedResult;
deleteFilter: DeleteFilterResult;
deleteHighlight: DeleteHighlightResult;
deleteIntegration: DeleteIntegrationResult;
@ -1385,6 +1564,7 @@ export type Mutation = {
deleteNewsletterEmail: DeleteNewsletterEmailResult;
deleteRule: DeleteRuleResult;
deleteWebhook: DeleteWebhookResult;
editDiscoverFeed: EditDiscoverFeedResult;
emptyTrash: EmptyTrashResult;
fetchContent: FetchContentResult;
generateApiKey: GenerateApiKeyResult;
@ -1405,6 +1585,7 @@ export type Mutation = {
reportItem: ReportItemResult;
revokeApiKey: RevokeApiKeyResult;
saveArticleReadingProgress: SaveArticleReadingProgressResult;
saveDiscoverArticle: SaveDiscoverArticleResult;
saveFile: SaveResult;
saveFilter: SaveFilterResult;
savePage: SaveResult;
@ -1435,6 +1616,11 @@ export type Mutation = {
};
export type MutationAddDiscoverFeedArgs = {
input: AddDiscoverFeedInput;
};
export type MutationAddPopularReadArgs = {
name: Scalars['String'];
};
@ -1485,6 +1671,16 @@ export type MutationDeleteAccountArgs = {
};
export type MutationDeleteDiscoverArticleArgs = {
input: DeleteDiscoverArticleInput;
};
export type MutationDeleteDiscoverFeedArgs = {
input: DeleteDiscoverFeedInput;
};
export type MutationDeleteFilterArgs = {
id: Scalars['ID'];
};
@ -1520,6 +1716,11 @@ export type MutationDeleteWebhookArgs = {
};
export type MutationEditDiscoverFeedArgs = {
input: EditDiscoverFeedInput;
};
export type MutationFetchContentArgs = {
id: Scalars['ID'];
};
@ -1611,6 +1812,11 @@ export type MutationSaveArticleReadingProgressArgs = {
};
export type MutationSaveDiscoverArticleArgs = {
input: SaveDiscoverArticleInput;
};
export type MutationSaveFileArgs = {
input: SaveFileInput;
};
@ -1882,8 +2088,11 @@ export type Query = {
article: ArticleResult;
articleSavingRequest: ArticleSavingRequestResult;
deviceTokens: DeviceTokensResult;
discoverFeeds: DiscoverFeedResult;
discoverTopics: GetDiscoverTopicResults;
feeds: FeedsResult;
filters: FiltersResult;
getDiscoverFeedArticles: GetDiscoverFeedArticleResults;
getUserPersonalization: GetUserPersonalizationResult;
groups: GroupsResult;
hello?: Maybe<Scalars['String']>;
@ -1926,6 +2135,14 @@ export type QueryFeedsArgs = {
};
export type QueryGetDiscoverFeedArticlesArgs = {
after?: InputMaybe<Scalars['String']>;
discoverTopicId: Scalars['String'];
feedId?: InputMaybe<Scalars['ID']>;
first?: InputMaybe<Scalars['Int']>;
};
export type QueryRulesArgs = {
enabled?: InputMaybe<Scalars['Boolean']>;
};
@ -2290,6 +2507,31 @@ export type SaveArticleReadingProgressSuccess = {
updatedArticle: Article;
};
export type SaveDiscoverArticleError = {
__typename?: 'SaveDiscoverArticleError';
errorCodes: Array<SaveDiscoverArticleErrorCode>;
};
export enum SaveDiscoverArticleErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type SaveDiscoverArticleInput = {
discoverArticleId: Scalars['ID'];
locale?: InputMaybe<Scalars['String']>;
timezone?: InputMaybe<Scalars['String']>;
};
export type SaveDiscoverArticleResult = SaveDiscoverArticleError | SaveDiscoverArticleSuccess;
export type SaveDiscoverArticleSuccess = {
__typename?: 'SaveDiscoverArticleSuccess';
saveId: Scalars['String'];
url: Scalars['String'];
};
export type SaveError = {
__typename?: 'SaveError';
errorCodes: Array<SaveErrorCode>;
@ -3585,6 +3827,11 @@ export type DirectiveResolverFn<TResult = {}, TParent = {}, TContext = {}, TArgs
/** Mapping between all available schema types and the resolvers types */
export type ResolversTypes = {
AddDiscoverFeedError: ResolverTypeWrapper<AddDiscoverFeedError>;
AddDiscoverFeedErrorCode: AddDiscoverFeedErrorCode;
AddDiscoverFeedInput: AddDiscoverFeedInput;
AddDiscoverFeedResult: ResolversTypes['AddDiscoverFeedError'] | ResolversTypes['AddDiscoverFeedSuccess'];
AddDiscoverFeedSuccess: ResolverTypeWrapper<AddDiscoverFeedSuccess>;
AddPopularReadError: ResolverTypeWrapper<AddPopularReadError>;
AddPopularReadErrorCode: AddPopularReadErrorCode;
AddPopularReadResult: ResolversTypes['AddPopularReadError'] | ResolversTypes['AddPopularReadSuccess'];
@ -3673,6 +3920,16 @@ export type ResolversTypes = {
DeleteAccountErrorCode: DeleteAccountErrorCode;
DeleteAccountResult: ResolversTypes['DeleteAccountError'] | ResolversTypes['DeleteAccountSuccess'];
DeleteAccountSuccess: ResolverTypeWrapper<DeleteAccountSuccess>;
DeleteDiscoverArticleError: ResolverTypeWrapper<DeleteDiscoverArticleError>;
DeleteDiscoverArticleErrorCode: DeleteDiscoverArticleErrorCode;
DeleteDiscoverArticleInput: DeleteDiscoverArticleInput;
DeleteDiscoverArticleResult: ResolversTypes['DeleteDiscoverArticleError'] | ResolversTypes['DeleteDiscoverArticleSuccess'];
DeleteDiscoverArticleSuccess: ResolverTypeWrapper<DeleteDiscoverArticleSuccess>;
DeleteDiscoverFeedError: ResolverTypeWrapper<DeleteDiscoverFeedError>;
DeleteDiscoverFeedErrorCode: DeleteDiscoverFeedErrorCode;
DeleteDiscoverFeedInput: DeleteDiscoverFeedInput;
DeleteDiscoverFeedResult: ResolversTypes['DeleteDiscoverFeedError'] | ResolversTypes['DeleteDiscoverFeedSuccess'];
DeleteDiscoverFeedSuccess: ResolverTypeWrapper<DeleteDiscoverFeedSuccess>;
DeleteFilterError: ResolverTypeWrapper<DeleteFilterError>;
DeleteFilterErrorCode: DeleteFilterErrorCode;
DeleteFilterResult: ResolversTypes['DeleteFilterError'] | ResolversTypes['DeleteFilterSuccess'];
@ -3719,6 +3976,18 @@ export type ResolversTypes = {
DeviceTokensResult: ResolversTypes['DeviceTokensError'] | ResolversTypes['DeviceTokensSuccess'];
DeviceTokensSuccess: ResolverTypeWrapper<DeviceTokensSuccess>;
DirectionalityType: DirectionalityType;
DiscoverFeed: ResolverTypeWrapper<DiscoverFeed>;
DiscoverFeedArticle: ResolverTypeWrapper<DiscoverFeedArticle>;
DiscoverFeedError: ResolverTypeWrapper<DiscoverFeedError>;
DiscoverFeedErrorCode: DiscoverFeedErrorCode;
DiscoverFeedResult: ResolversTypes['DiscoverFeedError'] | ResolversTypes['DiscoverFeedSuccess'];
DiscoverFeedSuccess: ResolverTypeWrapper<DiscoverFeedSuccess>;
DiscoverTopic: ResolverTypeWrapper<DiscoverTopic>;
EditDiscoverFeedError: ResolverTypeWrapper<EditDiscoverFeedError>;
EditDiscoverFeedErrorCode: EditDiscoverFeedErrorCode;
EditDiscoverFeedInput: EditDiscoverFeedInput;
EditDiscoverFeedResult: ResolversTypes['EditDiscoverFeedError'] | ResolversTypes['EditDiscoverFeedSuccess'];
EditDiscoverFeedSuccess: ResolverTypeWrapper<EditDiscoverFeedSuccess>;
EmptyTrashError: ResolverTypeWrapper<EmptyTrashError>;
EmptyTrashErrorCode: EmptyTrashErrorCode;
EmptyTrashResult: ResolversTypes['EmptyTrashError'] | ResolversTypes['EmptyTrashSuccess'];
@ -3753,6 +4022,14 @@ export type ResolversTypes = {
GenerateApiKeyInput: GenerateApiKeyInput;
GenerateApiKeyResult: ResolversTypes['GenerateApiKeyError'] | ResolversTypes['GenerateApiKeySuccess'];
GenerateApiKeySuccess: ResolverTypeWrapper<GenerateApiKeySuccess>;
GetDiscoverFeedArticleError: ResolverTypeWrapper<GetDiscoverFeedArticleError>;
GetDiscoverFeedArticleErrorCode: GetDiscoverFeedArticleErrorCode;
GetDiscoverFeedArticleResults: ResolversTypes['GetDiscoverFeedArticleError'] | ResolversTypes['GetDiscoverFeedArticleSuccess'];
GetDiscoverFeedArticleSuccess: ResolverTypeWrapper<GetDiscoverFeedArticleSuccess>;
GetDiscoverTopicError: ResolverTypeWrapper<GetDiscoverTopicError>;
GetDiscoverTopicErrorCode: GetDiscoverTopicErrorCode;
GetDiscoverTopicResults: ResolversTypes['GetDiscoverTopicError'] | ResolversTypes['GetDiscoverTopicSuccess'];
GetDiscoverTopicSuccess: ResolverTypeWrapper<GetDiscoverTopicSuccess>;
GetFollowersError: ResolverTypeWrapper<GetFollowersError>;
GetFollowersErrorCode: GetFollowersErrorCode;
GetFollowersResult: ResolversTypes['GetFollowersError'] | ResolversTypes['GetFollowersSuccess'];
@ -3910,6 +4187,11 @@ export type ResolversTypes = {
SaveArticleReadingProgressInput: SaveArticleReadingProgressInput;
SaveArticleReadingProgressResult: ResolversTypes['SaveArticleReadingProgressError'] | ResolversTypes['SaveArticleReadingProgressSuccess'];
SaveArticleReadingProgressSuccess: ResolverTypeWrapper<SaveArticleReadingProgressSuccess>;
SaveDiscoverArticleError: ResolverTypeWrapper<SaveDiscoverArticleError>;
SaveDiscoverArticleErrorCode: SaveDiscoverArticleErrorCode;
SaveDiscoverArticleInput: SaveDiscoverArticleInput;
SaveDiscoverArticleResult: ResolversTypes['SaveDiscoverArticleError'] | ResolversTypes['SaveDiscoverArticleSuccess'];
SaveDiscoverArticleSuccess: ResolverTypeWrapper<SaveDiscoverArticleSuccess>;
SaveError: ResolverTypeWrapper<SaveError>;
SaveErrorCode: SaveErrorCode;
SaveFileInput: SaveFileInput;
@ -4129,6 +4411,10 @@ export type ResolversTypes = {
/** Mapping between all available schema types and the resolvers parents */
export type ResolversParentTypes = {
AddDiscoverFeedError: AddDiscoverFeedError;
AddDiscoverFeedInput: AddDiscoverFeedInput;
AddDiscoverFeedResult: ResolversParentTypes['AddDiscoverFeedError'] | ResolversParentTypes['AddDiscoverFeedSuccess'];
AddDiscoverFeedSuccess: AddDiscoverFeedSuccess;
AddPopularReadError: AddPopularReadError;
AddPopularReadResult: ResolversParentTypes['AddPopularReadError'] | ResolversParentTypes['AddPopularReadSuccess'];
AddPopularReadSuccess: AddPopularReadSuccess;
@ -4197,6 +4483,14 @@ export type ResolversParentTypes = {
DeleteAccountError: DeleteAccountError;
DeleteAccountResult: ResolversParentTypes['DeleteAccountError'] | ResolversParentTypes['DeleteAccountSuccess'];
DeleteAccountSuccess: DeleteAccountSuccess;
DeleteDiscoverArticleError: DeleteDiscoverArticleError;
DeleteDiscoverArticleInput: DeleteDiscoverArticleInput;
DeleteDiscoverArticleResult: ResolversParentTypes['DeleteDiscoverArticleError'] | ResolversParentTypes['DeleteDiscoverArticleSuccess'];
DeleteDiscoverArticleSuccess: DeleteDiscoverArticleSuccess;
DeleteDiscoverFeedError: DeleteDiscoverFeedError;
DeleteDiscoverFeedInput: DeleteDiscoverFeedInput;
DeleteDiscoverFeedResult: ResolversParentTypes['DeleteDiscoverFeedError'] | ResolversParentTypes['DeleteDiscoverFeedSuccess'];
DeleteDiscoverFeedSuccess: DeleteDiscoverFeedSuccess;
DeleteFilterError: DeleteFilterError;
DeleteFilterResult: ResolversParentTypes['DeleteFilterError'] | ResolversParentTypes['DeleteFilterSuccess'];
DeleteFilterSuccess: DeleteFilterSuccess;
@ -4231,6 +4525,16 @@ export type ResolversParentTypes = {
DeviceTokensError: DeviceTokensError;
DeviceTokensResult: ResolversParentTypes['DeviceTokensError'] | ResolversParentTypes['DeviceTokensSuccess'];
DeviceTokensSuccess: DeviceTokensSuccess;
DiscoverFeed: DiscoverFeed;
DiscoverFeedArticle: DiscoverFeedArticle;
DiscoverFeedError: DiscoverFeedError;
DiscoverFeedResult: ResolversParentTypes['DiscoverFeedError'] | ResolversParentTypes['DiscoverFeedSuccess'];
DiscoverFeedSuccess: DiscoverFeedSuccess;
DiscoverTopic: DiscoverTopic;
EditDiscoverFeedError: EditDiscoverFeedError;
EditDiscoverFeedInput: EditDiscoverFeedInput;
EditDiscoverFeedResult: ResolversParentTypes['EditDiscoverFeedError'] | ResolversParentTypes['EditDiscoverFeedSuccess'];
EditDiscoverFeedSuccess: EditDiscoverFeedSuccess;
EmptyTrashError: EmptyTrashError;
EmptyTrashResult: ResolversParentTypes['EmptyTrashError'] | ResolversParentTypes['EmptyTrashSuccess'];
EmptyTrashSuccess: EmptyTrashSuccess;
@ -4258,6 +4562,12 @@ export type ResolversParentTypes = {
GenerateApiKeyInput: GenerateApiKeyInput;
GenerateApiKeyResult: ResolversParentTypes['GenerateApiKeyError'] | ResolversParentTypes['GenerateApiKeySuccess'];
GenerateApiKeySuccess: GenerateApiKeySuccess;
GetDiscoverFeedArticleError: GetDiscoverFeedArticleError;
GetDiscoverFeedArticleResults: ResolversParentTypes['GetDiscoverFeedArticleError'] | ResolversParentTypes['GetDiscoverFeedArticleSuccess'];
GetDiscoverFeedArticleSuccess: GetDiscoverFeedArticleSuccess;
GetDiscoverTopicError: GetDiscoverTopicError;
GetDiscoverTopicResults: ResolversParentTypes['GetDiscoverTopicError'] | ResolversParentTypes['GetDiscoverTopicSuccess'];
GetDiscoverTopicSuccess: GetDiscoverTopicSuccess;
GetFollowersError: GetFollowersError;
GetFollowersResult: ResolversParentTypes['GetFollowersError'] | ResolversParentTypes['GetFollowersSuccess'];
GetFollowersSuccess: GetFollowersSuccess;
@ -4380,6 +4690,10 @@ export type ResolversParentTypes = {
SaveArticleReadingProgressInput: SaveArticleReadingProgressInput;
SaveArticleReadingProgressResult: ResolversParentTypes['SaveArticleReadingProgressError'] | ResolversParentTypes['SaveArticleReadingProgressSuccess'];
SaveArticleReadingProgressSuccess: SaveArticleReadingProgressSuccess;
SaveDiscoverArticleError: SaveDiscoverArticleError;
SaveDiscoverArticleInput: SaveDiscoverArticleInput;
SaveDiscoverArticleResult: ResolversParentTypes['SaveDiscoverArticleError'] | ResolversParentTypes['SaveDiscoverArticleSuccess'];
SaveDiscoverArticleSuccess: SaveDiscoverArticleSuccess;
SaveError: SaveError;
SaveFileInput: SaveFileInput;
SaveFilterError: SaveFilterError;
@ -4556,6 +4870,20 @@ export type SanitizeDirectiveArgs = {
export type SanitizeDirectiveResolver<Result, Parent, ContextType = ResolverContext, Args = SanitizeDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;
export type AddDiscoverFeedErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['AddDiscoverFeedError'] = ResolversParentTypes['AddDiscoverFeedError']> = {
errorCodes?: Resolver<Array<ResolversTypes['AddDiscoverFeedErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AddDiscoverFeedResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['AddDiscoverFeedResult'] = ResolversParentTypes['AddDiscoverFeedResult']> = {
__resolveType: TypeResolveFn<'AddDiscoverFeedError' | 'AddDiscoverFeedSuccess', ParentType, ContextType>;
};
export type AddDiscoverFeedSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['AddDiscoverFeedSuccess'] = ResolversParentTypes['AddDiscoverFeedSuccess']> = {
feed?: Resolver<ResolversTypes['DiscoverFeed'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AddPopularReadErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['AddPopularReadError'] = ResolversParentTypes['AddPopularReadError']> = {
errorCodes?: Resolver<Array<ResolversTypes['AddPopularReadErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4881,6 +5209,34 @@ export type DeleteAccountSuccessResolvers<ContextType = ResolverContext, ParentT
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteDiscoverArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverArticleError'] = ResolversParentTypes['DeleteDiscoverArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DeleteDiscoverArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteDiscoverArticleResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverArticleResult'] = ResolversParentTypes['DeleteDiscoverArticleResult']> = {
__resolveType: TypeResolveFn<'DeleteDiscoverArticleError' | 'DeleteDiscoverArticleSuccess', ParentType, ContextType>;
};
export type DeleteDiscoverArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverArticleSuccess'] = ResolversParentTypes['DeleteDiscoverArticleSuccess']> = {
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteDiscoverFeedErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverFeedError'] = ResolversParentTypes['DeleteDiscoverFeedError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DeleteDiscoverFeedErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteDiscoverFeedResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverFeedResult'] = ResolversParentTypes['DeleteDiscoverFeedResult']> = {
__resolveType: TypeResolveFn<'DeleteDiscoverFeedError' | 'DeleteDiscoverFeedSuccess', ParentType, ContextType>;
};
export type DeleteDiscoverFeedSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverFeedSuccess'] = ResolversParentTypes['DeleteDiscoverFeedSuccess']> = {
id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteFilterErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteFilterError'] = ResolversParentTypes['DeleteFilterError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DeleteFilterErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -5042,6 +5398,67 @@ export type DeviceTokensSuccessResolvers<ContextType = ResolverContext, ParentTy
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DiscoverFeedResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverFeed'] = ResolversParentTypes['DiscoverFeed']> = {
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
image?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
link?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
type?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
visibleName?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DiscoverFeedArticleResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverFeedArticle'] = ResolversParentTypes['DiscoverFeedArticle']> = {
author?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
description?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
feed?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
image?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
publishedDate?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
savedId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
savedLinkUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
siteName?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DiscoverFeedErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverFeedError'] = ResolversParentTypes['DiscoverFeedError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DiscoverFeedErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DiscoverFeedResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverFeedResult'] = ResolversParentTypes['DiscoverFeedResult']> = {
__resolveType: TypeResolveFn<'DiscoverFeedError' | 'DiscoverFeedSuccess', ParentType, ContextType>;
};
export type DiscoverFeedSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverFeedSuccess'] = ResolversParentTypes['DiscoverFeedSuccess']> = {
feeds?: Resolver<Array<Maybe<ResolversTypes['DiscoverFeed']>>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DiscoverTopicResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverTopic'] = ResolversParentTypes['DiscoverTopic']> = {
description?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EditDiscoverFeedErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EditDiscoverFeedError'] = ResolversParentTypes['EditDiscoverFeedError']> = {
errorCodes?: Resolver<Array<ResolversTypes['EditDiscoverFeedErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EditDiscoverFeedResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EditDiscoverFeedResult'] = ResolversParentTypes['EditDiscoverFeedResult']> = {
__resolveType: TypeResolveFn<'EditDiscoverFeedError' | 'EditDiscoverFeedSuccess', ParentType, ContextType>;
};
export type EditDiscoverFeedSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EditDiscoverFeedSuccess'] = ResolversParentTypes['EditDiscoverFeedSuccess']> = {
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EmptyTrashErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EmptyTrashError'] = ResolversParentTypes['EmptyTrashError']> = {
errorCodes?: Resolver<Array<ResolversTypes['EmptyTrashErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -5194,6 +5611,35 @@ export type GenerateApiKeySuccessResolvers<ContextType = ResolverContext, Parent
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetDiscoverFeedArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverFeedArticleError'] = ResolversParentTypes['GetDiscoverFeedArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['GetDiscoverFeedArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetDiscoverFeedArticleResultsResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverFeedArticleResults'] = ResolversParentTypes['GetDiscoverFeedArticleResults']> = {
__resolveType: TypeResolveFn<'GetDiscoverFeedArticleError' | 'GetDiscoverFeedArticleSuccess', ParentType, ContextType>;
};
export type GetDiscoverFeedArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverFeedArticleSuccess'] = ResolversParentTypes['GetDiscoverFeedArticleSuccess']> = {
discoverArticles?: Resolver<Maybe<Array<Maybe<ResolversTypes['DiscoverFeedArticle']>>>, ParentType, ContextType>;
pageInfo?: Resolver<ResolversTypes['PageInfo'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetDiscoverTopicErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverTopicError'] = ResolversParentTypes['GetDiscoverTopicError']> = {
errorCodes?: Resolver<Array<ResolversTypes['GetDiscoverTopicErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetDiscoverTopicResultsResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverTopicResults'] = ResolversParentTypes['GetDiscoverTopicResults']> = {
__resolveType: TypeResolveFn<'GetDiscoverTopicError' | 'GetDiscoverTopicSuccess', ParentType, ContextType>;
};
export type GetDiscoverTopicSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverTopicSuccess'] = ResolversParentTypes['GetDiscoverTopicSuccess']> = {
discoverTopics?: Resolver<Maybe<Array<ResolversTypes['DiscoverTopic']>>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetFollowersErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetFollowersError'] = ResolversParentTypes['GetFollowersError']> = {
errorCodes?: Resolver<Array<ResolversTypes['GetFollowersErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -5527,6 +5973,7 @@ export type MoveToFolderSuccessResolvers<ContextType = ResolverContext, ParentTy
};
export type MutationResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation']> = {
addDiscoverFeed?: Resolver<ResolversTypes['AddDiscoverFeedResult'], ParentType, ContextType, RequireFields<MutationAddDiscoverFeedArgs, 'input'>>;
addPopularRead?: Resolver<ResolversTypes['AddPopularReadResult'], ParentType, ContextType, RequireFields<MutationAddPopularReadArgs, 'name'>>;
bulkAction?: Resolver<ResolversTypes['BulkActionResult'], ParentType, ContextType, RequireFields<MutationBulkActionArgs, 'action' | 'query'>>;
createArticle?: Resolver<ResolversTypes['CreateArticleResult'], ParentType, ContextType, RequireFields<MutationCreateArticleArgs, 'input'>>;
@ -5536,6 +5983,8 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
createLabel?: Resolver<ResolversTypes['CreateLabelResult'], ParentType, ContextType, RequireFields<MutationCreateLabelArgs, 'input'>>;
createNewsletterEmail?: Resolver<ResolversTypes['CreateNewsletterEmailResult'], ParentType, ContextType, Partial<MutationCreateNewsletterEmailArgs>>;
deleteAccount?: Resolver<ResolversTypes['DeleteAccountResult'], ParentType, ContextType, RequireFields<MutationDeleteAccountArgs, 'userID'>>;
deleteDiscoverArticle?: Resolver<ResolversTypes['DeleteDiscoverArticleResult'], ParentType, ContextType, RequireFields<MutationDeleteDiscoverArticleArgs, 'input'>>;
deleteDiscoverFeed?: Resolver<ResolversTypes['DeleteDiscoverFeedResult'], ParentType, ContextType, RequireFields<MutationDeleteDiscoverFeedArgs, 'input'>>;
deleteFilter?: Resolver<ResolversTypes['DeleteFilterResult'], ParentType, ContextType, RequireFields<MutationDeleteFilterArgs, 'id'>>;
deleteHighlight?: Resolver<ResolversTypes['DeleteHighlightResult'], ParentType, ContextType, RequireFields<MutationDeleteHighlightArgs, 'highlightId'>>;
deleteIntegration?: Resolver<ResolversTypes['DeleteIntegrationResult'], ParentType, ContextType, RequireFields<MutationDeleteIntegrationArgs, 'id'>>;
@ -5543,6 +5992,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
deleteNewsletterEmail?: Resolver<ResolversTypes['DeleteNewsletterEmailResult'], ParentType, ContextType, RequireFields<MutationDeleteNewsletterEmailArgs, 'newsletterEmailId'>>;
deleteRule?: Resolver<ResolversTypes['DeleteRuleResult'], ParentType, ContextType, RequireFields<MutationDeleteRuleArgs, 'id'>>;
deleteWebhook?: Resolver<ResolversTypes['DeleteWebhookResult'], ParentType, ContextType, RequireFields<MutationDeleteWebhookArgs, 'id'>>;
editDiscoverFeed?: Resolver<ResolversTypes['EditDiscoverFeedResult'], ParentType, ContextType, RequireFields<MutationEditDiscoverFeedArgs, 'input'>>;
emptyTrash?: Resolver<ResolversTypes['EmptyTrashResult'], ParentType, ContextType>;
fetchContent?: Resolver<ResolversTypes['FetchContentResult'], ParentType, ContextType, RequireFields<MutationFetchContentArgs, 'id'>>;
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, RequireFields<MutationGenerateApiKeyArgs, 'input'>>;
@ -5563,6 +6013,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
reportItem?: Resolver<ResolversTypes['ReportItemResult'], ParentType, ContextType, RequireFields<MutationReportItemArgs, 'input'>>;
revokeApiKey?: Resolver<ResolversTypes['RevokeApiKeyResult'], ParentType, ContextType, RequireFields<MutationRevokeApiKeyArgs, 'id'>>;
saveArticleReadingProgress?: Resolver<ResolversTypes['SaveArticleReadingProgressResult'], ParentType, ContextType, RequireFields<MutationSaveArticleReadingProgressArgs, 'input'>>;
saveDiscoverArticle?: Resolver<ResolversTypes['SaveDiscoverArticleResult'], ParentType, ContextType, RequireFields<MutationSaveDiscoverArticleArgs, 'input'>>;
saveFile?: Resolver<ResolversTypes['SaveResult'], ParentType, ContextType, RequireFields<MutationSaveFileArgs, 'input'>>;
saveFilter?: Resolver<ResolversTypes['SaveFilterResult'], ParentType, ContextType, RequireFields<MutationSaveFilterArgs, 'input'>>;
savePage?: Resolver<ResolversTypes['SaveResult'], ParentType, ContextType, RequireFields<MutationSavePageArgs, 'input'>>;
@ -5673,8 +6124,11 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
article?: Resolver<ResolversTypes['ArticleResult'], ParentType, ContextType, RequireFields<QueryArticleArgs, 'slug' | 'username'>>;
articleSavingRequest?: Resolver<ResolversTypes['ArticleSavingRequestResult'], ParentType, ContextType, Partial<QueryArticleSavingRequestArgs>>;
deviceTokens?: Resolver<ResolversTypes['DeviceTokensResult'], ParentType, ContextType>;
discoverFeeds?: Resolver<ResolversTypes['DiscoverFeedResult'], ParentType, ContextType>;
discoverTopics?: Resolver<ResolversTypes['GetDiscoverTopicResults'], ParentType, ContextType>;
feeds?: Resolver<ResolversTypes['FeedsResult'], ParentType, ContextType, RequireFields<QueryFeedsArgs, 'input'>>;
filters?: Resolver<ResolversTypes['FiltersResult'], ParentType, ContextType>;
getDiscoverFeedArticles?: Resolver<ResolversTypes['GetDiscoverFeedArticleResults'], ParentType, ContextType, RequireFields<QueryGetDiscoverFeedArticlesArgs, 'discoverTopicId'>>;
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -5909,6 +6363,21 @@ export type SaveArticleReadingProgressSuccessResolvers<ContextType = ResolverCon
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SaveDiscoverArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SaveDiscoverArticleError'] = ResolversParentTypes['SaveDiscoverArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SaveDiscoverArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SaveDiscoverArticleResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SaveDiscoverArticleResult'] = ResolversParentTypes['SaveDiscoverArticleResult']> = {
__resolveType: TypeResolveFn<'SaveDiscoverArticleError' | 'SaveDiscoverArticleSuccess', ParentType, ContextType>;
};
export type SaveDiscoverArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SaveDiscoverArticleSuccess'] = ResolversParentTypes['SaveDiscoverArticleSuccess']> = {
saveId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SaveErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SaveError'] = ResolversParentTypes['SaveError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SaveErrorCode']>, ParentType, ContextType>;
message?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -6648,6 +7117,9 @@ export type WebhooksSuccessResolvers<ContextType = ResolverContext, ParentType e
};
export type Resolvers<ContextType = ResolverContext> = {
AddDiscoverFeedError?: AddDiscoverFeedErrorResolvers<ContextType>;
AddDiscoverFeedResult?: AddDiscoverFeedResultResolvers<ContextType>;
AddDiscoverFeedSuccess?: AddDiscoverFeedSuccessResolvers<ContextType>;
AddPopularReadError?: AddPopularReadErrorResolvers<ContextType>;
AddPopularReadResult?: AddPopularReadResultResolvers<ContextType>;
AddPopularReadSuccess?: AddPopularReadSuccessResolvers<ContextType>;
@ -6704,6 +7176,12 @@ export type Resolvers<ContextType = ResolverContext> = {
DeleteAccountError?: DeleteAccountErrorResolvers<ContextType>;
DeleteAccountResult?: DeleteAccountResultResolvers<ContextType>;
DeleteAccountSuccess?: DeleteAccountSuccessResolvers<ContextType>;
DeleteDiscoverArticleError?: DeleteDiscoverArticleErrorResolvers<ContextType>;
DeleteDiscoverArticleResult?: DeleteDiscoverArticleResultResolvers<ContextType>;
DeleteDiscoverArticleSuccess?: DeleteDiscoverArticleSuccessResolvers<ContextType>;
DeleteDiscoverFeedError?: DeleteDiscoverFeedErrorResolvers<ContextType>;
DeleteDiscoverFeedResult?: DeleteDiscoverFeedResultResolvers<ContextType>;
DeleteDiscoverFeedSuccess?: DeleteDiscoverFeedSuccessResolvers<ContextType>;
DeleteFilterError?: DeleteFilterErrorResolvers<ContextType>;
DeleteFilterResult?: DeleteFilterResultResolvers<ContextType>;
DeleteFilterSuccess?: DeleteFilterSuccessResolvers<ContextType>;
@ -6738,6 +7216,15 @@ export type Resolvers<ContextType = ResolverContext> = {
DeviceTokensError?: DeviceTokensErrorResolvers<ContextType>;
DeviceTokensResult?: DeviceTokensResultResolvers<ContextType>;
DeviceTokensSuccess?: DeviceTokensSuccessResolvers<ContextType>;
DiscoverFeed?: DiscoverFeedResolvers<ContextType>;
DiscoverFeedArticle?: DiscoverFeedArticleResolvers<ContextType>;
DiscoverFeedError?: DiscoverFeedErrorResolvers<ContextType>;
DiscoverFeedResult?: DiscoverFeedResultResolvers<ContextType>;
DiscoverFeedSuccess?: DiscoverFeedSuccessResolvers<ContextType>;
DiscoverTopic?: DiscoverTopicResolvers<ContextType>;
EditDiscoverFeedError?: EditDiscoverFeedErrorResolvers<ContextType>;
EditDiscoverFeedResult?: EditDiscoverFeedResultResolvers<ContextType>;
EditDiscoverFeedSuccess?: EditDiscoverFeedSuccessResolvers<ContextType>;
EmptyTrashError?: EmptyTrashErrorResolvers<ContextType>;
EmptyTrashResult?: EmptyTrashResultResolvers<ContextType>;
EmptyTrashSuccess?: EmptyTrashSuccessResolvers<ContextType>;
@ -6762,6 +7249,12 @@ export type Resolvers<ContextType = ResolverContext> = {
GenerateApiKeyError?: GenerateApiKeyErrorResolvers<ContextType>;
GenerateApiKeyResult?: GenerateApiKeyResultResolvers<ContextType>;
GenerateApiKeySuccess?: GenerateApiKeySuccessResolvers<ContextType>;
GetDiscoverFeedArticleError?: GetDiscoverFeedArticleErrorResolvers<ContextType>;
GetDiscoverFeedArticleResults?: GetDiscoverFeedArticleResultsResolvers<ContextType>;
GetDiscoverFeedArticleSuccess?: GetDiscoverFeedArticleSuccessResolvers<ContextType>;
GetDiscoverTopicError?: GetDiscoverTopicErrorResolvers<ContextType>;
GetDiscoverTopicResults?: GetDiscoverTopicResultsResolvers<ContextType>;
GetDiscoverTopicSuccess?: GetDiscoverTopicSuccessResolvers<ContextType>;
GetFollowersError?: GetFollowersErrorResolvers<ContextType>;
GetFollowersResult?: GetFollowersResultResolvers<ContextType>;
GetFollowersSuccess?: GetFollowersSuccessResolvers<ContextType>;
@ -6868,6 +7361,9 @@ export type Resolvers<ContextType = ResolverContext> = {
SaveArticleReadingProgressError?: SaveArticleReadingProgressErrorResolvers<ContextType>;
SaveArticleReadingProgressResult?: SaveArticleReadingProgressResultResolvers<ContextType>;
SaveArticleReadingProgressSuccess?: SaveArticleReadingProgressSuccessResolvers<ContextType>;
SaveDiscoverArticleError?: SaveDiscoverArticleErrorResolvers<ContextType>;
SaveDiscoverArticleResult?: SaveDiscoverArticleResultResolvers<ContextType>;
SaveDiscoverArticleSuccess?: SaveDiscoverArticleSuccessResolvers<ContextType>;
SaveError?: SaveErrorResolvers<ContextType>;
SaveFilterError?: SaveFilterErrorResolvers<ContextType>;
SaveFilterResult?: SaveFilterResultResolvers<ContextType>;

View file

@ -1,5 +1,26 @@
directive @sanitize(allowedTags: [String], maxLength: Int, minLength: Int, pattern: String) on INPUT_FIELD_DEFINITION
type AddDiscoverFeedError {
errorCodes: [AddDiscoverFeedErrorCode!]!
}
enum AddDiscoverFeedErrorCode {
BAD_REQUEST
CONFLICT
NOT_FOUND
UNAUTHORIZED
}
input AddDiscoverFeedInput {
url: String!
}
union AddDiscoverFeedResult = AddDiscoverFeedError | AddDiscoverFeedSuccess
type AddDiscoverFeedSuccess {
feed: DiscoverFeed!
}
type AddPopularReadError {
errorCodes: [AddPopularReadErrorCode!]!
}
@ -465,6 +486,47 @@ type DeleteAccountSuccess {
userID: ID!
}
type DeleteDiscoverArticleError {
errorCodes: [DeleteDiscoverArticleErrorCode!]!
}
enum DeleteDiscoverArticleErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
input DeleteDiscoverArticleInput {
discoverArticleId: ID!
}
union DeleteDiscoverArticleResult = DeleteDiscoverArticleError | DeleteDiscoverArticleSuccess
type DeleteDiscoverArticleSuccess {
id: ID!
}
type DeleteDiscoverFeedError {
errorCodes: [DeleteDiscoverFeedErrorCode!]!
}
enum DeleteDiscoverFeedErrorCode {
BAD_REQUEST
CONFLICT
NOT_FOUND
UNAUTHORIZED
}
input DeleteDiscoverFeedInput {
feedId: ID!
}
union DeleteDiscoverFeedResult = DeleteDiscoverFeedError | DeleteDiscoverFeedSuccess
type DeleteDiscoverFeedSuccess {
id: String!
}
type DeleteFilterError {
errorCodes: [DeleteFilterErrorCode!]!
}
@ -652,6 +714,72 @@ enum DirectionalityType {
RTL
}
type DiscoverFeed {
description: String
id: ID!
image: String
link: String!
title: String!
type: String!
visibleName: String
}
type DiscoverFeedArticle {
author: String
description: String!
feed: String!
id: ID!
image: String
publishedDate: Date
savedId: String
savedLinkUrl: String
siteName: String
slug: String!
title: String!
url: String!
}
type DiscoverFeedError {
errorCodes: [DiscoverFeedErrorCode!]!
}
enum DiscoverFeedErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
union DiscoverFeedResult = DiscoverFeedError | DiscoverFeedSuccess
type DiscoverFeedSuccess {
feeds: [DiscoverFeed]!
}
type DiscoverTopic {
description: String!
name: String!
}
type EditDiscoverFeedError {
errorCodes: [EditDiscoverFeedErrorCode!]!
}
enum EditDiscoverFeedErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
input EditDiscoverFeedInput {
feedId: ID!
name: String!
}
union EditDiscoverFeedResult = EditDiscoverFeedError | EditDiscoverFeedSuccess
type EditDiscoverFeedSuccess {
id: ID!
}
type EmptyTrashError {
errorCodes: [EmptyTrashErrorCode!]!
}
@ -822,6 +950,37 @@ type GenerateApiKeySuccess {
apiKey: ApiKey!
}
type GetDiscoverFeedArticleError {
errorCodes: [GetDiscoverFeedArticleErrorCode!]!
}
enum GetDiscoverFeedArticleErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
union GetDiscoverFeedArticleResults = GetDiscoverFeedArticleError | GetDiscoverFeedArticleSuccess
type GetDiscoverFeedArticleSuccess {
discoverArticles: [DiscoverFeedArticle]
pageInfo: PageInfo!
}
type GetDiscoverTopicError {
errorCodes: [GetDiscoverTopicErrorCode!]!
}
enum GetDiscoverTopicErrorCode {
UNAUTHORIZED
}
union GetDiscoverTopicResults = GetDiscoverTopicError | GetDiscoverTopicSuccess
type GetDiscoverTopicSuccess {
discoverTopics: [DiscoverTopic!]
}
type GetFollowersError {
errorCodes: [GetFollowersErrorCode!]!
}
@ -1228,6 +1387,7 @@ type MoveToFolderSuccess {
}
type Mutation {
addDiscoverFeed(input: AddDiscoverFeedInput!): AddDiscoverFeedResult!
addPopularRead(name: String!): AddPopularReadResult!
bulkAction(action: BulkActionType!, arguments: JSON, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult!
createArticle(input: CreateArticleInput!): CreateArticleResult!
@ -1237,6 +1397,8 @@ type Mutation {
createLabel(input: CreateLabelInput!): CreateLabelResult!
createNewsletterEmail(input: CreateNewsletterEmailInput): CreateNewsletterEmailResult!
deleteAccount(userID: ID!): DeleteAccountResult!
deleteDiscoverArticle(input: DeleteDiscoverArticleInput!): DeleteDiscoverArticleResult!
deleteDiscoverFeed(input: DeleteDiscoverFeedInput!): DeleteDiscoverFeedResult!
deleteFilter(id: ID!): DeleteFilterResult!
deleteHighlight(highlightId: ID!): DeleteHighlightResult!
deleteIntegration(id: ID!): DeleteIntegrationResult!
@ -1244,6 +1406,7 @@ type Mutation {
deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult!
deleteRule(id: ID!): DeleteRuleResult!
deleteWebhook(id: ID!): DeleteWebhookResult!
editDiscoverFeed(input: EditDiscoverFeedInput!): EditDiscoverFeedResult!
emptyTrash: EmptyTrashResult!
fetchContent(id: ID!): FetchContentResult!
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
@ -1264,6 +1427,7 @@ type Mutation {
reportItem(input: ReportItemInput!): ReportItemResult!
revokeApiKey(id: ID!): RevokeApiKeyResult!
saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult!
saveDiscoverArticle(input: SaveDiscoverArticleInput!): SaveDiscoverArticleResult!
saveFile(input: SaveFileInput!): SaveResult!
saveFilter(input: SaveFilterInput!): SaveFilterResult!
savePage(input: SavePageInput!): SaveResult!
@ -1419,8 +1583,11 @@ type Query {
article(format: String, slug: String!, username: String!): ArticleResult!
articleSavingRequest(id: ID, url: String): ArticleSavingRequestResult!
deviceTokens: DeviceTokensResult!
discoverFeeds: DiscoverFeedResult!
discoverTopics: GetDiscoverTopicResults!
feeds(input: FeedsInput!): FeedsResult!
filters: FiltersResult!
getDiscoverFeedArticles(after: String, discoverTopicId: String!, feedId: ID, first: Int): GetDiscoverFeedArticleResults!
getUserPersonalization: GetUserPersonalizationResult!
groups: GroupsResult!
hello: String
@ -1726,6 +1893,29 @@ type SaveArticleReadingProgressSuccess {
updatedArticle: Article!
}
type SaveDiscoverArticleError {
errorCodes: [SaveDiscoverArticleErrorCode!]!
}
enum SaveDiscoverArticleErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
input SaveDiscoverArticleInput {
discoverArticleId: ID!
locale: String
timezone: String
}
union SaveDiscoverArticleResult = SaveDiscoverArticleError | SaveDiscoverArticleSuccess
type SaveDiscoverArticleSuccess {
saveId: String!
url: String!
}
type SaveError {
errorCodes: [SaveErrorCode!]!
message: String

View file

@ -24,7 +24,10 @@ const logger = buildLogger('pubsub')
const client = new PubSub()
type EntityData<T> = Merge<T, { libraryItemId: string }>
type EntityData<T extends Record<string, any>> = Merge<
T,
{ libraryItemId: string }
>
const isYouTubeVideoURL = (url: string | undefined): boolean => {
if (!url) {
@ -70,7 +73,7 @@ export const createPubSubClient = (): PubsubClient => {
Buffer.from(JSON.stringify({ userId, email, name, username }))
)
},
entityCreated: async <T>(
entityCreated: async <T extends Record<string, any>>(
type: EntityType,
data: EntityData<T>,
userId: string
@ -124,7 +127,7 @@ export const createPubSubClient = (): PubsubClient => {
Buffer.from(JSON.stringify({ type, userId, ...cleanData }))
)
},
entityUpdated: async <T>(
entityUpdated: async <T extends Record<string, any>>(
type: EntityType,
data: EntityData<T>,
userId: string
@ -192,6 +195,7 @@ export enum EntityType {
PAGE = 'page',
HIGHLIGHT = 'highlight',
LABEL = 'label',
RSS_FEED = 'feed',
}
export interface PubsubClient {
@ -201,12 +205,12 @@ export interface PubsubClient {
name: string,
username: string
) => Promise<void>
entityCreated: <T>(
entityCreated: <T extends Record<string, any>>(
type: EntityType,
data: EntityData<T>,
userId: string
) => Promise<void>
entityUpdated: <T>(
entityUpdated: <T extends Record<string, any>>(
type: EntityType,
data: EntityData<T>,
userId: string

View file

@ -1,23 +1,28 @@
import * as httpContext from 'express-http-context2'
import { DatabaseError } from 'pg'
import {
EntityManager,
EntityTarget,
ObjectLiteral,
QueryBuilder,
QueryFailedError,
Repository,
} from 'typeorm'
import { DatabaseError } from 'pg'
import { appDataSource } from '../data_source'
import { Claims } from '../resolvers/types'
import { SetClaimsRole } from '../utils/dictionary'
export const getColumns = <T>(repository: Repository<T>): (keyof T)[] => {
export const getColumns = <T extends ObjectLiteral>(
repository: Repository<T>
): (keyof T)[] => {
return repository.metadata.columns.map(
(col) => col.propertyName
) as (keyof T)[]
}
export const getColumnsDbName = <T>(repository: Repository<T>): string[] => {
export const getColumnsDbName = <T extends ObjectLiteral>(
repository: Repository<T>
): string[] => {
return repository.metadata.columns.map((col) => col.databaseName)
}
@ -53,11 +58,15 @@ export const authTrx = async <T>(
})
}
export const getRepository = <T>(entity: EntityTarget<T>) => {
export const getRepository = <T extends ObjectLiteral>(
entity: EntityTarget<T>
) => {
return appDataSource.getRepository(entity)
}
export const queryBuilderToRawSql = <T>(q: QueryBuilder<T>): string => {
export const queryBuilderToRawSql = <T extends ObjectLiteral>(
q: QueryBuilder<T>
): string => {
const queryAndParams = q.getQueryAndParameters()
let sql = queryAndParams[0]
const params = queryAndParams[1]

View file

@ -0,0 +1,203 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/require-await */
import { authorized } from '../../utils/gql-utils'
import {
AddDiscoverFeedError,
AddDiscoverFeedErrorCode,
AddDiscoverFeedSuccess,
DiscoverFeed,
MutationAddDiscoverFeedArgs,
} from '../../generated/graphql'
import { appDataSource } from '../../data_source'
import { QueryRunner } from 'typeorm'
import axios from 'axios'
import { RSS_PARSER_CONFIG } from '../../utils/parser'
import { XMLParser } from 'fast-xml-parser'
import { EntityType } from '../../pubsub'
import { v4 } from 'uuid'
const parser = new XMLParser({
ignoreAttributes: false,
parseTagValue: true,
ignoreDeclaration: false,
ignorePiTags: false,
})
type DiscoverFeedRows = {
rows: DiscoverFeed[]
}
const extractAtomData = (
url: string,
feed: {
title: string
subtitle?: string
icon?: string
}
): Partial<DiscoverFeed> => ({
description: feed.subtitle ?? '',
title: feed.title ?? url,
image: feed.icon,
link: url,
type: 'atom',
})
const extractRssData = (
url: string,
parsedXml: {
channel: {
title: string
description?: string
['sy:updateFrequency']: number
}
image: { url: string }
}
): Partial<DiscoverFeed> => ({
description: parsedXml.channel?.description ?? '',
title: parsedXml.channel.title ?? url,
image: parsedXml.image?.url,
link: url,
type: 'rss',
})
const handleExistingSubscription = async (
queryRunner: QueryRunner,
feed: DiscoverFeed,
userId: string
): Promise<AddDiscoverFeedSuccess | AddDiscoverFeedError> => {
// Add to existing, otherwise conflict.
const existingSubscription = await queryRunner.query(
'SELECT * FROM omnivore.discover_feed_subscription WHERE user_id = $1 and feed_id = $2',
[userId, feed.id]
)
if (existingSubscription.rows > 1) {
await queryRunner.release()
return {
__typename: 'AddDiscoverFeedError',
errorCodes: [AddDiscoverFeedErrorCode.Conflict],
}
}
const addSubscription = await queryRunner.query(
'INSERT INTO omnivore.discover_feed_subscription(feed_id, user_id) VALUES($1, $2)',
[feed.id, userId]
)
return {
__typename: 'AddDiscoverFeedSuccess',
feed,
}
}
const addNewSubscription = async (
queryRunner: QueryRunner,
url: string,
userId: string
): Promise<AddDiscoverFeedSuccess | AddDiscoverFeedError> => {
// First things first, we need to validate that this is an actual RSS or ATOM feed.
const response = await axios.get(url, RSS_PARSER_CONFIG)
const content = response.data
const contentType = response.headers['content-type']
const isXML =
contentType?.includes('text/rss+xml') ||
contentType?.includes('text/atom+xml') ||
contentType?.includes('application/xml')
if (!isXML) {
return {
__typename: 'AddDiscoverFeedError',
errorCodes: [AddDiscoverFeedErrorCode.BadRequest],
}
}
const parsedFeed = parser.parse(content)
if (!parsedFeed?.rss && !parsedFeed['rdf:RDF'] && !parsedFeed['feed']) {
return {
__typename: 'AddDiscoverFeedError',
errorCodes: [AddDiscoverFeedErrorCode.BadRequest],
}
}
const feed =
parsedFeed?.rss || parsedFeed['rdf:RDF']
? extractRssData(url, parsedFeed.rss || parsedFeed['rdf:RDF'])
: extractAtomData(url, parsedFeed.feed)
if (!feed.title) {
return {
__typename: 'AddDiscoverFeedError',
errorCodes: [AddDiscoverFeedErrorCode.BadRequest],
}
}
const discoverFeedId = v4()
await queryRunner.query(
'INSERT INTO omnivore.discover_feed(id, title, link, image, type, description) VALUES($1, $2, $3, $4, $5, $6)',
[
discoverFeedId,
feed.title,
feed.link,
feed.image,
feed.type,
feed.description,
]
)
await queryRunner.query(
'INSERT INTO omnivore.discover_feed_subscription(feed_id, user_id) VALUES($2, $1)',
[userId, discoverFeedId]
)
await queryRunner.release()
return {
__typename: 'AddDiscoverFeedSuccess',
feed: { ...feed, id: discoverFeedId } as DiscoverFeed,
}
}
export const addDiscoverFeedResolver = authorized<
AddDiscoverFeedSuccess,
AddDiscoverFeedError,
MutationAddDiscoverFeedArgs
>(async (_, { input: { url } }, { uid, log, pubsub }) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
const existingFeed = (await queryRunner.query(
'SELECT id from omnivore.discover_feed where link = $1',
[url]
)) as DiscoverFeedRows
if (existingFeed.rows.length > 0) {
return await handleExistingSubscription(
queryRunner,
existingFeed.rows[0],
uid
)
}
const result = await addNewSubscription(queryRunner, url, uid)
if (result.__typename == 'AddDiscoverFeedSuccess') {
await pubsub.entityCreated(
EntityType.RSS_FEED,
{ feed: result.feed, libraryItemId: 'NA' },
uid
)
}
return result
} catch (error) {
log.error('Error Getting Discover Articles', error)
return {
__typename: 'AddDiscoverFeedError',
errorCodes: [AddDiscoverFeedErrorCode.Unauthorized],
}
}
})

View file

@ -0,0 +1,97 @@
import { authorized } from '../../../utils/gql-utils'
import {
InputMaybe,
MutationSaveDiscoverArticleArgs,
SaveDiscoverArticleError,
SaveDiscoverArticleErrorCode,
SaveDiscoverArticleSuccess,
SaveSuccess,
} from '../../../generated/graphql'
import { appDataSource } from '../../../data_source'
import { QueryRunner } from 'typeorm'
import { userRepository } from '../../../repository/user'
import { saveUrl } from '../../../services/save_url'
import { v4 } from 'uuid'
export const saveDiscoverArticleResolver = authorized<
SaveDiscoverArticleSuccess,
SaveDiscoverArticleError,
MutationSaveDiscoverArticleArgs
>(
async (
_,
{ input: { discoverArticleId, timezone, locale } },
{ uid, log }
) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
const user = await userRepository.findById(uid)
if (!user) {
return {
__typename: 'SaveDiscoverArticleError',
errorCodes: [SaveDiscoverArticleErrorCode.Unauthorized],
}
}
const { rows: discoverArticles } = (await queryRunner.query(
`SELECT url FROM omnivore.discover_feed_articles WHERE id=$1`,
[discoverArticleId]
)) as {
rows: {
url: string
}[]
}
if (discoverArticles.length != 1) {
return {
__typename: 'SaveDiscoverArticleError',
errorCodes: [SaveDiscoverArticleErrorCode.NotFound],
}
}
const url = discoverArticles[0].url
const savedArticle = await saveUrl(
{
url,
source: 'add-link',
clientRequestId: v4(),
locale: locale as InputMaybe<string>,
timezone: timezone as InputMaybe<string>,
},
user
)
if (savedArticle.__typename == 'SaveError') {
return {
__typename: 'SaveDiscoverArticleError',
errorCodes: [SaveDiscoverArticleErrorCode.BadRequest],
}
}
const saveSuccess = savedArticle as SaveSuccess
await queryRunner.query(
`insert into omnivore.discover_feed_save_link (discover_article_id, user_id, article_save_id, article_save_url) VALUES ($1, $2, $3, $4) ON CONFLICT ON CONSTRAINT user_discover_feed_link DO UPDATE SET (article_save_id, article_save_url, deleted) = ($3, $4, false);`,
[discoverArticleId, uid, saveSuccess.clientRequestId, saveSuccess.url]
)
await queryRunner.release()
return {
__typename: 'SaveDiscoverArticleSuccess',
url: saveSuccess.url,
saveId: saveSuccess.clientRequestId,
}
} catch (error) {
log.error('Error Saving Article', error)
return {
__typename: 'SaveDiscoverArticleError',
errorCodes: [SaveDiscoverArticleErrorCode.Unauthorized],
}
}
}
)

View file

@ -0,0 +1,75 @@
import { authorized } from '../../../utils/gql-utils'
import {
DeleteDiscoverArticleError,
DeleteDiscoverArticleErrorCode,
DeleteDiscoverArticleSuccess,
MutationDeleteDiscoverArticleArgs,
} from '../../../generated/graphql'
import { appDataSource } from '../../../data_source'
import { QueryRunner } from 'typeorm'
import { userRepository } from '../../../repository/user'
import { updateLibraryItem } from '../../../services/library_item'
import { LibraryItemState } from '../../../entity/library_item'
export const deleteDiscoverArticleResolver = authorized<
DeleteDiscoverArticleSuccess,
DeleteDiscoverArticleError,
MutationDeleteDiscoverArticleArgs
>(async (_, { input: { discoverArticleId } }, { uid, log, pubsub }) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
const user = await userRepository.findById(uid)
if (!user) {
return {
__typename: 'DeleteDiscoverArticleError',
errorCodes: [DeleteDiscoverArticleErrorCode.Unauthorized],
}
}
const { rows: discoverArticles } = (await queryRunner.query(
`SELECT article_save_id FROM omnivore.discover_feed_save_link WHERE discover_article_id=$1 and user_id=$2`,
[discoverArticleId, uid]
)) as {
rows: { article_save_id: string }[]
}
if (discoverArticles.length != 1) {
return {
__typename: 'DeleteDiscoverArticleError',
errorCodes: [DeleteDiscoverArticleErrorCode.NotFound],
}
}
await queryRunner.query(
`UPDATE omnivore.discover_feed_save_link set deleted = true WHERE discover_article_id=$1 and user_id=$2`,
[discoverArticleId, uid]
)
await updateLibraryItem(
discoverArticles[0].article_save_id,
{
state: LibraryItemState.Deleted,
deletedAt: new Date(),
},
uid,
pubsub
)
await queryRunner.release()
return {
__typename: 'DeleteDiscoverArticleSuccess',
id: discoverArticleId,
}
} catch (error) {
log.error('Error Deleting Article', error)
return {
__typename: 'DeleteDiscoverArticleError',
errorCodes: [DeleteDiscoverArticleErrorCode.Unauthorized],
}
}
})

View file

@ -0,0 +1,201 @@
import { authorized } from '../../../utils/gql-utils'
import {
GetDiscoverFeedArticleSuccess,
GetDiscoverFeedArticleError,
QueryGetDiscoverFeedArticlesArgs,
GetDiscoverFeedArticleErrorCode,
} from '../../../generated/graphql'
import { appDataSource } from '../../../data_source'
import { QueryRunner } from 'typeorm'
const COMMUNITY_FEED_ID = '8217d320-aa5a-11ee-bbfe-a7cde356f524'
type DiscoverFeedArticleDBRows = {
rows: {
id: string
feed: string
title: string
slug: string
url: string
author: string
image: string
published_at: Date
description: string
saves: number
article_save_id: string | undefined
article_save_url: string | undefined
}[]
}
const getPopularTopics = (
queryRunner: QueryRunner,
uid: string,
after: string,
amt: number,
feedId: string | null = null
): Promise<DiscoverFeedArticleDBRows> => {
const params = [uid, amt + 1, after]
if (feedId) {
params.push(feedId)
}
return queryRunner.query(
`
SELECT id, title, feed_id as feed, slug, description, url, author, image, published_at, COALESCE(sl.count / (EXTRACT(EPOCH FROM (NOW() - published_at)) / 3600 / 24), 0) as popularity_score, article_save_id, article_save_url
FROM omnivore.omnivore.discover_feed_articles
LEFT JOIN (SELECT discover_article_id as article_id, count(*) as count FROM omnivore.discover_feed_save_link group by discover_article_id) sl on id=sl.article_id
LEFT JOIN (SELECT discover_article_id, article_save_id, article_save_url FROM omnivore.discover_feed_save_link WHERE user_id=$1 and deleted = false) su on id=su.discover_article_id
WHERE COALESCE(sl.count / (EXTRACT(EPOCH FROM (NOW() - published_at)) / 3600 / 24), 0) > 0.0
AND (feed_id in (SELECT feed_id FROM omnivore.discover_feed_subscription WHERE user_id = $1) OR feed_id = '${COMMUNITY_FEED_ID}') ${
feedId != null ? `AND feed_id = $4` : ''
}
ORDER BY popularity_score DESC
LIMIT $2 OFFSET $3
`,
params
) as Promise<DiscoverFeedArticleDBRows>
}
const getAllTopics = (
queryRunner: QueryRunner,
uid: string,
after: string,
amt: number,
feedId: string | null = null
): Promise<DiscoverFeedArticleDBRows> => {
const params = [uid, amt + 1, after]
if (feedId) {
params.push(feedId)
}
return queryRunner.query(
`
SELECT id, title, feed_id as feed, slug, description, url, author, image, published_at, article_save_id, article_save_url
FROM omnivore.omnivore.discover_feed_articles
LEFT JOIN (SELECT discover_article_id, article_save_id, article_save_url FROM omnivore.discover_feed_save_link WHERE user_id=$1 and deleted = false) su on id=su.discover_article_id
WHERE (feed_id in (SELECT feed_id FROM omnivore.discover_feed_subscription WHERE user_id = $1) OR feed_id = '${COMMUNITY_FEED_ID}')
${feedId != null ? `AND feed_id = $4` : ''}
ORDER BY published_at DESC
LIMIT $2 OFFSET $3
`,
params
) as Promise<DiscoverFeedArticleDBRows>
}
const getTopicInformation = (
queryRunner: QueryRunner,
discoverTopicId: string,
uid: string,
after: string,
amt: number,
feedId: string | null = null
): Promise<DiscoverFeedArticleDBRows> => {
const params = [uid, discoverTopicId, amt + 1, Number(after)]
if (feedId) {
params.push(feedId)
}
return queryRunner.query(
`SELECT id, title, feed_id as feed, slug, description, url, author, image, published_at, article_save_id, article_save_url
FROM omnivore.discover_feed_articles
INNER JOIN (SELECT discover_feed_article_id FROM omnivore.discover_feed_article_topic_link WHERE discover_topic_name=$2) topic on topic.discover_feed_article_id=id
LEFT JOIN (SELECT discover_article_id, article_save_id, article_save_url FROM omnivore.discover_feed_save_link WHERE user_id=$1 and deleted = false) su on id=su.discover_article_id
WHERE (feed_id in (SELECT feed_id FROM omnivore.discover_feed_subscription WHERE user_id = $1) OR feed_id = '${COMMUNITY_FEED_ID}')
${feedId != null ? `AND feed_id = $5` : ''}
ORDER BY published_at DESC
LIMIT $3 OFFSET $4
`,
params
) as Promise<DiscoverFeedArticleDBRows>
}
export const getDiscoverFeedArticlesResolver = authorized<
GetDiscoverFeedArticleSuccess,
GetDiscoverFeedArticleError,
QueryGetDiscoverFeedArticlesArgs
>(async (_, { discoverTopicId, feedId, first, after }, { uid, log }) => {
try {
const startCursor: string = after || ''
const firstAmnt = Math.min(first || 10, 100) // limit to 100 items
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
const { rows: topics } = (await queryRunner.query(
`SELECT * FROM "omnivore"."discover_topics" WHERE "name" = $1`,
[discoverTopicId]
)) as { rows: unknown[] }
if (topics.length == 0) {
return {
__typename: 'GetDiscoverFeedArticleError',
errorCodes: [GetDiscoverFeedArticleErrorCode.Unauthorized], // TODO - no.
}
}
let discoverArticles: DiscoverFeedArticleDBRows = { rows: [] }
if (discoverTopicId === 'Popular') {
discoverArticles = await getPopularTopics(
queryRunner,
uid,
startCursor,
firstAmnt,
feedId ?? null
)
} else if (discoverTopicId === 'All') {
discoverArticles = await getAllTopics(
queryRunner,
uid,
startCursor,
firstAmnt,
feedId ?? null
)
} else {
discoverArticles = await getTopicInformation(
queryRunner,
discoverTopicId,
uid,
startCursor,
firstAmnt,
feedId ?? null
)
}
await queryRunner.release()
return {
__typename: 'GetDiscoverFeedArticleSuccess',
discoverArticles: discoverArticles.rows.slice(0, firstAmnt).map((it) => ({
author: it.author,
id: it.id,
feed: it.feed,
slug: it.slug,
publishedDate: it.published_at,
description: it.description,
url: it.url,
title: it.title,
image: it.image,
saves: it.saves,
savedLinkUrl: it.article_save_url,
savedId: it.article_save_id,
__typename: 'DiscoverFeedArticle',
siteName: it.url,
})),
pageInfo: {
endCursor: `${
Number(startCursor) +
Math.min(discoverArticles.rows.length, firstAmnt)
}`,
hasNextPage: discoverArticles.rows.length > firstAmnt,
hasPreviousPage: Number(startCursor) != 0,
startCursor: Number(startCursor).toString(),
totalCount: Math.min(discoverArticles.rows.length, firstAmnt),
},
}
} catch (error) {
log.error('Error Getting Discover Feed Articles', error)
return {
__typename: 'GetDiscoverFeedArticleError',
errorCodes: [GetDiscoverFeedArticleErrorCode.Unauthorized],
}
}
})

View file

@ -0,0 +1,58 @@
import { authorized } from '../../utils/gql-utils'
import {
DeleteDiscoverFeedError,
DeleteDiscoverFeedErrorCode,
DeleteDiscoverFeedSuccess,
MutationDeleteDiscoverFeedArgs,
} from '../../generated/graphql'
import { appDataSource } from '../../data_source'
import { QueryRunner } from 'typeorm'
export const deleteDiscoverFeedsResolver = authorized<
DeleteDiscoverFeedSuccess,
DeleteDiscoverFeedError,
MutationDeleteDiscoverFeedArgs
>(async (_, { input: { feedId } }, { uid, log }) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
// Ensure that it actually exists for the user.
const feeds = (await queryRunner.query(
`SELECT * FROM omnivore.discover_feed_subscription sub
WHERE sub.user_id = $1 and sub.feed_id = $2`,
[uid, feedId]
)) as {
rows: {
feed_id: string
}[]
}
if (feeds.rows.length == 0) {
return {
__typename: 'DeleteDiscoverFeedError',
errorCodes: [DeleteDiscoverFeedErrorCode.NotFound],
}
}
await queryRunner.query(
`DELETE FROM omnivore.discover_feed_subscription sub
WHERE sub.user_id = $1 and sub.feed_id = $2`,
[uid, feedId]
)
await queryRunner.release()
return {
__typename: 'DeleteDiscoverFeedSuccess',
id: feedId,
}
} catch (error) {
log.error('Error Getting Discover Feed Subscriptions', error)
return {
__typename: 'DeleteDiscoverFeedError',
errorCodes: [DeleteDiscoverFeedErrorCode.Unauthorized],
}
}
})

View file

@ -0,0 +1,58 @@
import { authorized } from '../../utils/gql-utils'
import {
EditDiscoverFeedError,
EditDiscoverFeedErrorCode,
EditDiscoverFeedSuccess,
MutationEditDiscoverFeedArgs,
} from '../../generated/graphql'
import { appDataSource } from '../../data_source'
import { QueryRunner } from 'typeorm'
export const editDiscoverFeedsResolver = authorized<
EditDiscoverFeedSuccess,
EditDiscoverFeedError,
MutationEditDiscoverFeedArgs
>(async (_, { input: { feedId, name } }, { uid, log }) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
// Ensure that it actually exists for the user.
const feeds = (await queryRunner.query(
`SELECT * FROM omnivore.discover_feed_subscription sub
WHERE sub.user_id = $1 and sub.feed_id = $2`,
[uid, feedId]
)) as {
rows: {
feed_id: string
}[]
}
if (feeds.rows.length == 0) {
return {
__typename: 'EditDiscoverFeedError',
errorCodes: [EditDiscoverFeedErrorCode.NotFound],
}
}
await queryRunner.query(
`UPDATE omnivore.discover_feed_subscription SET visible_name = $1
WHERE user_id = $2 and feed_id = $3`,
[name, uid, feedId]
)
await queryRunner.release()
return {
__typename: 'EditDiscoverFeedSuccess',
id: feedId,
}
} catch (error) {
log.error('Error Updating Discover Feed Subscriptions', error)
return {
__typename: 'EditDiscoverFeedError',
errorCodes: [EditDiscoverFeedErrorCode.Unauthorized],
}
}
})

View file

@ -0,0 +1,42 @@
import { authorized } from '../../utils/gql-utils'
import {
DiscoverFeed,
DiscoverFeedError,
DiscoverFeedErrorCode,
DiscoverFeedSuccess,
} from '../../generated/graphql'
import { appDataSource } from '../../data_source'
import { QueryRunner } from 'typeorm'
export const getDiscoverFeedsResolver = authorized<
DiscoverFeedSuccess,
DiscoverFeedError
>(async (_, _args, { uid, log }) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
const existingFeed = (await queryRunner.query(
`SELECT *, COALESCE(visible_name, title) as "visibleName" FROM omnivore.discover_feed_subscription sub
INNER JOIN omnivore.discover_feed feed on sub.feed_id=id
WHERE sub.user_id = $1`,
[uid]
)) as {
rows: DiscoverFeed[]
}
await queryRunner.release()
return {
__typename: 'DiscoverFeedSuccess',
feeds: existingFeed.rows || [],
}
} catch (error) {
log.error('Error Getting Discover Feed Subscriptions', error)
return {
__typename: 'DiscoverFeedError',
errorCodes: [DiscoverFeedErrorCode.Unauthorized],
}
}
})

View file

@ -0,0 +1,7 @@
export * from './add'
export * from './articles/get'
export * from './get'
export * from './articles/delete'
export * from './delete'
export * from './articles/add'
export * from './edit'

View file

@ -141,6 +141,15 @@ import { markEmailAsItemResolver, recentEmailsResolver } from './recent_emails'
import { recentSearchesResolver } from './recent_searches'
import { WithDataSourcesContext } from './types'
import { updateEmailResolver } from './user'
import {
addDiscoverFeedResolver,
getDiscoverFeedsResolver,
getDiscoverFeedArticlesResolver,
saveDiscoverArticleResolver,
deleteDiscoverArticleResolver,
deleteDiscoverFeedsResolver,
editDiscoverFeedsResolver,
} from './discover_feeds'
import { getAISummary } from '../services/ai-summaries'
import { findUserFeatures, getFeatureName } from '../services/features'
@ -295,13 +304,20 @@ export const functionResolvers = {
updateSubscription: updateSubscriptionResolver,
updateFilter: updateFilterResolver,
updateEmail: updateEmailResolver,
saveDiscoverArticle: saveDiscoverArticleResolver,
deleteDiscoverArticle: deleteDiscoverArticleResolver,
moveToFolder: moveToFolderResolver,
updateNewsletterEmail: updateNewsletterEmailResolver,
addDiscoverFeed: addDiscoverFeedResolver,
deleteDiscoverFeed: deleteDiscoverFeedsResolver,
editDiscoverFeed: editDiscoverFeedsResolver,
emptyTrash: emptyTrashResolver,
fetchContent: fetchContentResolver,
},
Query: {
me: getMeUserResolver,
getDiscoverFeedArticles: getDiscoverFeedArticlesResolver,
discoverFeeds: getDiscoverFeedsResolver,
user: getUserResolver,
users: getAllUsersResolver,
validateUsername: validateUsernameResolver,

View file

@ -2684,6 +2684,112 @@ const schema = gql`
email: String!
}
# Query: GetDiscoverTopic
union GetDiscoverTopicResults =
GetDiscoverTopicSuccess
| GetDiscoverTopicError
enum GetDiscoverTopicErrorCode {
UNAUTHORIZED
}
type GetDiscoverTopicError {
errorCodes: [GetDiscoverTopicErrorCode!]!
}
type GetDiscoverTopicSuccess {
discoverTopics: [DiscoverTopic!]
}
type DiscoverTopic {
name: String!
description: String!
}
# Query: GetDiscoverFeedArticle
union GetDiscoverFeedArticleResults =
GetDiscoverFeedArticleSuccess
| GetDiscoverFeedArticleError
enum GetDiscoverFeedArticleErrorCode {
UNAUTHORIZED
NOT_FOUND
BAD_REQUEST
}
type GetDiscoverFeedArticleError {
errorCodes: [GetDiscoverFeedArticleErrorCode!]!
}
type GetDiscoverFeedArticleSuccess {
discoverArticles: [DiscoverFeedArticle]
pageInfo: PageInfo!
}
type DiscoverFeedArticle {
id: ID!
feed: String!
title: String!
url: String!
image: String
publishedDate: Date
description: String!
siteName: String
slug: String!
author: String
savedLinkUrl: String
savedId: String
}
# Mutation: SaveDiscoverArticle
input SaveDiscoverArticleInput {
discoverArticleId: ID!
locale: String
timezone: String
}
union SaveDiscoverArticleResult =
SaveDiscoverArticleSuccess
| SaveDiscoverArticleError
type SaveDiscoverArticleSuccess {
url: String!
saveId: String!
}
type SaveDiscoverArticleError {
errorCodes: [SaveDiscoverArticleErrorCode!]!
}
enum SaveDiscoverArticleErrorCode {
UNAUTHORIZED
BAD_REQUEST
NOT_FOUND
}
# Mutation: DeleteDiscoverArticle
input DeleteDiscoverArticleInput {
discoverArticleId: ID!
}
union DeleteDiscoverArticleResult =
DeleteDiscoverArticleSuccess
| DeleteDiscoverArticleError
type DeleteDiscoverArticleSuccess {
id: ID!
}
type DeleteDiscoverArticleError {
errorCodes: [DeleteDiscoverArticleErrorCode!]!
}
enum DeleteDiscoverArticleErrorCode {
UNAUTHORIZED
BAD_REQUEST
NOT_FOUND
}
input FeedsInput {
after: String
first: Int
@ -2813,6 +2919,96 @@ const schema = gql`
UNAUTHORIZED
}
type DiscoverFeed {
id: ID!
title: String!
link: String!
description: String
image: String
type: String!
visibleName: String
}
union DiscoverFeedResult = DiscoverFeedSuccess | DiscoverFeedError
type DiscoverFeedSuccess {
feeds: [DiscoverFeed]!
}
type DiscoverFeedError {
errorCodes: [DiscoverFeedErrorCode!]!
}
enum DiscoverFeedErrorCode {
UNAUTHORIZED
BAD_REQUEST
}
input AddDiscoverFeedInput {
url: String!
}
union AddDiscoverFeedResult = AddDiscoverFeedSuccess | AddDiscoverFeedError
type AddDiscoverFeedSuccess {
feed: DiscoverFeed!
}
type AddDiscoverFeedError {
errorCodes: [AddDiscoverFeedErrorCode!]!
}
enum AddDiscoverFeedErrorCode {
UNAUTHORIZED
BAD_REQUEST
CONFLICT
NOT_FOUND
}
union DeleteDiscoverFeedResult =
DeleteDiscoverFeedSuccess
| DeleteDiscoverFeedError
type DeleteDiscoverFeedSuccess {
id: String!
}
type DeleteDiscoverFeedError {
errorCodes: [DeleteDiscoverFeedErrorCode!]!
}
enum DeleteDiscoverFeedErrorCode {
UNAUTHORIZED
BAD_REQUEST
CONFLICT
NOT_FOUND
}
input DeleteDiscoverFeedInput {
feedId: ID!
}
union EditDiscoverFeedResult = EditDiscoverFeedSuccess | EditDiscoverFeedError
type EditDiscoverFeedSuccess {
id: ID!
}
type EditDiscoverFeedError {
errorCodes: [EditDiscoverFeedErrorCode!]!
}
enum EditDiscoverFeedErrorCode {
UNAUTHORIZED
BAD_REQUEST
NOT_FOUND
}
input EditDiscoverFeedInput {
feedId: ID!
name: String!
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -2880,6 +3076,12 @@ const schema = gql`
unsubscribe(name: String!, subscriptionId: ID): UnsubscribeResult!
subscribe(input: SubscribeInput!): SubscribeResult!
addPopularRead(name: String!): AddPopularReadResult!
saveDiscoverArticle(
input: SaveDiscoverArticleInput!
): SaveDiscoverArticleResult!
deleteDiscoverArticle(
input: DeleteDiscoverArticleInput!
): DeleteDiscoverArticleResult!
setWebhook(input: SetWebhookInput!): SetWebhookResult!
deleteWebhook(id: ID!): DeleteWebhookResult!
revokeApiKey(id: ID!): RevokeApiKeyResult!
@ -2924,6 +3126,11 @@ const schema = gql`
updateNewsletterEmail(
input: UpdateNewsletterEmailInput!
): UpdateNewsletterEmailResult!
addDiscoverFeed(input: AddDiscoverFeedInput!): AddDiscoverFeedResult!
deleteDiscoverFeed(
input: DeleteDiscoverFeedInput!
): DeleteDiscoverFeedResult!
editDiscoverFeed(input: EditDiscoverFeedInput!): EditDiscoverFeedResult!
emptyTrash: EmptyTrashResult!
}
@ -2961,6 +3168,13 @@ const schema = gql`
includeContent: Boolean
format: String
): SearchResult!
getDiscoverFeedArticles(
discoverTopicId: String!
feedId: ID
after: String
first: Int
): GetDiscoverFeedArticleResults!
discoverTopics: GetDiscoverTopicResults!
subscriptions(
sort: SortParams
type: SubscriptionType
@ -2985,6 +3199,7 @@ const schema = gql`
groups: GroupsResult!
recentEmails: RecentEmailsResult!
feeds(input: FeedsInput!): FeedsResult!
discoverFeeds: DiscoverFeedResult!
scanFeeds(input: ScanFeedsInput!): ScanFeedsResult!
}
`

View file

@ -59,7 +59,7 @@ const optInLimitedFeature = async (
return feature
}
const optedInFeatures = (await appDataSource.query(
const optedInFeatures: Feature[] = (await appDataSource.query(
`insert into omnivore.features (user_id, name, granted_at)
select $1, $2, $3 from omnivore.features
where name = $2 and granted_at is not null

View file

@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-base-to-string */
import { preParseContent } from '@omnivore/content-handler'
import { Readability } from '@omnivore/readability'
import addressparser from 'addressparser'

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,11 @@
-- Type: UNDO
-- Name: add_discover_feed_tables
-- Description: Add Discovery Feed Tables, including counts.
DROP TABLE omnivore.discover_feed;
DROP TABLE omnivore.discover_feed_subscription;
DROP TABLE omnivore.discover_feed_articles;
DROP TABLE omnivore.discover_feed_save_link CASCADE;
DROP TABLE omnivore.discover_feed_article_topic_link;
DROP TABLE omnivore.discover_topics CASCADE;
DROP TABLE omnivore.discover_topic_embedding_link;

View file

@ -0,0 +1,11 @@
API_ENV=local
PG_HOST=localhost
PG_PORT=5432
PG_USER=postgres
PG_PASSWORD=postgres
PG_POOL_MAX=20
PG_DB=omnivore
IMAGE_PROXY_URL=http://localhost:8080
IMAGE_PROXY_SECRET_KEY=some-secret
GCP_PROJECT_ID=omnivore-local
OPENAI_API_KEY=some-key

View file

@ -0,0 +1,13 @@
{
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/strictNullChecks": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off"
}
}

131
packages/discover/.gitignore vendored Normal file
View file

@ -0,0 +1,131 @@
.idea/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

View file

@ -0,0 +1,33 @@
FROM node:18.16 as builder
WORKDIR /app
RUN apt-get update && apt-get install -y g++ make python3
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .prettierrc .
COPY .eslintrc .
COPY /packages/discover/src ./packages/discover/src
COPY /packages/discover/package.json ./packages/discover/package.json
COPY /packages/discover/tsconfig.json ./packages/discover/tsconfig.json
RUN yarn install --pure-lockfile
RUN yarn workspace @omnivore/discover build
FROM node:18.16 as runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/packages/discover/dist /app/packages/discover/dist
COPY --from=builder /app/packages/discover/package.json /app/packages/discover/package.json
COPY --from=builder /app/packages/discover/node_modules /app/packages/discover/node_modules
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/package.json /app/package.json
CMD ["yarn", "workspace", "@omnivore/discover", "start"]

View file

@ -0,0 +1,78 @@
# omnivore-discover
## What is this?
One of my bi ggest problems is actually discoverability of articles. I have my five sites, and my link aggregators like Reddit. This is a bubble, and I miss a lot this way.
So I wanted to see if I could create something that would enable discoverability from Omnivore.
I had a few goals when creating Omnivore Discover.
![Example Screen](./docs/example.png)
## Features
### Automatic Categorisation
A while ago I worked a proof of concept for automatically adding user tags to an article. I ultimately still need to work on that further, but the basics for it worked well.
I wanted to take the learnings from this and use it to add automatic categorisation of stories.
I created a few topics, and added some descriptions to them. I generate an Embedding from this using OpenAIs embedding. These can be seen below.
![Topics](./docs/topic-tab.png)
When ingesting articles (see Ingesting Articles) we use their title and small description to create an Embedding. We can then use Cosine Similarity to identify which category this story should be a part of.
This is of course not 100% accurate, but it does a good enough job at categorising articles.
### Social Features
#### Discord Integration.
I created Omnivore Discover, and added it to the Omnivore WebApp.
I wanted to also add some social features to this. We have a fantastic community within the Omnivore Discord. I have found a lot of interesting reads in the #recommendations channel.
I wanted to be able to take these recommendations, and expose them to the Omnivore Community.
We do this using a Discord Bot. In order to moderate these recommendations a moderator must add an emoji (🦥) to the story.
This then gets ingested in the same way as the other stories. Meaning that it is also categorised. It also gets added to the Community Picks tab.
![Tomnivore Slack](./docs/tomnivore.png)
![community tab](./docs/community.png)
#### Popularity
There is also a popularity feed. This provides a score based on recent saves, weighting more heavily for newer articles. This allows us to have a popular tab, which shows in order the most popular stories on Omnivore Right now according to the community
![Popular Items](./docs/popular.png)
### Ingesting Articles
I ensured that articles could come from multiple locations. This is why I chose an RXJS Poller.
This project also started from the automatic labelling project. So that too was an important part of the decision to enable ingestion from multiple plages. Including a PubSub queue.
I wanted one of the main sources of the articles to be RSS Feeds.
I did this because I thought that some of this functionality might, in the future, be extendable to other RSS Feeds.
I have chose 3 article sources for now, Wired, ArsTechnica, and The Atlantic.
## Technologies
Below is a list of the technologies that were used to design this feature. This repository represents the RXJS side.
* RxJS
* Typescript
* Axios
* PGVector
* Discord Bot
* PubSub
## Running
Creation of the PubSub Topic and Subscription is external to this app.

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -0,0 +1,44 @@
{
"name": "@omnivore/discover",
"version": "0.0.1",
"scripts": {
"build": "tsc",
"dev": "ts-node-dev --files src/index.ts",
"start": "node dist/index.js",
"lint": "eslint src --ext ts,js,tsx,jsx",
"lint:fix": "eslint src --fix --ext ts,js,tsx,jsx",
"test:typecheck": "tsc --noEmit"
},
"dependencies": {
"aws4-axios": "^3.3.0",
"axios": "^1.5.1",
"dotenv": "^16.3.1",
"fast-xml-parser": "^4.3.2",
"html-to-text": "^9.0.5",
"lodash": "^4.17.21",
"linkedom": "^0.16.5",
"openai": "^4.11.1",
"pg": "^8.11.3",
"pg-format": "^1.0.4",
"pgvector": "^0.1.5",
"postgres": "^3.4.0",
"rxjs": "^7.8.1",
"@google-cloud/pubsub": "^4.0.0",
"uuid": "^9.0.1",
"urlsafe-base64": "^1.0.0"
},
"devDependencies": {
"@types/jsdom": "^21.1.3",
"@types/pg-format": "^1.0.3",
"@types/html-to-text": "^9.0.2",
"@types/lodash": "^4.14.201",
"@types/node": "^20.8.4",
"@types/pg": "^8.10.5",
"@types/voca": "^1.4.3",
"ts-node": "^10.9.1",
"tslib": "^2.6.2",
"@types/uuid": "^9.0.1",
"@types/urlsafe-base64": "^1.0.28"
}
}

67
packages/discover/src/env.ts Executable file
View file

@ -0,0 +1,67 @@
import * as dotenv from 'dotenv'
dotenv.config({ path: __dirname + '/./../env' })
interface BackendEnv {
pg: {
host: string
port: number
userName: string
password: string
dbName: string
pool: {
max: number
}
}
apiKey: string
openAiApiKey: string
imageProxy: {
url?: string
secretKey?: string
}
}
const envParser =
(env: { [key: string]: string | undefined }) =>
(varName: string, throwOnUndefined = true): string | undefined => {
const value = env[varName]
if (typeof value === 'string' && value) {
return value
}
if (throwOnUndefined) {
throw new Error(
`Missing ${varName} with a non-empty value in process environment`
)
}
return
}
export function getEnv(): BackendEnv {
// Dotenv parses env file merging into proces.env which is then read into custom struct here.
dotenv.config({ path: __dirname + '/./../.env' })
const parse = envParser(process.env)
const pg = {
host: parse('PG_HOST')!,
port: parseInt(parse('PG_PORT')!, 10),
userName: parse('PG_USER')!,
password: parse('PG_PASSWORD')!,
dbName: parse('PG_DB')!,
pool: {
max: parseInt(parse('PG_POOL_MAX')!, 10),
},
}
return {
pg,
apiKey: parse('OMNIVORE_API_KEY')!,
openAiApiKey: parse('OPENAI_API_KEY')!,
imageProxy: {
url: parse('IMAGE_PROXY_URL', false),
secretKey: parse('IMAGE_PROXY_SECRET', false),
},
}
}
export const env = getEnv()

View file

@ -0,0 +1,28 @@
import { addEmbeddingToArticle$, addTopicsToArticle$ } from './lib/ai/embedding'
import {
insertArticleToStore$,
removeDuplicateArticles$,
} from './lib/store/articles'
import { merge, Observable } from 'rxjs'
import { OmnivoreArticle } from './types/OmnivoreArticle'
import { rss$ } from './lib/inputSources/articles/rss/rssIngestor'
import { putImageInProxy$ } from './lib/clients/omnivore/imageProxy'
import { communityArticles$ } from './lib/inputSources/articles/communityArticles'
const enrichedArticles$ = (): Observable<OmnivoreArticle> => {
return merge(communityArticles$, rss$) as Observable<OmnivoreArticle>
}
;(() => {
enrichedArticles$()
.pipe(
// removeDuplicateArticles$,
addEmbeddingToArticle$,
addTopicsToArticle$,
putImageInProxy$,
insertArticleToStore$
)
.subscribe((it) => {
console.log('enriched: ', it)
})
})()

View file

@ -0,0 +1,142 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { mergeMap } from 'rxjs/operators'
import { OmnivoreArticle } from '../../types/OmnivoreArticle'
import { OperatorFunction, pipe, share } from 'rxjs'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
import { client } from '../clients/ai/client'
import { onErrorContinue, rateLimiter } from '../utils/reactive'
import { Label } from '../../types/OmnivoreSchema'
import { sqlClient } from '../store/db'
import { toSql } from 'pgvector/pg'
export type EmbeddedOmnivoreArticle = {
embedding: Array<number>
article: OmnivoreArticle
topics: string[]
}
export type EmbeddedOmnivoreLabel = {
embedding: Array<number>
label: Label
}
// Remove, for instance, "The Verge" and " - The Verge" to avoid the cosine similarity matching on that.
const prepareTitle = (article: OmnivoreArticle): string =>
article.title
.replace(article.site, '')
.replace(/[`~!@#$%^&*()_|+\-=?;:'",.<>{}[]\\\/]/gi, '')
const getEmbeddingForArticle = async (
it: OmnivoreArticle
): Promise<EmbeddedOmnivoreArticle> => {
// console.log(`${prepareTitle(it)}: ${it.description}`)
const embedding = await client.getEmbeddings(
`${prepareTitle(it)}: ${it.summary}`
)
return {
embedding,
article: it,
topics: [],
}
}
const addTopicsToArticle = async (
it: EmbeddedOmnivoreArticle
): Promise<EmbeddedOmnivoreArticle> => {
const articleEmbedding = it.embedding
const topics = await sqlClient.query(
`SELECT name, similarity
FROM (SELECT discover_topic_name as name, MAX(ABS(embed.embedding <#> $1)) AS "similarity" FROM omnivore.omnivore.discover_topic_embedding_link embed group by discover_topic_name) topics
ORDER BY similarity desc`,
[toSql(articleEmbedding)]
)
// OpenAI seems to cluster things around 0.7-0.9. Through trial and error I have found 0.77 to be a fairly accurate score.
const topicNames = topics.rows
.filter(({ similarity }) => similarity > 0.77)
.map(({ name }) => name as string)
if (topicNames.length == 0) {
topicNames.push(topics.rows[0]?.name)
}
// I basically want to check if there's anything between the top one and the others.
// If the gap is miniscule, then we should include it. IE: 0.7688 and 0.765
const topTopic = topics.rows[0]
const extraTopics = topics.rows
.filter(
({ similarity, name }) =>
similarity < 0.77 &&
topTopic.name != name &&
topTopic.similarity - similarity < 0.01
)
.map(({ name }) => name as string)
if (extraTopics.length > 0) {
console.log(`${it.article.title}: ${it.article.description}`)
console.log(topics.rows)
console.log(extraTopics)
}
topicNames.push(...extraTopics)
if (it.article.type == 'community') {
topicNames.push('Community Picks')
}
return {
...it,
topics: topicNames,
}
}
const getEmbeddingForLabel = async (
label: Label
): Promise<EmbeddedOmnivoreLabel> => {
const embedding = await client.getEmbeddings(
`${label.name}${label.description ? ' : ' + label.description : ''}`
)
console.log(
`${label.name}${label.description ? ' : ' + label.description : ''}`
)
return {
embedding,
label,
}
}
export const rateLimitEmbedding = <T>() =>
pipe(share(), rateLimiter<T>({ resetLimit: 1000, timeMs: 60_000 }))
export const rateLimiting = rateLimitEmbedding<any>()
export const addEmbeddingToLabel$: OperatorFunction<
Label,
EmbeddedOmnivoreLabel
> = pipe(
rateLimiting,
mergeMap((it: Label) => fromPromise(getEmbeddingForLabel(it)))
)
export const addEmbeddingToArticle$: OperatorFunction<
OmnivoreArticle,
EmbeddedOmnivoreArticle
> = pipe(
rateLimiting,
onErrorContinue(
mergeMap((it: OmnivoreArticle) => fromPromise(getEmbeddingForArticle(it)))
)
)
export const addTopicsToArticle$: OperatorFunction<
EmbeddedOmnivoreArticle,
EmbeddedOmnivoreArticle
> = pipe(
onErrorContinue(
mergeMap((it: EmbeddedOmnivoreArticle) =>
fromPromise(addTopicsToArticle(it))
)
)
)

View file

@ -0,0 +1,66 @@
import { OmnivoreClient } from '../clients/omnivore/omnivore'
import { OmnivoreArticle } from '../../types/OmnivoreArticle'
import { mergeMap, OperatorFunction, pipe } from 'rxjs'
import { client } from '../clients/ai/client'
import { convert } from 'html-to-text'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
import { exponentialBackOff, rateLimiter } from '../utils/reactive'
import { env } from '../../env'
const omnivoreClient = OmnivoreClient.createOmnivoreClient(env.apiKey)
// A basic metric for now, we will see later if anything needs to be improved in this area.
// 10 Words is probably sufficient, and will reduce the need for the bill on the Summary side.
export const needsPopulating = (article: OmnivoreArticle) => {
return article.description?.split(' ').length <= 3
}
const setArticleDescription = async (
article: OmnivoreArticle
): Promise<OmnivoreArticle> => {
const client = await omnivoreClient
const { content } = await client.fetchPage(article.slug)
return {
...article,
description: convert(content).split(' ').slice(0, 25).join(' '),
}
}
export const setArticleDescriptionAsSubsetOfContent: OperatorFunction<
OmnivoreArticle,
OmnivoreArticle
> = mergeMap(
(it: OmnivoreArticle) => fromPromise(setArticleDescription(it)),
10
)
const enrichArticleWithAiSummary = (it: OmnivoreArticle) =>
fromPromise(
(async (article: OmnivoreArticle): Promise<OmnivoreArticle> => {
const omniClient = await omnivoreClient
const { content } = await omniClient.fetchPage(article.slug)
try {
const tokens = convert(content).slice(
0,
Math.floor(client.tokenLimit * 0.75)
)
const description = await client.summarizeText(tokens)
return { ...article, description }
} catch (e) {
console.log(`Error article: ${article.title}`)
console.log(e)
throw e
}
})(it)
)
export const enrichArticleWithAiGeneratedDescription: OperatorFunction<
OmnivoreArticle,
OmnivoreArticle
> = pipe(
rateLimiter({ resetLimit: 50, timeMs: 60_000 }),
mergeMap((it: OmnivoreArticle) =>
enrichArticleWithAiSummary(it).pipe(exponentialBackOff(30))
)
)

View file

@ -0,0 +1,82 @@
import { EmbeddedOmnivoreLabel } from './embedding'
export type PredefinedEmbeds = Partial<
EmbeddedOmnivoreLabel & {
children?: EmbeddedOmnivoreLabel[]
parent?: EmbeddedOmnivoreLabel
}
>
// const importedEmbeddedLabels = fs
// .readFileSync(`${__dirname}/../../resources/embeddings.json`)
// .toString("utf-8");
// const embeddedLabels: PredefinedEmbeds[] = JSON.parse(importedEmbeddedLabels);
//
// export const getRelatedConcepts = async (label: Label): Promise<Label[]> => {
// const labelEmbedding = await client.getEmbeddings(label.name.toLowerCase());
//
// const predefined = (it: PredefinedEmbeds) => {
// console.log(label.name, it.label.name);
// const cosineSim = cosineSimilarity(it.embedding, labelEmbedding);
// console.log(cosineSim);
// return { sim: cosineSim, ...it };
// };
//
// const parentComparisons = embeddedLabels.reduce((acc, prev) => {
// return { ...acc, [prev.label.name]: predefined(prev) };
// }, {});
//
// const mostRelated = embeddedLabels
// .flatMap((parent) => {
// return parent.children.map((child) => ({
// ...predefined(child),
// parent: parentComparisons[parent.label.name],
// }));
// })
// .sort((a, b) => b.sim - a.sim)
// .slice(0, 2);
//
// return mostRelated.flatMap((it) => {
// return [
// {
// ...label,
// name: `article is about ${label.name.toLowerCase()} in the category ${it.parent.label.name.toLowerCase()}`,
// },
// { ...label, name: `article is about ${label.name.toLowerCase()}` },
// {
// ...label,
// name: `${it.parent.label.name.toLowerCase()}: ${label.name.toLowerCase()}`,
// },
// {
// ...label,
// name: `${it.parent.label.name.toLowerCase()}: ${label.name.toLowerCase()}, ${it.label.name.toLowerCase()}`,
// },
// {
// ...label,
// name: `article is about ${label.name.toLowerCase()} in the category ${it.parent.label.name.toLowerCase()} related to ${it.label.name.toLowerCase()}`,
// },
// {
// ...label,
// name: `article is about ${label.name.toLowerCase()} in the category ${it.label.name.toLowerCase()}`,
// },
// ];
// });
// };
//
// export const createRelatedConceptsIfNoDescription = (
// observable: Observable<Label>,
// ) => {
// return observable.pipe(
// switchMap((label: Label) => {
// if (label.description) {
// return observable;
// }
//
// return observable.pipe(
// rateLimiting,
// mergeMap((it: Label) => fromPromise(getRelatedConcepts(it))),
// mergeMap((it: Label[]) => it),
// );
// }),
// );
// };

View file

@ -0,0 +1,72 @@
import axios, { AxiosInstance } from 'axios'
import {
BedrockClientParams,
BedrockClientResponse,
BedrockInvokeParams,
} from '../../../types/Bedrock'
import { aws4Interceptor } from 'aws4-axios'
import { AiClient, Embedding } from '../../../types/AiClient'
import { SUMMARISE_PROMPT } from './prompt'
export class BedrockClient implements AiClient {
client: AxiosInstance
tokenLimit = 100_000 // (Perhaps. Not even sure of the validity of this.)
embeddingLimit = 8000
constructor(
params: BedrockClientParams = {
region: 'us-west-2',
endpoint: 'https://bedrock-runtime.us-west-2.amazonaws.com',
}
) {
this.client = axios.create({
baseURL: params.endpoint,
})
const interceptor = aws4Interceptor({
options: {
region: params.region,
service: 'bedrock',
},
})
this.client.interceptors.request.use(interceptor)
this.client.defaults.headers.common['Accept'] = '*/*'
this.client.defaults.headers.common['Content-Type'] = 'application/json'
}
_extractHttpBody(
invokeParams: BedrockInvokeParams
): Partial<BedrockInvokeParams> {
const { model: _, prompt, ...httpCommands } = invokeParams
return { ...httpCommands, prompt: this._wrapPrompt(prompt) }
}
_wrapPrompt(prompt: string): string {
return `\nHuman: ${prompt}\nAssistant:`
}
async getEmbeddings(text: string): Promise<Embedding> {
const { data } = await this.client.post<BedrockClientResponse>(
`/model/cohere.embed-english-v3/invoke`,
{ texts: [text], input_type: 'clustering' }
)
return data.embeddings![0]
}
async summarizeText(text: string): Promise<string> {
const summariseParams = {
model: 'anthropic.claude-v2',
max_tokens_to_sample: 8192,
temperature: 1,
top_k: 250,
top_p: 0.999,
stop_sequences: ['\\n\\Human:'],
anthropic_version: 'bedrock-2023-05-31',
prompt: SUMMARISE_PROMPT(text),
}
const { data } = await this.client.post<BedrockClientResponse>(
`/model/${summariseParams.model}/invoke`,
this._extractHttpBody(summariseParams)
)
return data.completion
}
}

View file

@ -0,0 +1,4 @@
import { AiClient } from '../../../types/AiClient'
import { OpenAiClient } from './openAi'
export const client: AiClient = new OpenAiClient()

View file

@ -0,0 +1,38 @@
import { AiClient, Embedding } from '../../../types/AiClient'
import { OpenAI } from 'openai'
import { SUMMARISE_PROMPT } from './prompt'
import { env } from '../../../env'
export type OpenAiParams = {
apiKey: string // defaults to process.env["OPEN_AI_KEY"]
}
export class OpenAiClient implements AiClient {
client: OpenAI
tokenLimit = 4096
embeddingLimit = 8191
constructor(openAiParams: OpenAiParams = { apiKey: env.openAiApiKey }) {
this.client = new OpenAI(openAiParams)
}
async getEmbeddings(input: string): Promise<Embedding> {
const embedding = await this.client.embeddings.create({
input,
model: 'text-embedding-ada-002',
})
return embedding.data[0].embedding
}
async summarizeText(text: string): Promise<string> {
const prompt = `${SUMMARISE_PROMPT(text)}`
const completion = await this.client.chat.completions.create({
messages: [{ role: 'user', content: prompt }],
model: 'gpt-3.5-turbo',
stream: false,
})
return completion.choices[0]?.message?.content ?? ''
}
}

View file

@ -0,0 +1,2 @@
export const SUMMARISE_PROMPT = (articleContent: string) =>
`Please create a summary of the article below. Please Do not exceed 25 words. Please do not add any of your own prose.\n${articleContent}\n' Here is a 25 word summary of the article:\n`

View file

@ -0,0 +1,28 @@
import { pipe } from 'rxjs'
import { map } from 'rxjs/operators'
import { EmbeddedOmnivoreArticle } from '../../ai/embedding'
import { env } from '../../../env'
import { createImageProxyUrl } from '../../utils/imageproxy'
import { onErrorContinue } from '../../utils/reactive'
export const addImageToProxy = (imageUrl: string): string => {
// For testing purposes, really.
if (env.imageProxy.url) {
return createImageProxyUrl(imageUrl)
}
return imageUrl
}
export const putImageInProxy$ = pipe(
onErrorContinue(
map((it: EmbeddedOmnivoreArticle, _idx: number) => {
return {
...it,
article: {
...it.article,
image: it.article.image && addImageToProxy(it.article.image),
},
}
})
)
)

View file

@ -0,0 +1,227 @@
import axios, { type AxiosResponse } from 'axios'
import {
type Article,
type SearchItemEdge,
type ArticleSuccess,
Label,
LabelsSuccess,
} from '../../../types/OmnivoreSchema'
const API_URL =
process.env.OMNIVORE_API_URL ?? 'https://api-prod.omnivore.app/api'
export class OmnivoreClient {
username: string
token: string
private constructor(username: string, token: string) {
this.username = username
this.token = token
}
static async createOmnivoreClient(token: string): Promise<OmnivoreClient> {
return new OmnivoreClient(await this.getUsername(token), token)
}
private static async getUsername(token: string): Promise<string> {
const data = JSON.stringify({
query: `query GetUsername {
me {
profile {
username
}
}
}
`,
})
const response = await axios
.post(`${API_URL}/graphql`, data, {
headers: {
Cookie: `auth=${token};`,
'Content-Type': 'application/json',
},
})
.catch((error) => {
console.error(error)
throw error
})
return response.data.data.me.profile.username as string
}
async fetchPages(): Promise<SearchItemEdge[]> {
const data = {
query: `query Search($after: String, $first: Int, $query: String) {
search(first: $first, after: $after, query: $query) {
... on SearchSuccess {
edges {
cursor
node {
id
title
slug
url
pageType
contentReader
createdAt
isArchived
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
id
name
color
}
pageId
shortId
quote
annotation
state
siteName
subscription
readAt
savedAt
wordsCount
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on SearchError {
errorCodes
}
}
}`,
variables: { query: 'in:inbox', after: '0', first: 1000 },
}
const response = await axios
.post(`${API_URL}/graphql`, data, {
headers: {
Cookie: `auth=${process.env.OMNIVORE_AUTH_TOKEN!};`,
'Content-Type': 'application/json',
},
})
.catch((error) => {
console.error(error)
throw error
})
return response.data.data.search.edges as SearchItemEdge[]
}
async fetchPage(slug: string): Promise<Article> {
const data = JSON.stringify({
variables: {
username: this.username,
slug,
},
query: `query GetArticle(
$username: String!
$slug: String!
) {
article(username: $username, slug: $slug) {
... on ArticleSuccess {
article {
id,
title,
url,
author,
savedAt,
description,
image
content
}
}
... on ArticleError {
errorCodes
}
}
}
`,
})
const response: AxiosResponse<{ data: { article: ArticleSuccess } }> =
await axios.post(`${API_URL}/graphql`, data, {
headers: {
Cookie: `auth=${this.token};`,
'Content-Type': 'application/json',
},
})
return response.data.data.article.article
}
async getUsersTags(): Promise<Label[]> {
const data = JSON.stringify({
query: `query GetLabels{
labels {
... on LabelsSuccess {
labels {
id,
name,
color,
description,
createdAt,
position,
internal
}
}
... on LabelsError {
errorCodes
}
}
}
`,
})
const response: AxiosResponse<{ data: { labels: LabelsSuccess } }> =
await axios.post(`${API_URL}/graphql`, data, {
headers: {
Cookie: `auth=${this.token};`,
'Content-Type': 'application/json',
},
})
return response.data.data.labels.labels
}
async archiveLink(id: string): Promise<boolean> {
const mutation = `mutation ArchivePage($id: ID!) {
setLinkArchived (input: {linkId: $id, archived: true}) {
... on ArchiveLinkSuccess {
linkId
message
}
... on ArchiveLinkError {
message
errorCodes
}
}
}`
return await axios
.post(
`${API_URL}/graphql`,
{ query: mutation, variables: { id } },
{
headers: {
Cookie: `auth=${this.token};`,
'Content-Type': 'application/json',
},
}
)
.then((_) => true)
}
}

View file

@ -0,0 +1,64 @@
import { PubSub } from '@google-cloud/pubsub'
import { catchError, EMPTY, Observable, Subscriber } from 'rxjs'
import { Message } from '@google-cloud/pubsub/build/src/subscriber'
import { OmnivoreArticle } from '../../../types/OmnivoreArticle'
const TOPIC_NAME = 'discordCommunityArticles'
const client = new PubSub()
export const COMMUNITY = 'OMNIVORE_COMMUNITY'
const extractArticleFromMessage = (message: Message): OmnivoreArticle => {
const parsedMessage: OmnivoreArticle = JSON.parse(
message.data.toString()
) as OmnivoreArticle
return {
...parsedMessage,
feedId: COMMUNITY,
publishedAt: parsedMessage.publishedAt ?? new Date(),
type: 'community',
}
}
export const communityArticles$ = new Observable(
(subscriber: Subscriber<any>) => {
client
.topic(TOPIC_NAME)
.exists()
.then((exists) => {
if (exists[0]) {
return client.topic(TOPIC_NAME).subscription(TOPIC_NAME)
}
return client
.createTopic(TOPIC_NAME)
.then((_topic) => {
return client.topic(TOPIC_NAME).createSubscription(TOPIC_NAME)
})
.then((_sub) => {
return client.topic(TOPIC_NAME).subscription(TOPIC_NAME)
})
})
.then((subscription) => {
subscription.on('message', (msg: Message) => {
subscriber.next(extractArticleFromMessage(msg))
msg.ack()
})
})
.catch((e) => {
console.error(
'Error creating Subscription, continuing without community articles...',
e
)
})
}
).pipe(
catchError((err) => {
console.log('Caught Error, continuing')
console.error(err)
// Return an empty Observable which gets collapsed in the output
return EMPTY
})
)

View file

@ -0,0 +1,75 @@
import { PubSub } from '@google-cloud/pubsub'
import { catchError, EMPTY, Observable, Subscriber } from 'rxjs'
import { Message } from '@google-cloud/pubsub/build/src/subscriber'
import { OmnivoreFeed } from '../../../../types/Feeds'
const TOPIC_NAME = 'entityCreated'
const client = new PubSub()
// If a user creates a brand new Feed (IE: Never before subscribed to) we will endeavor to
// create all the items from it immediately.
export const newFeeds$ = new Observable<OmnivoreFeed>(
(subscriber: Subscriber<any>) => {
client
.topic(TOPIC_NAME)
.exists()
.then((exists) => {
if (exists[0]) {
return client
.topic(TOPIC_NAME)
.subscription(`${TOPIC_NAME}Discover`)
.exists()
.then((subExists) => {
if (subExists[0]) {
return client
.topic(TOPIC_NAME)
.subscription(`${TOPIC_NAME}Discover`)
}
return client
.topic(TOPIC_NAME)
.createSubscription(`${TOPIC_NAME}Discover`)
.then((_sub) => {
return client
.topic(TOPIC_NAME)
.subscription(`${TOPIC_NAME}Discover`)
})
})
}
return client.createTopic(TOPIC_NAME).then((_) => {
return client
.topic(TOPIC_NAME)
.createSubscription(`${TOPIC_NAME}Discover`)
.then((_sub) => {
return client
.topic(TOPIC_NAME)
.subscription(`${TOPIC_NAME}Discover`)
})
})
})
.then((subscription) => {
subscription.on('message', (msg: Message) => {
const parsedMessage = JSON.parse(msg.data.toString())
if (parsedMessage.type == 'feed') {
subscriber.next(parsedMessage.feed as OmnivoreFeed)
}
msg.ack()
})
})
.catch((e) => {
console.error(
'Error creating Subscription, continuing without new feed parsing...',
e
)
})
}
).pipe(
catchError((err) => {
console.log('Caught Error, continuing')
console.error(err)
// Return an empty Observable which gets collapsed in the output
return EMPTY
})
)

View file

@ -0,0 +1,73 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { OmnivoreArticle } from '../../../../../types/OmnivoreArticle'
import { slugify } from 'voca'
import { Observable, tap } from 'rxjs'
import { fromArrayLike } from 'rxjs/internal/observable/innerFrom'
import { mapOrNull } from '../../../../utils/reactive'
import {
getFirstParagraphForEmbedding,
removeHTMLTag,
streamHeadAndRetrieveOpenGraph,
} from './generic'
import { JSDOM } from 'jsdom'
import { OmnivoreFeed } from '../../../../../types/Feeds'
const getImage = (article: any): string | undefined => {
const html = new JSDOM(`<html>${article.content['#text']}</html>`)
return (
(html.window.document.querySelectorAll('img')[0] &&
removeHTMLTag(html.window.document.querySelectorAll('img')[0].src)) ||
undefined
)
}
const getDescription = (article: any): string | undefined => {
return getFirstParagraphForEmbedding(article.content['#text'])
}
const getDescriptionAndImage = async (article: any) => {
let image = getImage(article)
let description: string | undefined
// If we do not have the image, we should try to grab the image and description from the
// <head> of the HTML page (using OpenGraph data). We may no longer need to grab the description from the RSS feed at this point.
if (!image) {
const ogData = await streamHeadAndRetrieveOpenGraph(article.link['@_href'])
image = ogData.image
description = ogData.description
}
if (!description) {
description = getDescription(article)
}
return { image, description }
}
export const convertAtomStream = (feed: OmnivoreFeed) => (parsedXml: any) => {
return fromArrayLike(parsedXml.feed.entry).pipe(
mapOrNull(async (article: any) => {
const { image, description } = await getDescriptionAndImage(article)
return {
authors: Array.isArray(article.author.name)
? article.author.name[0]
: article.author.name,
slug: slugify(article.link['@_href']),
url: article.link['@_href'],
title: removeHTMLTag(article.title),
description: description ?? '',
summary: description ?? '',
image: image ?? '',
site: new URL(article.link['@_href']).host,
publishedAt: new Date(article.published ?? Date.now()),
type: 'rss',
feedId: feed.title,
}
})
)
}

View file

@ -0,0 +1,5 @@
import { parseAtomOrRss } from './generic'
export = {
generic: parseAtomOrRss,
}

View file

@ -0,0 +1,102 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { OmnivoreArticle } from '../../../../../types/OmnivoreArticle'
import { XMLParser } from 'fast-xml-parser'
import { Observable } from 'rxjs'
import { parseRss } from './rss'
import { parseHTML } from 'linkedom'
import { JSDOM } from 'jsdom'
import { convertAtomStream } from './atom'
import { OmnivoreContentFeed } from '../../../../../types/Feeds'
const parser = new XMLParser({
ignoreAttributes: false,
parseTagValue: true,
ignoreDeclaration: false,
ignorePiTags: false,
})
export const removeHTMLTag = (text: string): string => {
return text.replace(/<(?:"[^"]*"['"]*|'[^']*'['"]*|[^'">])+>/g, '')
}
export const getFirstParagraphForEmbedding = (text: string): string => {
const html = parseHTML(`<html>${text}</html>`)
return (
(html.document.querySelectorAll('p')[0] &&
removeHTMLTag(html.document.querySelectorAll('p')[0].innerHTML)
.split(' ')
.slice(0, 15)
.join(' ')) ||
''
)
}
export const sanitizeHtml = (html: string) => {
return html
.replace(/<style([\S\s]*?)>([\S\s]*?)<\/style>/gim, '')
?.replace(/<script([\S\s]*?)>([\S\s]*?)<\/script>/gim, '')
}
export const streamHeadAndRetrieveOpenGraph = async (link: string) => {
const html = await fetch(link).then((response) => {
if (response.body) {
const reader = response.body.getReader()
// Read chunks of data
let html = ''
const read = (): Promise<string> => {
return reader.read().then(async ({ done, value }) => {
if (done) {
return html
}
html += new TextDecoder().decode(value)
if (html.includes('</head>')) {
await reader.cancel()
return `${html.slice(0, html.indexOf('</head>') + 7)}</html>`
}
return read()
})
}
// Start reading the stream
return read()
}
})
if (html) {
const dom = new JSDOM(sanitizeHtml(html))
const description =
dom?.window?.document
.querySelector('meta[property="og:description"]')
?.getAttribute('content') ?? undefined
const image =
dom?.window?.document
?.querySelector('meta[property="og:image"]')
?.getAttribute('content') ?? undefined
return {
image,
description,
}
}
return {
image: undefined,
description: undefined,
}
}
export const parseAtomOrRss = (contentFeed: OmnivoreContentFeed) => {
const parsedXml = parser.parse(contentFeed.content)
return parsedXml.rss || parsedXml['rdf:RDF']
? parseRss(contentFeed.feed)(
parsedXml.rss?.channel?.item ||
parsedXml['rdf:RDF'].channel?.item ||
parsedXml['rdf:RDF'].item
)
: convertAtomStream(contentFeed.feed)(parsedXml)
}

View file

@ -0,0 +1,118 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { JSDOM } from 'jsdom'
import { get } from 'lodash'
import { fromArrayLike } from 'rxjs/internal/observable/innerFrom'
import { mapOrNull } from '../../../../utils/reactive'
import { slugify } from 'voca'
import {
getFirstParagraphForEmbedding,
removeHTMLTag,
streamHeadAndRetrieveOpenGraph,
} from './generic'
import { OmnivoreFeed } from '../../../../../types/Feeds'
const getImage = (article: any): string | undefined => {
// If there's a thumbnail exposed in the RSS Feed, we should default to that as it is the most likely
if (article['media:thumbnail']) {
return (
get(article, '[media:thumbnail][@_url]') ||
get(article, '[media:thumbnail][0][@_url]')
)
}
// Otherwise, if there's Media Content, we should grab that, We will grab the first as it's the most likely
// to represent the article.
if (article['media:content']) {
return (
get(article, '[media:content][@_url]') ||
get(article, '[media:content][0][@_url]')
)
}
const extractImageFromHtml = (document: string) => {
const dom = new JSDOM(document)
return dom.window.document.getElementsByTagName('img')[0]?.src
}
// I've noticed some RSS feeds have some of the content encoded like this, and sometimes this contains an img tag
if (article['content:encoded']) {
const img = extractImageFromHtml(article['content:encoded'])
if (img) {
return img
}
}
// Similarly, some of the descriptions are HTML based.
if (article['description']) {
const img = extractImageFromHtml(article['description'])
if (img) {
return img
}
}
}
const getDescription = (article: any): string | undefined => {
// So first let's check there's some description.
if (article['description']) {
// Then we need to check if there's any <p> tags - If there are we enclose the entire thing in a DOM and do the extraction.
if (/<p\b[^>]*>(.*?)<\/p>/g.test(article['description'])) {
return getFirstParagraphForEmbedding(article['description'])
}
// If there aren't, then we should just use the description. It is likely correctly formatted.
return article['description']
}
// Otherwise we might have the content HTML encoded in this, and we should grab it from here.
if (article['content:encoded']) {
return getFirstParagraphForEmbedding(article['content:encoded'])
}
return
}
const getDescriptionAndImage = async (article: any) => {
let image = getImage(article)
let description: string | undefined
// If we do not have the image, we should try to grab the image and description from the
// <head> of the HTML page (using OpenGraph data). We may no longer need to grab the description from the RSS feed at this point.
if (!image) {
const ogData = await streamHeadAndRetrieveOpenGraph(article.link)
image = ogData.image
description = ogData.description
}
if (!description) {
description = getDescription(article)
}
return { image, description }
}
export const parseRss = (feed: OmnivoreFeed) => (parsedXml: any) => {
return fromArrayLike(parsedXml).pipe(
mapOrNull(async (article: any) => {
const { description, image } = await getDescriptionAndImage(article)
return {
authors: article['dc:creator'],
slug: slugify(article.link),
url: article.link,
title: removeHTMLTag(article.title),
description: description ?? '',
summary: description ?? '',
image: image ?? '',
site: new URL(article.link).host,
publishedAt: new Date(
article.pubDate ?? article['dc:date'] ?? Date.now()
),
type: 'rss',
feedId: feed.id,
}
})
)
}

View file

@ -0,0 +1,81 @@
import {
concatMap,
merge,
mergeAll,
mergeMap,
Observable,
tap,
timer,
} from 'rxjs'
import axios from 'axios'
import { fromArrayLike, fromPromise } from 'rxjs/internal/observable/innerFrom'
import { OmnivoreArticle } from '../../../../types/OmnivoreArticle'
import converters from './rssConverters/converters'
import { filter, finalize } from 'rxjs/operators'
import { getRssFeeds$ } from '../../../store/feeds'
import { OmnivoreContentFeed, OmnivoreFeed } from '../../../../types/Feeds'
import { newFeeds$ } from './newFeedIngestor'
import { exponentialBackOff, onErrorContinue } from '../../../utils/reactive'
const REFRESH_DELAY_MS = 3_600_000
const getRssFeed = async (
feed: OmnivoreFeed
): Promise<OmnivoreContentFeed | null> => {
try {
const rss = await axios.get<string>(feed.link)
return {
feed,
content: rss.data,
}
} catch (e) {
console.error('Error retrieving RSS Feed Content', e)
throw e
}
}
const rssToArticles = (site: OmnivoreFeed) =>
fromPromise(getRssFeed(site)).pipe(
filter((it): it is OmnivoreContentFeed => !!it),
mergeMap<OmnivoreContentFeed, Observable<OmnivoreArticle>>((item) =>
converters.generic(item)
)
)
export const rss$ = (() => {
let lastUpdatedTime = new Date(0)
const filteredRss$ = getRssFeeds$.pipe(
onErrorContinue(
mergeMap((it) => rssToArticles(it).pipe(exponentialBackOff(5)))
),
filter((it: OmnivoreArticle) => it.publishedAt > lastUpdatedTime),
finalize(() => {
lastUpdatedTime = new Date()
console.log(lastUpdatedTime)
})
)
return merge(
newFeeds$.pipe(
onErrorContinue(
mergeMap((it) => rssToArticles(it).pipe(exponentialBackOff(5)))
)
),
timer(0, REFRESH_DELAY_MS).pipe(
tap((e) => console.log('Refreshing Stream')),
concatMap(() => filteredRss$)
)
)
// return fromArrayLike([
// {
// id: 'ABC',
// description:
// 'Though AI companies said they put some guardrails in place, researchers were able to easily create images related to claims of election fraud.',
// image: 'string',
// link: 'https://www.wired.com/story/genai-images-election-fraud/',
// title: 'AI Tools Are Still Generating Misleading Election Images',
// type: 'RSS',
// },
// ])
})()

View file

@ -0,0 +1,224 @@
import { Label } from '../../../types/OmnivoreSchema'
import { fromArrayLike } from 'rxjs/internal/observable/innerFrom'
// We use this to generate the Embeddings for our topics.
const baseTopics = [
{
name: 'Technology',
description: 'this article is about Hardware',
},
{
name: 'Technology',
description: 'this article is about Big Tech',
},
{
name: 'Technology',
description: 'this article is about Software Engineering',
},
{
name: 'Technology',
description: 'this article is about Artificial Intelligence',
},
{
name: 'Technology',
description: 'this article is about Cloud Engineering',
},
{
name: 'Technology',
description: 'this article is about Security',
},
{
name: 'Politics',
description: 'this article is about world politics',
},
{
name: 'Politics',
description: 'this article is about Geopolitics',
},
{
name: 'Politics',
description: 'this article is about Climate Change',
},
{
name: 'Politics',
description: 'this article is about the economy',
},
{
name: 'Politics',
description: 'this article is about the healthcare',
},
{
name: 'Politics',
description: 'this article is about Social Justice',
},
{
name: 'Politics',
description: 'this article is about Republicans',
},
{
name: 'Politics',
description: 'this article is about Democrats',
},
{
name: 'Politics',
description: 'this article is about Elections',
},
{
name: 'Politics',
description: 'this article is about War',
},
{
name: 'Politics',
description: 'this article is about Policy',
},
{
name: 'Politics',
description: 'this article is about Laws',
},
{
name: 'Health & Wellbeing',
description: 'this article is about mental health',
},
{
name: 'Health & Wellbeing',
description: 'this article is about healthcare',
},
{
name: 'Health & Wellbeing',
description: 'this article is about food',
},
{
name: 'Health & Wellbeing',
description: 'this article is about family',
},
{
name: 'Health & Wellbeing',
description: 'this article is about relationship advice',
},
{
name: 'Health & Wellbeing',
description: 'this article is about sexual advice',
},
{
name: 'Health & Wellbeing',
description: 'this article is about physical health and working out',
},
{
name: 'Health & Wellbeing',
description: 'this article is about self care',
},
{
name: 'Health & Wellbeing',
description: 'this article is about self help',
},
{
name: 'Health & Wellbeing',
description: 'this article is about dating',
},
{
name: 'Business & Finance',
description: 'this article is about investments',
},
{
name: 'Business & Finance',
description: 'this article is about economics',
},
{
name: 'Business & Finance',
description: 'this article is about the economy',
},
{
name: 'Business & Finance',
description: 'this article is about capitalism',
},
{
name: 'Business & Finance',
description: 'this article is about Business',
},
{
name: 'Business & Finance',
description: 'this article is about Work and the Office',
},
{
name: 'Science & Education',
description: 'this article is about space',
},
{
name: 'Science & Education',
description: 'this article is about climate change',
},
{
name: 'Science & Education',
description: 'this article is about school',
},
{
name: 'Science & Education',
description: 'this article is about physics',
},
{
name: 'Science & Education',
description: 'this article is about pyschology',
},
{
name: 'Science & Education',
description: 'this article is about biology',
},
{
name: 'Science & Education',
description: 'this article is about breakthroughs',
},
{
name: 'Culture',
description: 'this article is about Entertainment',
},
{
name: 'Culture',
description: 'this article is about Books',
},
{
name: 'Culture',
description: 'this article is about Movies',
},
{
name: 'Culture',
description: 'this article is about Sports',
},
{
name: 'Culture',
description: 'this article is about Music',
},
{
name: 'Culture',
description: 'this article is about Actors',
},
{
name: 'Culture',
description: 'this article is about TV',
},
{
name: 'Culture',
description: 'this article is about Streaming',
},
{
name: 'Gaming',
description: 'this article is about PC Gaming',
},
{
name: 'Gaming',
description: 'this article is about Video Games',
},
{
name: 'Gaming',
description: 'this article is about XBOX',
},
{
name: 'Gaming',
description: 'this article is about PlayStation',
},
{
name: 'Gaming',
description: 'this article is about Nintendo',
},
]
export const discoverTopics$ = fromArrayLike(baseTopics as Label[])

View file

@ -0,0 +1,83 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { EmbeddedOmnivoreArticle } from '../ai/embedding'
import { filter, map, mergeMap, bufferTime } from 'rxjs/operators'
import { toSql } from 'pgvector/pg'
import { OmnivoreArticle } from '../../types/OmnivoreArticle'
import { from, pipe } from 'rxjs'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
import { sqlClient } from './db'
import pgformat from 'pg-format'
import { v4 } from 'uuid'
import { onErrorContinue } from '../utils/reactive'
const hasStoredInDatabase = async (articleSlug: string, feedId: string) => {
const { rows } = await sqlClient.query(
'SELECT slug FROM omnivore.discover_feed_articles WHERE slug = $1 and feed_id = $2',
[articleSlug, feedId]
)
return rows && rows.length === 0
}
export const removeDuplicateArticles$ = onErrorContinue(
mergeMap((x: OmnivoreArticle) =>
fromPromise(hasStoredInDatabase(x.slug, x.feedId)).pipe(
filter(Boolean),
map(() => x)
)
)
)
export const batchInsertArticlesSql = async (
articles: EmbeddedOmnivoreArticle[]
) => {
const params = articles.map((embedded) => [
v4(),
embedded.article.title,
embedded.article.feedId,
embedded.article.slug,
embedded.article.description,
embedded.article.url,
embedded.article.authors,
embedded.article.image,
embedded.article.publishedAt,
toSql(embedded.embedding),
])
if (articles.length > 0) {
const formattedMultiInsert = pgformat(
`INSERT INTO omnivore.discover_feed_articles(id, title, feed_id, slug, description, url, author, image, published_at, embedding) VALUES %L ON CONFLICT DO NOTHING`,
params
)
await sqlClient.query(formattedMultiInsert)
const topicLinks = articles.flatMap((it, idx) => {
const [uuid] = params[idx]
return it.topics.map((topic) => [topic, uuid])
})
const formattedTopicInsert = pgformat(
`INSERT INTO omnivore.discover_feed_article_topic_link(discover_topic_name, discover_feed_article_id) VALUES %L ON CONFLICT DO NOTHING`,
topicLinks
)
await sqlClient.query(formattedTopicInsert)
return articles
}
return articles
}
export const insertArticleToStore$ = pipe(
bufferTime<EmbeddedOmnivoreArticle>(5000, null, 100),
onErrorContinue(
mergeMap((x: EmbeddedOmnivoreArticle[]) =>
fromPromise(batchInsertArticlesSql(x))
)
),
mergeMap((it: EmbeddedOmnivoreArticle[]) => from(it))
)

View file

@ -0,0 +1,11 @@
import { Pool } from 'pg'
import { env } from '../../env'
export const sqlClient = new Pool({
port: env.pg.port,
host: env.pg.host,
user: env.pg.userName,
password: env.pg.password,
max: env.pg.pool.max,
database: env.pg.dbName,
})

View file

@ -0,0 +1,14 @@
import { mergeMap, Observable, OperatorFunction } from 'rxjs'
import { sqlClient } from './db'
import { OmnivoreFeed } from '../../types/Feeds'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
export const getRssFeeds$ = fromPromise(
(async (): Promise<OmnivoreFeed[]> => {
const { rows } = (await sqlClient.query(
`SELECT * FROM omnivore.discover_feed WHERE title != 'OMNIVORE_COMMUNITY'`
)) as { rows: OmnivoreFeed[] }
return rows
})()
).pipe(mergeMap((it) => it))

View file

@ -0,0 +1,48 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { EmbeddedOmnivoreLabel } from '../ai/embedding'
import { filter, map, mergeMap } from 'rxjs/operators'
import { toSql } from 'pgvector/pg'
import { OperatorFunction } from 'rxjs'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
import { sqlClient } from './db'
import { Label } from '../../types/OmnivoreSchema'
const hasLabelsStoredInDatabase = async (label: string) => {
const { rows } = await sqlClient.query(
`SELECT label FROM label_embeddings where label = $1`,
[label]
)
return rows && rows.length === 0
}
export const removeDuplicateLabels = mergeMap((x: Label) =>
fromPromise(hasLabelsStoredInDatabase(x.name)).pipe(
filter(Boolean),
map(() => x)
)
)
export const insertLabels = async (
label: EmbeddedOmnivoreLabel
): Promise<EmbeddedOmnivoreLabel> => {
if (label.label.name && label.label.description) {
await sqlClient.query(
'INSERT INTO omnivore.discover_topic_embedding_link(discover_topic_name, embedding_description, embedding) VALUES($1, $2, $3)',
[label.label.name, label.label.description, toSql(label.embedding)]
)
}
return label
}
// export const insertLabelsToFile = async (
// label: EmbeddedOmnivoreLabel,
// ): Promise<EmbeddedOmnivoreLabel> => {
// fs.appendFileSync('./output.json', JSON.stringify(label))
// return label
// }
export const insertLabelToStore: OperatorFunction<
EmbeddedOmnivoreLabel,
EmbeddedOmnivoreLabel
> = mergeMap((x) => fromPromise(insertLabels(x)))

View file

@ -0,0 +1,29 @@
import crypto from 'crypto'
import { encode } from 'urlsafe-base64'
import { env } from '../../env'
function signImageProxyUrl(url: string): string {
return encode(
crypto.createHmac('sha256', env.imageProxy.secretKey!).update(url).digest()
)
}
export function createImageProxyUrl(
url: string,
width = 0,
height = 0
): string {
if (!env.imageProxy.url || !env.imageProxy.secretKey) {
return url
}
// url is already signed
if (url.startsWith(env.imageProxy.url)) {
return url
}
const urlWithOptions = `${url}#${width}x${height}`
const signature = signImageProxyUrl(urlWithOptions)
return `${env.imageProxy.url}/${width}x${height},s${signature}/${url}`
}

View file

@ -0,0 +1,13 @@
function calcVectorSize(vec: number[]) {
return Math.sqrt(vec.reduce((accum, curr) => accum + Math.pow(curr, 2), 0))
}
export function cosineSimilarity(vec1: number[], vec2: number[]) {
const dotProduct = vec1
.map((val, i) => val * vec2[i])
.reduce((accum, curr) => accum + curr, 0)
const vec1Size = calcVectorSize(vec1)
const vec2Size = calcVectorSize(vec2)
return dotProduct / (vec1Size * vec2Size)
}

View file

@ -0,0 +1,69 @@
import {
catchError,
concatMap,
delay,
EMPTY,
mergeMap,
MonoTypeOperatorFunction,
Observable,
of,
OperatorFunction,
pipe,
timer,
} from 'rxjs'
import { filter, retry } from 'rxjs/operators'
import { OmnivoreArticle } from '../../types/OmnivoreArticle'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
export const exponentialBackOff = <T>(
count: number
): MonoTypeOperatorFunction<T> =>
retry({
count,
delay: (error, retryIndex, interval = 200) => {
const delay = Math.pow(2, retryIndex - 1) * interval
console.log(
`Backing off: attempt ${retryIndex}, Trying again in: ${delay}ms`
)
return timer(delay)
},
})
export const onErrorContinue = (...pipes: OperatorFunction<any, any>[]) =>
mergeMap((it: any) => {
let observer: Observable<any> = of(it)
pipes.forEach((pipe) => {
observer = observer.pipe(pipe)
})
return observer.pipe(
catchError((e) => {
console.error('Error caught in pipe, skipping', e)
return EMPTY
})
)
})
export const rateLimiter = <T>(params: {
resetLimit: number
timeMs: number
}) => {
return concatMap((it: T) => {
return of(it).pipe(delay(params.timeMs / params.resetLimit))
})
}
export function mapOrNull(project: (article: any) => Promise<OmnivoreArticle>) {
return pipe(
concatMap((item: any, _value: number) => {
try {
return fromPromise(project(item).catch((_e) => null)).pipe(
filter((it) => !!it)
) as Observable<OmnivoreArticle>
} catch (e) {
return EMPTY
}
})
)
}

View file

@ -0,0 +1,7 @@
export type Embedding = Array<number>
export interface AiClient {
getEmbeddings(text: string): Promise<Embedding>
summarizeText(text: string): Promise<string>
tokenLimit: number
embeddingLimit: number
}

View file

@ -0,0 +1,23 @@
import { Embedding } from './AiClient'
export type BedrockClientParams = {
region: string
endpoint: string
}
export type BedrockClientResponse = {
completion: string
embedding: Embedding
embeddings?: Embedding[]
}
export type BedrockInvokeParams = {
model: string
max_tokens_to_sample: number
temperature: number
top_k: number
top_p: number
stop_sequences: string[]
anthropic_version?: string //TODO: Add the actual params.
prompt: string
}

View file

@ -0,0 +1,13 @@
export type OmnivoreFeed = {
id: string
description?: string
image?: string
link: string
title: string
type: string
}
export type OmnivoreContentFeed = {
feed: OmnivoreFeed
content: string
}

View file

@ -0,0 +1,22 @@
export type OmnivoreArticle = {
slug: string
title: string
description: string
summary: string
image?: string
authors: string
site: string
url: string
publishedAt: Date
type: 'community' | 'rss'
feedId: string
}
export type RSSArticle = {
title: string
link: string
description: string
'media:thumbnail': { '@_url': string }
'dc:creator': string
pubDate: string
}

File diff suppressed because it is too large Load diff

View file

View file

@ -0,0 +1,9 @@
{
"extends": "./../../tsconfig.json",
"compileOnSave": false,
"include": ["./src/**/*"],
"compilerOptions": {
"outDir": "dist",
"typeRoots": ["./../../node_modules/pgvector/types"]
}
}

View file

@ -0,0 +1,51 @@
{
"extends": "tslint:recommended",
"rulesDirectory": ["codelyzer"],
"rules": {
"array-type": false,
"arrow-parens": false,
"deprecation": {
"severity": "warn"
},
"import-blacklist": [true, "rxjs/Rx"],
"interface-name": false,
"max-classes-per-file": false,
"max-line-length": [true, 140],
"member-access": false,
"member-ordering": [
true,
{
"order": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"no-consecutive-blank-lines": false,
"no-console": [true, "debug", "info", "time", "timeEnd", "trace"],
"no-empty": false,
"no-inferrable-types": [true, "ignore-params"],
"no-non-null-assertion": true,
"no-redundant-jsdoc": true,
"no-switch-case-fall-through": true,
"no-use-before-declare": true,
"no-var-requires": false,
"object-literal-key-quotes": [true, "as-needed"],
"object-literal-sort-keys": false,
"ordered-imports": false,
"quotemark": [true, "single"],
"trailing-comma": false,
"no-output-on-prefix": true,
"no-inputs-metadata-property": true,
"no-outputs-metadata-property": true,
"no-host-metadata-property": true,
"no-input-rename": true,
"no-output-rename": true,
"use-life-cycle-interface": true,
"use-pipe-transform-interface": true,
"component-class-suffix": true,
"directive-class-suffix": true
}
}

View file

@ -105,7 +105,7 @@ export const updateMetrics = async (
)
// if the task is finished, send email
if (state == ImportTaskState.FINISHED) {
if ((state as ImportTaskState) == ImportTaskState.FINISHED) {
const metrics = await getMetrics(redisClient, userId, taskId)
if (metrics) {
await sendImportCompletedEmail(userId, metrics.imported, metrics.failed)

View file

@ -0,0 +1,70 @@
import { HStack, SpanBox, VStack } from './LayoutPrimitives'
import { StyledText } from './StyledText'
import { NewspaperClipping } from 'phosphor-react'
import { theme } from '../tokens/stitches.config'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
export function Discover(): JSX.Element {
const [isUsed, setIsUsed] = useState(false)
const router = useRouter()
useEffect(() => {
setIsUsed(window.location.pathname.includes('/discover'))
}, [])
return (
<VStack
css={{
m: '0px',
width: '100%',
borderBottom: '1px solid $thBorderColor',
px: '15px',
background: isUsed ? '$thLibrarySelectionColor' : 'none',
'&:hover': {
background: '$thLibrarySelectionColor',
cursor: 'pointer',
},
}}
onClick={() => {
router.push('/discover')
}}
alignment="start"
distribution="start"
>
<HStack css={{ width: '100%' }} distribution="start" alignment="center">
<StyledText
css={{
fontFamily: '$inter',
fontWeight: '600',
fontSize: '16px',
lineHeight: '125%',
color: '$thLibraryMenuPrimary',
pl: '10px',
pb: '10px',
mt: '20px',
mb: '10px',
}}
>
Discover
</StyledText>
<SpanBox
css={{
display: 'flex',
height: '100%',
mt: '0px',
marginLeft: 'auto',
position: 'relative',
left: '-5px',
verticalAlign: 'middle',
}}
>
<NewspaperClipping
size={15}
color={theme.colors.thLibraryMenuPrimary.toString()}
/>
</SpanBox>
</HStack>
</VStack>
)
}

View file

@ -0,0 +1,28 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class DiscoverIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 30 30" width={size} height={size} >
<path fill={color} stroke={color} d="M5.37,16.18c0.65-0.03,1.2-0.28,1.65-0.75c0.45-0.47,0.68-1.03,0.68-1.68c0,0.65,0.22,1.21,0.67,1.68
c0.45,0.47,1,0.72,1.65,0.75c-0.65,0.03-1.2,0.28-1.65,0.75c-0.45,0.47-0.67,1.03-0.67,1.68c0-0.65-0.22-1.21-0.68-1.68
C6.57,16.46,6.02,16.21,5.37,16.18z M7.7,8.98c1.26-0.06,2.33-0.55,3.21-1.47c0.88-0.92,1.32-2.01,1.32-3.28
c0,1.27,0.44,2.36,1.32,3.28s1.95,1.4,3.22,1.47c-0.83,0.04-1.59,0.27-2.29,0.71c-0.69,0.43-1.24,1.01-1.65,1.73
c-0.4,0.72-0.6,1.49-0.6,2.33c0-1.27-0.44-2.37-1.32-3.29C10.03,9.53,8.96,9.04,7.7,8.98z M11.02,19.75
c0.95-0.04,1.76-0.41,2.42-1.1c0.66-0.69,0.99-1.51,0.99-2.47c0,0.96,0.33,1.78,0.99,2.47c0.66,0.69,1.46,1.06,2.41,1.1
c-0.95,0.04-1.75,0.41-2.41,1.1c-0.66,0.69-0.99,1.51-0.99,2.47c0-0.96-0.33-1.78-0.99-2.47C12.77,20.16,11.97,19.8,11.02,19.75z
M17.83,15.01c0.95-0.04,1.75-0.41,2.41-1.1c0.66-0.69,0.98-1.51,0.98-2.48c0,0.96,0.33,1.78,0.99,2.47s1.47,1.06,2.42,1.1
c-0.95,0.04-1.76,0.41-2.42,1.1c-0.66,0.69-0.99,1.51-0.99,2.47c0-0.96-0.33-1.78-0.98-2.47C19.58,15.42,18.78,15.05,17.83,15.01z"
/>
</svg>
)
}
}

View file

@ -0,0 +1,216 @@
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
import { LibraryFilterMenu } from '../navMenu/LibraryMenu'
import { DiscoverHeader } from './DiscoverHeader/DiscoverHeader'
import { useRouter } from 'next/router'
import React, { useCallback, useEffect, useState } from "react"
import { DiscoverItemFeed } from './DiscoverFeed/DiscoverFeed'
import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery'
import toast from 'react-hot-toast'
import { Button } from '../../elements/Button'
import { showErrorToast } from '../../../lib/toastHelpers'
import {
saveDiscoverArticleMutation,
SaveDiscoverArticleOutput
} from "../../../lib/networking/mutations/saveDiscoverArticle"
import { saveUrlMutation } from "../../../lib/networking/mutations/saveUrlMutation"
import { useFetchMore } from "../../../lib/hooks/useFetchMoreScroll"
import { AddLinkModal } from "../homeFeed/AddLinkModal"
import { useGetDiscoverFeedItems } from "../../../lib/networking/queries/useGetDiscoverFeedItems"
import { useGetDiscoverFeeds } from "../../../lib/networking/queries/useGetDiscoverFeeds"
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
export type TopicTabData = { title: string; subTitle: string }
export function DiscoverContainer(): JSX.Element {
const router = useRouter()
const viewer = useGetViewerQuery()
const [showFilterMenu, setShowFilterMenu] = useState(false)
const [layoutType, setLayoutType] = useState<LayoutType>('GRID_LAYOUT')
const [showAddLinkModal, setShowAddLinkModal] = useState(false);
const {feeds, revalidate, isValidating} = useGetDiscoverFeeds()
const topics = [
{
title: 'Popular',
subTitle: 'Stories that are popular on Omnivore right now...',
},
{
title: 'All',
subTitle: 'All the discover stories...',
},
{
title: 'Technology',
subTitle:
'Stories about Gadgets, AI, Software and other technology related topics',
},
{
title: 'Politics',
subTitle:
'Stories about Leadership, Elections, and issues affecting countries and the world',
},
{
title: 'Health & Wellbeing',
subTitle: 'Stories about Physical, Mental and Preventative Health',
},
{
title: 'Business & Finance',
subTitle:
'Stories about the business world, startups, and the world of financial advice. ',
},
{
title: 'Science & Education',
subTitle:
'Stories about science, breakthroughs, and the way the world works. ',
},
{
title: 'Culture',
subTitle:
'Entertainment, Movies, Television and things that make life worth living',
},
{
title: 'Gaming',
subTitle: 'PC and Console gaming, reviews, and opinions',
},
]
const [selectedFeed, setSelectedFeed] = useState("All Feeds");
const { discoverItems, setTopic, activeTopic, isLoading, hasMore, setPage, page } = useGetDiscoverFeedItems(topics[1], selectedFeed)
const handleFetchMore = useCallback(() => {
if (isLoading || !hasMore) {
return
}
setPage(page + 1)
}, [page, isLoading])
useFetchMore(handleFetchMore)
const handleSaveDiscover = async (
discoverArticleId: string,
timezone: string,
locale: string
): Promise<SaveDiscoverArticleOutput | undefined> => {
const result = await saveDiscoverArticleMutation({discoverArticleId, timezone, locale})
if (result?.saveDiscoverArticle) {
toast(
() => (
<Box>
Link Saved
<span style={{ padding: '16px' }} />
<Button
style="ctaDarkYellow"
autoFocus
onClick={() => {
window.location.href = `/article?url=${encodeURIComponent(
result.saveDiscoverArticle.url
)}`
}}
>
Read Now
</Button>
</Box>
),
{ position: 'bottom-right' }
)
return result
} else {
showErrorToast('Error saving link', { position: 'bottom-right' })
}
}
const handleLinkSave = async (
link: string,
timezone: string,
locale: string
): Promise<void> => {
const result = await saveUrlMutation(link, timezone, locale)
if (result) {
toast(
() => (
<Box>
Link Saved
<span style={{ padding: '16px' }} />
<Button
style="ctaDarkYellow"
autoFocus
onClick={() => {
window.location.href = `/article?url=${encodeURIComponent(
link
)}`
}}
>
Read Now
</Button>
</Box>
),
{ position: 'bottom-right' }
)
} else {
showErrorToast('Error saving link', { position: 'bottom-right' })
}
}
useEffect(() => {
if (window) {
setLayoutType(
JSON.parse(
window.localStorage.getItem('libraryLayout') || 'GRID_LAYOUT'
)
)
}
}, [])
const setTopicAndReturnToTop = (topic: TopicTabData) => {
window.scroll(0,0);
setTopic(topic);
}
return (
<VStack
css={{
height: '100%',
width: 'unset',
}}
>
<DiscoverHeader
handleLinkSubmission={handleLinkSave}
allowSelectMultiple={true}
alwaysShowHeader={false}
showFilterMenu={showFilterMenu}
setShowFilterMenu={setShowFilterMenu}
selectedFeedFilter={selectedFeed}
applyFeedFilter={setSelectedFeed}
feeds={feeds}
activeTab={activeTopic}
setActiveTab={setTopicAndReturnToTop}
layout={layoutType}
setShowAddLinkModal={setShowAddLinkModal}
setLayoutType={setLayoutType}
topics={topics}
/>
<HStack css={{ width: '100%', height: '100%' }}>
<LibraryFilterMenu
setShowAddLinkModal={setShowAddLinkModal}
searchTerm={'NONE'} // This is done to stop the library filter menu actually having a highlight. Hacky.
applySearchQuery={(searchQuery: string) => {
router?.push(`/home?q=${searchQuery}`)
}}
showFilterMenu={showFilterMenu}
setShowFilterMenu={setShowFilterMenu}
/>
<DiscoverItemFeed
layout={layoutType}
activeTab={activeTopic}
handleLinkSubmission={handleSaveDiscover}
items={discoverItems ?? []}
viewer={viewer.viewerData?.me}
/>
{ showAddLinkModal &&
<AddLinkModal
handleLinkSubmission={handleLinkSave}
onOpenChange={() => setShowAddLinkModal(false)}
/>
}
</HStack>
</VStack>
)
}

View file

@ -0,0 +1,71 @@
import { HStack, VStack } from "../../../elements/LayoutPrimitives"
import { Toaster } from 'react-hot-toast'
import { LayoutType } from '../../homeFeed/HomeFeedContainer'
import { UserBasicData } from '../../../../lib/networking/queries/useGetViewerQuery'
import { DiscoverItems } from '../DiscoverItems/DiscoverItems'
import { SaveDiscoverArticleOutput } from "../../../../lib/networking/mutations/saveDiscoverArticle"
import { HeaderText } from "../DiscoverHeader/HeaderText"
import React from "react"
import { TopicTabData } from "../DiscoverContainer"
import { DiscoverFeedItem } from "../../../../lib/networking/queries/useGetDiscoverFeedItems"
type DiscoverItemFeedProps = {
items: DiscoverFeedItem[]
layout: LayoutType
viewer?: UserBasicData
activeTab: TopicTabData
handleLinkSubmission: (
link: string,
timezone: string,
locale: string
) => Promise<SaveDiscoverArticleOutput | undefined>
}
export const DiscoverItemFeed = (props: DiscoverItemFeedProps) => {
return (
<>
<VStack
alignment="start"
distribution="start"
css={{
height: '100%',
minHeight: '100vh',
}}
>
<Toaster />
<HStack
alignment="center"
distribution={'start'}
css={{
gap: '10px',
width: '95%',
display: 'block',
'@mdDown': {
width: '95%',
display: 'none',
},
'@media (max-width: 930px)': {
display: 'none',
},
'@media (min-width: 930px)': {
width: '660px',
},
'@media (min-width: 1280px)': {
width: '1000px',
},
'@media (min-width: 1600px)': {
width: '1340px',
},
}}
>
<HeaderText
title={props.activeTab.title}
subTitle={props.activeTab.subTitle}
/>
</HStack>
<DiscoverItems {...props} />
</VStack>
</>
)
}

View file

@ -0,0 +1,81 @@
import React from 'react'
import { Box, VStack } from '../../../elements/LayoutPrimitives'
import { LIBRARY_LEFT_MENU_WIDTH } from '../../navMenu/LibraryMenu'
import { LargeHeaderLayout } from './LargerHeaderLayout'
import { SmallHeaderLayout } from './SmallerHeaderLayout'
import { LayoutType, TopicTabData } from '../DiscoverContainer'
import { DiscoverFeed } from "../../../../lib/networking/queries/useGetDiscoverFeeds"
export type DiscoverHeaderProps = {
alwaysShowHeader: boolean
allowSelectMultiple: boolean
showFilterMenu: boolean
setShowFilterMenu: (show: boolean) => void
setShowAddLinkModal: (show: boolean) => void
handleLinkSubmission: (
link: string,
timezone: string,
locale: string
) => Promise<void>
activeTab: TopicTabData
setActiveTab: (tab: TopicTabData) => void
topics: TopicTabData[]
feeds: DiscoverFeed[]
applyFeedFilter: (feedFilter: string) => void
selectedFeedFilter: string
layout: LayoutType
setLayoutType: (layout: LayoutType) => void
}
function DiscoverHeaderSpace() {
return (
<Box
css={{
height: '90px',
bg: '$grayBase',
'@media (max-width: 930px)': {
height: '70px',
},
}}
></Box>
)
}
export function DiscoverHeader(props: DiscoverHeaderProps): JSX.Element {
return (
<>
<VStack
alignment="center"
distribution="start"
css={{
pt: '15px',
top: '0',
right: '0',
left: LIBRARY_LEFT_MENU_WIDTH,
zIndex: 5,
position: 'fixed',
bg: '$thLibraryBackground',
'@mdDown': {
left: '0px',
pt: '0px',
},
}}
>
{/* These will display/hide depending on breakpoints */}
<LargeHeaderLayout {...props} />
<SmallHeaderLayout {...props} />
</VStack>
{/* This spacer is put in to push library content down
below the fixed header height. */}
<DiscoverHeaderSpace />
</>
)
}

View file

@ -0,0 +1,56 @@
import { HStack, VStack } from '../../../elements/LayoutPrimitives'
import React, { useEffect, useRef, useState } from 'react'
type HeaderTextProps = {
title: string
subTitle: string
}
export function HeaderText(props: HeaderTextProps): JSX.Element {
return (
<>
<VStack alignment={'start'}>
<HStack
css={{
font: '$inter',
fontSize: 'min(4.25vw, 75px)',
fontWeight: '750',
whiteSpace: 'nowrap',
lineHeight: '75px',
color: '$thLibraryMenuPrimary',
}}
>
{props.title}
</HStack>
</VStack>
<VStack
alignment={'end'}
distribution={'end'}
css={{ width: '100%', fontSize: '30px' }}
>
<HStack
distribution={'end'}
css={{
font: '$inter',
fontWeight: '100',
whiteSpace: 'nowrap',
fontSize: '2vw',
color: '$thLibraryMenuPrimary',
}}
>
{props.subTitle}
</HStack>
</VStack>
<VStack alignment={'end'} distribution={'end'} css={{ width: '100%' }}>
<HStack
css={{
width: '100%',
height: '1px',
backgroundColor: '$thBorderColor',
}}
></HStack>
</VStack>
</>
)
}

View file

@ -0,0 +1,94 @@
import { HStack, VStack } from '../../../elements/LayoutPrimitives'
import { TopicBar } from './TopicBar'
import { Button } from '../../../elements/Button'
import { DiscoverHeaderProps } from './DiscoverHeader'
import React from 'react'
import { PinnedFeeds } from "./PinnedFeeds"
import { HeaderToggleGridIcon } from "../../../elements/icons/HeaderToggleGridIcon"
import { HeaderToggleListIcon } from "../../../elements/icons/HeaderToggleListIcon"
export function LargeHeaderLayout(props: DiscoverHeaderProps): JSX.Element {
return (
<HStack
alignment="center"
distribution="center"
css={{
width: '100%',
height: '100%',
'@mdDown': {
display: 'none',
},
}}
>
<VStack alignment={'center'} distribution={'center'}>
<HStack
alignment="center"
distribution={'start'}
css={{
gap: '10px',
width: '95%',
'@mdDown': {
width: '95%',
display: 'none',
},
'@media (min-width: 930px)': {
width: '660px',
},
'@media (min-width: 1280px)': {
width: '1000px',
},
'@media (min-width: 1600px)': {
width: '1340px',
},
}}
>
<TopicBar
setActiveTab={props.setActiveTab}
activeTab={props.activeTab}
topics={props.topics}
/>
<Button
style="plainIcon"
css={{ display: 'flex', marginLeft: 'auto' }}
onClick={(e) => {
props.setLayoutType(
props.layout == 'GRID_LAYOUT' ? 'LIST_LAYOUT' : 'GRID_LAYOUT'
)
e.preventDefault()
}}
>
{props.layout == 'LIST_LAYOUT' ? (
<HeaderToggleGridIcon />
) : (
<HeaderToggleListIcon />
)}
</Button>
</HStack>
<HStack
alignment="center"
distribution={'start'}
css={{
gap: '10px',
width: '95%',
paddingBottom: '5px',
'@mdDown': {
width: '95%',
display: 'none',
},
'@media (min-width: 930px)': {
width: '660px',
},
'@media (min-width: 1280px)': {
width: '1000px',
},
'@media (min-width: 1600px)': {
width: '1340px',
},
}}
>
<PinnedFeeds items={props.feeds} selected={props.selectedFeedFilter} applyFeedFilter={props.applyFeedFilter} />
</HStack>
</VStack>
</HStack>
)
}

View file

@ -0,0 +1,86 @@
import { HStack, SpanBox } from '../../../elements/LayoutPrimitives'
import { theme } from '../../../tokens/stitches.config'
import { Button } from '../../../elements/Button'
import { Dropdown, DropdownOption } from '../../../elements/DropdownElements'
import { MoreOptionsIcon } from '../../../elements/images/MoreOptionsIcon'
import { useRouter } from 'next/router'
import { DiscoverFeed } from "../../../../lib/networking/queries/useGetDiscoverFeeds"
type PinnedFeedsProps = {
items: DiscoverFeed[]
selected: string
applyFeedFilter: (feedFilter: string) => void
}
export const PinnedFeeds = (props: PinnedFeedsProps): JSX.Element => {
const router = useRouter()
return (
<HStack
alignment="center"
distribution="start"
css={{
width: '100%',
maxWidth: '100%',
pt: '10px',
pb: '0px',
gap: '10px',
bg: 'transparent',
// overflowX: 'scroll',
}}
>
{[{ title: "All Feeds", id:"All Feeds" }, { title: "Community", id: "Community" }, ...props.items.map(({visibleName, id}) => ({ title: visibleName, id }))].map((it) => {
const style =
it.id == props.selected ? 'ctaPill' : 'ctaPillUnselected'
return (
<Button
key={it.id}
style={style}
onClick={(event) => {
props.applyFeedFilter(it.id)
event.preventDefault()
}}
>
{it.title}
</Button>
)
})}
<Dropdown
triggerElement={
<SpanBox
css={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
width: '24px',
height: '24px',
border: '1px solid $thBackground4',
backgroundColor: '$thBackground4',
'&:hover': {
bg: '$grayBgHover',
border: '1px solid $grayBgHover',
},
}}
>
<MoreOptionsIcon
size={16}
strokeColor={theme.colors.grayText.toString()}
orientation={'horizontal'}
/>
</SpanBox>
}
css={{}}
>
<DropdownOption
onSelect={() => {
router.push('/settings/discover-feeds')
}}
title="Edit"
/>
</Dropdown>
</HStack>
)
}

View file

@ -0,0 +1,117 @@
import { HStack } from '../../../elements/LayoutPrimitives'
import { TopicTab } from './TopicTab'
import { CaretLeft, CaretRight } from 'phosphor-react'
import React, { useEffect, useRef, useState } from 'react'
import { TopicTabData } from '../DiscoverContainer'
export type TopicBarProps = {
activeTab: TopicTabData
setActiveTab: (tab: TopicTabData) => void
topics: TopicTabData[]
}
export function SmallTopicBar(props: TopicBarProps): JSX.Element {
const [overflowing, setOverflowing] = useState(false)
let scrollToken: NodeJS.Timer | null = null
const topicParent = useRef<HTMLDivElement>(null)
const topicChild = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleResize = () => {
if (
topicChild.current &&
topicParent.current &&
topicChild.current.offsetWidth < topicChild.current.scrollWidth
) {
setOverflowing(true)
return
}
setOverflowing(false)
}
handleResize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
const scroll = (rightOrLeft: 'right' | 'left') => () => {
const offset = rightOrLeft == 'right' ? +1 : -1
scrollToken = setInterval(() => {
if (topicChild.current) {
topicChild.current.scrollLeft += offset
}
})
}
const clearScroll = () => {
clearInterval(scrollToken as NodeJS.Timeout)
scrollToken = null
}
return (
<>
<HStack
alignment="start"
distribution="evenly"
css={{ width: '100%', height: '100%' }}
>
<HStack
ref={topicParent}
alignment={'center'}
distribution={'start'}
css={{
overflow: 'hidden',
position: 'relative',
flexGrow: '1',
width: '0px',
}}
>
<CaretLeft
size={18}
style={{
pointerEvents: 'all',
cursor: 'pointer',
minWidth: '40px',
width: '40px',
}}
onMouseEnter={scroll('left')}
onMouseLeave={clearScroll}
/>
<HStack
alignment={'start'}
distribution={'start'}
css={{ pl: '15px', pr: '15px', overflow: 'hidden' }}
ref={topicChild}
>
{(props.topics ?? []).map((topic) => {
return (
<TopicTab
key={topic.title + props.activeTab.title}
title={topic.title}
selected={props.activeTab.title == topic.title}
onClick={() => {
props.setActiveTab(topic)
}}
/>
)
})}
</HStack>
<CaretRight
size={18}
style={{
pointerEvents: 'all',
cursor: 'pointer',
minWidth: '40px',
width: '40px',
}}
onMouseEnter={scroll('right')}
onMouseLeave={clearScroll}
/>
</HStack>
</HStack>
</>
)
}

View file

@ -0,0 +1,76 @@
import React from 'react'
import { HStack } from '../../../elements/LayoutPrimitives'
import { OmnivoreSmallLogo } from '../../../elements/images/OmnivoreNameLogo'
import { theme } from '../../../tokens/stitches.config'
import { FunnelSimple } from 'phosphor-react'
import { DiscoverHeaderProps } from './DiscoverHeader'
import { SmallTopicBar } from './SmallTopicBar'
import { PrimaryDropdown } from '../../PrimaryDropdown'
export function SmallHeaderLayout(props: DiscoverHeaderProps): JSX.Element {
return (
<HStack
alignment="center"
distribution="start"
css={{
width: '100%',
height: '100%',
pt: '10px',
pb: '10px',
pr: '20px',
bg: '$thBackground3',
'@md': {
display: 'none',
},
}}
>
<>
<MenuHeaderButton {...props} />
<SmallTopicBar {...props} />
</>
</HStack>
)
}
type MenuHeaderButtonProps = {
showFilterMenu: boolean
setShowFilterMenu: (show: boolean) => void
}
export function MenuHeaderButton(props: MenuHeaderButtonProps): JSX.Element {
return (
<HStack
css={{
ml: '10px',
width: '67px',
height: '40px',
bg: props.showFilterMenu ? '$thTextContrast2' : '$thBackground2',
borderRadius: '5px',
px: '5px',
cursor: 'pointer',
}}
alignment="center"
distribution="around"
onClick={() => {
props.setShowFilterMenu(!props.showFilterMenu)
}}
>
<OmnivoreSmallLogo
size={20}
strokeColor={
props.showFilterMenu
? theme.colors.thBackground.toString()
: theme.colors.thTextContrast2.toString()
}
/>
<FunnelSimple
size={20}
color={
props.showFilterMenu
? theme.colors.thBackground.toString()
: theme.colors.thTextContrast2.toString()
}
/>
</HStack>
)
}

View file

@ -0,0 +1,150 @@
import { Box, HStack } from '../../../elements/LayoutPrimitives'
import { TopicTab } from './TopicTab'
import { CaretLeft, CaretRight } from 'phosphor-react'
import React, { useEffect, useRef, useState } from 'react'
import { TopicTabData } from '../DiscoverContainer'
export type TopicBarProps = {
activeTab: TopicTabData
setActiveTab: (tab: TopicTabData) => void
topics: TopicTabData[]
}
export function TopicBar(props: TopicBarProps): JSX.Element {
const [overflowing, setOverflowing] = useState(true)
let scrollToken: NodeJS.Timer | null = null
const topicParent = useRef<HTMLDivElement>(null)
const topicChild = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleResize = () => {
if (
topicChild.current &&
topicParent.current &&
topicChild.current.offsetWidth < topicChild.current.scrollWidth
) {
setOverflowing(true)
return
}
setOverflowing(false)
}
handleResize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
const scroll = (rightOrLeft: 'right' | 'left') => () => {
const offset = rightOrLeft == 'right' ? +5 : -5
scrollToken = setInterval(() => {
if (topicChild.current) {
topicChild.current.scrollLeft += offset
}
})
}
const clearScroll = () => {
clearInterval(scrollToken as NodeJS.Timeout)
scrollToken = null
}
return (
<Box
css={{
height: '38px',
width: 'calc(100% - 80px)',
bg: '$thLibrarySearchbox',
borderRadius: '6px',
border: '2px solid transparent',
boxShadow:
'0 1px 3px 0 rgba(0, 0, 0, 0.1),0 1px 2px 0 rgba(0, 0, 0, 0.06);',
'@media (max-width: 930px)': {
width: '420px',
},
}}
>
<HStack
alignment="center"
distribution="start"
css={{ width: '100%', height: '100%' }}
>
<HStack
alignment="center"
distribution="start"
css={{
height: '100%',
}}
onClick={(e) => {
e.preventDefault()
}}
></HStack>
<form style={{ width: '100%' }}>
<HStack
ref={topicParent}
alignment={'start'}
distribution={'start'}
css={{
position: 'relative',
overflow: 'hidden',
'@media (max-width: 930px)': {
width: '400px',
},
}}
>
{overflowing && (
<HStack
alignment={'center'}
distribution={'between'}
css={{
position: 'absolute',
pl: '3px',
pr: '3px',
width: '100%',
top: 'calc(50% - 9px)',
pointerEvents: 'none',
}}
>
<CaretLeft
size={18}
style={{ pointerEvents: 'all', cursor: 'pointer' }}
onMouseEnter={scroll('left')}
onMouseLeave={clearScroll}
/>
<CaretRight
size={18}
style={{ pointerEvents: 'all', cursor: 'pointer' }}
onMouseEnter={scroll('right')}
onMouseLeave={clearScroll}
/>
</HStack>
)}
<HStack
alignment={'start'}
distribution={'start'}
css={{ pl: '15px', pr: '15px', overflow: 'hidden' }}
ref={topicChild}
>
{props.topics.map((topic) => {
return (
<TopicTab
key={topic.title + props.activeTab.title}
title={topic.title}
selected={props.activeTab.title == topic.title}
onClick={() => {
props.setActiveTab(topic)
}}
/>
)
})}
</HStack>
</HStack>
</form>
</HStack>
</Box>
)
}

View file

@ -0,0 +1,91 @@
import { Button } from '../../../elements/Button'
import { isDarkTheme } from '../../../../lib/themeUpdater'
import { useEffect, useState } from 'react'
export type TopicTabProps = {
title: string
selected: boolean
onClick: () => void
}
const selectedStyle = {
cursor: 'pointer',
borderRadius: '15px',
px: '12px',
py: '5px',
font: '$inter',
fontSize: '12px',
fontWeight: '500',
whiteSpace: 'nowrap',
border: '1px solid $thBackground4',
backgroundColor: '$thBackground4',
color: '$thLibraryMenuPrimary',
}
const unselectedStyle = {
cursor: 'pointer',
borderRadius: '15px',
px: '12px',
py: '5px',
font: '$inter',
fontSize: '12px',
fontWeight: '500',
whiteSpace: 'nowrap',
border: '1px solid $thLeftMenuBackground',
backgroundColor: '$thLeftMenuBackground',
'&:hover': {
bg: '$thBackground5',
border: '1px solid $thBackground5',
},
}
export function TopicTab(props: TopicTabProps): JSX.Element {
const [style, setStyle] = useState(
isDarkTheme()
? props.selected
? unselectedStyle
: selectedStyle
: props.selected
? selectedStyle
: unselectedStyle
)
useEffect(() => {
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
setStyle(
isDarkTheme()
? props.selected
? unselectedStyle
: selectedStyle
: props.selected
? selectedStyle
: unselectedStyle
)
}
})
})
mutationObserver.observe(document.getElementsByTagName('html')[0], {
attributes: true,
})
return () => {
mutationObserver.disconnect()
}
}, [])
return (
<Button
key={props.title}
css={style}
onClick={(event) => {
event.preventDefault()
props.onClick()
}}
>
{props.title}
</Button>
)
}

View file

@ -0,0 +1,127 @@
import { UserBasicData } from '../../../../lib/networking/queries/useGetViewerQuery'
import { Box } from '../../../elements/LayoutPrimitives'
import { Button } from '../../../elements/Button'
import { theme } from '../../../tokens/stitches.config'
import {
BookmarkSimple,
Browsers,
MinusCircle,
PlusCircle,
} from 'phosphor-react'
import { timeZone, locale } from '../../../../lib/dateFormatting'
import React from 'react'
import { SaveDiscoverArticleOutput } from "../../../../lib/networking/mutations/saveDiscoverArticle"
import { DiscoverFeedItem } from "../../../../lib/networking/queries/useGetDiscoverFeedItems"
import { BrowserIcon } from "../../../elements/icons/BrowserIcon"
type DiscoverHoverActionsProps = {
viewer?: UserBasicData
isHovered: boolean
handleLinkSubmission: (
link: string,
timezone: string,
locale: string
) => Promise<SaveDiscoverArticleOutput | undefined>
item: DiscoverFeedItem
setSavedId: (slug: string) => void
savedId?: string
setSavedUrl: (url: string) => void
savedUrl?: string
deleteDiscoverItem: (item: DiscoverFeedItem) => Promise<void>,
}
export const DiscoverHoverActions = (props: DiscoverHoverActionsProps) => {
return (
<Box
css={{
overflow: 'clip',
height: '33px',
width: '75px',
bg: '$thBackground',
display: 'flex',
pt: '0px',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid $thBackground5',
borderRadius: '5px',
visibility: props.isHovered ? 'visible' : 'hidden',
gap: '5px',
px: '5px',
'&:hover': {
boxShadow:
'0 1px 3px 0 rgba(0, 0, 0, 0.1),0 1px 2px 0 rgba(0, 0, 0, 0.06);',
},
}}
>
<Button
title={
(props.savedId && 'Remove From Library (A)') ||
'Add to Library (A)'
}
style="hoverActionIcon"
onClick={(event) => {
console.log(props);
if (!props.savedUrl) {
props.handleLinkSubmission(props.item.id, timeZone, locale)
.then((item) => {
if (item) {
props.setSavedId(item.saveDiscoverArticle.saveId)
props.setSavedUrl(item.saveDiscoverArticle.url)
}
})
} else {
props.deleteDiscoverItem(props.item)
}
event.preventDefault()
event.stopPropagation()
}}
>
<BookmarkSimple
size={21}
color={theme.colors.thNotebookSubtle.toString()}
/>
<div style={{ position: 'absolute', top: '2px', left: '20px' }}>
{props.savedId == undefined && (
<>
{' '}
<PlusCircle size={12} color="#70c44f" weight="fill" />{' '}
<PlusCircle
size={12}
color="white"
style={{ position: 'absolute', top: '3.5px', left: '0px' }}
/>{' '}
</>
)}
{props.savedId != undefined && (
<>
{' '}
<MinusCircle size={12} color="#de5454" weight="fill" />{' '}
<MinusCircle
size={12}
color="white"
style={{ position: 'absolute', top: '3.5px', left: '0px' }}
/>{' '}
</>
)}
</div>
</Button>
<Button
title="Go to Original Article (O)"
style="hoverActionIcon"
onClick={(event) => {
// OK So we go to the original article.
window.open(props.item.url, '_blank', 'noreferrer')
event.preventDefault()
event.stopPropagation()
}}
>
<BrowserIcon size={21} color={theme.colors.thNotebookSubtle.toString()} />
</Button>
</Box>
)
}

View file

@ -0,0 +1,54 @@
import { LayoutType } from '../../homeFeed/HomeFeedContainer'
import { UserBasicData } from '../../../../lib/networking/queries/useGetViewerQuery'
import { DiscoverGridCard } from './DiscoverItemGridCard'
import { DiscoverItemListCard } from './DiscoverItemListCard'
import { SaveDiscoverArticleOutput } from "../../../../lib/networking/mutations/saveDiscoverArticle"
import { deleteDiscoverArticleMutation } from "../../../../lib/networking/mutations/deleteDiscoverArticle"
import { showErrorToast, showSuccessToast } from "../../../../lib/toastHelpers"
import { useState } from "react"
import { DiscoverFeedItem } from "../../../../lib/networking/queries/useGetDiscoverFeedItems"
export type DiscoverItemCardProps = {
item: DiscoverFeedItem
layout: LayoutType
viewer?: UserBasicData
isHovered?: boolean
handleLinkSubmission: (
link: string,
timezone: string,
locale: string
) => Promise<SaveDiscoverArticleOutput | undefined>
}
export type DiscoverItemSubCardProps = DiscoverItemCardProps & {
deleteDiscoverItem: (item: DiscoverFeedItem) => Promise<void>,
savedId?: string,
setSavedId: (id: string | undefined) => void
savedUrl?: string,
setSavedUrl: (id: string | undefined) => void
}
export function DiscoverItemCard(props: DiscoverItemCardProps): JSX.Element {
const [savedId, setSavedId] = useState(props.item.savedId)
const [savedUrl, setSavedUrl] = useState(props.item.savedLinkUrl)
const deleteDiscoverItem = (item: DiscoverFeedItem) : Promise<void> => {
return deleteDiscoverArticleMutation({ discoverArticleId: item.id })
.then(it => {
if (it?.deleteDiscoverArticle.id) {
showSuccessToast('Article deleted', { position: 'bottom-right' })
setSavedId(undefined)
setSavedUrl(undefined)
} else {
showErrorToast('Unable to delete Article', { position: 'bottom-right' })
}
})
}
if (props.layout == 'LIST_LAYOUT') {
return <DiscoverItemListCard {...{...props, savedId, savedUrl, setSavedId, setSavedUrl, deleteDiscoverItem}} />
} else {
return <DiscoverGridCard {...{...props, savedId, savedUrl, setSavedId, setSavedUrl, deleteDiscoverItem}} />
}
}

View file

@ -0,0 +1,246 @@
import React, { useState } from 'react'
import {
autoUpdate,
offset,
size,
useFloating,
useHover,
useInteractions,
} from '@floating-ui/react'
import {
Box,
HStack,
SpanBox,
VStack,
} from '../../../elements/LayoutPrimitives'
import { isTouchScreenDevice } from '../../../../lib/deviceType'
import { GridFallbackImage } from '../../../patterns/LibraryCards/FallbackImage'
import { CoverImage } from '../../../elements/CoverImage'
import {
AuthorInfoStyle,
MetaStyle,
siteName,
TitleStyle,
} from '../../../patterns/LibraryCards/LibraryCardStyles'
import { DiscoverItemCardProps, DiscoverItemSubCardProps } from "./DiscoverItemCard"
import { DiscoverItemMetadata } from './DiscoverItemMetadata'
import { DiscoverHoverActions } from './DiscoverHoverActions'
import { CheckCircle, Circle } from 'phosphor-react'
export function DiscoverGridCard(props: DiscoverItemSubCardProps): JSX.Element {
const [isHovered, setIsHovered] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [
offset({
mainAxis: -25,
}),
size(),
],
placement: 'top-end',
whileElementsMounted: autoUpdate,
})
const hover = useHover(context)
const { getReferenceProps, getFloatingProps } = useInteractions([hover])
return (
<VStack
ref={refs.setReference}
{...getReferenceProps()}
css={{
pl: '0px',
padding: '0px',
width: '293px',
height: '100%',
minHeight: '270px',
background: 'white',
borderRadius: '5px',
borderWidth: '1px',
borderStyle: 'none',
overflow: 'hidden',
cursor: 'pointer',
'@media (max-width: 930px)': {
width: 'calc(100% - 30px)',
},
'@mdDown': {
width: '100%',
},
}}
alignment="start"
distribution="start"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
>
{!isTouchScreenDevice() && (
<Box
ref={refs.setFloating}
style={{ ...floatingStyles, zIndex: 3 }}
{...getFloatingProps()}
>
<DiscoverHoverActions
item={props.item}
viewer={props.viewer}
isHovered={isHovered ?? false}
handleLinkSubmission={props.handleLinkSubmission}
setSavedId={props.setSavedId}
savedId={props.savedId}
savedUrl={props.savedUrl}
setSavedUrl={props.setSavedUrl}
deleteDiscoverItem={props.deleteDiscoverItem}
/>
</Box>
)}
<DiscoverGridCardContent {...props} savedId={props.savedId} savedUrl={props.savedUrl} isHovered={isHovered} />
</VStack>
)
}
const DiscoverGridCardContent = (
props: DiscoverItemCardProps & { savedId?: string; savedUrl?: string }
): JSX.Element => {
const { item } = props
const [displayFallback, setDisplayFallback] = useState(
props.item.image == undefined
)
const originText = siteName(props.item.url, props.item.url)
const goToUrl = () => {
if (props.savedUrl) {
window.location.href = props.savedUrl
}
}
return (
<VStack css={{ p: '0px', m: '0px', width: '100%', cursor: props.savedId ? 'pointer' : 'default' }} onClick={goToUrl} >
<Box css={{ position: 'relative', width: '100%', height: '150px' }}>
<>
<HStack
css={{
position: 'absolute',
left: '5px',
top: '5px',
color: '$thTextContrast2',
opacity: props.savedId ? 1 : 0.25,
}}
>
<CheckCircle
size={26}
color="#669852"
weight="fill"
style={{ zIndex: 2 }}
/>
<Circle
size={26}
color="white"
weight="fill"
style={{ position: 'absolute', zIndex: 1 }}
/>
</HStack>
{displayFallback ? (
<GridFallbackImage
title={item.title ?? 'Omnivore Fallback'}
width="100%"
height="150px"
fontSize="128px"
/>
) : (
<CoverImage
src={props.item.image}
width="100%"
height="150px"
css={{
bg: '$thBackground',
cursor: props.savedId ? 'pointer' : 'default'
}}
onError={(e) => {
setDisplayFallback(true)
}}
/>
)}
</>
</Box>
<HStack
css={{
...MetaStyle,
mt: '15px',
px: '15px',
}}
distribution="start"
>
<DiscoverItemMetadata item={props.item} />
</HStack>
<VStack
alignment="start"
distribution="start"
css={{ height: '100%', width: '100%', px: '15px' }}
>
<Box
css={{
...TitleStyle,
mt: '5px',
}}
>
{props.item.title}
</Box>
<SpanBox
css={{
color: '$thTextSubtle2',
fontSize: '12px',
fontWeight: '400',
maxLines: 3,
height: '45px',
lineHeight: 1.25,
fontFamily: '$display',
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
display: '-webkit-box',
'-webkit-line-clamp': '3',
'-webkit-box-orient': 'vertical',
mt: '5px',
mb: '15px',
}}
>
{props.item.description}
</SpanBox>
<SpanBox
css={{
...AuthorInfoStyle,
mt: '5px',
}}
>
{props.item.author}
{props.item.author && originText && ' | '}
{originText}
</SpanBox>
<HStack
distribution="start"
alignment="start"
css={{ width: '100%', minHeight: '15px', pb: '15px' }}
>
<HStack
css={{
display: 'block',
minHeight: '0px',
marginLeft: '-4px', // offset because the chips have margin
}}
></HStack>
</HStack>
</VStack>
</VStack>
)
}

View file

@ -0,0 +1,228 @@
import React, { useState } from 'react'
import {
autoUpdate,
offset,
size,
useFloating,
useHover,
useInteractions,
} from '@floating-ui/react'
import {
Box,
HStack,
SpanBox,
VStack,
} from '../../../elements/LayoutPrimitives'
import { LIBRARY_LEFT_MENU_WIDTH } from '../../navMenu/LibraryMenu'
import { isTouchScreenDevice } from '../../../../lib/deviceType'
import { ListFallbackImage } from '../../../patterns/LibraryCards/FallbackImage'
import { CoverImage } from '../../../elements/CoverImage'
import {
AuthorInfoStyle,
MetaStyle,
siteName,
TitleStyle,
} from '../../../patterns/LibraryCards/LibraryCardStyles'
import { CheckCircle, Circle } from 'phosphor-react'
import { DiscoverItemCardProps, DiscoverItemSubCardProps } from "./DiscoverItemCard"
import { DiscoverItemMetadata } from './DiscoverItemMetadata'
import { DiscoverHoverActions } from './DiscoverHoverActions'
export function DiscoverItemListCard(
props: DiscoverItemSubCardProps
): JSX.Element {
const [isOpen, setIsOpen] = useState(false)
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [
offset({
mainAxis: -25,
}),
size(),
],
placement: 'top-end',
whileElementsMounted: autoUpdate,
})
const hover = useHover(context)
const { getReferenceProps, getFloatingProps } = useInteractions([hover])
return (
<VStack
ref={refs.setReference}
{...getReferenceProps()}
css={{
px: '20px',
pl: '10px',
py: '15px',
height: '100%',
cursor: 'pointer',
gap: '10px',
borderStyle: 'none',
borderBottom: 'none',
borderRadius: '6px',
width: '100%',
'@media (min-width: 768px)': {
width: `calc(100vw - ${LIBRARY_LEFT_MENU_WIDTH})`,
},
'@media (min-width: 930px)': {
width: '660px',
},
'@media (min-width: 1280px)': {
width: '1000px',
},
'@media (min-width: 1600px)': {
width: '1340px',
},
'@media (max-width: 930px)': {
borderRadius: '0px',
},
}}
alignment="start"
distribution="start"
>
{!isTouchScreenDevice() && (
<Box
ref={refs.setFloating}
style={{ ...floatingStyles, zIndex: 3 }}
{...getFloatingProps()}
>
<DiscoverHoverActions
item={props.item}
viewer={props.viewer}
isHovered={isOpen ?? false}
handleLinkSubmission={props.handleLinkSubmission}
setSavedId={props.setSavedId}
savedId={props.savedId}
savedUrl={props.savedUrl}
setSavedUrl={props.setSavedUrl}
deleteDiscoverItem={props.deleteDiscoverItem}
/>
</Box>
)}
<DiscoverListCardContent {...props} savedId={props.savedId} isHovered={isOpen} />
</VStack>
)
}
export function DiscoverListCardContent(
props: DiscoverItemCardProps & { savedId?: string; savedUrl? : string }
): JSX.Element {
const originText = siteName(props.item.url, props.item.url)
const [displayFallback, setDisplayFallback] = useState(
props.item.image == undefined
)
const goToUrl = () => {
if (props.savedUrl) {
window.location.href = props.savedUrl
}
}
return (
<HStack css={{ gap: '15px', width: '100%', cursor: props.savedId ? 'pointer' : 'default' }} onClick={goToUrl} >
<Box css={{ position: 'relative', width: '55px' }}>
<HStack
css={{
position: 'absolute',
left: '0px',
top: '0px',
color: '$thTextContrast2',
opacity: props.savedId ? 1 : 0.25,
}}
>
<CheckCircle
size={15}
color="#669852"
weight="fill"
style={{ zIndex: 2 }}
/>
<Circle
size={15}
color="white"
weight="fill"
style={{ position: 'absolute', zIndex: 1 }}
/>
</HStack>
{displayFallback ? (
<ListFallbackImage
title={props.item.title ?? 'Omnivore Fallback'}
width="55px"
height="55px"
fontSize="36pt"
/>
) : (
<CoverImage
src={props.item.image}
width={55}
height={55}
css={{
bg: '$thBackground',
borderRadius: '4px',
}}
onError={(e) => {
setDisplayFallback(true)
}}
/>
)}
</Box>
<VStack
alignment="start"
distribution="start"
css={{
height: '100%',
width: '100%',
lineHeight: 1,
gap: '3px',
position: 'relative',
}}
>
<HStack
css={{
...MetaStyle,
}}
distribution="between"
>
<DiscoverItemMetadata item={props.item} />
{(props.item.author?.length ?? 0 + originText.length) > 0 && (
<HStack
css={{
...AuthorInfoStyle,
fontWeight: '100',
}}
>
{props.item.author}
{props.item.author && originText && ' | '}
{originText}
</HStack>
)}
</HStack>
<Box css={{ ...TitleStyle }}>{props.item.title}</Box>
<SpanBox
css={{
color: '$thTextSubtle2',
fontSize: '12px',
fontWeight: '400',
maxLines: 2,
height: '30px',
lineHeight: 1.25,
fontFamily: '$display',
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
display: '-webkit-box',
'-webkit-line-clamp': '2',
'-webkit-box-orient': 'vertical',
}}
>
{props.item.description}
</SpanBox>
</VStack>
</HStack>
)
}

View file

@ -0,0 +1,18 @@
import { HStack } from '../../../elements/LayoutPrimitives'
import { timeAgo } from '../../../patterns/LibraryCards/LibraryCardStyles'
import { DiscoverFeedItem } from "../../../../lib/networking/queries/useGetDiscoverFeedItems"
type DiscoverItemMetadataProps = {
item: DiscoverFeedItem
}
export function DiscoverItemMetadata(
props: DiscoverItemMetadataProps
): JSX.Element {
return (
<HStack css={{ gap: '5px' }}>
{timeAgo(props.item.publishedDate?.toString())}
{` `}
</HStack>
)
}

View file

@ -0,0 +1,101 @@
import { Box } from '../../../elements/LayoutPrimitives'
import { UserBasicData } from '../../../../lib/networking/queries/useGetViewerQuery'
import { LayoutType } from '../../homeFeed/HomeFeedContainer'
import { DiscoverItemCard } from './DiscoverItemCard'
import { SaveDiscoverArticleOutput } from "../../../../lib/networking/mutations/saveDiscoverArticle"
import { DiscoverFeedItem } from "../../../../lib/networking/queries/useGetDiscoverFeedItems"
type DiscoverItemsProps = {
items: DiscoverFeedItem[]
layout: LayoutType
viewer?: UserBasicData
handleLinkSubmission: (
link: string,
timezone: string,
locale: string
) => Promise<SaveDiscoverArticleOutput | undefined>
}
export function DiscoverItems(props: DiscoverItemsProps): JSX.Element {
return (
<Box
id={"DiscoverItems"}
css={{
py: '$3',
display: 'grid',
width: '100%',
gridAutoRows: 'auto',
borderRadius: '6px',
gridGap: props.layout == 'LIST_LAYOUT' ? '10px' : '20px',
marginTop: '10px',
marginBottom: '0px',
paddingTop: '0',
paddingBottom: '0px',
'@media (max-width: 930px)': {
gridGap: props.layout == 'LIST_LAYOUT' ? '0px' : '20px',
},
'@xlgDown': {
borderRadius: props.layout == 'LIST_LAYOUT' ? 0 : undefined,
},
'@smDown': {
border: 'unset',
width: props.layout == 'LIST_LAYOUT' ? '100vw' : undefined,
margin: props.layout == 'LIST_LAYOUT' ? '16px -16px' : undefined,
borderRadius: props.layout == 'LIST_LAYOUT' ? 0 : undefined,
},
'@media (min-width: 930px)': {
gridTemplateColumns:
props.layout == 'LIST_LAYOUT' ? 'none' : 'repeat(2, 1fr)',
},
'@media (min-width: 1280px)': {
gridTemplateColumns:
props.layout == 'LIST_LAYOUT' ? 'none' : 'repeat(3, 1fr)',
},
'@media (min-width: 1600px)': {
gridTemplateColumns:
props.layout == 'LIST_LAYOUT' ? 'none' : 'repeat(4, 1fr)',
},
}}
>
{props.items.map((linkedItem) => (
<Box
id={linkedItem.id}
tabIndex={0}
key={linkedItem.id + linkedItem.image}
css={{
width: '100%',
'&:focus-visible': {
outline: 'none',
},
'&> div': {
bg: '$thLeftMenuBackground',
},
'&:focus': {
outline: 'none',
'> div': {
outline: 'none',
bg: '$thBackgroundActive',
},
},
'&:hover': {
'> div': {
bg: '$thBackgroundActive',
boxShadow: '$cardBoxShadow',
},
'> a': {
bg: '$thBackgroundActive',
},
},
}}
>
<DiscoverItemCard
layout={props.layout}
item={linkedItem}
handleLinkSubmission={props.handleLinkSubmission}
viewer={props.viewer}
/>
</Box>
))}
</Box>
)
}

View file

@ -1075,7 +1075,6 @@ export function LibraryItemsLayout(
const [showUnsubscribeConfirmation, setShowUnsubscribeConfirmation] =
useState(false)
const [showUploadModal, setShowUploadModal] = useState(false)
const [, updateState] = useState({})
const unsubscribe = () => {
if (!props.linkToUnsubscribe) {

View file

@ -2,7 +2,7 @@ import { ReactNode, useEffect, useMemo, useRef } from 'react'
import { StyledText } from '../../elements/StyledText'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { Circle, X } from 'phosphor-react'
import { Circle, NewspaperClipping, X } from 'phosphor-react'
import {
Subscription,
SubscriptionType,

View file

@ -1,4 +1,4 @@
import { ReactNode, useEffect, useMemo, useRef } from 'react'
import { ReactNode, useEffect, useMemo, useRef, useState } from "react"
import { StyledText } from '../../elements/StyledText'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
@ -30,6 +30,7 @@ import { OutlinedLabelChip } from '../../elements/OutlinedLabelChip'
import { NewsletterIcon } from '../../elements/icons/NewsletterIcon'
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
import { useRouter } from 'next/router'
import { DiscoverIcon } from "../../elements/icons/DiscoverIcon"
export const LIBRARY_LEFT_MENU_WIDTH = '275px'
@ -217,6 +218,12 @@ const LibraryNav = (props: LibraryFilterMenuProps): JSX.Element => {
filterTerm="in:all has:highlights mode:highlights"
icon={<HighlightsIcon color={theme.colors.highlight.toString()} />}
/>
<NavRedirectButton
{...props}
text="Discover"
redirectLocation={"/discover"}
icon={<DiscoverIcon color={theme.colors.discover.toString()} />}
/>
</VStack>
)
}
@ -720,6 +727,70 @@ type NavButtonProps = {
setShowFilterMenu: (show: boolean) => void
}
type NavButtonRedirectProps = {
text: string
icon: ReactNode
redirectLocation: string
}
function NavRedirectButton(props: NavButtonRedirectProps): JSX.Element {
const [selected, setSelected] = useState(false);
const router = useRouter()
useEffect(() => {
setSelected(window.location.pathname.includes(props.redirectLocation))
}, [])
return (
<HStack
alignment="center"
distribution="start"
css={{
pl: '10px',
mb: '2px',
gap: '10px',
display: 'flex',
width: '100%',
maxWidth: '100%',
height: '34px',
backgroundColor: selected ? '$thLibrarySelectionColor' : 'unset',
fontSize: '15px',
fontWeight: 'regular',
fontFamily: '$display',
color: selected
? '$thLibraryMenuSecondary'
: '$thLibraryMenuUnselected',
verticalAlign: 'middle',
borderRadius: '3px',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
'&:hover': {
backgroundColor: selected
? '$thLibrarySelectionColor'
: '$thBackground4',
},
'&:active': {
backgroundColor: selected
? '$thLibrarySelectionColor'
: '$thBackground4',
},
}}
title={props.text}
onClick={(e) => {
router.push(props.redirectLocation)
e.preventDefault()
}}
>
{props.icon}
{props.text}
</HStack>
)
}
function NavButton(props: NavButtonProps): JSX.Element {
const isInboxFilter = (filter: string) => {
return filter === '' || filter === 'in:inbox'

View file

@ -140,6 +140,8 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
highlightText: '#3D3D3D',
error: '#FA5E4A',
discover: '#7274d5',
// Brand Colors
omnivoreRed: '#FA5E4A;',
omnivoreGray: '#3D3D3D',

View file

@ -0,0 +1,34 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export type DeleteDiscoverArticleInput = {
discoverArticleId: string
}
export type DeleteDiscoverArticleOutput = {
deleteDiscoverArticle: { id: string }
}
export async function deleteDiscoverArticleMutation(
input: DeleteDiscoverArticleInput,
): Promise<DeleteDiscoverArticleOutput | undefined> {
const mutation = gql`
mutation DeleteDiscoverArticle($input: DeleteDiscoverArticleInput!) {
deleteDiscoverArticle(input: $input) {
... on DeleteDiscoverArticleSuccess {
id
}
... on DeleteDiscoverArticleError {
errorCodes
}
}
}
`
const data = (await gqlFetcher(mutation, {
input,
})) as DeleteDiscoverArticleOutput
return data
}

View file

@ -0,0 +1,51 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { DiscoverFeed } from '../queries/useGetDiscoverFeeds'
type DiscoverFeedResult = {
addDiscoverFeed: {
feed?: DiscoverFeed
errorCodes?: DiscoverFeedErrorCode[]
}
}
enum DiscoverFeedErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED',
Conflict = 'CONFLICT',
}
export type AddDiscoverFeedInput = {
url: string
}
export async function addDiscoverFeedMutation(
input: AddDiscoverFeedInput,
): Promise<DiscoverFeedResult> {
const mutation = gql`
mutation AddDiscoverFeed($input: AddDiscoverFeedInput!) {
addDiscoverFeed(input: $input) {
... on AddDiscoverFeedSuccess {
feed {
description
}
}
... on AddDiscoverFeedError {
errorCodes
}
}
}
`
try {
const data = (await gqlFetcher(mutation, { input })) as DiscoverFeedResult
return data
} catch (error) {
console.log('subscribeMutation error', error)
return {
addDiscoverFeed: {
errorCodes: [DiscoverFeedErrorCode.BadRequest],
},
}
}
}

View file

@ -0,0 +1,36 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export type AddDiscoverArticleInput = {
discoverArticleId: string
locale: string
timezone: string
}
export type SaveDiscoverArticleOutput = {
saveDiscoverArticle: { url: string; saveId: string }
}
export async function saveDiscoverArticleMutation(
input: AddDiscoverArticleInput,
): Promise<SaveDiscoverArticleOutput | undefined> {
const mutation = gql`
mutation SaveDiscover($input: SaveDiscoverArticleInput!) {
saveDiscoverArticle(input: $input) {
... on SaveDiscoverArticleSuccess {
url
saveId
}
... on SaveDiscoverArticleError {
errorCodes
}
}
}
`
const data = (await gqlFetcher(mutation, {
input,
})) as SaveDiscoverArticleOutput
return data
}

View file

@ -0,0 +1,40 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
type DeleteDiscoverFeedResult = {
deleteDiscoverFeed: DeleteDiscoverFeed
}
type DeleteDiscoverFeed = {
id: string
errorCodes?: unknown[]
}
export async function unsubscribeDiscoverFeedMutation(
feedId: string,
): Promise<any | undefined> {
const mutation = gql`
mutation UnsubscribeDiscoverFeed($input: DeleteDiscoverFeedInput!) {
deleteDiscoverFeed(input: $input) {
... on DeleteDiscoverFeedSuccess {
id
}
... on DeleteDiscoverFeedError {
errorCodes
}
}
}
`
try {
const data = (await gqlFetcher(mutation, {
input: { feedId },
})) as DeleteDiscoverFeedResult
return data.deleteDiscoverFeed.errorCodes
? undefined
: data.deleteDiscoverFeed.id
} catch (error) {
console.log('unsubscribeMutation error', error)
return undefined
}
}

View file

@ -0,0 +1,53 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
interface UpdateDiscoverFeedResult {
editDiscoverFeed: UpdateDiscoverFeed
}
export enum UpdateSubscriptionErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED',
}
interface UpdateDiscoverFeed {
id?: string
errorCodes?: UpdateSubscriptionErrorCode[]
}
export interface UpdateDiscoverFeedInput {
feedId: string
name?: string
}
export async function updateDiscoverFeedMutation(
input: UpdateDiscoverFeedInput,
): Promise<UpdateDiscoverFeedResult> {
const mutation = gql`
mutation UpdateDiscoverFeed($input: EditDiscoverFeedInput!) {
editDiscoverFeed(input: $input) {
... on EditDiscoverFeedSuccess {
id
}
... on EditDiscoverFeedError {
errorCodes
}
}
}
`
try {
const data = (await gqlFetcher(mutation, {
input,
})) as UpdateDiscoverFeedResult
return data
} catch (error) {
console.log('updateDiscoverFeed error', error)
return {
editDiscoverFeed: {
errorCodes: [UpdateSubscriptionErrorCode.BadRequest],
},
}
}
}

View file

@ -0,0 +1,124 @@
import { gql } from 'graphql-request'
import { publicGqlFetcher } from '../networkHelpers'
import { useEffect, useState } from 'react'
import { TopicTabData } from '../../../components/templates/discoverFeed/DiscoverContainer'
const OMNIVORE_COMMUNITY_ID = '8217d320-aa5a-11ee-bbfe-a7cde356f524'
export type DiscoverFeedItem = {
id: string
feed: string
title: string
url: string
author?: string
image?: string
publishedDate?: Date
slug: string
description: string
siteName?: string
saves?: number
savedId?: string // Has the user saved this? If so then we can get it from here. This will allow us to link back
savedLinkUrl?: string
}
type DiscoverItemResponse = {
error?: any
discoverItems?: DiscoverFeedItem[]
discoverItemErrors?: unknown
isLoading: boolean
setTopic: (topic: TopicTabData) => void
activeTopic: TopicTabData
hasMore: boolean
page: number
setPage: (page: number) => void
}
export function useGetDiscoverFeedItems(
startingTopic: TopicTabData,
selectedFeed = 'All Feeds',
limit = 10
): DiscoverItemResponse {
const [activeTopic, setTopic] = useState(startingTopic)
const [discoverItems, setDiscoverItems] = useState<DiscoverFeedItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [hasMore, setHasMore] = useState(false)
const [page, setPage] = useState(0)
const fixedSelectedFeed =
selectedFeed == 'Community' ? OMNIVORE_COMMUNITY_ID : selectedFeed
const callDiscoverItems = () => {
const query = gql`
query GetDiscoverFeedItems {
getDiscoverFeedArticles(discoverTopicId: "${
activeTopic.title
}", first: ${limit}, after: "${page * limit}" ${
fixedSelectedFeed == 'All Feeds' ? '' : `feedId: "${fixedSelectedFeed}"`
}) {
... on GetDiscoverFeedArticleSuccess {
discoverArticles {
id,
feed,
title,
url,
image,
description,
publishedDate,
siteName,
slug,
author,
savedId,
savedLinkUrl,
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on GetDiscoverFeedArticleError {
errorCodes
}
}
}
`
return publicGqlFetcher(query)
}
useEffect(() => {
setDiscoverItems([])
if (page == 0) {
setIsLoading(true)
callDiscoverItems().then((it: any) => {
setIsLoading(false)
setDiscoverItems(it.getDiscoverFeedArticles.discoverArticles)
setHasMore(it.getDiscoverFeedArticles.pageInfo.hasNextPage)
})
} else {
setPage(0)
}
}, [activeTopic, selectedFeed])
useEffect(() => {
setIsLoading(true)
callDiscoverItems().then((it: any) => {
setIsLoading(false)
setDiscoverItems([
...(discoverItems || []),
...(it.getDiscoverFeedArticles.discoverArticles || []),
])
setHasMore(it.getDiscoverFeedArticles.pageInfo.hasNextPage)
})
}, [page])
return {
setTopic,
activeTopic,
discoverItems,
isLoading,
hasMore,
page,
setPage,
}
}

View file

@ -0,0 +1,75 @@
import { gql } from "graphql-request"
import useSWR from "swr"
import { makeGqlFetcher } from "../networkHelpers"
type DiscoverFeedsQueryResponse = {
error: any
isLoading: boolean
isValidating: boolean
feeds: DiscoverFeed[]
revalidate: () => void
}
export type DiscoverFeed = {
id: string
visibleName: string
title: string
link: string
description?: string,
image? : string,
type: "rss" | "atom"
}
export function useGetDiscoverFeeds(): DiscoverFeedsQueryResponse {
const query = gql`
query GetDiscoverFeeds {
discoverFeeds {
... on DiscoverFeedSuccess {
feeds {
visibleName,
id,
title,
link,
description,
image,
type
}
}
... on DiscoverFeedError{
errorCodes
}
}
}
`
const { data, error, mutate, isValidating } = useSWR(
[query],
makeGqlFetcher()
)
try {
if (data) {
const result = data as { discoverFeeds: { feeds: DiscoverFeed[] }}
const feeds = result.discoverFeeds.feeds as DiscoverFeed[]
return {
error,
isLoading: !error && !data,
isValidating,
feeds,
revalidate: () => {
mutate()
},
}
}
} catch (error) {
console.log('error', error)
}
return {
error,
isLoading: !error && !data,
isValidating: true,
feeds: [],
// eslint-disable-next-line @typescript-eslint/no-empty-function
revalidate: () => {},
}
}

View file

@ -0,0 +1,27 @@
import { PrimaryLayout } from "../components/templates/PrimaryLayout"
import { VStack } from "./../components/elements/LayoutPrimitives"
import { DiscoverContainer } from "../components/templates/discoverFeed/DiscoverContainer"
export default function Discover(): JSX.Element {
return <LoadedContent />
}
function LoadedContent(): JSX.Element {
return (
<PrimaryLayout
pageMetaDataProps={{
title: 'Discover - Omnivore',
path: '/discover',
}}
pageTestId="discover-page-tag"
>
<VStack
alignment="center"
distribution="center"
css={{ backgroundColor: '$thLibraryBackground' }}
>
<DiscoverContainer />
</VStack>
</PrimaryLayout>
)
}

View file

@ -0,0 +1,144 @@
import { styled } from "@stitches/react"
import { useRouter } from "next/router"
import { useCallback, useState } from "react"
import { Button } from "../../../components/elements/Button"
import { Box, HStack, VStack } from "../../../components/elements/LayoutPrimitives"
import { StyledText } from "../../../components/elements/StyledText"
import { PageMetaData } from "../../../components/patterns/PageMetaData"
import { SettingsLayout } from "../../../components/templates/SettingsLayout"
import { showSuccessToast } from "../../../lib/toastHelpers"
import { formatMessage } from "../../../locales/en/messages"
import { addDiscoverFeedMutation } from "../../../lib/networking/mutations/discoverFeedMutation"
// Styles
const Header = styled(Box, {
color: '$utilityTextDefault',
fontSize: 'x-large',
})
const FormInput = styled('input', {
border: '1px solid $textNonessential',
width: '100%',
bg: 'transparent',
fontSize: '16px',
fontFamily: 'inter',
fontWeight: 'normal',
lineHeight: '1.35',
borderRadius: '5px',
textIndent: '8px',
marginBottom: '2px',
height: '38px',
color: '$grayTextContrast',
'&:focus': {
border: '1px solid transparent',
outline: '2px solid $omnivoreCtaYellow',
},
})
export default function AddDiscoverFeed(): JSX.Element {
const router = useRouter()
const [errorMessage, setErrorMessage] =
useState<string | undefined>(undefined)
const [feedUrl, setFeedUrl] = useState<string>('')
const subscribe = useCallback(async () => {
if (!feedUrl) {
setErrorMessage('Please enter a valid feed URL')
return
}
let normailizedUrl: string
// normalize the url
try {
normailizedUrl = new URL(feedUrl.trim()).toString()
} catch (e) {
setErrorMessage('Please enter a valid feed URL')
return
}
const result = await addDiscoverFeedMutation({
url: normailizedUrl,
})
if (result.addDiscoverFeed.errorCodes) {
const errorMessage = formatMessage({
id: `error.${result.addDiscoverFeed.errorCodes[0]}`,
})
setErrorMessage(`There was an error adding new feed: ${errorMessage}`)
return
}
router.push(`/settings/discover-feeds`)
showSuccessToast('New feed has been added.')
}, [feedUrl, router])
return (
<>
<PageMetaData title="Add new Discover Feed" path="/settings/discover-feeds/add" />
<SettingsLayout>
<VStack
distribution={'start'}
alignment={'center'}
css={{
margin: '0 auto',
width: '80%',
padding: '24px',
maxWidth: '865px',
height: '100%',
gap: '20px',
}}
>
<HStack
alignment={'start'}
distribution={'start'}
css={{
width: '100%',
}}
>
<Header>Add new Discover Feed</Header>
</HStack>
<FormInput
type="url"
key="feedUrl"
tabIndex={1}
autoFocus={true}
value={feedUrl}
placeholder={'Enter the feed URL here'}
onChange={(e) => {
setErrorMessage(undefined)
setFeedUrl(e.target.value)
}}
/>
{errorMessage && (
<StyledText style="error">{errorMessage}</StyledText>
)}
<HStack
css={{ width: '100%', gap: '10px' }}
alignment="center"
distribution="end"
>
<Button
style="cancelGeneric"
css={{}}
onClick={async () => {
router.push('/settings/discover-feeds')
}}
>
Back
</Button>
<Button
tabIndex={1}
style="ctaDarkYellow"
css={{ marginRight: '10px' }}
onClick={subscribe}
>
Add
</Button>
</HStack>
</VStack>
</SettingsLayout>
<div data-testid="settings-feeds-subscribe-page-tag" />
</>
)
}

View file

@ -0,0 +1,225 @@
import { useRouter } from 'next/router'
import { FloppyDisk, Pencil, XCircle } from 'phosphor-react'
import { useMemo, useState } from 'react'
import { FormInput } from '../../../components/elements/FormElements'
import { HStack, SpanBox } from '../../../components/elements/LayoutPrimitives'
import { ConfirmationModal } from '../../../components/patterns/ConfirmationModal'
import {
EmptySettingsRow,
SettingsTable,
SettingsTableRow,
} from '../../../components/templates/settings/SettingsTable'
import { theme } from '../../../components/tokens/stitches.config'
import { unsubscribeDiscoverFeedMutation } from '../../../lib/networking/mutations/unsubscribeDiscoverFeedMutation'
import { applyStoredTheme } from '../../../lib/themeUpdater'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { formatMessage } from '../../../locales/en/messages'
import { useGetDiscoverFeeds } from "../../../lib/networking/queries/useGetDiscoverFeeds"
import { UpdateDiscoverFeedInput, updateDiscoverFeedMutation } from "../../../lib/networking/mutations/updateDiscoverFeedMutation"
export default function DiscoverFeedsSettings(): JSX.Element {
const router = useRouter()
const { feeds, revalidate, isValidating } = useGetDiscoverFeeds()
const [onDeleteId, setOnDeleteId] = useState<string>('')
const [onEditId, setOnEditId] = useState('')
const [onEditName, setOnEditName] = useState('')
const sortedFeeds = useMemo(() => {
if (!feeds) {
return []
}
return feeds
}, [feeds])
async function updateSubscription(
input: UpdateDiscoverFeedInput
): Promise<void> {
const result = await updateDiscoverFeedMutation(input)
if (result.editDiscoverFeed.errorCodes) {
const errorMessage = formatMessage({
id: `error.${result.editDiscoverFeed.errorCodes[0]}`,
})
showErrorToast(`failed to update subscription: ${errorMessage}`, {
position: 'bottom-right',
})
return
}
showSuccessToast('Discover Feed updated', { position: 'bottom-right' })
revalidate()
}
async function onDelete(id: string): Promise<void> {
const result = await unsubscribeDiscoverFeedMutation(id)
if (result) {
showSuccessToast('Discover Feed unsubscribed', { position: 'bottom-right' })
} else {
showErrorToast('Failed to unsubscribe', { position: 'bottom-right' })
}
revalidate()
}
applyStoredTheme()
return (
<SettingsTable
pageId={'feeds'}
pageInfoLink="https://docs.omnivore.app/using/feeds.html"
headerTitle="Subscribed feeds"
createTitle="Add a Discover feed"
createAction={() => {
router.push('/settings/discover-feeds/add')
}}
suggestionInfo={{
title: 'Add RSS and Atom feeds to your Omnivore account',
message:
'When you add a new feed the last 24hrs of items, or at least one item will be added to your account. Feeds will be checked for updates every hour, and new items will be added to your Following. You can also add feeds to your Library by checking the box below.',
docs: 'https://docs.omnivore.app/using/feeds.html',
key: '--settings-feeds-show-help',
CTAText: 'Add a feed',
onClickCTA: () => {
router.push('/settings/discover-feeds/add')
},
}}
>
{sortedFeeds.length === 0 ? (
<EmptySettingsRow text={isValidating ? '-' : 'No feeds subscribed'} />
) : (
sortedFeeds.map((feed, i) => {
return (
<SettingsTableRow
key={feed.id}
title={
onEditId === feed.id ? (
<HStack alignment={'center'} distribution={'start'}>
<FormInput
value={onEditName}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setOnEditName(e.target.value)}
placeholder="Description"
css={{
m: '0px',
fontSize: '18px',
'@mdDown': {
fontSize: '12px',
fontWeight: 'bold',
},
width: '400px',
}}
/>
<HStack>
<FloppyDisk
style={{ cursor: 'pointer', marginLeft: '5px' }}
color={theme.colors.omnivoreCtaYellow.toString()}
onClick={async (e) => {
e.stopPropagation()
await updateSubscription({
feedId: onEditId,
name: onEditName,
})
setOnEditId('')
}}
/>
<XCircle
style={{ cursor: 'pointer', marginLeft: '5px' }}
color={theme.colors.omnivoreRed.toString()}
onClick={(e) => {
e.stopPropagation()
setOnEditId('')
setOnEditName('')
}}
/>
</HStack>
</HStack>
) : (
<HStack alignment={'center'} distribution={'start'}>
<SpanBox
css={{
m: '0px',
fontSize: '18px',
'@mdDown': {
fontSize: '12px',
fontWeight: 'bold',
},
}}
>
{feed.visibleName}
</SpanBox>
<Pencil
style={{ cursor: 'pointer', marginLeft: '5px' }}
color={theme.colors.omnivoreLightGray.toString()}
onClick={(e) => {
e.stopPropagation()
setOnEditName(feed.visibleName)
setOnEditId(feed.id)
}}
/>
</HStack>
)
}
isLast={i === sortedFeeds.length - 1}
onDelete={() => {
console.log('onDelete triggered: ', feed.title)
setOnDeleteId(feed.id)
}}
deleteTitle="Unsubscribe"
sublineElement={
<SpanBox
css={{
my: '8px',
fontSize: '11px',
}}
>
{`URL: ${feed.link}, `}
</SpanBox>
}
onClick={() => {
// router.push(`/home?q=in:inbox rss:"${subscription.url}"`)
}}
// extraElement={
// <HStack
// distribution="start"
// alignment="center"
// css={{
// padding: '0 5px',
// }}
// >
// <CheckboxComponent
// checked={!!subscription.autoAddToLibrary}
// setChecked={async (checked) => {
// await updateSubscriptionMutation({
// id: subscription.id,
// autoAddToLibrary: checked,
// })
// revalidate()
// }}
// />
// <SpanBox
// css={{
// padding: '0 5px',
// fontSize: '12px',
// }}
// >
// Auto add to library
// </SpanBox>
// </HStack>
// }
/>
)
})
)}
{onDeleteId && (
<ConfirmationModal
message={'Discover Feed will be unsubscribed.'}
onAccept={async () => {
await onDelete(onDeleteId)
setOnDeleteId('')
}}
onOpenChange={() => setOnDeleteId('')}
/>
)}
</SettingsTable>
)
}

2
pkg/discord/.env.test Normal file
View file

@ -0,0 +1,2 @@
API_ENV=local
DISCORD_BOT_KEY=BlaBlaBla

56
pkg/discord/.eslintrc Normal file
View file

@ -0,0 +1,56 @@
{
"extends": "eslint:recommended",
"env": {
"node": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 2021,
"project": "tsconfig.json"
},
"rules": {
"arrow-spacing": ["warn", { "before": true, "after": true }],
"brace-style": ["error", "stroustrup", { "allowSingleLine": true }],
"comma-dangle": ["warn", {
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "never"
}],
"comma-spacing": "error",
"comma-style": "error",
"curly": ["error", "multi-line", "consistent"],
"dot-location": ["error", "property"],
"handle-callback-err": "off",
"indent": ["warn", 2],
"keyword-spacing": "error",
"max-nested-callbacks": ["error", { "max": 4 }],
"max-statements-per-line": ["error", { "max": 2 }],
"no-console": "off",
"no-empty-function": "error",
"no-floating-decimal": "error",
"no-inline-comments": "error",
"no-lonely-if": "error",
"no-multi-spaces": "error",
"no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }],
"no-shadow": ["error", { "allow": ["err", "resolve", "reject"] }],
"no-trailing-spaces": ["error"],
"no-var": "error",
"object-curly-spacing": ["error", "always"],
"prefer-const": "error",
"quotes": ["error", "single"],
"semi": ["warn", "never"],
"space-before-blocks": "error",
"space-before-function-paren": ["error", {
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}],
"space-in-parens": "error",
"space-infix-ops": "error",
"space-unary-ops": "error",
"spaced-comment": "error",
"yoda": "error"
}
}

Some files were not shown because too many files have changed in this diff Show more