Files
omnivore/packages/web/lib/networking/library_items/useLibraryItems.tsx
2024-08-12 11:09:56 +08:00

614 lines
15 KiB
TypeScript

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_DELETE_LIBRARY_ITEM,
GQL_GET_LIBRARY_ITEM_CONTENT,
GQL_MOVE_ITEM_TO_FOLDER,
GQL_SEARCH_QUERY,
GQL_SET_LINK_ARCHIVED,
GQL_UPDATE_LIBRARY_ITEM,
} from './gql'
import { gqlEndpoint } from '../../appConfig'
function gqlFetcher(
query: string,
variables?: unknown,
requiresAuth = true
): Promise<unknown> {
// 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
) => {
updateItemPropertyInCache(queryClient, itemId, 'state', newState)
}
function createDictionary(
propertyName: string,
value: any
): { [key: string]: any } {
return {
[propertyName]: value,
}
}
const updateItemPropertyInCache = (
queryClient: QueryClient,
itemId: string,
propertyName: string,
propertyValue: any
) => {
updateItemProperty(queryClient, itemId, (oldItem) => {
const setter = createDictionary(propertyName, propertyValue)
return {
...oldItem,
...setter,
}
})
}
export const updateItemProperty = (
queryClient: QueryClient,
itemId: string,
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)
queryClient.setQueryData(
['libraryItem', foundItemSlug],
(oldData: ArticleAttributes) => {
return {
...oldData,
...updateFunc(oldData),
}
}
)
})
}
const updateItemPropertiesInCache = (
queryClient: QueryClient,
itemId: string,
item: ArticleAttributes
) => {
const keys = queryClient
.getQueryCache()
.findAll({ queryKey: ['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, ...item } }
: edge
),
})),
}
})
})
}
export function useGetLibraryItems(
folder: string | undefined,
{ limit, searchQuery }: LibraryItemsQueryInput,
enabled = true
) {
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 (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({
mutationFn: archiveItem,
onMutate: async (input: SetLinkArchivedInput) => {
await queryClient.cancelQueries({ queryKey: ['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: () => {
queryClient.invalidateQueries({
queryKey: ['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({
mutationFn: deleteItem,
onMutate: async (itemId: string) => {
await queryClient.cancelQueries({
queryKey: ['libraryItems'],
})
updateItemStateInCache(queryClient, itemId, State.DELETED)
return { previousItems: queryClient.getQueryData(['libraryItems']) }
},
onError: (error, itemId, context) => {
if (context?.previousItems) {
queryClient.setQueryData(['libraryItems'], context.previousItems)
}
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: ['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
if (result.updatePage.errorCodes?.length) {
throw new Error(result.updatePage.errorCodes[0])
}
return result.updatePage
}
return useMutation({
mutationFn: restoreItem,
onMutate: async (itemId: string) => {
await queryClient.cancelQueries({ queryKey: ['libraryItems'] })
updateItemStateInCache(queryClient, itemId, State.SUCCEEDED)
return { previousItems: queryClient.getQueryData(['libraryItems']) }
},
onError: (error, itemId, context) => {
if (context?.previousItems) {
queryClient.setQueryData(['libraryItems'], context.previousItems)
}
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: ['libraryItems'],
})
},
})
}
export const useMoveItemToFolder = () => {
const queryClient = useQueryClient()
const restoreItem = async (variables: { itemId: string; 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: restoreItem,
onMutate: async (variables: { itemId: string; folder: string }) => {
await queryClient.cancelQueries({ queryKey: ['libraryItems'] })
updateItemPropertyInCache(
queryClient,
variables.itemId,
'folder',
variables.folder
)
return { previousItems: queryClient.getQueryData(['libraryItems']) }
},
onError: (error, itemId, context) => {
if (context?.previousItems) {
queryClient.setQueryData(['libraryItems'], context.previousItems)
}
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: ['libraryItems'],
})
},
})
}
export const useUpdateItemReadStatus = () => {
const queryClient = useQueryClient()
const updateItemReadStatus = async (
input: ArticleReadingProgressMutationInput
) => {
const result = (await gqlFetcher(GQL_SAVE_ARTICLE_READING_PROGRESS, {
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 (input: ArticleReadingProgressMutationInput) => {
await queryClient.cancelQueries({ queryKey: ['libraryItems'] })
updateItemPropertyInCache(
queryClient,
input.id,
'readingProgressPercent',
input.readingProgressPercent
)
return { previousItems: queryClient.getQueryData(['libraryItems']) }
},
onError: (error, input, context) => {
if (context?.previousItems) {
queryClient.setQueryData(['libraryItems'], context.previousItems)
}
},
onSettled: (data, error, variables, context) => {
if (data) {
updateItemPropertyInCache(
queryClient,
data.id,
'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) {
updateItemPropertiesInCache(queryClient, article.id, article)
}
return response.article.article
},
})
}
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
}
const GQL_SAVE_ARTICLE_READING_PROGRESS = gql`
mutation SaveArticleReadingProgress(
$input: SaveArticleReadingProgressInput!
) {
saveArticleReadingProgress(input: $input) {
... on SaveArticleReadingProgressSuccess {
updatedArticle {
id
readingProgressPercent
readingProgressAnchorIndex
}
}
... on SaveArticleReadingProgressError {
errorCodes
}
}
}
`
// export async function articleReadingProgressMutation(
// input: ArticleReadingProgressMutationInput
// ): Promise<boolean> {
// const mutation = gql`
// mutation SaveArticleReadingProgress(
// $input: SaveArticleReadingProgressInput!
// ) {
// saveArticleReadingProgress(input: $input) {
// ... on SaveArticleReadingProgressSuccess {
// updatedArticle {
// id
// readingProgressPercent
// readingProgressAnchorIndex
// }
// }
// ... on SaveArticleReadingProgressError {
// errorCodes
// }
// }
// }
// `
// try {
// await gqlFetcher(mutation, { input })
// return true
// } catch {
// return false
// }
// }
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
}