614 lines
15 KiB
TypeScript
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
|
|
}
|