diff --git a/packages/web/lib/networking/queries/gql-queries.tsx b/packages/web/lib/networking/queries/gql-queries.tsx new file mode 100644 index 000000000..30d2a549f --- /dev/null +++ b/packages/web/lib/networking/queries/gql-queries.tsx @@ -0,0 +1,155 @@ +import { gql } from 'graphql-request' +import { highlightFragment } from '../fragments/highlightFragment' + +export const recommendationFragment = gql` + fragment RecommendationFields on Recommendation { + id + name + note + user { + userId + name + username + profileImageURL + } + recommendedAt + } +` + +export const GQL_SEARCH_QUERY = gql` + query Search( + $after: String + $first: Int + $query: String + $includeContent: Boolean + ) { + search( + first: $first + after: $after + query: $query + includeContent: $includeContent + ) { + ... on SearchSuccess { + edges { + cursor + node { + id + title + slug + url + folder + pageType + contentReader + createdAt + isArchived + readingProgressPercent + readingProgressTopPercent + readingProgressAnchorIndex + author + image + description + publishedAt + ownedByViewer + originalArticleUrl + uploadFileId + labels { + id + name + color + } + pageId + shortId + quote + annotation + state + siteName + siteIcon + subscription + readAt + savedAt + wordsCount + recommendations { + id + name + note + user { + userId + name + username + profileImageURL + } + recommendedAt + } + highlights { + ...HighlightFields + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + totalCount + } + } + ... on SearchError { + errorCodes + } + } + } + ${highlightFragment} +` + +export const GQL_SET_LINK_ARCHIVED = gql` + mutation SetLinkArchived($input: ArchiveLinkInput!) { + setLinkArchived(input: $input) { + ... on ArchiveLinkSuccess { + linkId + message + } + ... on ArchiveLinkError { + message + errorCodes + } + } + } +` + +export const GQL_DELETE_LIBRARY_ITEM = gql` + mutation SetBookmarkArticle($input: SetBookmarkArticleInput!) { + setBookmarkArticle(input: $input) { + ... on SetBookmarkArticleSuccess { + bookmarkedArticle { + id + } + } + ... on SetBookmarkArticleError { + errorCodes + } + } + } +` + +export const GQL_UPDATE_LIBRARY_ITEM = gql` + mutation UpdatePage($input: UpdatePageInput!) { + updatePage(input: $input) { + ... on UpdatePageSuccess { + updatedPage { + id + title + url + createdAt + author + image + description + savedAt + publishedAt + } + } + ... on UpdatePageError { + errorCodes + } + } + } +` diff --git a/packages/web/lib/networking/queries/types.tsx b/packages/web/lib/networking/queries/types.tsx new file mode 100644 index 000000000..c028c7417 --- /dev/null +++ b/packages/web/lib/networking/queries/types.tsx @@ -0,0 +1,138 @@ +import { State } from '../fragments/articleFragment' + +export interface ReadableItem { + id: string + title: string + slug: string +} + +export type LibraryItemsQueryInput = { + limit: number + sortDescending: boolean + searchQuery?: string + cursor?: string + includeContent?: boolean +} + +export 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 + isArchived: boolean + 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 +} + +export type SetLinkArchivedInput = { + linkId: string + archived: boolean +} + +type SetLinkArchivedSuccess = { + linkId: string + message?: string +} + +export type SetLinkArchivedData = { + setLinkArchived: SetLinkArchivedSuccess + errorCodes?: string[] +} + +export type DeleteItemInput = { + articleID: string + bookmark: boolean +} + +export type SetBookmarkArticle = { + errorCodes?: string[] +} + +export type SetBookmarkArticleData = { + setBookmarkArticle: SetBookmarkArticle +} + +export type UpdateLibraryItemInput = { + pageId: string + title?: string + byline?: string | undefined + description?: string + savedAt?: string + publishedAt?: string + state?: State +} + +export type UpdateLibraryItem = { + errorCodes?: string[] +} + +export type UpdateLibraryItemData = { + updatePage: UpdateLibraryItem +} diff --git a/packages/web/lib/networking/queries/useLibraryItems.tsx b/packages/web/lib/networking/queries/useLibraryItems.tsx new file mode 100644 index 000000000..c86951662 --- /dev/null +++ b/packages/web/lib/networking/queries/useLibraryItems.tsx @@ -0,0 +1,322 @@ +import { gql, GraphQLClient } from 'graphql-request' +import { + InfiniteData, + QueryClient, + useInfiniteQuery, + useMutation, + useQueryClient, +} from 'react-query' +import { + showErrorToast, + showSuccessToast, + showSuccessToastWithUndo, +} from '../../toastHelpers' +import { ContentReader, PageType, State } from '../fragments/articleFragment' +import { Highlight, highlightFragment } from '../fragments/highlightFragment' +import { articleReadingProgressMutation } from '../mutations/articleReadingProgressMutation' +import { deleteLinkMutation } from '../mutations/deleteLinkMutation' +import { setLinkArchivedMutation } from '../mutations/setLinkArchivedMutation' +import { updatePageMutation } from '../mutations/updatePageMutation' +import { makeGqlFetcher, requestHeaders } from '../networkHelpers' +import { Label } from '../fragments/labelFragment' +import { moveToFolderMutation } from '../mutations/moveToLibraryMutation' +import { + LibraryItemNode, + LibraryItems, + LibraryItemsData, + LibraryItemsQueryInput, + SetBookmarkArticleData, + SetLinkArchivedData, + SetLinkArchivedInput, + UpdateLibraryItemData, +} from './types' +import { + GQL_DELETE_LIBRARY_ITEM, + GQL_SEARCH_QUERY, + GQL_SET_LINK_ARCHIVED, + GQL_UPDATE_LIBRARY_ITEM, +} from './gql-queries' +import { parseGraphQLResponse } from './gql-errors' +import { gqlEndpoint } from '../../appConfig' +import { GraphQLResponse } from 'graphql-request/dist/types' + +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, + newState: State +) => { + const keys = queryClient.getQueryCache().findAll('libraryItems') + keys.forEach((query) => { + queryClient.setQueryData(query.queryKey, (data: any) => { + if (!data) return data + return { + ...data, + pages: data.pages.map((page: any) => ({ + ...page, + edges: page.edges.map((edge: any) => + edge.node.id === itemId + ? { ...edge, node: { ...edge.node, state: newState } } + : edge + ), + })), + } + }) + }) +} + +export function useGetLibraryItems( + folder: string | undefined, + { limit, searchQuery }: LibraryItemsQueryInput +) { + const fullQuery = folder + ? (`in:${folder} use:folders ` + (searchQuery ?? '')).trim() + : searchQuery ?? '' + + return useInfiniteQuery( + ['libraryItems', fullQuery], + async ({ pageParam }) => { + const response = (await gqlFetcher(GQL_SEARCH_QUERY, { + after: pageParam, + first: limit, + query: fullQuery, + includeContent: false, + })) as LibraryItemsData + return response.search + }, + { + getNextPageParam: (lastPage: LibraryItems) => { + return lastPage.pageInfo.hasNextPage + ? lastPage?.pageInfo?.endCursor + : undefined + }, + } + ) +} + +export const useArchiveItem = () => { + const queryClient = useQueryClient() + const archiveItem = async (input: SetLinkArchivedInput) => { + const result = (await gqlFetcher(GQL_SET_LINK_ARCHIVED, { + input, + })) as SetLinkArchivedData + if (result.errorCodes?.length) { + throw new Error(result.errorCodes[0]) + } + return result.setLinkArchived + } + return useMutation(archiveItem, { + onMutate: async (input: SetLinkArchivedInput) => { + await queryClient.cancelQueries('libraryItems') + + updateItemStateInCache( + queryClient, + input.linkId, + input.archived ? State.ARCHIVED : State.SUCCEEDED + ) + + return { previousItems: queryClient.getQueryData('libraryItems') } + }, + onError: (error, itemId, context) => { + if (context?.previousItems) { + queryClient.setQueryData('libraryItems', context.previousItems) + } + }, + onSettled: () => { + console.log('settled') + queryClient.invalidateQueries('libraryItems') + }, + }) +} + +export const useDeleteItem = () => { + const queryClient = useQueryClient() + const deleteItem = async (itemId: string) => { + const result = (await gqlFetcher(GQL_DELETE_LIBRARY_ITEM, { + input: { articleID: itemId, bookmark: false }, + })) as SetBookmarkArticleData + if (result.setBookmarkArticle.errorCodes?.length) { + throw new Error(result.setBookmarkArticle.errorCodes[0]) + } + return result.setBookmarkArticle + } + return useMutation(deleteItem, { + onMutate: async (itemId: string) => { + await queryClient.cancelQueries('libraryItems') + updateItemStateInCache(queryClient, itemId, State.DELETED) + return { previousItems: queryClient.getQueryData('libraryItems') } + }, + onError: (error, itemId, context) => { + if (context?.previousItems) { + queryClient.setQueryData('libraryItems', context.previousItems) + } + }, + onSettled: () => { + console.log('settled') + queryClient.invalidateQueries('libraryItems') + }, + }) +} + +export const useRestoreItem = () => { + const queryClient = useQueryClient() + const restoreItem = async (itemId: string) => { + const result = (await gqlFetcher(GQL_UPDATE_LIBRARY_ITEM, { + input: { pageId: itemId, state: State.SUCCEEDED }, + })) as UpdateLibraryItemData + console.log('result: ', result) + if (result.updatePage.errorCodes?.length) { + throw new Error(result.updatePage.errorCodes[0]) + } + return result.updateLibraryItem + } + return useMutation(restoreItem, { + onMutate: async (itemId: string) => { + await queryClient.cancelQueries('libraryItems') + updateItemStateInCache(queryClient, itemId, State.SUCCEEDED) + return { previousItems: queryClient.getQueryData('libraryItems') } + }, + onError: (error, itemId, context) => { + if (context?.previousItems) { + queryClient.setQueryData('libraryItems', context.previousItems) + } + }, + onSettled: () => { + console.log('settled') + queryClient.invalidateQueries('libraryItems') + }, + }) +} + +// export const useRestoreItem = () => { +// const queryClient = useQueryClient() +// return useMutation(restoreItem, { +// onMutate: async (itemId) => { +// await queryClient.cancelQueries('libraryItems') +// const previousItems = queryClient.getQueryData('libraryItems') + +// updateItemStateInCache(queryClient, itemId, 'ACTIVE') + +// return { previousItems } +// }, +// onError: (error, itemId, context) => { +// if (context?.previousItems) { +// queryClient.setQueryData('libraryItems', context.previousItems) +// } +// }, +// onSettled: () => { +// queryClient.invalidateQueries('libraryItems') +// }, +// }) +// } + +export function useGetRawSearchItemsQuery( + { + limit, + searchQuery, + cursor, + includeContent = false, + }: LibraryItemsQueryInput, + shouldFetch = true +) { + // const query = gql` + // query Search( + // $after: String + // $first: Int + // $query: String + // $includeContent: Boolean + // ) { + // search( + // first: $first + // after: $after + // query: $query + // includeContent: $includeContent + // ) { + // ... on SearchSuccess { + // edges { + // cursor + // node { + // id + // title + // slug + // url + // folder + // createdAt + // author + // image + // description + // publishedAt + // originalArticleUrl + // siteName + // siteIcon + // subscription + // readAt + // savedAt + // wordsCount + // } + // } + // pageInfo { + // hasNextPage + // hasPreviousPage + // startCursor + // endCursor + // totalCount + // } + // } + // ... on SearchError { + // errorCodes + // } + // } + // } + // ` + + // const { data, error, isFetching, refetch } = useQuery( + // ['rawSearchItems', searchQuery, cursor], + // () => + // makeGqlFetcher(query, { + // after: cursor, + // first: limit, + // query: searchQuery, + // includeContent, + // }), + // { + // enabled: shouldFetch, + // refetchOnWindowFocus: false, + // } + // ) + + // const responseData = data as LibraryItemsData | undefined + + // if (responseData?.errorCodes) { + return { + isFetching: false, + items: [], + isLoading: false, + error: true, + } + // } + + // return { + // isFetching, + // items: responseData?.search.edges.map((edge) => edge.node) ?? [], + // itemsDataError: error, + // isLoading: !error && !data, + // error: !!error, + // } +}