import { gql, GraphQLClient } from 'graphql-request' import { InfiniteData, QueryClient, useInfiniteQuery, useMutation, useQuery, useQueryClient, } from '@tanstack/react-query' import { ContentReader, PageType, State } from '../fragments/articleFragment' import { Highlight, highlightFragment } from '../fragments/highlightFragment' import { makeGqlFetcher, requestHeaders } from '../networkHelpers' import { Label } from '../fragments/labelFragment' import { GQL_BULK_ACTION, GQL_DELETE_LIBRARY_ITEM, GQL_GET_LIBRARY_ITEM_CONTENT, GQL_MOVE_ITEM_TO_FOLDER, GQL_SAVE_ARTICLE_READING_PROGRESS, GQL_SEARCH_QUERY, GQL_SET_LABELS, GQL_SET_LINK_ARCHIVED, GQL_UPDATE_LIBRARY_ITEM, } from './gql' import { gqlEndpoint } from '../../appConfig' function gqlFetcher( query: string, variables?: unknown, requiresAuth = true ): Promise { // if (requiresAuth) { // verifyAuth() // } const graphQLClient = new GraphQLClient(gqlEndpoint, { credentials: 'include', mode: 'cors', }) return graphQLClient.request(query, variables, requestHeaders()) } const updateItemStateInCache = ( queryClient: QueryClient, itemId: string, slug: string | undefined, newState: State ) => { updateItemPropertyInCache(queryClient, itemId, slug, 'state', newState) } function createDictionary( propertyName: string, value: any ): { [key: string]: any } { return { [propertyName]: value, } } const updateItemPropertyInCache = ( queryClient: QueryClient, itemId: string, slug: string | undefined, propertyName: string, propertyValue: any ) => { updateItemProperty(queryClient, itemId, slug, (oldItem) => { const setter = createDictionary(propertyName, propertyValue) return { ...oldItem, ...setter, } }) } export const updateItemProperty = ( queryClient: QueryClient, itemId: string, slug: string | undefined, updateFunc: (input: ArticleAttributes) => ArticleAttributes ) => { let foundItemSlug: string | undefined const keys = queryClient .getQueryCache() .findAll({ queryKey: ['libraryItems'] }) keys.forEach((query) => { queryClient.setQueryData(query.queryKey, (data: any) => { if (!data) return data const updatedData = { ...data, pages: data.pages.map((page: any) => ({ ...page, edges: page.edges.map((edge: any) => { if (edge.node.id === itemId) { foundItemSlug = edge.node.slug return { ...edge, node: { ...edge.node, ...updateFunc(edge.node) }, } } return edge }), })), } return updatedData }) }) if (foundItemSlug || slug) { queryClient.setQueryData( ['libraryItem', foundItemSlug ?? slug], (oldData: ArticleAttributes) => { return { ...oldData, ...updateFunc(oldData), } } ) } } const overwriteItemPropertiesInCache = ( queryClient: QueryClient, itemId: string, slug: string | undefined, item: any ) => { let foundItemSlug: string | undefined const keys = queryClient .getQueryCache() .findAll({ queryKey: ['libraryItems'] }) keys.forEach((query) => { queryClient.setQueryData(query.queryKey, (data: any) => { if (!data) return data const updatedData = { ...data, pages: data.pages.map((page: any) => ({ ...page, edges: page.edges.map((edge: any) => { if (edge.node.id === itemId) { foundItemSlug = edge.node.slug return { ...edge, node: { ...edge.node, ...item }, } } return edge }), })), } return updatedData }) }) if (foundItemSlug || slug) { queryClient.setQueryData( ['libraryItem', foundItemSlug ?? slug], (oldData: ArticleAttributes) => { return { ...oldData, ...item, } } ) } } export function useGetLibraryItems( folder: string | undefined, { limit, searchQuery }: LibraryItemsQueryInput, enabled = true ) { console.log('folder: ', folder) const fullQuery = folder ? (`in:${folder} use:folders ` + (searchQuery ?? '')).trim() : searchQuery ?? '' return useInfiniteQuery({ queryKey: ['libraryItems', fullQuery], queryFn: async ({ pageParam }) => { const response = (await gqlFetcher(GQL_SEARCH_QUERY, { after: pageParam, first: limit, query: fullQuery, includeContent: false, })) as LibraryItemsData return response.search }, enabled, initialPageParam: '0', getNextPageParam: (lastPage: LibraryItems) => { return lastPage.pageInfo.hasNextPage ? lastPage?.pageInfo?.endCursor : undefined }, }) } export const useArchiveItem = () => { const queryClient = useQueryClient() const archiveItem = async (variables: { itemId: string slug: string input: SetLinkArchivedInput }) => { const result = (await gqlFetcher(GQL_SET_LINK_ARCHIVED, { input: variables.input, })) as SetLinkArchivedData if (result.errorCodes?.length) { throw new Error(result.errorCodes[0]) } return result.setLinkArchived } return useMutation({ mutationFn: archiveItem, onMutate: async (variables: { itemId: string slug: string input: SetLinkArchivedInput }) => { await queryClient.cancelQueries({ queryKey: ['libraryItems'] }) const previousState = { previousDetail: queryClient.getQueryData([ 'libraryItem', variables.slug, ]), previousItems: queryClient.getQueryData(['libraryItems']), } updateItemStateInCache( queryClient, variables.itemId, variables.slug, variables.input.archived ? State.ARCHIVED : State.SUCCEEDED ) return previousState }, onError: (error, variables, context) => { if (context?.previousItems) { queryClient.setQueryData(['libraryItems'], context.previousItems) } if (context?.previousDetail) { queryClient.setQueryData( ['libraryItem', variables.slug], context.previousDetail ) } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['libraryItems'], }) }, }) } export const useDeleteItem = () => { const queryClient = useQueryClient() const deleteItem = async (variables: { itemId: string; slug: string }) => { const result = (await gqlFetcher(GQL_DELETE_LIBRARY_ITEM, { input: { articleID: variables.itemId, bookmark: false }, })) as SetBookmarkArticleData if (result.setBookmarkArticle.errorCodes?.length) { throw new Error(result.setBookmarkArticle.errorCodes[0]) } return result.setBookmarkArticle } return useMutation({ mutationFn: deleteItem, onMutate: async (variables: { itemId: string; slug: string }) => { await queryClient.cancelQueries({ queryKey: ['libraryItems'] }) const previousState = { previousDetail: queryClient.getQueryData([ 'libraryItem', variables.slug, ]), previousItems: queryClient.getQueryData(['libraryItems']), } updateItemStateInCache( queryClient, variables.itemId, variables.slug, State.DELETED ) return previousState }, onError: (error, variables, context) => { if (context?.previousItems) { queryClient.setQueryData(['libraryItems'], context.previousItems) } if (context?.previousDetail) { queryClient.setQueryData( ['libraryItem', variables.slug], context.previousDetail ) } }, onSettled: async () => { await queryClient.invalidateQueries({ queryKey: ['libraryItems'], }) }, }) } export const useRestoreItem = () => { const queryClient = useQueryClient() const restoreItem = async (variables: { itemId: string; slug: string }) => { const result = (await gqlFetcher(GQL_UPDATE_LIBRARY_ITEM, { input: { pageId: variables.itemId, state: State.SUCCEEDED }, })) as UpdateLibraryItemData if (result.updatePage.errorCodes?.length) { throw new Error(result.updatePage.errorCodes[0]) } return result.updatePage } return useMutation({ mutationFn: restoreItem, onMutate: async (variables: { itemId: string; slug: string }) => { const previousState = { previousDetail: queryClient.getQueryData([ 'libraryItem', variables.slug, ]), previousItems: queryClient.getQueryData(['libraryItems']), } updateItemStateInCache( queryClient, variables.itemId, variables.slug, State.SUCCEEDED ) return previousState }, onError: (error, variables, context) => { if (context?.previousItems) { queryClient.setQueryData(['libraryItems'], context.previousItems) } if (context?.previousDetail) { queryClient.setQueryData( ['libraryItem', variables.slug], context.previousDetail ) } }, onSettled: async () => { await queryClient.invalidateQueries({ queryKey: ['libraryItems'], }) }, }) } export const useUpdateItem = () => { const queryClient = useQueryClient() const updateItem = async (variables: { itemId: string slug: string | undefined input: UpdateLibraryItemInput }) => { const result = (await gqlFetcher(GQL_UPDATE_LIBRARY_ITEM, { input: variables.input, })) as UpdateLibraryItemData if (result.updatePage.errorCodes?.length) { throw new Error(result.updatePage.errorCodes[0]) } return result.updatePage } return useMutation({ mutationFn: updateItem, onMutate: async (variables: { itemId: string slug: string | undefined input: UpdateLibraryItemInput }) => { const previousState = { previousDetail: queryClient.getQueryData([ 'libraryItem', variables.slug, ]), previousItems: queryClient.getQueryData(['libraryItems']), } overwriteItemPropertiesInCache( queryClient, variables.itemId, variables.slug, variables.input ) return previousState }, onError: (error, variables, context) => { if (context?.previousItems) { queryClient.setQueryData(['libraryItems'], context.previousItems) } if (context?.previousDetail) { queryClient.setQueryData( ['libraryItem', variables.slug], context.previousDetail ) } }, onSuccess: async (data, variables) => { await queryClient.invalidateQueries({ queryKey: ['libraryItems'], }) await queryClient.invalidateQueries({ queryKey: ['libraryItem', variables.slug], }) }, }) } export const useUpdateItemReadStatus = () => { const queryClient = useQueryClient() const updateItemReadStatus = async (variables: { itemId: string slug: string input: ArticleReadingProgressMutationInput }) => { const result = (await gqlFetcher(GQL_SAVE_ARTICLE_READING_PROGRESS, { input: variables.input, })) as ArticleReadingProgressMutationData if (result.saveArticleReadingProgress.errorCodes?.length) { throw new Error(result.saveArticleReadingProgress.errorCodes[0]) } return result.saveArticleReadingProgress.updatedArticle } return useMutation({ mutationFn: updateItemReadStatus, onMutate: async (variables: { itemId: string slug: string input: ArticleReadingProgressMutationInput }) => { const previousState = { previousDetail: queryClient.getQueryData([ 'libraryItem', variables.slug, ]), previousItems: queryClient.getQueryData(['libraryItems']), } updateItemPropertyInCache( queryClient, variables.itemId, variables.slug, 'readingProgressPercent', variables.input.readingProgressPercent ) return previousState }, onError: (error, variables, context) => { if (context?.previousItems) { queryClient.setQueryData(['libraryItems'], context.previousItems) } if (context?.previousDetail) { queryClient.setQueryData( ['libraryItem', variables.slug], context.previousDetail ) } }, onSuccess: (data, variables, context) => { if (data) { updateItemPropertyInCache( queryClient, variables.itemId, variables.slug, 'readingProgressPercent', data.readingProgressPercent ) } }, }) } export const useGetLibraryItemContent = (username: string, slug: string) => { const queryClient = useQueryClient() return useQuery({ queryKey: ['libraryItem', slug], queryFn: async () => { const response = (await gqlFetcher(GQL_GET_LIBRARY_ITEM_CONTENT, { slug, username, includeFriendsHighlights: false, })) as ArticleData if (response.article.errorCodes?.length) { throw new Error(response.article.errorCodes[0]) } const article = response.article.article if (article) { overwriteItemPropertiesInCache( queryClient, article.id, article.slug, article ) } return response.article.article }, }) } export const useMoveItemToFolder = () => { const queryClient = useQueryClient() const moveItem = async (variables: { itemId: string slug: string | undefined folder: string }) => { const result = (await gqlFetcher(GQL_MOVE_ITEM_TO_FOLDER, { id: variables.itemId, folder: variables.folder, })) as MoveToFolderData if (result.moveToFolder.errorCodes?.length) { throw new Error(result.moveToFolder.errorCodes[0]) } return result.moveToFolder } return useMutation({ mutationFn: moveItem, onMutate: async (variables: { itemId: string slug: string | undefined folder: string }) => { await queryClient.cancelQueries({ queryKey: ['libraryItems'] }) const previousState = { previousDetail: queryClient.getQueryData([ 'libraryItem', variables.slug, ]), previousItems: queryClient.getQueryData(['libraryItems']), } updateItemPropertyInCache( queryClient, variables.itemId, variables.slug, 'folder', variables.folder ) return previousState }, onError: (error, variables, context) => { if (context?.previousItems) { queryClient.setQueryData(['libraryItems'], context.previousItems) } if (context?.previousDetail) { queryClient.setQueryData( ['libraryItem', variables.slug], context.previousDetail ) } }, onSettled: async () => { await queryClient.invalidateQueries({ queryKey: ['libraryItems'], }) }, }) } export const useSetItemLabels = () => { const queryClient = useQueryClient() const setLabels = async (variables: { itemId: string slug: string | undefined labels: Label[] }) => { const labelIds = variables.labels.map((l) => l.id) const result = (await gqlFetcher(GQL_SET_LABELS, { input: { pageId: variables.itemId, labelIds }, })) as SetLabelsData if (result.setLabels.errorCodes?.length) { throw new Error(result.setLabels.errorCodes[0]) } return result.setLabels.labels } return useMutation({ mutationFn: setLabels, onMutate: async (variables: { itemId: string slug: string | undefined labels: Label[] }) => { await queryClient.cancelQueries({ queryKey: ['libraryItems'] }) const previousState = { previousDetail: queryClient.getQueryData([ 'libraryItem', variables.slug, ]), previousItems: queryClient.getQueryData(['libraryItems']), } updateItemPropertyInCache( queryClient, variables.itemId, variables.slug, 'labels', variables.labels ) return previousState }, onError: (error, variables, context) => { if (context?.previousItems) { queryClient.setQueryData(['libraryItems'], context.previousItems) } if (context?.previousDetail) { queryClient.setQueryData( ['libraryItem', variables.slug], context.previousDetail ) } }, onSuccess: async (newLabels, variables) => { updateItemPropertyInCache( queryClient, variables.itemId, variables.slug, 'labels', newLabels ) }, }) } export const useBulkActions = () => { const queryClient = useQueryClient() const bulkAction = async (variables: { action: BulkAction query: string expectedCount: number labelIds?: string[] arguments?: any }) => { const result = (await gqlFetcher(GQL_BULK_ACTION, { ...variables, })) as BulkActionData if (result.bulkAction?.errorCodes?.length) { throw new Error(result.bulkAction.errorCodes[0]) } return result.bulkAction.success } return useMutation({ mutationFn: bulkAction, onMutate: async (variables: { action: BulkAction query: string expectedCount: number labelIds?: string[] }) => { await queryClient.cancelQueries({ queryKey: ['libraryItems'] }) }, onSettled: async (newLabels, variables) => { await queryClient.invalidateQueries({ queryKey: ['libraryItems'], }) }, }) } export enum BulkAction { ARCHIVE = 'ARCHIVE', DELETE = 'DELETE', ADD_LABELS = 'ADD_LABELS', MARK_AS_READ = 'MARK_AS_READ', MOVE_TO_FOLDER = 'MOVE_TO_FOLDER', } type BulkActionResult = { success?: boolean errorCodes?: string[] } type BulkActionData = { bulkAction: BulkActionResult } type UpdateLibraryItemInput = { pageId: string title?: string byline?: string | undefined description?: string savedAt?: string publishedAt?: string state?: State } type SetLabelsData = { setLabels: SetLabelsResult } type SetLabelsResult = { labels?: Label[] errorCodes?: string[] } export type TextDirection = 'RTL' | 'LTR' export type ArticleAttributes = { id: string title: string url: string originalArticleUrl: string author?: string image?: string savedAt: string createdAt: string publishedAt?: string description?: string wordsCount?: number originalHtml?: string contentReader: ContentReader readingProgressPercent: number readingProgressTopPercent?: number readingProgressAnchorIndex: number slug: string folder: string savedByViewer?: boolean content: string highlights: Highlight[] linkId: string labels?: Label[] state?: State directionality?: TextDirection recommendations?: Recommendation[] } type MoveToFolderData = { moveToFolder: MoveToFolderResult } type MoveToFolderResult = { success?: boolean errorCodes?: string[] } type ArticleResult = { article?: ArticleAttributes errorCodes?: string[] } type ArticleData = { article: ArticleResult } type ArticleReadingProgressUpdatedArticle = { id: string readingProgressPercent: number readingProgressAnchorIndex: string } type ArticleReadingProgressResult = { errorCodes?: string[] updatedArticle?: ArticleReadingProgressUpdatedArticle } type ArticleReadingProgressMutationData = { saveArticleReadingProgress: ArticleReadingProgressResult } export type ArticleReadingProgressMutationInput = { id: string force?: boolean readingProgressPercent?: number readingProgressTopPercent?: number readingProgressAnchorIndex?: number } export interface ReadableItem { id: string title: string slug: string } export type LibraryItemsQueryInput = { limit: number sortDescending: boolean searchQuery?: string cursor?: string includeContent?: boolean } type LibraryItemsData = { search: LibraryItems errorCodes?: string[] } export type LibraryItems = { edges: LibraryItem[] pageInfo: PageInfo errorCodes?: string[] } export type LibraryItem = { cursor: string node: LibraryItemNode isLoading?: boolean | undefined } export type LibraryItemNode = { id: string title: string url: string author?: string image?: string createdAt: string publishedAt?: string contentReader?: ContentReader originalArticleUrl: string readingProgressPercent: number readingProgressTopPercent?: number readingProgressAnchorIndex: number slug: string folder?: string description: string ownedByViewer: boolean uploadFileId: string labels?: Label[] pageId: string shortId: string quote: string annotation: string state: State pageType: PageType siteName?: string siteIcon?: string subscription?: string readAt?: string savedAt?: string wordsCount?: number aiSummary?: string recommendations?: Recommendation[] highlights?: Highlight[] } export type Recommendation = { id: string name: string note?: string user?: RecommendingUser recommendedAt: Date } export type RecommendingUser = { userId: string name: string username: string profileImageURL?: string } export type PageInfo = { hasNextPage: boolean hasPreviousPage: boolean startCursor: string endCursor: string totalCount: number } type SetLinkArchivedInput = { linkId: string archived: boolean } type SetLinkArchivedSuccess = { linkId: string message?: string } type SetLinkArchivedData = { setLinkArchived: SetLinkArchivedSuccess errorCodes?: string[] } type SetBookmarkArticle = { errorCodes?: string[] } type SetBookmarkArticleData = { setBookmarkArticle: SetBookmarkArticle } type UpdateLibraryItem = { errorCodes?: string[] } type UpdateLibraryItemData = { updatePage: UpdateLibraryItem }