diff --git a/packages/web/components/elements/LabelsPicker.tsx b/packages/web/components/elements/LabelsPicker.tsx index 3aac0a1eb..e8ebf0e3d 100644 --- a/packages/web/components/elements/LabelsPicker.tsx +++ b/packages/web/components/elements/LabelsPicker.tsx @@ -2,16 +2,17 @@ import AutosizeInput_, { AutosizeInputProps } from 'react-input-autosize' import { Box, SpanBox } from './LayoutPrimitives' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Label } from '../../lib/networking/fragments/labelFragment' -import { useGetLabelsQuery } from '../../lib/networking/queries/useGetLabelsQuery' import { isTouchScreenDevice } from '../../lib/deviceType' import { EditLabelChip } from './EditLabelChip' import { LabelsDispatcher } from '../../lib/hooks/useSetPageLabels' import { EditLabelChipStack } from './EditLabelChipStack' +import { useGetLabels } from '../../lib/networking/labels/useLabels' // AutosizeInput is a Class component, but the types are broken in React 18. // TODO: Maybe move away from this component, since it hasn't been updated for 3 years. // https://github.com/JedWatson/react-input-autosize/issues -const AutosizeInput = AutosizeInput_ as unknown as React.FunctionComponent +const AutosizeInput = + AutosizeInput_ as unknown as React.FunctionComponent const MaxUnstackedLabels = 7 @@ -40,7 +41,7 @@ type LabelsPickerProps = { export const LabelsPicker = (props: LabelsPickerProps): JSX.Element => { const inputRef = useRef() - const availableLabels = useGetLabelsQuery() + const { data: availableLabels } = useGetLabels() const [isStackExpanded, setIsStackExpanded] = useState(false) const { focused, @@ -80,9 +81,10 @@ export const LabelsPicker = (props: LabelsPickerProps): JSX.Element => { setTabCount(_tabCount) } - const matches = availableLabels.labels.filter((l) => - l.name.toLowerCase().startsWith(_tabStartValue) - ) + const matches = + availableLabels?.filter((l) => + l.name.toLowerCase().startsWith(_tabStartValue) + ) ?? [] if (_tabCount < matches.length) { setInputValue(matches[_tabCount].name) diff --git a/packages/web/components/elements/icons/ConfusedSlothIcon.tsx b/packages/web/components/elements/icons/ConfusedSlothIcon.tsx index 47f300a3e..7b99e3799 100644 --- a/packages/web/components/elements/icons/ConfusedSlothIcon.tsx +++ b/packages/web/components/elements/icons/ConfusedSlothIcon.tsx @@ -8,7 +8,6 @@ import React from 'react' export function ConfusedSlothIcon(): JSX.Element { const { currentThemeIsDark } = useCurrentTheme() - console.log('is dark mdoe: ', currentThemeIsDark) return currentThemeIsDark ? ( ) : ( diff --git a/packages/web/components/elements/icons/UntrashIcon.tsx b/packages/web/components/elements/icons/UntrashIcon.tsx new file mode 100644 index 000000000..d5952fd36 --- /dev/null +++ b/packages/web/components/elements/icons/UntrashIcon.tsx @@ -0,0 +1,35 @@ +/* eslint-disable functional/no-class */ +/* eslint-disable functional/no-this-expression */ +import { IconProps } from './IconProps' + +import React from 'react' + +export class UntrashIcon extends React.Component { + render() { + const size = (this.props.size || 26).toString() + const color = (this.props.color || '#2A2A2A').toString() + + return ( + + + + + + + + + + + ) + } +} diff --git a/packages/web/components/elements/images/OmnivoreLogoBase.tsx b/packages/web/components/elements/images/OmnivoreLogoBase.tsx index 7358001f7..94c3fae86 100644 --- a/packages/web/components/elements/images/OmnivoreLogoBase.tsx +++ b/packages/web/components/elements/images/OmnivoreLogoBase.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { DEFAULT_HOME_PATH } from '../../../lib/navigations' +import { Box } from '../LayoutPrimitives' export type OmnivoreLogoBaseProps = { color?: string href?: string @@ -9,13 +10,8 @@ export type OmnivoreLogoBaseProps = { } export function OmnivoreLogoBase(props: OmnivoreLogoBaseProps): JSX.Element { - const href = props.href || '/home' - const router = useRouter() - return ( - {props.children} - + ) } diff --git a/packages/web/components/nav-containers/HighlightsContainer.tsx b/packages/web/components/nav-containers/HighlightsContainer.tsx index 5a74fe514..84792e30e 100644 --- a/packages/web/components/nav-containers/HighlightsContainer.tsx +++ b/packages/web/components/nav-containers/HighlightsContainer.tsx @@ -31,6 +31,7 @@ import { highlightColor } from '../../lib/themeUpdater' import { HighlightViewNote } from '../patterns/HighlightNotes' import { theme } from '../tokens/stitches.config' +import { useDeleteHighlight } from '../../lib/networking/highlights/useItemHighlights' const PAGE_SIZE = 10 @@ -131,8 +132,10 @@ function HighlightCard(props: HighlightCardProps): JSX.Element { const [isOpen, setIsOpen] = useState(false) const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] = useState(undefined) - const [labelsTarget, setLabelsTarget] = - useState(undefined) + const [labelsTarget, setLabelsTarget] = useState( + undefined + ) + const deleteHighlight = useDeleteHighlight() const viewInReader = useCallback( (highlightId: string) => { @@ -283,24 +286,22 @@ function HighlightCard(props: HighlightCardProps): JSX.Element { message={'Are you sure you want to delete this highlight?'} onAccept={() => { ;(async () => { - const highlightId = showConfirmDeleteHighlightId - const success = await deleteHighlightMutation( - props.highlight.libraryItem?.id || '', - showConfirmDeleteHighlightId - ) - props.mutate() - if (success) { - showSuccessToast('Highlight deleted.', { - position: 'bottom-right', - }) - const event = new CustomEvent('deleteHighlightbyId', { - detail: highlightId, - }) - document.dispatchEvent(event) - } else { - showErrorToast('Error deleting highlight', { - position: 'bottom-right', + if (props.highlight.libraryItem) { + const success = await deleteHighlight.mutateAsync({ + itemId: props.highlight.libraryItem?.id, + slug: props.highlight.libraryItem?.slug, + highlightId: showConfirmDeleteHighlightId, }) + + if (success) { + showSuccessToast('Highlight deleted.', { + position: 'bottom-right', + }) + } else { + showErrorToast('Error deleting highlight', { + position: 'bottom-right', + }) + } } })() setShowConfirmDeleteHighlightId(undefined) diff --git a/packages/web/components/nav-containers/HomeContainer.tsx b/packages/web/components/nav-containers/HomeContainer.tsx index 79498322c..017d616e1 100644 --- a/packages/web/components/nav-containers/HomeContainer.tsx +++ b/packages/web/components/nav-containers/HomeContainer.tsx @@ -37,7 +37,7 @@ import { Toaster } from 'react-hot-toast' import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery' import useLibraryItemActions from '../../lib/hooks/useLibraryItemActions' import { SyncLoader } from 'react-spinners' -import { useGetRawSearchItemsQuery } from '../../lib/networking/queries/useGetLibraryItemsQuery' +import { useGetLibraryItems } from '../../lib/networking/library_items/useLibraryItems' import { useRegisterActions } from 'kbar' type HomeState = { @@ -182,8 +182,9 @@ type NavigationContextType = { dispatch: React.Dispatch } -const NavigationContext = - createContext(undefined) +const NavigationContext = createContext( + undefined +) export const useNavigation = (): NavigationContextType => { const context = useContext(NavigationContext) @@ -210,13 +211,15 @@ export function HomeContainer(): JSX.Element { const shouldFallback = homeData.error || (!homeData.isValidating && !hasTopPicks(homeData)) - const searchData = useGetRawSearchItemsQuery( + const searchData = useGetLibraryItems( + undefined, { limit: 10, searchQuery: 'in:inbox', includeContent: false, sortDescending: true, }, + // only enable this search if we didn't get home data shouldFallback ) @@ -227,27 +230,28 @@ export function HomeContainer(): JSX.Element { }, [viewerData]) const searchItems = useMemo(() => { - return searchData.items.map((item) => { - return { - id: item.id, - date: item.savedAt, - title: item.title, - url: item.url, - slug: item.slug, - score: 1.0, - thumbnail: item.image, - previewContent: item.description, - source: { - name: item.folder == 'following' ? item.subscription : item.siteName, - icon: item.siteIcon, - type: 'LIBRARY', - }, - canArchive: true, - canDelete: true, - canShare: true, - canMove: item.folder == 'following', - } as HomeItem - }) + return [] + // return searchData.items.map((item) => { + // return { + // id: item.id, + // date: item.savedAt, + // title: item.title, + // url: item.url, + // slug: item.slug, + // score: 1.0, + // thumbnail: item.image, + // previewContent: item.description, + // source: { + // name: item.folder == 'following' ? item.subscription : item.siteName, + // icon: item.siteIcon, + // type: 'LIBRARY', + // }, + // canArchive: true, + // canDelete: true, + // canShare: true, + // canMove: item.folder == 'following', + // } as HomeItem + // }) }, [searchData]) useEffect(() => { @@ -383,7 +387,7 @@ export function HomeContainer(): JSX.Element { ) const dataReady = - !homeData.isValidating && (!shouldFallback || !searchData.isValidating) + !homeData.isValidating && (!shouldFallback || !searchData.isLoading) if (!dataReady || (homeData.error && homeData.errorMessage == 'PENDING')) { console.log('showing pending') return ( @@ -544,7 +548,7 @@ const JustAddedHomeSection = (props: HomeSectionProps): JSX.Element => { ) : ( )} - {!props.article?.isArchived ? ( + {props.article?.state !== State.ARCHIVED ? ( - {!props.article?.isArchived ? ( + {props.article?.state !== State.ARCHIVED ? ( - ) : ( - - )} - - - - {props.showEditTitleModal && ( - - props.actionHandler('update-item', item) - } - onOpenChange={() => { - props.setShowEditTitleModal(false) - props.setLinkToEdit(undefined) - }} - item={props.linkToEdit as LibraryItem} - /> - )} - {showUnsubscribeConfirmation && ( - setShowUnsubscribeConfirmation(false)} - /> - )} - {props.labelsTarget?.node.id && ( - { - if (props.labelsTarget) { - const activate = props.labelsTarget - props.setActiveItem(activate) - props.setLabelsTarget(undefined) - } - }} - /> - )} - {props.viewer && props.notebookTarget?.node.id && ( - { - // onClose={(highlights: Highlight[]) => { - // if (props.notebookTarget?.node.highlights) { - // props.notebookTarget.node.highlights = highlights - // } - props.setNotebookTarget(open ? props.notebookTarget : undefined) - }} - /> - )} - {showUploadModal && ( - setShowUploadModal(false)} /> - )} - - ) -} - -type LibraryItemsProps = { - items: LibraryItem[] - layout: LayoutType - viewer: UserBasicData | undefined - - gridContainerRef: React.RefObject - - setShowEditTitleModal: (show: boolean) => void - setLinkToEdit: (set: LibraryItem | undefined) => void - setShowUnsubscribeConfirmation: (show: true) => void - setLinkToUnsubscribe: (set: LibraryItem | undefined) => void - - isChecked: (itemId: string) => boolean - setIsChecked: (itemId: string, set: boolean) => void - multiSelectMode: MultiSelectMode - - actionHandler: ( - action: LinkedItemCardAction, - item: LibraryItem | undefined - ) => Promise -} - -function LibraryItems(props: LibraryItemsProps): JSX.Element { - return ( - - {props.items.map((linkedItem) => ( - div': { - bg: '$thLeftMenuBackground', - // bg: '$thLibraryBackground', - }, - '&:focus': { - outline: 'none', - '> div': { - outline: 'none', - bg: '$thBackgroundActive', - }, - }, - '&:hover': { - '> div': { - bg: '$thBackgroundActive', - boxShadow: '$cardBoxShadow', - }, - '> a': { - bg: '$thBackgroundActive', - }, - }, - }} - > - {props.viewer && ( - { - if (action === 'editTitle') { - props.setShowEditTitleModal(true) - props.setLinkToEdit(linkedItem) - } else if (action == 'unsubscribe') { - props.setShowUnsubscribeConfirmation(true) - props.setLinkToUnsubscribe(linkedItem) - } else { - props.actionHandler(action, linkedItem) - } - document.body.style.removeProperty('pointer-events') - }} - /> - )} - - ))} - - ) -} diff --git a/packages/web/components/templates/homeFeed/LibraryHeader.tsx b/packages/web/components/templates/homeFeed/LibraryHeader.tsx index 99412b1df..6e789ad38 100644 --- a/packages/web/components/templates/homeFeed/LibraryHeader.tsx +++ b/packages/web/components/templates/homeFeed/LibraryHeader.tsx @@ -10,7 +10,7 @@ import { LayoutType, LibraryMode } from './HomeFeedContainer' import { OmnivoreSmallLogo } from '../../elements/images/OmnivoreNameLogo' import { DEFAULT_HEADER_HEIGHT, HeaderSpacer } from './HeaderSpacer' import { LIBRARY_LEFT_MENU_WIDTH } from '../navMenu/LibraryMenu' -import { BulkAction } from '../../../lib/networking/mutations/bulkActionMutation' +import { BulkAction } from '../../../lib/networking/library_items/useLibraryItems' import { HeaderToggleGridIcon } from '../../elements/icons/HeaderToggleGridIcon' import { HeaderToggleListIcon } from '../../elements/icons/HeaderToggleListIcon' import { HeaderToggleTLDRIcon } from '../../elements/icons/HeaderToggleTLDRIcon' @@ -123,7 +123,7 @@ function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element { > {props.multiSelectMode !== 'off' ? ( <> - + ) : ( @@ -310,7 +310,7 @@ export function SearchBox(props: SearchBoxProps): JSX.Element { }, }} > - + void @@ -116,7 +119,7 @@ export const MultiSelectControls = (props: MultiSelectProps): JSX.Element => { { > {props.numItemsSelected} items - + {props.folder !== 'archive' && } - + {props.folder == 'subscriptions' && ( + + )} + {props.folder !== 'trash' && ( + + )} {showConfirmDelete && ( { ) } +export const MoveToLibraryButton = (props: MultiSelectProps): JSX.Element => { + const [color, setColor] = useState( + theme.colors.thTextContrast2.toString() + ) + return ( + + ) +} + type AddLabelsButtonProps = { setShowLabelsModal: (set: boolean) => void } diff --git a/packages/web/components/templates/homeFeed/TLDRLayout.tsx b/packages/web/components/templates/homeFeed/TLDRLayout.tsx index 13d8881f1..7274c8f31 100644 --- a/packages/web/components/templates/homeFeed/TLDRLayout.tsx +++ b/packages/web/components/templates/homeFeed/TLDRLayout.tsx @@ -1,6 +1,6 @@ import { LayoutType } from './HomeFeedContainer' import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery' -import { LibraryItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery' +import { LibraryItem } from '../../../lib/networking/library_items/useLibraryItems' import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' import { Toaster } from 'react-hot-toast' import TopBarProgress from 'react-topbar-progress-indicator' diff --git a/packages/web/components/templates/library/LibraryContainer.tsx b/packages/web/components/templates/library/LibraryContainer.tsx index e9f0128c7..74fd822d8 100644 --- a/packages/web/components/templates/library/LibraryContainer.tsx +++ b/packages/web/components/templates/library/LibraryContainer.tsx @@ -8,21 +8,24 @@ import { useFetchMore } from '../../../lib/hooks/useFetchMoreScroll' import { usePersistedState } from '../../../lib/hooks/usePersistedState' import { libraryListCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts' import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts' -import { - PageType, - State, -} from '../../../lib/networking/fragments/articleFragment' import { SearchItem, TypeaheadSearchItemsData, typeaheadSearchQuery, } from '../../../lib/networking/queries/typeaheadSearch' -import type { +import { LibraryItem, LibraryItemNode, + LibraryItems, LibraryItemsQueryInput, -} from '../../../lib/networking/queries/useGetLibraryItemsQuery' -import { useGetLibraryItemsQuery } from '../../../lib/networking/queries/useGetLibraryItemsQuery' + useArchiveItem, + useBulkActions, + useDeleteItem, + useGetLibraryItems, + useMoveItemToFolder, + useRefreshProcessingItems, + useUpdateItemReadStatus, +} from '../../../lib/networking/library_items/useLibraryItems' import { useGetViewerQuery, UserBasicData, @@ -38,17 +41,10 @@ import { EditLibraryItemModal } from '../homeFeed/EditItemModals' import { EmptyLibrary } from '../homeFeed/EmptyLibrary' import { MultiSelectMode } from '../homeFeed/LibraryHeader' import { UploadModal } from '../UploadModal' -import { BulkAction } from '../../../lib/networking/mutations/bulkActionMutation' -import { bulkActionMutation } from '../../../lib/networking/mutations/bulkActionMutation' -import { - showErrorToast, - showSuccessToast, - showSuccessToastWithAction, -} from '../../../lib/toastHelpers' +import { BulkAction } from '../../../lib/networking/library_items/useLibraryItems' +import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers' import { SetPageLabelsModalPresenter } from '../article/SetLabelsModalPresenter' import { NotebookPresenter } from '../article/NotebookPresenter' -import { saveUrlMutation } from '../../../lib/networking/mutations/saveUrlMutation' -import { articleQuery } from '../../../lib/networking/queries/useGetArticleQuery' import { PinnedButtons } from '../homeFeed/PinnedButtons' import { PinnedSearch } from '../../../pages/settings/pinned-searches' import { FetchItemsError } from '../homeFeed/FetchItemsError' @@ -56,6 +52,9 @@ import { LibraryHeader } from './LibraryHeader' import { TrashIcon } from '../../elements/icons/TrashIcon' import { theme } from '../../tokens/stitches.config' import { emptyTrashMutation } from '../../../lib/networking/mutations/emptyTrashMutation' +import { State } from '../../../lib/networking/fragments/articleFragment' +import { useHandleAddUrl } from '../../../lib/hooks/useHandleAddUrl' +import { QueryClient, useQueryClient } from '@tanstack/react-query' export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT' @@ -72,21 +71,16 @@ const debouncedFetchSearchResults = debounce((query, cb) => { fetchSearchResults(query, cb) }, 300) -// We set a relatively high delay for the refresh at the end, as it's likely there's an issue -// in processing. We give it the best attempt to be able to resolve, but if it doesn't we set -// the state as Failed. On refresh it will try again if the backend sends "PROCESSING" -const TIMEOUT_DELAYS = [2000, 3500, 5000] - type LibraryContainerProps = { - folder: string + folder: string | undefined filterFunc: (item: LibraryItemNode) => boolean showNavigationMenu: boolean } export function LibraryContainer(props: LibraryContainerProps): JSX.Element { - const { viewerData } = useGetViewerQuery() const router = useRouter() + const { viewerData } = useGetViewerQuery() const { queryValue } = useKBar((state) => ({ queryValue: state.searchQuery })) const [searchResults, setSearchResults] = useState([]) @@ -110,31 +104,22 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { const [linkToEdit, setLinkToEdit] = useState() const [linkToUnsubscribe, setLinkToUnsubscribe] = useState() + const archiveItem = useArchiveItem() + const deleteItem = useDeleteItem() + const moveToFolder = useMoveItemToFolder() + const bulkAction = useBulkActions() + const updateItemReadStatus = useUpdateItemReadStatus() + const [queryInputs, setQueryInputs] = useState(defaultQuery) const { - itemsPages, - size, - setSize, - isValidating, - performActionOnItem, - mutate, + data: itemsPages, + isLoading, + fetchNextPage, + hasNextPage, error: fetchItemsError, - } = useGetLibraryItemsQuery(props.folder, queryInputs) - - useEffect(() => { - const handleRevalidate = () => { - ;(async () => { - console.log('revalidating library') - await mutate() - })() - } - document.addEventListener('revalidateLibrary', handleRevalidate) - return () => { - document.removeEventListener('revalidateLibrary', handleRevalidate) - } - }, [mutate]) + } = useGetLibraryItems(props.folder, queryInputs) useEffect(() => { if (queryValue.startsWith('#')) { @@ -157,7 +142,7 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { if (qs !== (queryInputs.searchQuery || '')) { setQueryInputs({ ...queryInputs, searchQuery: qs }) - performActionOnItem('refresh', undefined as unknown as any) + // performActionOnItem('refresh', undefined as unknown as any) } // intentionally not watching queryInputs and performActionOnItem @@ -169,25 +154,18 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { window.localStorage.setItem('nav-return', router.asPath) }, [router.asPath]) - const hasMore = useMemo(() => { - if (!itemsPages) { - return false - } - return itemsPages[itemsPages.length - 1].search.pageInfo.hasNextPage - }, [itemsPages]) - const libraryItems = useMemo(() => { const items = - itemsPages - ?.flatMap((ad) => { - return ad.search.edges.map((it) => ({ + itemsPages?.pages + .flatMap((ad: LibraryItems) => { + return ad.edges.map((it) => ({ ...it, isLoading: it.node.state === 'PROCESSING', })) }) .filter((item) => props.filterFunc(item.node)) || [] return items - }, [itemsPages, performActionOnItem]) + }, [itemsPages]) useEffect(() => { if (localStorage) { @@ -198,78 +176,22 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { } }, [libraryItems]) - useEffect(() => { - const timeout: NodeJS.Timeout[] = [] + const processingItems = useMemo(() => { + return libraryItems + .filter((li) => li.node.state === State.PROCESSING) + .map((li) => li.node.id) + }, [libraryItems]) - const items = ( - itemsPages?.flatMap((ad) => { - return ad.search.edges.map((it) => ({ - ...it, - isLoading: it.node.state === 'PROCESSING', - })) - }) || [] - ).filter((it) => it.isLoading) - - items.map(async (item) => { - let startIdx = 0 - - const seeIfUpdated = async () => { - if (startIdx >= TIMEOUT_DELAYS.length) { - item.node.state = State.FAILED - const updatedArticle = { ...item } - updatedArticle.node = { ...item.node } - updatedArticle.isLoading = false - performActionOnItem('update-item', updatedArticle) - return - } - - const username = viewerData?.me?.profile.username - const itemsToUpdate = libraryItems.filter((it) => it.isLoading) - - if (itemsToUpdate.length > 0) { - const link = await articleQuery({ - username, - slug: item.node.id, - includeFriendsHighlights: false, - }) - - if (link && link.state != 'PROCESSING') { - const updatedArticle = { ...item } - updatedArticle.node = { ...item.node, ...link } - updatedArticle.isLoading = false - console.log(`Updating Metadata of ${item.node.slug}.`) - performActionOnItem('update-item', updatedArticle) - return - } - - console.log( - `Trying to get the metadata of item ${item.node.slug}... Retry ${startIdx} of 5` - ) - timeout.push(setTimeout(seeIfUpdated, TIMEOUT_DELAYS[startIdx++])) - } - } - - await seeIfUpdated() - }) - - return () => { - timeout.forEach(clearTimeout) - } - }, [itemsPages]) - - const handleFetchMore = useCallback(() => { - if (isValidating || !hasMore) { - return - } - setSize(size + 1) - }, [size, isValidating]) + const refreshProcessingItems = useRefreshProcessingItems() useEffect(() => { - if (isValidating || !hasMore || size !== 1) { - return + if (processingItems.length) { + refreshProcessingItems.mutateAsync({ + attempt: 0, + itemIds: processingItems, + }) } - setSize(size + 1) - }, [size, isValidating]) + }, [processingItems]) const focusFirstItem = useCallback(() => { if (libraryItems.length < 1) { @@ -374,10 +296,6 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { if (activeCardId && !alreadyScrolled.current) { scrollToActiveCard(activeCardId) alreadyScrolled.current = true - - if (activeItem) { - performActionOnItem('refresh', activeItem) - } } }, [activeCardId, scrollToActiveCard]) @@ -397,11 +315,7 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { if (item.node.state === State.PROCESSING) { router.push(`/article?url=${encodeURIComponent(item.node.url)}`) } else { - const dl = - item.node.pageType === PageType.HIGHLIGHTS - ? `#${item.node.id}` - : '' - router.push(`/${username}/${item.node.slug}` + dl) + router.push(`/${username}/${item.node.slug}`) } } break @@ -412,19 +326,94 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { } break case 'archive': - performActionOnItem('archive', item) - break case 'unarchive': - performActionOnItem('unarchive', item) + try { + await archiveItem.mutateAsync({ + itemId: item.node.id, + slug: item.node.slug, + input: { + linkId: item.node.id, + archived: action == 'archive', + }, + }) + } catch (err) { + console.log('Error setting archive state: ', err) + showErrorToast(`Error ${action}ing item`, { + position: 'bottom-right', + }) + return + } + showSuccessToast(`Item ${action}d`, { + position: 'bottom-right', + }) break case 'delete': - performActionOnItem('delete', item) + try { + await deleteItem.mutateAsync({ + itemId: item.node.id, + slug: item.node.slug, + }) + } catch (err) { + console.log('Error deleting item: ', err) + showErrorToast(`Error deleting item`, { + position: 'bottom-right', + }) + return + } + showSuccessToast(`Item deleted`, { + position: 'bottom-right', + }) break case 'mark-read': - performActionOnItem('mark-read', item) - break case 'mark-unread': - performActionOnItem('mark-unread', item) + const desc = action == 'mark-read' ? 'read' : 'unread' + const values = + action == 'mark-read' + ? { + readingProgressPercent: 100, + readingProgressTopPercent: 100, + readingProgressAnchorIndex: 0, + } + : { + readingProgressPercent: 0, + readingProgressTopPercent: 0, + readingProgressAnchorIndex: 0, + } + try { + await updateItemReadStatus.mutateAsync({ + itemId: item.node.id, + slug: item.node.slug, + input: { + id: item.node.id, + force: true, + ...values, + }, + }) + } catch (err) { + console.log('Error marking item: ', err) + showErrorToast(`Error marking as ${desc}`, { + position: 'bottom-right', + }) + return + } + break + case 'move-to-inbox': + try { + await moveToFolder.mutateAsync({ + itemId: item.node.id, + slug: item.node.slug, + folder: 'inbox', + }) + } catch (err) { + console.log('Error moving item: ', err) + showErrorToast(`Error moving item`, { + position: 'bottom-right', + }) + return + } + showSuccessToast(`Item moved to library`, { + position: 'bottom-right', + }) break case 'set-labels': setLabelsTarget(item) @@ -437,10 +426,10 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { } break case 'unsubscribe': - performActionOnItem('unsubscribe', item) - case 'update-item': - performActionOnItem('update-item', item) + // setLinkToUnsubscribe(item.node) break + default: + console.warn('unknown action: ', action) } } @@ -590,19 +579,20 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { }) ) - const ARCHIVE_ACTION = !activeItem?.node.isArchived - ? createAction({ - section: 'Library', - name: 'Archive selected item', - shortcut: ['e'], - perform: () => handleCardAction('archive', activeItem), - }) - : createAction({ - section: 'Library', - name: 'UnArchive selected item', - shortcut: ['e'], - perform: () => handleCardAction('unarchive', activeItem), - }) + const ARCHIVE_ACTION = + activeItem?.node.state !== State.ARCHIVED + ? createAction({ + section: 'Library', + name: 'Archive selected item', + shortcut: ['e'], + perform: () => handleCardAction('archive', activeItem), + }) + : createAction({ + section: 'Library', + name: 'UnArchive selected item', + shortcut: ['e'], + perform: () => handleCardAction('unarchive', activeItem), + }) const ACTIVE_ACTIONS = [ ARCHIVE_ACTION, @@ -676,7 +666,7 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { activeCardId ? [...ACTIVE_ACTIONS, ...UNACTIVE_ACTIONS] : UNACTIVE_ACTIONS, [activeCardId, activeItem] ) - useFetchMore(handleFetchMore) + useFetchMore(fetchNextPage) const setIsChecked = useCallback( (itemId: string, set: boolean) => { @@ -710,8 +700,8 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { case 'search': case 'visible': const allIds = ( - itemsPages?.flatMap((ad) => { - return ad.search.edges + itemsPages?.pages.flatMap((ad) => { + return ad.edges }) || [] ).map((item) => item.node.id) setCheckedItems(allIds) @@ -739,21 +729,23 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { multiSelectMode === 'search' ? queryInputs.searchQuery || 'in:inbox' : `includes:${checkedItems.join(',')}` - const expectedCount = - multiSelectMode === 'search' - ? itemsPages?.[0].search.pageInfo.totalCount || 0 - : checkedItems.length + const expectedCount = checkedItems.length + + let bulkArguments = undefined + if (action == BulkAction.MOVE_TO_FOLDER) { + bulkArguments = { folder: 'inbox ' } + } try { - const res = await bulkActionMutation( + const res = await bulkAction.mutateAsync({ action, query, expectedCount, - labelIds - ) + labelIds, + arguments: bulkArguments, + }) if (res) { let successMessage: string | undefined = undefined - console.log(action) switch (action) { case BulkAction.ARCHIVE: successMessage = 'Link Archived' @@ -767,6 +759,9 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { case BulkAction.MARK_AS_READ: successMessage = 'Items marked as read' break + case BulkAction.MOVE_TO_FOLDER: + successMessage = 'Items moved to library' + break } if (successMessage) { showSuccessToast(successMessage, { position: 'bottom-right' }) @@ -781,37 +776,18 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { position: 'bottom-right', }) } - mutate() + // mutate() })() setMultiSelectMode('off') }, [itemsPages, multiSelectMode, checkedItems] ) - const handleLinkSubmission = async ( - link: string, - timezone: string, - locale: string - ) => { - const result = await saveUrlMutation(link, timezone, locale) - if (result) { - showSuccessToastWithAction('Link saved', 'Read now', async () => { - window.location.href = `/article?url=${encodeURIComponent(link)}` - return Promise.resolve() - }) - const id = result.url?.match(/[^/]+$/)?.[0] ?? '' - performActionOnItem('refresh', undefined as unknown as any) - } else { - showErrorToast('Error saving link', { position: 'bottom-right' }) - } - } - return ( { setQueryInputs({ ...queryInputs, @@ -836,18 +811,11 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { const href = `${window.location.pathname}?${qp.toString()}` router.push(href, href, { shallow: true }) window.sessionStorage.setItem('q', qp.toString()) - performActionOnItem('refresh', undefined as unknown as any) }} - loadMore={() => { - if (isValidating) { - return - } - setSize(size + 1) - }} - hasMore={hasMore} + loadMore={fetchNextPage} + hasMore={hasNextPage ?? false} hasData={!!itemsPages} - totalItems={itemsPages?.[0].search.pageInfo.totalCount || 0} - isValidating={isValidating} + isValidating={isLoading} fetchItemsError={!!fetchItemsError} labelsTarget={labelsTarget} setLabelsTarget={setLabelsTarget} @@ -864,25 +832,19 @@ export function LibraryContainer(props: LibraryContainerProps): JSX.Element { setLinkToEdit={setLinkToEdit} linkToUnsubscribe={linkToUnsubscribe} setLinkToUnsubscribe={setLinkToUnsubscribe} - numItemsSelected={ - multiSelectMode == 'search' - ? itemsPages?.[0].search.pageInfo.totalCount || 0 - : checkedItems.length - } + numItemsSelected={checkedItems.length} /> ) } export type HomeFeedContentProps = { - folder: string + folder: string | undefined items: LibraryItem[] searchTerm?: string - reloadItems: () => void gridContainerRef: React.RefObject applySearchQuery: (searchQuery: string) => void hasMore: boolean hasData: boolean - totalItems: number isValidating: boolean fetchItemsError: boolean @@ -909,12 +871,6 @@ export type HomeFeedContentProps = { item: LibraryItem | undefined ) => Promise - handleLinkSubmission: ( - link: string, - timezone: string, - locale: string - ) => Promise - showNavigationMenu: boolean setIsChecked: (itemId: string, set: boolean) => void @@ -953,6 +909,8 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element { return true }, [props]) + const addUrl = useHandleAddUrl() + return ( props.setShowAddLinkModal(false)} /> )} @@ -1014,7 +973,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element { } type LibraryItemsLayoutProps = { - folder: string + folder: string | undefined layout: LayoutType viewer?: UserBasicData @@ -1136,6 +1095,9 @@ export function LibraryItemsLayout( css={{ textDecoration: 'underline' }} onClick={async (event) => { event.preventDefault() + alert( + 'Emptying trash happens in the background and could take a few minutes depending on the number of items you have in the trash. You may see old items in your trash during this time.' + ) await emptyTrashMutation() showSuccessToast('Emptying trash') setTimeout(() => { @@ -1162,7 +1124,7 @@ export function LibraryItemsLayout( }} style={{ height: '100%', width: '100%' }} > - {props.showEditTitleModal && ( - props.actionHandler('update-item', item) - } onOpenChange={() => { props.setShowEditTitleModal(false) props.setLinkToEdit(undefined) }} + updateItem={async () => { + await Promise.resolve() + console.log('item updated') + }} item={props.linkToEdit as LibraryItem} /> )} @@ -1219,8 +1182,7 @@ export function LibraryItemsLayout( )} {props.labelsTarget?.node.id && ( { if (props.labelsTarget) { const activate = props.labelsTarget @@ -1252,7 +1214,7 @@ export function LibraryItemsLayout( } type LibraryItemsProps = { - folder: string + folder: string | undefined items: LibraryItem[] layout: LayoutType viewer: UserBasicData | undefined @@ -1274,7 +1236,7 @@ type LibraryItemsProps = { ) => Promise } -function LibraryItems(props: LibraryItemsProps): JSX.Element { +function LibraryItemsList(props: LibraryItemsProps): JSX.Element { return ( void + folder: string | undefined searchTerm: string | undefined applySearchQuery: (searchQuery: string) => void @@ -250,26 +251,28 @@ export function SearchBox(props: SearchBoxProps): JSX.Element { distribution="start" css={{ width: '100%', height: '100%' }} > - - - + {props.folder !== 'trash' && ( + + + + )} { if ( !labelsResponse.error && !labelsResponse.isLoading && - labelsResponse.labels + labelsResponse.data ) { - setLabels(labelsResponse.labels) + setLabels(labelsResponse.data) } }, [setLabels, labelsResponse]) @@ -78,9 +78,9 @@ export function LibraryLegacyMenu(props: LibraryFilterMenuProps): JSX.Element { if ( !searchesResponse.error && !searchesResponse.isLoading && - searchesResponse.savedSearches + searchesResponse?.data ) { - setSavedSearches(searchesResponse.savedSearches) + setSavedSearches(searchesResponse.data) } }, [setSavedSearches, searchesResponse]) diff --git a/packages/web/components/templates/navMenu/LibraryMenu.tsx b/packages/web/components/templates/navMenu/LibraryMenu.tsx index 000507889..f23589e96 100644 --- a/packages/web/components/templates/navMenu/LibraryMenu.tsx +++ b/packages/web/components/templates/navMenu/LibraryMenu.tsx @@ -6,15 +6,12 @@ import { Circle, DotsThree, MagnifyingGlass, X } from '@phosphor-icons/react' import { Subscription, SubscriptionType, - useGetSubscriptionsQuery, } from '../../../lib/networking/queries/useGetSubscriptionsQuery' -import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery' import { Label } from '../../../lib/networking/fragments/labelFragment' import { theme } from '../../tokens/stitches.config' import { useRegisterActions } from 'kbar' import { LogoBox } from '../../elements/LogoBox' import { usePersistedState } from '../../../lib/hooks/usePersistedState' -import { useGetSavedSearchQuery } from '../../../lib/networking/queries/useGetSavedSearchQuery' import { SavedSearch } from '../../../lib/networking/fragments/savedSearchFragment' import { ToggleCaretDownIcon } from '../../elements/icons/ToggleCaretDownIcon' import Link from 'next/link' @@ -25,13 +22,13 @@ import { HomeIcon } from '../../elements/icons/HomeIcon' import { LibraryIcon } from '../../elements/icons/LibraryIcon' import { HighlightsIcon } from '../../elements/icons/HighlightsIcon' import { CoverImage } from '../../elements/CoverImage' -import { Shortcut } from './NavigationMenu' 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' import { escapeQuotes } from '../../../utils/helper' +import { Shortcut } from '../../../lib/networking/shortcuts/useShortcuts' export const LIBRARY_LEFT_MENU_WIDTH = '275px' @@ -185,59 +182,6 @@ const Shortcuts = (props: LibraryFilterMenuProps): JSX.Element => { initialValue: [], }) - console.log('got shortcuts: ', shortcuts) - - // const shortcuts: Shortcut[] = [ - // { - // id: '12asdfasdf', - // name: 'Omnivore Blog', - // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png', - // filter: 'subscription:"Money Talk"', - // type: 'feed', - // }, - // { - // id: 'sdfsdfgdsfg', - // name: 'Follow the Money | Arne & Harr', - // filter: 'subscription:"Money Talk"', - // type: 'feed', - // }, - // { - // id: 'sdfasdfasdfsdfsdfsgasdfg', - // name: 'Andrew Kenneson from Center for the Study of Partisanship and Ideology', - // // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png', - // filter: 'in:all label:"Hockey"', - // type: 'newsletter', - // }, - // { - // id: 'sdfasdfasdfsdfsdfsgasdfg', - // name: 'Robert的博客', - // // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png', - // filter: 'in:all label:"Hockey"', - // type: 'feed', - // }, - // { - // id: 'sdfasdfasdfasdfasf', - // name: 'Oldest First', - // // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png', - // filter: 'in:all label:"Hockey"', - // type: 'search', - // }, - // { - // id: 'sdfasdfasdfgasdfg', - // name: 'Hockey', - // // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png', - // filter: 'in:all label:"Hockey"', - // type: 'label', - // label: { - // id: 'sdfsdfsdf', - // name: 'Hockey', - // color: '#E98B8B', - // createdAt: new Date(), - // }, - // }, - // ] - // - return ( { // on small screens we want to dismiss the menu after click - if (window.innerWidth <= 768) { - setDismissed(true) - setTimeout(() => { - props.setShowMenu(false) - }, 100) - } + // if (window.innerWidth <= 768) { + // setDismissed(true) + // setTimeout(() => { + // props.setShowMenu(false) + // }, 100) + // } event.stopPropagation() }} > @@ -217,6 +183,7 @@ const LibraryNav = (props: NavigationMenuProps): JSX.Element => { onClick={(event) => { setMoreFolderSectionOpen(!moreFolderSectionOpen) event.preventDefault() + event.stopPropagation() }} > { } const Shortcuts = (props: NavigationMenuProps): JSX.Element => { + const router = useRouter() const treeRef = useRef | undefined>(undefined) - const { trigger: resetShortcutsTrigger } = useSWRMutation( - '/api/shortcuts', - resetShortcuts - ) + const resetShortcuts = useResetShortcuts() const createNewFolder = useCallback(async () => { if (treeRef.current) { @@ -283,9 +248,7 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => { }, [treeRef]) const resetShortcutsToDefault = useCallback(async () => { - resetShortcutsTrigger(null, { - revalidate: true, - }) + await resetShortcuts.mutateAsync() }, []) return ( @@ -323,6 +286,12 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => { triggerElement={} css={{ ml: 'auto' }} > + { + router.push(`/settings/shortcuts`) + }} + title="Edit shortcuts" + /> { outline: 'none', }, }} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + }} > @@ -352,525 +325,6 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => { ) } -type ShortcutsTreeProps = { - treeRef: React.MutableRefObject | undefined> -} - -async function getShortcuts(path: string): Promise { - const url = new URL(path, fetchEndpoint) - try { - const response = await fetch(url.toString(), { - method: 'GET', - headers: requestHeaders(), - credentials: 'include', - mode: 'cors', - }) - const payload = await response.json() - if ('shortcuts' in payload) { - return payload['shortcuts'] as Shortcut[] - } - return [] - } catch (err) { - console.log('error getting shortcuts: ', err) - throw err - } -} - -async function setShortcuts( - path: string, - { arg }: { arg: { shortcuts: Shortcut[] } } -): Promise { - const url = new URL(path, fetchEndpoint) - try { - const response = await fetch(url.toString(), { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - ...requestHeaders(), - }, - credentials: 'include', - mode: 'cors', - body: JSON.stringify(arg), - }) - const payload = await response.json() - if (!('shortcuts' in payload)) { - throw new Error('Error syncing shortcuts') - } - return payload['shortcuts'] as Shortcut[] - } catch (err) { - showErrorToast('Error syncing shortcut changes.') - } - return arg.shortcuts -} - -async function resetShortcuts(path: string): Promise { - const url = new URL(path, fetchEndpoint) - try { - const response = await fetch(url.toString(), { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - ...requestHeaders(), - }, - credentials: 'include', - mode: 'cors', - }) - const payload = await response.json() - if (!('shortcuts' in payload)) { - throw new Error('Error syncing shortcuts') - } - return payload['shortcuts'] as Shortcut[] - } catch (err) { - showErrorToast('Error syncing shortcut changes.') - } - return [] -} - -const cachedShortcutsData = (): Shortcut[] | undefined => { - if (typeof localStorage !== 'undefined') { - const str = localStorage.getItem('/api/shortcuts') - if (str) { - return JSON.parse(str) as Shortcut[] - } - } - return undefined -} - -const ShortcutsTree = (props: ShortcutsTreeProps): JSX.Element => { - const router = useRouter() - const { ref, width, height } = useResizeObserver() - - const { isValidating, data } = useSWR('/api/shortcuts', getShortcuts, { - revalidateOnFocus: false, - fallbackData: cachedShortcutsData(), - onSuccess(data) { - localStorage.setItem('/api/shortcuts', JSON.stringify(data)) - }, - }) - const { trigger, isMutating } = useSWRMutation('/api/shortcuts', setShortcuts) - const [folderOpenState, setFolderOpenState] = usePersistedState< - Record - >({ - key: 'nav-menu-open-state', - isSessionStorage: false, - initialValue: {}, - }) - const tree = useMemo(() => { - const result = new SimpleTree((data ?? []) as Shortcut[]) - return result - }, [data]) - - const syncTreeData = (data: Shortcut[]) => { - trigger( - { shortcuts: data }, - { - optimisticData: data, - rollbackOnError: true, - populateCache: (updatedShortcuts) => { - return updatedShortcuts - }, - revalidate: false, - } - ) - } - - const onMove = useCallback( - (args: { dragIds: string[]; parentId: null | string; index: number }) => { - for (const id of args.dragIds) { - tree?.move({ id, parentId: args.parentId, index: args.index }) - } - syncTreeData(tree.data) - }, - [tree, data] - ) - - const onCreate = useCallback( - (args: { parentId: string | null; index: number; type: string }) => { - const data = { id: uuidv4(), name: '', type: 'folder' } as any - if (args.type === 'internal') { - data.children = [] - } - tree.create({ parentId: args.parentId, index: args.index, data }) - syncTreeData(tree.data) - return data - }, - [tree, data] - ) - - const onDelete = useCallback( - (args: { ids: string[] }) => { - args.ids.forEach((id) => tree.drop({ id })) - syncTreeData(tree.data) - }, - [tree, data] - ) - - const onRename = useCallback( - (args: { name: string; id: string }) => { - tree.update({ id: args.id, changes: { name: args.name } as any }) - syncTreeData(tree.data) - }, - [tree, data] - ) - - const onToggle = useCallback( - (id: string) => { - if (id && props.treeRef.current) { - const isOpen = props.treeRef.current?.isOpen(id) - const newItem: OpenMap = {} - newItem[id] = isOpen - setFolderOpenState({ ...folderOpenState, ...newItem }) - } - }, - [props, folderOpenState, setFolderOpenState] - ) - - const onActivate = useCallback( - (node: NodeApi) => { - if (node.data.type == 'folder') { - const join = node.data.join - if (join == 'or') { - const query = node.children - ?.map((child) => { - return `(${child.data.filter})` - }) - .join(' OR ') - } - } else if (node.data.section != null && node.data.filter != null) { - router.push(`/l/${node.data.section}?q=${node.data.filter}`) - } - }, - [tree, router] - ) - - function countTotalShortcuts(shortcuts: Shortcut[]): number { - let total = 0 - - for (const shortcut of shortcuts) { - // Count the current shortcut - total++ - - // If the shortcut has children, recursively count them - if (shortcut.children && shortcut.children.length > 0) { - total += countTotalShortcuts(shortcut.children) - } - } - - return total - } - - const maximumHeight = useMemo(() => { - if (!data) { - return 320 - } - return countTotalShortcuts(data as Shortcut[]) * 36 - }, [data]) - - return ( - - {!isValidating && ( - - {NodeRenderer} - - )} - - ) -} - -function NodeRenderer(args: { - style: CSSProperties - node: NodeApi - tree: TreeApi - dragHandle?: (el: HTMLDivElement | null) => void - preview?: boolean -}) { - const isSelected = false - const [menuVisible, setMenuVisible] = useState(false) - const [menuOpened, setMenuOpened] = useState(false) - - const router = useRouter() - - return ( - { - setMenuVisible(true) - }} - onMouseLeave={() => { - setMenuVisible(false) - }} - title={args.node.data.name} - onClick={(e) => { - // router.push(`/` + props.section) - }} - > - - - - } - css={{ ml: 'auto' }} - onOpenChange={(open) => { - setMenuOpened(open) - }} - > - { - args.tree.delete(args.node) - }} - title="Remove" - /> - {/* {args.node.data.type == 'folder' && ( - { - args.node.data.join = 'or' - }} - title="Folder query: OR" - /> - )} */} - - - - - ) -} - -type NodeItemContentsProps = { - node: NodeApi -} - -const NodeItemContents = (props: NodeItemContentsProps): JSX.Element => { - if (props.node.isEditing) { - return ( - e.currentTarget.select()} - onBlur={() => props.node.reset()} - onKeyDown={(e) => { - if (e.key === 'Escape') { - props.node.reset() - } - if (e.key === 'Enter') { - // props.node.data = { - // id: 'new-folder', - // type: 'folder', - // name: e.currentTarget.value, - // } - props.node.submit(e.currentTarget.value) - props.node.activate() - } - }} - /> - ) - } - if (props.node.isLeaf) { - const shortcut = props.node.data - if (shortcut) { - switch (shortcut.type) { - case 'feed': - case 'newsletter': - return ( - - - - ) - case 'label': - return ( - - - - ) - case 'search': - return ( - - - - ) - } - } - } else { - return ( - { - props.node.toggle() - event.preventDefault() - }} - > - {props.node.isClosed ? ( - - ) : ( - - )} - {props.node.data.name} - - ) - } - return <> -} - -type ShortcutItemProps = { - shortcut: Shortcut -} - -const FeedOrNewsletterShortcut = (props: ShortcutItemProps): JSX.Element => { - return ( - - - {props.shortcut.icon ? ( - - ) : props.shortcut.type == 'newsletter' ? ( - - ) : ( - - )} - - {props.shortcut.name} - - ) -} - -const SearchShortcut = (props: ShortcutItemProps): JSX.Element => { - return ( - - - - - {props.shortcut.name} - - ) -} - -const LabelShortcut = (props: ShortcutItemProps): JSX.Element => { - // - return ( - - - - {props.shortcut.name} - - - ) -} - type NavButtonProps = { text: string icon: ReactNode @@ -925,8 +379,7 @@ function NavButton(props: NavButtonProps): JSX.Element { }} title={props.text} onClick={(e) => { - // console.log('clicked navigation menu') - router.push(`/l/` + props.section) + router.push(`/` + props.section) }} > {props.icon} diff --git a/packages/web/components/templates/settings/SettingsTable.tsx b/packages/web/components/templates/settings/SettingsTable.tsx index 5f81689fd..e651c9294 100644 --- a/packages/web/components/templates/settings/SettingsTable.tsx +++ b/packages/web/components/templates/settings/SettingsTable.tsx @@ -8,8 +8,6 @@ import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' import { StyledText } from '../../elements/StyledText' import { styled, theme } from '../../tokens/stitches.config' import { SettingsLayout } from '../SettingsLayout' -import { usePersistedState } from '../../../lib/hooks/usePersistedState' -import { FeatureHelpBox } from '../../elements/FeatureHelpBox' // Styles export const Header = styled(Box, { @@ -26,8 +24,6 @@ type SettingsTableProps = { createTitle?: string createAction?: () => void - suggestionInfo: SuggestionInfo - children: React.ReactNode } @@ -63,16 +59,6 @@ type MoreOptionsProps = { onEdit?: () => void } -type SuggestionInfo = { - title: string - message: string - docs: string - key: string - - CTAText?: string - onClickCTA?: () => void -} - const MoreOptions = (props: MoreOptionsProps) => ( { } export const SettingsTable = (props: SettingsTableProps): JSX.Element => { - const [showSuggestion, setShowSuggestion] = usePersistedState({ - key: props.suggestionInfo.key, - initialValue: !!props.suggestionInfo, - }) - return ( { }, }} > - {props.suggestionInfo && showSuggestion && ( - { - setShowSuggestion(false) - }} - helpCTAText={props.suggestionInfo.CTAText} - onClickCTA={props.suggestionInfo.onClickCTA} - /> - )} 0 const { range, selection } = input.selection diff --git a/packages/web/lib/highlights/useSelection.tsx b/packages/web/lib/highlights/useSelection.tsx index c32db86d2..6d12c74ea 100644 --- a/packages/web/lib/highlights/useSelection.tsx +++ b/packages/web/lib/highlights/useSelection.tsx @@ -8,19 +8,20 @@ import type { SelectionAttributes } from './highlightHelpers' /** * Get the range of text with {@link SelectionAttributes} that user has selected - * + * * Event Handlers for detecting/using new highlight selection are registered - * + * * If the new highlight selection overlaps with existing highlights, the new selection is merged. - * + * * @param highlightLocations existing highlights * @returns selection range and its setter */ export function useSelection( highlightLocations: HighlightLocation[] ): [SelectionAttributes | null, (x: SelectionAttributes | null) => void] { - const [touchStartPos, setTouchStartPos] = - useState<{ x: number; y: number } | undefined>(undefined) + const [touchStartPos, setTouchStartPos] = useState< + { x: number; y: number } | undefined + >(undefined) const [selectionAttributes, setSelectionAttributes] = useState(null) @@ -246,32 +247,38 @@ async function makeSelectionRange(): Promise< /** * Edge case: - * If the selection ends on range endContainer (or startContainer in reverse select) but no text is selected (i.e. selection ends at - * an empty area), the preceding text is highlighted due to range normalizing. + * If the selection ends on range endContainer (or startContainer in reverse select) but no text is selected (i.e. selection ends at + * an empty area), the preceding text is highlighted due to range normalizing. * This is a visual bug and would sometimes lead to weird highlight behavior during removal. */ const selectionEndNode = selection.focusNode const selectionEndOffset = selection.focusOffset - const selectionStartNode = isReverseSelected ? range.endContainer : range.startContainer + const selectionStartNode = isReverseSelected + ? range.endContainer + : range.startContainer if (selectionEndNode?.nodeType === Node.TEXT_NODE) { - const selectionEndNodeEdgeIndex = isReverseSelected ? selectionEndNode.textContent?.length : 0 + const selectionEndNodeEdgeIndex = isReverseSelected + ? selectionEndNode.textContent?.length + : 0 - if (selectionStartNode !== selectionEndNode && - selectionEndOffset == selectionEndNodeEdgeIndex) { - clipRangeToNearestAnchor(range, selectionEndNode, isReverseSelected) + if ( + selectionStartNode !== selectionEndNode && + selectionEndOffset == selectionEndNodeEdgeIndex + ) { + clipRangeToNearestAnchor(range, selectionEndNode, isReverseSelected) } - } - + } + return isRangeAllowed ? { range, isReverseSelected, selection } : undefined } /** * Clip selection range to the beginning/end of the adjacent anchor element - * + * * @param range selection range * @param selectionEndNode the node where the selection ended at - * @param isReverseSelected + * @param isReverseSelected */ const clipRangeToNearestAnchor = ( range: Range, @@ -279,30 +286,44 @@ const clipRangeToNearestAnchor = ( isReverseSelected: boolean ) => { let nearestAnchorElement = selectionEndNode.parentElement - while (nearestAnchorElement !== null && !nearestAnchorElement.hasAttribute('data-omnivore-anchor-idx')) { - nearestAnchorElement = nearestAnchorElement.parentElement; + while ( + nearestAnchorElement !== null && + !nearestAnchorElement.hasAttribute('data-omnivore-anchor-idx') + ) { + nearestAnchorElement = nearestAnchorElement.parentElement } if (!nearestAnchorElement) { - throw Error('Unable to find nearest anchor element for node: ' + selectionEndNode) + throw Error( + 'Unable to find nearest anchor element for node: ' + selectionEndNode + ) } - let anchorId = Number(nearestAnchorElement.getAttribute('data-omnivore-anchor-idx')!) + let anchorId = Number( + nearestAnchorElement.getAttribute('data-omnivore-anchor-idx')! + ) let adjacentAnchorId, adjacentAnchor, adjacentAnchorOffset if (isReverseSelected) { // move down to find adjacent anchor node and clip at its beginning adjacentAnchorId = anchorId + 1 - adjacentAnchor = document.querySelectorAll(`[data-omnivore-anchor-idx='${adjacentAnchorId}']`)[0] + adjacentAnchor = document.querySelectorAll( + `[data-omnivore-anchor-idx='${adjacentAnchorId}']` + )[0] adjacentAnchorOffset = 0 range.setStart(adjacentAnchor, adjacentAnchorOffset) } else { // move up to find adjacent anchor node and clip at its end do { adjacentAnchorId = --anchorId - adjacentAnchor = document.querySelectorAll(`[data-omnivore-anchor-idx='${adjacentAnchorId}']`)[0] + adjacentAnchor = document.querySelectorAll( + `[data-omnivore-anchor-idx='${adjacentAnchorId}']` + )[0] } while (adjacentAnchor.contains(selectionEndNode)) if (adjacentAnchor.textContent) { let lastTextNodeChild = adjacentAnchor.lastChild - while (!!lastTextNodeChild && lastTextNodeChild.nodeType !== Node.TEXT_NODE) { - lastTextNodeChild = lastTextNodeChild.previousSibling; + while ( + !!lastTextNodeChild && + lastTextNodeChild.nodeType !== Node.TEXT_NODE + ) { + lastTextNodeChild = lastTextNodeChild.previousSibling } adjacentAnchor = lastTextNodeChild adjacentAnchorOffset = adjacentAnchor?.nodeValue?.length ?? 0 @@ -326,7 +347,7 @@ export type RangeEndPos = { /** * Return coordinates of the screen area occupied by the last line of user selection - * + * * @param range range of user selection * @param getFirst whether to get first line of user selection. Get last if false (default) * @returns {RangeEndPos} selection coordinates diff --git a/packages/web/lib/hooks/useHandleAddUrl.ts b/packages/web/lib/hooks/useHandleAddUrl.ts new file mode 100644 index 000000000..577f74f29 --- /dev/null +++ b/packages/web/lib/hooks/useHandleAddUrl.ts @@ -0,0 +1,26 @@ +import { useCallback } from 'react' +import { v4 as uuidv4 } from 'uuid' +import { useAddItem } from '../networking/library_items/useLibraryItems' +import { showErrorToast, showSuccessToastWithAction } from '../toastHelpers' + +export const useHandleAddUrl = () => { + const addItem = useAddItem() + return useCallback(async (url: string, timezone: string, locale: string) => { + const itemId = uuidv4() + const result = await addItem.mutateAsync({ + itemId, + url, + timezone, + locale, + }) + console.log('result: ', result) + if (result) { + showSuccessToastWithAction('Item saving', 'Read now', async () => { + window.location.href = `/article?url=${encodeURIComponent(url)}` + return Promise.resolve() + }) + } else { + showErrorToast('Error saving url', { position: 'bottom-right' }) + } + }, []) +} diff --git a/packages/web/lib/hooks/useLibraryItemActions.tsx b/packages/web/lib/hooks/useLibraryItemActions.tsx index 18474f702..5d1f7234f 100644 --- a/packages/web/lib/hooks/useLibraryItemActions.tsx +++ b/packages/web/lib/hooks/useLibraryItemActions.tsx @@ -1,20 +1,30 @@ import { useCallback } from 'react' -import { setLinkArchivedMutation } from '../networking/mutations/setLinkArchivedMutation' import { showErrorToast, showSuccessToast, showSuccessToastWithUndo, } from '../toastHelpers' -import { deleteLinkMutation } from '../networking/mutations/deleteLinkMutation' -import { updatePageMutation } from '../networking/mutations/updatePageMutation' -import { State } from '../networking/fragments/articleFragment' -import { moveToFolderMutation } from '../networking/mutations/moveToLibraryMutation' +import { + useArchiveItem, + useDeleteItem, + useMoveItemToFolder, + useRestoreItem, +} from '../networking/library_items/useLibraryItems' export default function useLibraryItemActions() { - const archiveItem = useCallback(async (itemId: string) => { - const result = await setLinkArchivedMutation({ - linkId: itemId, - archived: true, + const archiveItem = useArchiveItem() + const deleteItem = useDeleteItem() + const moveItem = useMoveItemToFolder() + const restoreItem = useRestoreItem() + + const doArchiveItem = useCallback(async (itemId: string, slug: string) => { + const result = await archiveItem.mutateAsync({ + itemId: itemId, + slug: slug, + input: { + linkId: itemId, + archived: true, + }, }) console.log('result: ', result) @@ -27,33 +37,37 @@ export default function useLibraryItemActions() { return !!result }, []) - const deleteItem = useCallback(async (itemId: string, undo: () => void) => { - const result = await deleteLinkMutation(itemId) + const doDeleteItem = useCallback( + async (itemId: string, slug: string, undo: () => void) => { + const result = await deleteItem.mutateAsync({ itemId, slug }) - if (result) { - showSuccessToastWithUndo('Item removed', async () => { - const result = await updatePageMutation({ - pageId: itemId, - state: State.SUCCEEDED, + if (result) { + showSuccessToastWithUndo('Item removed', async () => { + const result = await restoreItem.mutateAsync({ itemId, slug }) + + undo() + + if (result) { + showSuccessToast('Item recovered') + } else { + showErrorToast('Error recovering, check your deleted items') + } }) + } else { + showErrorToast('Error removing item', { position: 'bottom-right' }) + } - undo() + return !!result + }, + [] + ) - if (result) { - showSuccessToast('Item recovered') - } else { - showErrorToast('Error recovering, check your deleted items') - } - }) - } else { - showErrorToast('Error removing item', { position: 'bottom-right' }) - } - - return !!result - }, []) - - const moveItem = useCallback(async (itemId: string) => { - const result = await moveToFolderMutation(itemId, 'inbox') + const doMoveItem = useCallback(async (itemId: string, slug: string) => { + const result = await moveItem.mutateAsync({ + itemId, + slug, + folder: 'inbox', + }) if (result) { showSuccessToast('Moved to library', { position: 'bottom-right' }) } else { @@ -85,5 +99,10 @@ export default function useLibraryItemActions() { [] ) - return { archiveItem, deleteItem, moveItem, shareItem } + return { + archiveItem: doArchiveItem, + deleteItem: doDeleteItem, + moveItem: doMoveItem, + shareItem, + } } diff --git a/packages/web/lib/hooks/useReaderSettings.tsx b/packages/web/lib/hooks/useReaderSettings.tsx index 92da1b635..55050c295 100644 --- a/packages/web/lib/hooks/useReaderSettings.tsx +++ b/packages/web/lib/hooks/useReaderSettings.tsx @@ -2,7 +2,7 @@ import { useRegisterActions } from 'kbar' import { useCallback, useState } from 'react' import { applyStoredTheme } from '../themeUpdater' import { usePersistedState } from './usePersistedState' -import { TextDirection } from '../networking/queries/useGetArticleQuery' +import { TextDirection } from '../networking/library_items/useLibraryItems' const DEFAULT_FONT = 'Inter' diff --git a/packages/web/lib/hooks/useSetPageLabels.tsx b/packages/web/lib/hooks/useSetPageLabels.tsx index 2f07de068..666fefeab 100644 --- a/packages/web/lib/hooks/useSetPageLabels.tsx +++ b/packages/web/lib/hooks/useSetPageLabels.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useReducer } from 'react' -import { setLabelsMutation } from '../networking/mutations/setLabelsMutation' import { Label } from '../networking/fragments/labelFragment' import { showErrorToast } from '../toastHelpers' import throttle from 'lodash/throttle' +import { useSetItemLabels } from '../networking/library_items/useLibraryItems' export type LabelAction = 'RESET' | 'TEMP' | 'SAVE' export type LabelsDispatcher = (action: { @@ -11,13 +11,22 @@ export type LabelsDispatcher = (action: { }) => void export const useSetPageLabels = ( - articleId?: string + libraryItemId?: string, + libraryItemSlug?: string ): [{ labels: Label[] }, LabelsDispatcher] => { - const saveLabels = (labels: Label[], articleId: string) => { + const setItemLabels = useSetItemLabels() + const saveLabels = ( + labels: Label[], + libraryItemId: string, + libraryItemSlug: string + ) => { ;(async () => { - const labelIds = labels.map((l) => l.id) - if (articleId) { - const result = await setLabelsMutation(articleId, labelIds) + if (libraryItemId) { + const result = await setItemLabels.mutateAsync({ + itemId: libraryItemId, + slug: libraryItemSlug, + labels, + }) if (!result) { showErrorToast('Error saving labels', { position: 'bottom-right', @@ -31,12 +40,14 @@ export const useSetPageLabels = ( state: { labels: Label[] articleId: string | undefined - throttledSave: (labels: Label[], articleId: string) => void + slug: string | undefined + throttledSave: (labels: Label[], articleId: string, slug: string) => void }, action: { type: string labels: Label[] articleId?: string + slug?: string } ) => { switch (action.type) { @@ -53,8 +64,8 @@ export const useSetPageLabels = ( } } case 'SAVE': { - if (state.articleId) { - state.throttledSave(action.labels, state.articleId) + if (state.articleId && state.slug) { + state.throttledSave(action.labels, state.articleId, state.slug) } else { showErrorToast('Unable to update labels', { position: 'bottom-right', @@ -68,6 +79,7 @@ export const useSetPageLabels = ( case 'UPDATE_ARTICLE_ID': { return { ...state, + slug: action.slug, articleId: action.articleId, } } @@ -78,7 +90,8 @@ export const useSetPageLabels = ( const debouncedSave = useCallback( throttle( - (labels: Label[], articleId: string) => saveLabels(labels, articleId), + (labels: Label[], articleId: string, slug: string) => + saveLabels(labels, articleId, slug), 2000 ), [] @@ -88,13 +101,15 @@ export const useSetPageLabels = ( dispatchLabels({ type: 'UPDATE_ARTICLE_ID', labels: [], - articleId: articleId, + slug: libraryItemSlug, + articleId: libraryItemId, }) - }, [articleId]) + }, [libraryItemId]) const [labels, dispatchLabels] = useReducer(labelsReducer, { labels: [], - articleId: articleId, + articleId: libraryItemId, + slug: libraryItemSlug, throttledSave: debouncedSave, }) diff --git a/packages/web/lib/keyboardShortcuts/navigationShortcuts.ts b/packages/web/lib/keyboardShortcuts/navigationShortcuts.ts index 116436d3d..8628c792f 100644 --- a/packages/web/lib/keyboardShortcuts/navigationShortcuts.ts +++ b/packages/web/lib/keyboardShortcuts/navigationShortcuts.ts @@ -13,7 +13,7 @@ export function navigationCommands(router: NextRouter | undefined): Action[] { keywords: 'go home', perform: () => { console.log('go home') - router?.push(`/l/home`) + router?.push(`/home`) }, }, { @@ -24,7 +24,7 @@ export function navigationCommands(router: NextRouter | undefined): Action[] { keywords: 'go library', perform: () => { console.log('go library') - router?.push(`/l/library`) + router?.push(`/library`) }, }, { @@ -35,7 +35,7 @@ export function navigationCommands(router: NextRouter | undefined): Action[] { keywords: 'go subscriptions', perform: () => { console.log('go subscriptions') - router?.push(`/l/subscriptions`) + router?.push(`/subscriptions`) }, }, { @@ -46,7 +46,7 @@ export function navigationCommands(router: NextRouter | undefined): Action[] { keywords: 'go highlights', perform: () => { console.log('go highlights') - router?.push(`/l/highlights`) + router?.push(`/highlights`) }, }, { diff --git a/packages/web/lib/networking/fragments/articleFragment.ts b/packages/web/lib/networking/fragments/articleFragment.ts index c4a293f24..702db7d57 100644 --- a/packages/web/lib/networking/fragments/articleFragment.ts +++ b/packages/web/lib/networking/fragments/articleFragment.ts @@ -17,7 +17,6 @@ export const articleFragment = gql` readingProgressAnchorIndex slug folder - isArchived description linkId state @@ -60,7 +59,6 @@ export type ArticleFragmentData = { readingProgressTopPercent?: number readingProgressAnchorIndex: number slug: string - isArchived: boolean description: string linkId?: string state?: State diff --git a/packages/web/lib/networking/fragments/highlightFragment.ts b/packages/web/lib/networking/fragments/highlightFragment.ts index 6f6064b46..0096634de 100644 --- a/packages/web/lib/networking/fragments/highlightFragment.ts +++ b/packages/web/lib/networking/fragments/highlightFragment.ts @@ -1,5 +1,5 @@ import { gql } from 'graphql-request' -import { LibraryItemNode } from '../queries/useGetLibraryItemsQuery' +import { LibraryItemNode } from '../library_items/useLibraryItems' import { Label } from './labelFragment' export const highlightFragment = gql` diff --git a/packages/web/lib/networking/highlights/gql.tsx b/packages/web/lib/networking/highlights/gql.tsx new file mode 100644 index 000000000..9225f8795 --- /dev/null +++ b/packages/web/lib/networking/highlights/gql.tsx @@ -0,0 +1,77 @@ +import { gql } from 'graphql-request' +import { highlightFragment } from '../fragments/highlightFragment' + +export const GQL_CREATE_HIGHLIGHT = gql` + mutation CreateHighlight($input: CreateHighlightInput!) { + createHighlight(input: $input) { + ... on CreateHighlightSuccess { + highlight { + ...HighlightFields + } + } + + ... on CreateHighlightError { + errorCodes + } + } + } + ${highlightFragment} +` + +export const GQL_DELETE_HIGHLIGHT = gql` + mutation DeleteHighlight($highlightId: ID!) { + deleteHighlight(highlightId: $highlightId) { + ... on DeleteHighlightSuccess { + highlight { + id + } + } + ... on DeleteHighlightError { + errorCodes + } + } + } +` + +export const GQL_UPDATE_HIGHLIGHT = gql` + mutation UpdateHighlight($input: UpdateHighlightInput!) { + updateHighlight(input: $input) { + ... on UpdateHighlightSuccess { + highlight { + id + } + } + + ... on UpdateHighlightError { + errorCodes + } + } + } +` + +export const GQL_MERGE_HIGHLIGHT = gql` + mutation MergeHighlight($input: MergeHighlightInput!) { + mergeHighlight(input: $input) { + ... on MergeHighlightSuccess { + highlight { + id + shortId + quote + prefix + suffix + patch + color + createdAt + updatedAt + annotation + sharedAt + createdByMe + } + overlapHighlightIdList + } + ... on MergeHighlightError { + errorCodes + } + } + } +` diff --git a/packages/web/lib/networking/highlights/useItemHighlights.tsx b/packages/web/lib/networking/highlights/useItemHighlights.tsx new file mode 100644 index 000000000..8058ffdf2 --- /dev/null +++ b/packages/web/lib/networking/highlights/useItemHighlights.tsx @@ -0,0 +1,233 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { gqlFetcher } from '../networkHelpers' +import { + GQL_CREATE_HIGHLIGHT, + GQL_DELETE_HIGHLIGHT, + GQL_MERGE_HIGHLIGHT, + GQL_UPDATE_HIGHLIGHT, +} from './gql' +import { updateItemProperty } from '../library_items/useLibraryItems' +import { Highlight, HighlightType } from '../fragments/highlightFragment' +import { UpdateHighlightInput } from '../mutations/updateHighlightMutation' +import { MergeHighlightInput } from '../mutations/mergeHighlightMutation' + +export const useCreateHighlight = () => { + const queryClient = useQueryClient() + const createHighlight = async (variables: { + itemId: string + slug: string | undefined + input: CreateHighlightInput + }) => { + const result = (await gqlFetcher(GQL_CREATE_HIGHLIGHT, { + input: variables.input, + })) as CreateHighlightData + if (result.createHighlight.errorCodes?.length) { + throw new Error(result.createHighlight.errorCodes[0]) + } + return result.createHighlight.highlight + } + return useMutation({ + mutationFn: createHighlight, + onSuccess: (newHighlight, variables) => { + if (newHighlight) { + updateItemProperty( + queryClient, + variables.itemId, + variables.slug, + (item) => { + return { + ...item, + highlights: [...item.highlights, newHighlight], + } + } + ) + } + }, + }) +} + +export const useDeleteHighlight = () => { + const queryClient = useQueryClient() + const deleteHighlight = async (variables: { + itemId: string + slug: string + highlightId: string + }) => { + const result = (await gqlFetcher(GQL_DELETE_HIGHLIGHT, { + highlightId: variables.highlightId, + })) as DeleteHighlightData + if (result.deleteHighlight.errorCodes?.length) { + throw new Error(result.deleteHighlight.errorCodes[0]) + } + return result.deleteHighlight.highlight + } + return useMutation({ + mutationFn: deleteHighlight, + onSuccess: (deletedHighlight, variables) => { + if (deletedHighlight) { + updateItemProperty( + queryClient, + variables.itemId, + variables.slug, + (item) => { + return { + ...item, + highlights: item.highlights.filter( + (h) => h.id != deletedHighlight.id + ), + } + } + ) + } + }, + }) +} + +export const useUpdateHighlight = () => { + const queryClient = useQueryClient() + const updateHighlight = async (variables: { + itemId: string + slug: string | undefined + input: UpdateHighlightInput + }) => { + const result = (await gqlFetcher(GQL_UPDATE_HIGHLIGHT, { + input: variables.input, + })) as UpdateHighlightData + if (result.updateHighlight.errorCodes?.length) { + throw new Error(result.updateHighlight.errorCodes[0]) + } + return result.updateHighlight.highlight + } + return useMutation({ + mutationFn: updateHighlight, + onSuccess: (updatedHighlight, variables) => { + if (updatedHighlight) { + updateItemProperty( + queryClient, + variables.itemId, + variables.slug, + (item) => { + return { + ...item, + highlights: [ + ...item.highlights.filter((h) => h.id != updatedHighlight.id), + updatedHighlight, + ], + } + } + ) + } + }, + }) +} + +export const useMergeHighlight = () => { + const queryClient = useQueryClient() + const mergeHighlight = async (variables: { + itemId: string + slug: string + input: MergeHighlightInput + }) => { + const result = (await gqlFetcher(GQL_MERGE_HIGHLIGHT, { + input: { + id: variables.input.id, + shortId: variables.input.shortId, + articleId: variables.input.articleId, + patch: variables.input.patch, + quote: variables.input.quote, + prefix: variables.input.prefix, + suffix: variables.input.suffix, + html: variables.input.html, + annotation: variables.input.annotation, + overlapHighlightIdList: variables.input.overlapHighlightIdList, + highlightPositionPercent: variables.input.highlightPositionPercent, + highlightPositionAnchorIndex: + variables.input.highlightPositionAnchorIndex, + }, + })) as MergeHighlightData + if (result.mergeHighlight.errorCodes?.length) { + throw new Error(result.mergeHighlight.errorCodes[0]) + } + return result.mergeHighlight + } + return useMutation({ + mutationFn: mergeHighlight, + onSuccess: (mergeHighlights, variables) => { + if (mergeHighlights && mergeHighlights.highlight) { + const newHighlight = mergeHighlights.highlight + const mergedIds = mergeHighlights.overlapHighlightIdList ?? [] + updateItemProperty( + queryClient, + variables.itemId, + variables.slug, + (item) => { + return { + ...item, + highlights: [ + ...item.highlights.filter((h) => mergedIds.indexOf(h.id) == -1), + newHighlight, + ], + } + } + ) + } + }, + }) +} + +type MergeHighlightData = { + mergeHighlight: MergeHighlightResult +} + +type MergeHighlightResult = { + highlight?: Highlight + overlapHighlightIdList?: string[] + errorCodes?: string[] +} + +type UpdateHighlightData = { + updateHighlight: UpdateHighlightResult +} + +type UpdateHighlightResult = { + highlight?: Highlight + errorCodes?: string[] +} + +type DeleteHighlightData = { + deleteHighlight: DeleteHighlightResult +} + +type DeleteHighlightResult = { + highlight?: Highlight + errorCodes?: string[] +} + +type CreateHighlightData = { + createHighlight: CreateHighlightResult +} + +type CreateHighlightResult = { + highlight?: Highlight + errorCodes?: string[] +} + +export type CreateHighlightInput = { + id: string + shortId: string + articleId: string + + prefix?: string + suffix?: string + quote?: string + html?: string + color?: string + annotation?: string + + patch?: string + + highlightPositionPercent?: number + highlightPositionAnchorIndex?: number + + type?: HighlightType +} diff --git a/packages/web/lib/networking/labels/gql.tsx b/packages/web/lib/networking/labels/gql.tsx new file mode 100644 index 000000000..0f63cf2c5 --- /dev/null +++ b/packages/web/lib/networking/labels/gql.tsx @@ -0,0 +1,71 @@ +import { gql } from 'graphql-request' +import { labelFragment } from '../fragments/labelFragment' + +export const GQL_GET_LABELS = gql` + query GetLabels { + labels { + ... on LabelsSuccess { + labels { + ...LabelFields + } + } + ... on LabelsError { + errorCodes + } + } + } + ${labelFragment} +` + +export const GQL_CREATE_LABEL = gql` + mutation CreateLabel($input: CreateLabelInput!) { + createLabel(input: $input) { + ... on CreateLabelSuccess { + label { + id + name + color + description + createdAt + } + } + ... on CreateLabelError { + errorCodes + } + } + } +` + +export const GQL_DELETE_LABEL = gql` + mutation DeleteLabel($id: ID!) { + deleteLabel(id: $id) { + ... on DeleteLabelSuccess { + label { + id + } + } + ... on DeleteLabelError { + errorCodes + } + } + } +` + +export const GQL_UPDATE_LABEL = gql` + mutation UpdateLabel($input: UpdateLabelInput!) { + updateLabel(input: $input) { + ... on UpdateLabelSuccess { + label { + id + name + color + description + createdAt + } + } + ... on UpdateLabelError { + errorCodes + } + } + } +` diff --git a/packages/web/lib/networking/labels/useLabels.tsx b/packages/web/lib/networking/labels/useLabels.tsx new file mode 100644 index 000000000..b3f740ddc --- /dev/null +++ b/packages/web/lib/networking/labels/useLabels.tsx @@ -0,0 +1,159 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { gqlFetcher } from '../networkHelpers' +import { + GQL_CREATE_LABEL, + GQL_DELETE_LABEL, + GQL_GET_LABELS, + GQL_UPDATE_LABEL, +} from './gql' +import { Label } from '../fragments/labelFragment' + +export function useGetLabels() { + return useQuery({ + queryKey: ['labels'], + queryFn: async () => { + const response = (await gqlFetcher(GQL_GET_LABELS)) as LabelsData + if (response.labels?.errorCodes?.length) { + throw new Error(response.labels.errorCodes[0]) + } + return response.labels?.labels + }, + }) +} + +export const useCreateLabel = () => { + const queryClient = useQueryClient() + const createLabel = async (variables: { + name: string + color: string + description: string | undefined + }) => { + const result = (await gqlFetcher(GQL_CREATE_LABEL, { + input: { + name: variables.name, + color: variables.color, + description: variables.description, + }, + })) as CreateLabelData + if (result.createLabel.errorCodes?.length) { + throw new Error(result.createLabel.errorCodes[0]) + } + return result.createLabel.label + } + return useMutation({ + mutationFn: createLabel, + onSuccess: (newLabel) => { + const keys = queryClient.getQueryCache().findAll({ queryKey: ['labels'] }) + keys.forEach((query) => { + queryClient.setQueryData(query.queryKey, (data: Label[]) => { + return [...data, newLabel] + }) + }) + }, + }) +} + +export const useDeleteLabel = () => { + const queryClient = useQueryClient() + const deleteLabel = async (variables: { labelId: string }) => { + const result = (await gqlFetcher(GQL_DELETE_LABEL, { + id: variables.labelId, + })) as DeleteLabelData + if (result.deleteLabel.errorCodes?.length) { + throw new Error(result.deleteLabel.errorCodes[0]) + } + return result.deleteLabel?.label?.id + } + return useMutation({ + mutationFn: deleteLabel, + onSuccess: (deletedId) => { + if (deletedId) { + const keys = queryClient + .getQueryCache() + .findAll({ queryKey: ['labels'] }) + keys.forEach((query) => { + queryClient.setQueryData(query.queryKey, (data: Label[]) => { + return data.filter((label) => label.id !== deletedId) + }) + }) + } + }, + }) +} + +export const useUpdateLabel = () => { + const queryClient = useQueryClient() + const updateLabel = async (variables: { + labelId: string + name: string + color: string + description: string + }) => { + const result = (await gqlFetcher(GQL_UPDATE_LABEL, { + input: { + labelId: variables.labelId, + name: variables.name, + color: variables.color, + description: variables.description, + }, + })) as UpdateLabelData + if (result.updateLabel.errorCodes?.length) { + throw new Error(result.updateLabel.errorCodes[0]) + } + return result.updateLabel?.label + } + return useMutation({ + mutationFn: updateLabel, + onSuccess: (updatedLabel) => { + if (updatedLabel) { + const keys = queryClient + .getQueryCache() + .findAll({ queryKey: ['labels'] }) + keys.forEach((query) => { + queryClient.setQueryData(query.queryKey, (data: Label[]) => { + return [ + ...data.filter((label) => label.id !== updatedLabel.id), + updatedLabel, + ] + }) + }) + } + }, + }) +} + +type LabelsResult = { + labels?: Label[] + errorCodes?: string[] +} + +type LabelsData = { + labels?: LabelsResult +} + +type CreateLabelResult = { + label?: Label + errorCodes?: string[] +} + +type CreateLabelData = { + createLabel: CreateLabelResult +} + +type DeleteLabelResult = { + label?: Label + errorCodes?: string[] +} + +type DeleteLabelData = { + deleteLabel: DeleteLabelResult +} + +type UpdateLabelResult = { + label?: Label + errorCodes?: string[] +} + +type UpdateLabelData = { + updateLabel: UpdateLabelResult +} diff --git a/packages/web/lib/networking/library_items/gql.tsx b/packages/web/lib/networking/library_items/gql.tsx new file mode 100644 index 000000000..e74e118f4 --- /dev/null +++ b/packages/web/lib/networking/library_items/gql.tsx @@ -0,0 +1,307 @@ +import { gql } from 'graphql-request' +import { highlightFragment } from '../fragments/highlightFragment' +import { articleFragment } from '../fragments/articleFragment' +import { labelFragment } from '../fragments/labelFragment' + +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 + 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_MOVE_ITEM_TO_FOLDER = gql` + mutation MoveToFolder($id: ID!, $folder: String!) { + moveToFolder(id: $id, folder: $folder) { + ... on MoveToFolderSuccess { + success + } + ... on MoveToFolderError { + errorCodes + } + } + } +` + +export const GQL_SET_LABELS = gql` + mutation SetLabels($input: SetLabelsInput!) { + setLabels(input: $input) { + ... on SetLabelsSuccess { + labels { + ...LabelFields + } + } + ... on SetLabelsError { + errorCodes + } + } + } + ${labelFragment} +` + +export const GQL_SAVE_ARTICLE_READING_PROGRESS = gql` + mutation SaveArticleReadingProgress( + $input: SaveArticleReadingProgressInput! + ) { + saveArticleReadingProgress(input: $input) { + ... on SaveArticleReadingProgressSuccess { + updatedArticle { + id + readingProgressPercent + readingProgressAnchorIndex + } + } + ... on SaveArticleReadingProgressError { + 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 + } + } + } +` + +export const GQL_GET_LIBRARY_ITEM = gql` + query GetArticle( + $username: String! + $slug: String! + $includeFriendsHighlights: Boolean + ) { + article(username: $username, slug: $slug) { + ... on ArticleSuccess { + article { + ...ArticleFields + highlights(input: { includeFriends: $includeFriendsHighlights }) { + ...HighlightFields + } + labels { + ...LabelFields + } + recommendations { + ...RecommendationFields + } + } + } + ... on ArticleError { + errorCodes + } + } + } + ${articleFragment} + ${highlightFragment} + ${labelFragment} + ${recommendationFragment} +` + +export const GQL_GET_LIBRARY_ITEM_CONTENT = gql` + query GetArticle( + $username: String! + $slug: String! + $includeFriendsHighlights: Boolean + ) { + article(username: $username, slug: $slug) { + ... on ArticleSuccess { + article { + ...ArticleFields + content + highlights(input: { includeFriends: $includeFriendsHighlights }) { + ...HighlightFields + } + labels { + ...LabelFields + } + recommendations { + ...RecommendationFields + } + } + } + ... on ArticleError { + errorCodes + } + } + } + ${articleFragment} + ${highlightFragment} + ${labelFragment} + ${recommendationFragment} +` + +export const GQL_BULK_ACTION = gql` + mutation BulkAction( + $action: BulkActionType! + $query: String! + $expectedCount: Int + $labelIds: [ID!] + ) { + bulkAction( + query: $query + action: $action + labelIds: $labelIds + expectedCount: $expectedCount + ) { + ... on BulkActionSuccess { + success + } + ... on BulkActionError { + errorCodes + } + } + } +` + +export const GQL_SAVE_URL = gql` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + ... on SaveSuccess { + url + clientRequestId + } + ... on SaveError { + errorCodes + message + } + } + } +` diff --git a/packages/web/lib/networking/library_items/useLibraryItems.tsx b/packages/web/lib/networking/library_items/useLibraryItems.tsx new file mode 100644 index 000000000..e3512711c --- /dev/null +++ b/packages/web/lib/networking/library_items/useLibraryItems.tsx @@ -0,0 +1,1055 @@ +import { GraphQLClient } from 'graphql-request' +import { + QueryClient, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { ContentReader, PageType, State } from '../fragments/articleFragment' +import { Highlight } from '../fragments/highlightFragment' +import { requestHeaders } from '../networkHelpers' +import { Label } from '../fragments/labelFragment' +import { + GQL_BULK_ACTION, + GQL_DELETE_LIBRARY_ITEM, + GQL_GET_LIBRARY_ITEM, + GQL_GET_LIBRARY_ITEM_CONTENT, + GQL_MOVE_ITEM_TO_FOLDER, + GQL_SAVE_ARTICLE_READING_PROGRESS, + GQL_SAVE_URL, + GQL_SEARCH_QUERY, + GQL_SET_LABELS, + GQL_SET_LINK_ARCHIVED, + GQL_UPDATE_LIBRARY_ITEM, +} from './gql' +import { gqlEndpoint } from '../../appConfig' +import { useState } from 'react' + +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 const insertItemInCache = ( + queryClient: QueryClient, + itemId: string, + url: string +) => { + const keys = queryClient + .getQueryCache() + .findAll({ queryKey: ['libraryItems'] }) + console.log('keys: ', keys) + + keys.forEach((query) => { + queryClient.setQueryData(query.queryKey, (data: any) => { + console.log('data, data.pages', data) + if (!data) return data + if (data.pages.length > 0) { + const firstPage = data.pages[0] as LibraryItems + firstPage.edges = [ + ...firstPage.edges, + { + cursor: firstPage.pageInfo.endCursor, + node: { + id: itemId, + title: url, + url: url, + originalArticleUrl: url, + readingProgressPercent: 0, + readingProgressAnchorIndex: 0, + slug: url, + folder: 'inbox', + ownedByViewer: true, + state: State.PROCESSING, + pageType: PageType.UNKNOWN, + createdAt: new Date().toISOString(), + }, + }, + ] + data.pages[0] = firstPage + console.log('data: ', data) + return data + } + }) + }) +} + +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 (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 function useRefreshProcessingItems() { + const maxAttempts = 3 + + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + + const queryClient = useQueryClient() + const refreshItems = async (variables: { + attempt: number + itemIds: string[] + }) => { + const fullQuery = `in:all includes:${variables.itemIds.join(',')}` + const result = (await gqlFetcher(GQL_SEARCH_QUERY, { + first: 10, + query: fullQuery, + includeContent: false, + })) as LibraryItemsData + if (result.search.errorCodes?.length) { + throw new Error(result.search.errorCodes[0]) + } + return result.search + } + const mutation = useMutation({ + mutationFn: refreshItems, + retry: 3, + retryDelay: 10, + onSuccess: async ( + data: LibraryItems, + variables: { + attempt: number + itemIds: string[] + } + ) => { + let shouldRefetch = false + console.log('got processing items: ', data.edges) + for (const item of data.edges) { + if (item.node.state !== State.PROCESSING) { + overwriteItemPropertiesInCache( + queryClient, + item.node.id, + undefined, + item.node + ) + } else { + shouldRefetch = true + } + } + if (shouldRefetch && variables.attempt < maxAttempts) { + await delay(5000 * variables.attempt + 1) + mutation.mutate({ + attempt: variables.attempt + 1, + itemIds: data.edges + .filter((item) => item.node.state == State.PROCESSING) + .map((it) => it.node.id), + }) + } + }, + }) + return mutation +} + +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 useAddItem = () => { + const queryClient = useQueryClient() + const addItem = async (variables: { + itemId: string + url: string + timezone: string | undefined + locale: string | undefined + }) => { + const result = (await gqlFetcher(GQL_SAVE_URL, { + input: { + clientRequestId: variables.itemId, + url: variables.url, + source: 'add-link', + timezone: variables.timezone, + locale: variables.locale, + }, + })) as SaveUrlData + if (result.saveUrl?.errorCodes?.length) { + throw new Error(result.saveUrl.errorCodes[0]) + } + return result.saveUrl?.clientRequestId + } + return useMutation({ + mutationFn: addItem, + onMutate: async (variables: { + itemId: string + url: string + timezone: string | undefined + locale: string | undefined + }) => { + await queryClient.cancelQueries({ queryKey: ['libraryItems'] }) + const previousState = { + previousItems: queryClient.getQueryData(['libraryItems']), + } + insertItemInCache(queryClient, variables.itemId, variables.url) + return previousState + }, + onError: (error, variables, context) => { + if (context?.previousItems) { + queryClient.setQueryData(['libraryItems'], context.previousItems) + } + }, + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: ['libraryItems'], + }) + }, + }) +} + +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 +} + +export type SaveUrlResult = { + id?: string + url?: string + slug?: string + clientRequestId?: string + errorCodes?: string[] +} + +export type SaveUrlData = { + saveUrl?: SaveUrlResult +} + +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 + state: State + pageType: PageType + description?: string + ownedByViewer: boolean + uploadFileId?: string + labels?: Label[] + pageId?: string + shortId?: string + quote?: string + annotation?: string + 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 +} diff --git a/packages/web/lib/networking/mutations/articleReadingProgressMutation.ts b/packages/web/lib/networking/mutations/articleReadingProgressMutation.ts index 95fd754d7..86a176f1e 100644 --- a/packages/web/lib/networking/mutations/articleReadingProgressMutation.ts +++ b/packages/web/lib/networking/mutations/articleReadingProgressMutation.ts @@ -9,32 +9,32 @@ export type ArticleReadingProgressMutationInput = { readingProgressAnchorIndex?: number } -export async function articleReadingProgressMutation( - input: ArticleReadingProgressMutationInput -): Promise { - const mutation = gql` - mutation SaveArticleReadingProgress( - $input: SaveArticleReadingProgressInput! - ) { - saveArticleReadingProgress(input: $input) { - ... on SaveArticleReadingProgressSuccess { - updatedArticle { - id - readingProgressPercent - readingProgressAnchorIndex - } - } - ... on SaveArticleReadingProgressError { - errorCodes - } - } - } - ` +// export async function articleReadingProgressMutation( +// input: ArticleReadingProgressMutationInput +// ): Promise { +// 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 - } -} +// try { +// await gqlFetcher(mutation, { input }) +// return true +// } catch { +// return false +// } +// } diff --git a/packages/web/lib/networking/mutations/bulkActionMutation.ts b/packages/web/lib/networking/mutations/bulkActionMutation.ts index fd02e66c0..186de7087 100644 --- a/packages/web/lib/networking/mutations/bulkActionMutation.ts +++ b/packages/web/lib/networking/mutations/bulkActionMutation.ts @@ -1,62 +1,2 @@ import { gql } from 'graphql-request' import { gqlFetcher } from '../networkHelpers' - -export enum BulkAction { - ARCHIVE = 'ARCHIVE', - DELETE = 'DELETE', - ADD_LABELS = 'ADD_LABELS', - MARK_AS_READ = 'MARK_AS_READ', -} - -type BulkActionResponseData = { - success: boolean -} - -type BulkActionResponse = { - errorCodes?: string[] - bulkAction?: BulkActionResponseData -} - -export async function bulkActionMutation( - action: BulkAction, - query: string, - expectedCount: number, - labelIds?: string[] -): Promise { - const mutation = gql` - mutation BulkAction( - $action: BulkActionType! - $query: String! - $expectedCount: Int - $labelIds: [ID!] - ) { - bulkAction( - query: $query - action: $action - labelIds: $labelIds - expectedCount: $expectedCount - ) { - ... on BulkActionSuccess { - success - } - ... on BulkActionError { - errorCodes - } - } - } - ` - - try { - const response = await gqlFetcher(mutation, { - action, - query, - labelIds, - expectedCount, - }) - const data = response as BulkActionResponse | undefined - return data?.bulkAction?.success ?? false - } catch (error) { - console.error(error) - return false - } -} diff --git a/packages/web/lib/networking/mutations/createHighlightMutation.ts b/packages/web/lib/networking/mutations/createHighlightMutation.ts deleted file mode 100644 index f21268b96..000000000 --- a/packages/web/lib/networking/mutations/createHighlightMutation.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' -import { - Highlight, - highlightFragment, - HighlightType, -} from './../fragments/highlightFragment' - -export type CreateHighlightInput = { - id: string - shortId: string - articleId: string - - prefix?: string - suffix?: string - quote?: string - html?: string - color?: string - annotation?: string - - patch?: string - - highlightPositionPercent?: number - highlightPositionAnchorIndex?: number - - type?: HighlightType -} - -type CreateHighlightOutput = { - createHighlight: InnerCreateHighlightOutput -} - -type InnerCreateHighlightOutput = { - highlight: Highlight -} - -export async function createHighlightMutation( - input: CreateHighlightInput -): Promise { - const mutation = gql` - mutation CreateHighlight($input: CreateHighlightInput!) { - createHighlight(input: $input) { - ... on CreateHighlightSuccess { - highlight { - ...HighlightFields - } - } - - ... on CreateHighlightError { - errorCodes - } - } - } - ${highlightFragment} - ` - - try { - const data = await gqlFetcher(mutation, { input }) - const output = data as CreateHighlightOutput | undefined - return output?.createHighlight.highlight - } catch { - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/createLabelMutation.ts b/packages/web/lib/networking/mutations/createLabelMutation.ts deleted file mode 100644 index 791f29055..000000000 --- a/packages/web/lib/networking/mutations/createLabelMutation.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { gql } from 'graphql-request' -import { Label } from '../fragments/labelFragment' -import { gqlFetcher } from '../networkHelpers' - -type CreateLabelResult = { - createLabel: CreateLabel - errorCodes?: unknown[] -} - -type CreateLabel = { - label: Label -} - -export async function createLabelMutation( - name: string, - color: string, - description?: string -): Promise { - const mutation = gql` - mutation CreateLabel($input: CreateLabelInput!) { - createLabel(input: $input) { - ... on CreateLabelSuccess { - label { - id - name - color - description - createdAt - } - } - ... on CreateLabelError { - errorCodes - } - } - } - ` - - try { - const data = (await gqlFetcher(mutation, { - input: { - name, - color, - description, - }, - })) as CreateLabelResult - return data.errorCodes ? undefined : data.createLabel.label - } catch (error) { - console.log('createLabelMutation error', error) - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/createReminderMutation.ts b/packages/web/lib/networking/mutations/createReminderMutation.ts deleted file mode 100644 index 0f63bd9da..000000000 --- a/packages/web/lib/networking/mutations/createReminderMutation.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' - -export enum ReminderType { - Tonight = 'TONIGHT', - Tomorrow = 'TOMORROW', - ThisWeekend = 'THIS_WEEKEND', - NextWeek = 'NEXT_WEEK', -} - -export async function createReminderMutation( - linkId: string, - reminderType: ReminderType, - archiveUntil: boolean, - sendNotification: boolean -): Promise { - const mutation = gql` - mutation createReminderMutation($input: CreateReminderInput!) { - createReminder(input: $input) { - ... on CreateReminderSuccess { - reminder { - id - remindAt - } - } - ... on CreateReminderError { - errorCodes - } - } - } - ` - - try { - const input = { - linkId, - reminderType, - archiveUntil, - sendNotification, - scheduledAt: new Date(), - } - const data = await gqlFetcher(mutation, { input }) - return 'data' - } catch (error) { - console.log('createReminder error', error) - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/deleteFilterMutation.ts b/packages/web/lib/networking/mutations/deleteFilterMutation.ts deleted file mode 100644 index cacfea10c..000000000 --- a/packages/web/lib/networking/mutations/deleteFilterMutation.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' -import { SavedSearch } from "../fragments/savedSearchFragment" - -export type DeleteFilterInput = string - -type DeleteFilterOutput = { - deleteFilter: { filter: SavedSearch } -} - -export async function deleteFilterMutation ( - id: DeleteFilterInput -): Promise { - const mutation = gql` - mutation DeleteFilter($id: ID!) { - deleteFilter(id: $id) { - ... on DeleteFilterSuccess { - filter { - id - } - } - ... on DeleteFilterError { - errorCodes - } - } - } - ` - - try { - const data = await gqlFetcher(mutation, { id }) - const output = data as DeleteFilterOutput | undefined - return output?.deleteFilter.filter - } catch { - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/deleteLabelMutation.ts b/packages/web/lib/networking/mutations/deleteLabelMutation.ts deleted file mode 100644 index c8b7936a0..000000000 --- a/packages/web/lib/networking/mutations/deleteLabelMutation.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { gql } from 'graphql-request' -import { Label } from '../fragments/labelFragment' -import { gqlFetcher } from '../networkHelpers' - -type DeleteLabelResult = { - deleteLabel: DeleteLabel - errorCodes?: unknown[] -} - -type DeleteLabel = { - label: Label -} - -export async function deleteLabelMutation( - labelId: string -): Promise { - const mutation = gql` - mutation DeleteLabel($id: ID!) { - deleteLabel(id: $id) { - ... on DeleteLabelSuccess { - label { - id - name - color - description - createdAt - } - } - ... on DeleteLabelError { - errorCodes - } - } - } - ` - - try { - const data = (await gqlFetcher(mutation, { - id: labelId, - })) as DeleteLabelResult - return data.errorCodes ? undefined : data.deleteLabel.label.id - } catch (error) { - console.log('deleteLabelMutation error', error) - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/deleteLinkMutation.ts b/packages/web/lib/networking/mutations/deleteLinkMutation.ts deleted file mode 100644 index c76d35e47..000000000 --- a/packages/web/lib/networking/mutations/deleteLinkMutation.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' - -export async function deleteLinkMutation( - linkId: string -): Promise { - const mutation = gql` - mutation SetBookmarkArticle($input: SetBookmarkArticleInput!) { - setBookmarkArticle(input: $input) { - ... on SetBookmarkArticleSuccess { - bookmarkedArticle { - id - } - } - ... on SetBookmarkArticleError { - errorCodes - } - } - }` - - try { - const data = await gqlFetcher(mutation, { input: { articleID: linkId, bookmark: false }}) - return data - } catch (error) { - console.log('SetBookmarkArticleOutput error', error) - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/moveToLibraryMutation.ts b/packages/web/lib/networking/mutations/moveToLibraryMutation.ts deleted file mode 100644 index 205312dd6..000000000 --- a/packages/web/lib/networking/mutations/moveToLibraryMutation.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' - -type MoveToFolderResponseData = { - success?: boolean - errorCodes?: string[] -} - -type MoveToFolderResponse = { - moveToFolder?: MoveToFolderResponseData -} - -export async function moveToFolderMutation( - itemId: string, - folder: string -): Promise { - const mutation = gql` - mutation MoveToFolder($id: ID!, $folder: String!) { - moveToFolder(id: $id, folder: $folder) { - ... on MoveToFolderSuccess { - success - } - ... on MoveToFolderError { - errorCodes - } - } - } - ` - - try { - const response = await gqlFetcher(mutation, { id: itemId, folder }) - const data = response as MoveToFolderResponse | undefined - if (data?.moveToFolder?.errorCodes) { - return false - } - return data?.moveToFolder?.success ?? false - } catch (error) { - console.log('MoveToFolder error', error) - return false - } -} diff --git a/packages/web/lib/networking/mutations/saveFilterMutation.ts b/packages/web/lib/networking/mutations/saveFilterMutation.ts deleted file mode 100644 index 6c6becac5..000000000 --- a/packages/web/lib/networking/mutations/saveFilterMutation.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' -import { SavedSearch } from "../fragments/savedSearchFragment" - -export type AddFilterInput = { - name: string - filter: string - category: string - position: number - folder?: string -} - -type AddFilterOutput = { - saveFilter: { filter: SavedSearch } -} - -export async function saveFilterMutation ( - input: AddFilterInput -): Promise { - const mutation = gql` - mutation SaveFilter($input: SaveFilterInput!) { - saveFilter(input: $input) { - ... on SaveFilterSuccess { - filter { - id - name - filter - position - visible - defaultFilter - folder - category - } - } - - ... on SaveFilterError { - errorCodes - } - } - } - ` - - const data = await gqlFetcher(mutation, { input }) - const output = data as AddFilterOutput | undefined - return output?.saveFilter.filter -} diff --git a/packages/web/lib/networking/mutations/setLabelsMutation.ts b/packages/web/lib/networking/mutations/setLabelsMutation.ts deleted file mode 100644 index 9889c130a..000000000 --- a/packages/web/lib/networking/mutations/setLabelsMutation.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { gql } from 'graphql-request' -import { Label, labelFragment } from '../fragments/labelFragment' -import { gqlFetcher } from '../networkHelpers' - -type SetLabelsResult = { - setLabels: SetLabels -} - -type SetLabels = { - labels: Label[] - errorCodes?: unknown[] -} - -export async function setLabelsMutation( - pageId: string, - labelIds: string[] -): Promise { - const mutation = gql` - mutation SetLabels($input: SetLabelsInput!) { - setLabels(input: $input) { - ... on SetLabelsSuccess { - labels { - ...LabelFields - } - } - ... on SetLabelsError { - errorCodes - } - } - } - ${labelFragment} - ` - - try { - const data = (await gqlFetcher(mutation, { - input: { pageId, labelIds }, - })) as SetLabelsResult - return data.setLabels.errorCodes ? undefined : data.setLabels.labels - } catch (error) { - console.log(' -- SetLabelsOutput error', error) - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/setLinkArchivedMutation.ts b/packages/web/lib/networking/mutations/setLinkArchivedMutation.ts deleted file mode 100644 index cf25ec90c..000000000 --- a/packages/web/lib/networking/mutations/setLinkArchivedMutation.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' - -type SetLinkArchivedInput = { - linkId: string - archived: boolean -} - -export async function setLinkArchivedMutation( - input: SetLinkArchivedInput -): Promise | undefined> { - const mutation = gql` - mutation SetLinkArchived($input: ArchiveLinkInput!) { - setLinkArchived(input: $input) { - ... on ArchiveLinkSuccess { - linkId - message - } - ... on ArchiveLinkError { - message - errorCodes - } - } - } - ` - - try { - const data = await gqlFetcher(mutation, { input }) - return data as Record | undefined - } catch (error) { - console.log('SetLinkArchivedInput error', error) - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/shareHighlightToFeedMutation.ts b/packages/web/lib/networking/mutations/shareHighlightToFeedMutation.ts deleted file mode 100644 index 1e502a1fa..000000000 --- a/packages/web/lib/networking/mutations/shareHighlightToFeedMutation.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' - -type ShareHighlightToFeedMutationInput = { - id: string - share: boolean -} - -export async function shareHighlightToFeedMutation( - input: ShareHighlightToFeedMutationInput -): Promise { - const mutation = gql` - mutation SetShareHighlight($input: SetShareHighlightInput!) { - setShareHighlight(input: $input) { - ... on SetShareHighlightSuccess { - highlight { - id - } - } - ... on SetShareHighlightError { - errorCodes - } - } - } - ` - - try { - await gqlFetcher(mutation, { input }) - return true - } catch { - return false - } -} diff --git a/packages/web/lib/networking/mutations/updateFilterMutation.ts b/packages/web/lib/networking/mutations/updateFilterMutation.ts deleted file mode 100644 index 62885cd65..000000000 --- a/packages/web/lib/networking/mutations/updateFilterMutation.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' -import { SavedSearch } from "../fragments/savedSearchFragment" - -export type UpdateFilterInput = { - id?: string - name?: string - filter?: string - position?: number - category?: string - description?: string - visible?: boolean - folder?: string -} - -type UpdateFilterOutput = { - filter: SavedSearch -} - -export async function updateFilterMutation ( - input: UpdateFilterInput -): Promise { - const mutation = gql` - mutation UpdateFilter($input: UpdateFilterInput!) { - updateFilter(input: $input) { - ... on UpdateFilterSuccess { - filter { - id - } - } - - ... on UpdateFilterError { - errorCodes - } - } - } - ` - - try { - const { id, name, visible, filter, position } = input - const data = await gqlFetcher(mutation, { input: {id, name, filter, position, visible }}) - const output = data as UpdateFilterOutput | undefined - return output?.filter?.id - } catch { - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/updateLabelMutation.ts b/packages/web/lib/networking/mutations/updateLabelMutation.ts deleted file mode 100644 index 6e4ab6b59..000000000 --- a/packages/web/lib/networking/mutations/updateLabelMutation.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' - -export type UpdateLabelInput = { - labelId: string - name: string - color: string - description?: string -} - -export async function updateLabelMutation( - input: UpdateLabelInput -): Promise { - const mutation = gql` - mutation UpdateLabel($input: UpdateLabelInput!) { - updateLabel(input: $input) { - ... on UpdateLabelSuccess { - label { - id - name - color - description - createdAt - } - } - ... on UpdateLabelError { - errorCodes - } - } - } - ` - - try { - const data = await gqlFetcher(mutation, { - input, - }) - const output = data as any - return output?.updatedLabel - } catch (err) { - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/updatePageMutation.ts b/packages/web/lib/networking/mutations/updatePageMutation.ts deleted file mode 100644 index b7d9b3b67..000000000 --- a/packages/web/lib/networking/mutations/updatePageMutation.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' -import { State } from '../fragments/articleFragment' - -export type UpdatePageInput = { - pageId: string - title?: string - byline?: string | undefined - description?: string - savedAt?: string - publishedAt?: string - state?: State -} - -export async function updatePageMutation( - input: UpdatePageInput -): Promise { - const mutation = gql` - mutation UpdatePage($input: UpdatePageInput!) { - updatePage(input: $input) { - ... on UpdatePageSuccess { - updatedPage { - id - title - url - createdAt - author - image - description - savedAt - publishedAt - } - } - ... on UpdatePageError { - errorCodes - } - } - } - ` - - try { - const data = await gqlFetcher(mutation, { - input, - }) - const output = data as any - return output.updatePage - } catch (err) { - return undefined - } -} diff --git a/packages/web/lib/networking/mutations/updateShareHighlightCommentMutation.ts b/packages/web/lib/networking/mutations/updateShareHighlightCommentMutation.ts deleted file mode 100644 index d563862d0..000000000 --- a/packages/web/lib/networking/mutations/updateShareHighlightCommentMutation.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' - -type ShareHighlightCommentMutationInput = { - highlightId: string - annotation?: string -} - -export async function shareHighlightCommentMutation( - input: ShareHighlightCommentMutationInput -): Promise { - const mutation = gql` - mutation UpdateHighlight($input: UpdateHighlightInput!) { - updateHighlight(input: $input) { - ... on UpdateHighlightSuccess { - highlight { - id - } - } - - ... on UpdateHighlightError { - errorCodes - } - } - } - ` - - try { - await gqlFetcher(mutation, { input }) - return true - } catch { - return false - } -} diff --git a/packages/web/lib/networking/queries/search.tsx b/packages/web/lib/networking/queries/search.tsx index cd19944ee..c7993d2e2 100644 --- a/packages/web/lib/networking/queries/search.tsx +++ b/packages/web/lib/networking/queries/search.tsx @@ -1,94 +1,96 @@ import { gql } from 'graphql-request' -import { gqlFetcher } from '../networkHelpers' -import { LibraryItemsData } from './useGetLibraryItemsQuery' +// import { gqlFetcher } from '../networkHelpers' +// import { LibraryItems } from '../library_items/useLibraryItems' -export type LibraryItemsQueryInput = { - limit?: number - searchQuery?: string - includeContent?: boolean +const foo = () => { + return 'bar' } +// export type LibraryItemsQueryInput = { +// limit?: number +// searchQuery?: string +// includeContent?: boolean +// } -export async function searchQuery({ - limit = 10, - searchQuery, - includeContent = false, -}: LibraryItemsQueryInput): Promise { - 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 - pageType - contentReader - createdAt - isArchived - readingProgressPercent - readingProgressTopPercent - readingProgressAnchorIndex - author - image - description - publishedAt - ownedByViewer - originalArticleUrl - uploadFileId - labels { - id - name - color - } - pageId - shortId - quote - annotation - state - siteName - subscription - readAt - } - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - totalCount - } - } - ... on SearchError { - errorCodes - } - } - } - ` +// export async function searchQuery({ +// limit = 10, +// searchQuery, +// includeContent = false, +// }: LibraryItemsQueryInput): Promise { +// 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 +// pageType +// contentReader +// createdAt +// readingProgressPercent +// readingProgressTopPercent +// readingProgressAnchorIndex +// author +// image +// description +// publishedAt +// ownedByViewer +// originalArticleUrl +// uploadFileId +// labels { +// id +// name +// color +// } +// pageId +// shortId +// quote +// annotation +// state +// siteName +// subscription +// readAt +// } +// } +// pageInfo { +// hasNextPage +// hasPreviousPage +// startCursor +// endCursor +// totalCount +// } +// } +// ... on SearchError { +// errorCodes +// } +// } +// } +// ` - const variables = { - first: limit, - query: searchQuery, - includeContent, - } +// const variables = { +// first: limit, +// query: searchQuery, +// includeContent, +// } - try { - const data = await gqlFetcher(query, { ...variables }) - return (data as LibraryItemsData) || undefined - } catch (error) { - console.log('search error', error) - return undefined - } -} +// try { +// const data = await gqlFetcher(query, { ...variables }) +// return (data as LibraryItemsData) || undefined +// } catch (error) { +// console.log('search error', error) +// return undefined +// } +// } diff --git a/packages/web/lib/networking/queries/useGetArticleOriginalHtmlQuery.tsx b/packages/web/lib/networking/queries/useGetArticleOriginalHtmlQuery.tsx index 94870fd94..02ace5c84 100644 --- a/packages/web/lib/networking/queries/useGetArticleOriginalHtmlQuery.tsx +++ b/packages/web/lib/networking/queries/useGetArticleOriginalHtmlQuery.tsx @@ -1,6 +1,7 @@ import { gql } from 'graphql-request' import useSWRImmutable from 'swr' import { makeGqlFetcher, RequestContext, ssrFetcher } from '../networkHelpers' +import { ArticleAttributes } from '../library_items/useLibraryItems' type ArticleQueryInput = { username?: string @@ -17,11 +18,6 @@ type NestedArticleData = { errorCodes?: string[] } -export type ArticleAttributes = { - id: string - originalHtml: string -} - const query = gql` query GetArticle($username: String!, $slug: String!) { article(username: $username, slug: $slug) { diff --git a/packages/web/lib/networking/queries/useGetArticleQuery.tsx b/packages/web/lib/networking/queries/useGetArticleQuery.tsx index 282cff9e1..63974022b 100644 --- a/packages/web/lib/networking/queries/useGetArticleQuery.tsx +++ b/packages/web/lib/networking/queries/useGetArticleQuery.tsx @@ -10,11 +10,12 @@ import { Highlight, highlightFragment } from '../fragments/highlightFragment' import { ScopedMutator } from 'swr/dist/_internal' import { Label, labelFragment } from '../fragments/labelFragment' import { + ArticleAttributes, LibraryItems, Recommendation, - recommendationFragment, -} from './useGetLibraryItemsQuery' +} from '../library_items/useLibraryItems' import useSWR from 'swr' +import { recommendationFragment } from '../library_items/gql' type ArticleQueryInput = { username?: string @@ -39,37 +40,6 @@ type NestedArticleData = { errorCodes?: string[] } -export type TextDirection = 'RTL' | 'LTR' - -export type ArticleAttributes = { - id: string - title: string - url: string - originalArticleUrl: string - author?: string - image?: string - savedAt: string - isArchived: boolean - createdAt: string - publishedAt?: string - description?: string - wordsCount?: number - 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[] -} - const query = gql` query GetArticle( $username: String! diff --git a/packages/web/lib/networking/queries/useGetArticleSavingStatus.tsx b/packages/web/lib/networking/queries/useGetArticleSavingStatus.tsx index 8a87f10ac..a99cdef22 100644 --- a/packages/web/lib/networking/queries/useGetArticleSavingStatus.tsx +++ b/packages/web/lib/networking/queries/useGetArticleSavingStatus.tsx @@ -3,7 +3,7 @@ import useSWR from 'swr' import { articleFragment } from '../fragments/articleFragment' import { highlightFragment } from '../fragments/highlightFragment' import { makeGqlFetcher } from '../networkHelpers' -import { ArticleAttributes } from './useGetArticleQuery' +import { ArticleAttributes } from '../library_items/useLibraryItems' type ArticleSavingStatusInput = { id?: string diff --git a/packages/web/lib/networking/queries/useGetHighlights.tsx b/packages/web/lib/networking/queries/useGetHighlights.tsx index 09edb9a33..69a9505d8 100644 --- a/packages/web/lib/networking/queries/useGetHighlights.tsx +++ b/packages/web/lib/networking/queries/useGetHighlights.tsx @@ -2,7 +2,7 @@ import { gql } from 'graphql-request' import useSWRInfinite from 'swr/infinite' import { Highlight, highlightFragment } from '../fragments/highlightFragment' import { gqlFetcher } from '../networkHelpers' -import { PageInfo } from './useGetLibraryItemsQuery' +import { PageInfo } from '../library_items/useLibraryItems' interface HighlightsResponse { data?: Array diff --git a/packages/web/lib/networking/queries/useGetHome.tsx b/packages/web/lib/networking/queries/useGetHome.tsx index 1031da91c..e310f5c20 100644 --- a/packages/web/lib/networking/queries/useGetHome.tsx +++ b/packages/web/lib/networking/queries/useGetHome.tsx @@ -1,6 +1,6 @@ import { gql } from 'graphql-request' import useSWR from 'swr' -import { gqlFetcher, makeGqlFetcher, publicGqlFetcher } from '../networkHelpers' +import { makeGqlFetcher } from '../networkHelpers' type HomeResult = { home: { diff --git a/packages/web/lib/networking/queries/useGetLabelsQuery.tsx b/packages/web/lib/networking/queries/useGetLabelsQuery.tsx deleted file mode 100644 index abd4295fd..000000000 --- a/packages/web/lib/networking/queries/useGetLabelsQuery.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { gql } from 'graphql-request' -import useSWR from 'swr' -import { Label, labelFragment } from '../fragments/labelFragment' -import { publicGqlFetcher } from '../networkHelpers' - -type LabelsQueryResponse = { - error: any - isLoading: boolean - isValidating: boolean - labels: Label[] - revalidate: () => void -} - -type LabelsResponseData = { - labels?: LabelsData -} - -type LabelsData = { - labels?: unknown -} - -export function useGetLabelsQuery(): LabelsQueryResponse { - const query = gql` - query GetLabels { - labels { - ... on LabelsSuccess { - labels { - ...LabelFields - } - } - ... on LabelsError { - errorCodes - } - } - } - ${labelFragment} - ` - - const { data, error, mutate, isValidating } = useSWR(query, publicGqlFetcher) - - try { - if (data && !error) { - const result = data as LabelsResponseData - const labels = result.labels?.labels as Label[] - return { - error, - isLoading: !error && !data, - isValidating, - labels, - revalidate: () => { - mutate() - }, - } - } - } catch (error) { - console.log('error', error) - } - return { - error, - isLoading: !error && !data, - isValidating: false, - labels: [], - // eslint-disable-next-line @typescript-eslint/no-empty-function - revalidate: () => {}, - } -} diff --git a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx deleted file mode 100644 index 820b36d58..000000000 --- a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx +++ /dev/null @@ -1,582 +0,0 @@ -import { gql } from 'graphql-request' -import useSWRInfinite from 'swr/infinite' -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 { gqlFetcher, makeGqlFetcher } from '../networkHelpers' -import { Label } from './../fragments/labelFragment' -import { moveToFolderMutation } from '../mutations/moveToLibraryMutation' -import useSWR from 'swr' - -export interface ReadableItem { - id: string - title: string - slug: string -} - -export type LibraryItemsQueryInput = { - limit: number - sortDescending: boolean - searchQuery?: string - cursor?: string - includeContent?: boolean -} - -type LibraryItemsQueryResponse = { - itemsPages?: LibraryItemsData[] - itemsDataError?: unknown - isLoading: boolean - isValidating: boolean - error: boolean - size: number - setSize: ( - size: number | ((_size: number) => number) - ) => Promise - performActionOnItem: (action: LibraryItemAction, item: LibraryItem) => void - mutate: () => void -} - -type LibraryItemsRawQueryResponse = { - items: LibraryItemNode[] - itemsDataError?: unknown - isLoading: boolean - isValidating: boolean - error: boolean -} - -type LibraryItemAction = - | 'archive' - | 'unarchive' - | 'delete' - | 'mark-read' - | 'mark-unread' - | 'refresh' - | 'unsubscribe' - | 'update-item' - | 'move-to-inbox' - -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 const recommendationFragment = gql` - fragment RecommendationFields on Recommendation { - id - name - note - user { - userId - name - username - profileImageURL - } - recommendedAt - } -` - -export function useGetLibraryItemsQuery( - folder: string, - { limit, searchQuery, cursor, includeContent = false }: LibraryItemsQueryInput -): LibraryItemsQueryResponse { - const fullQuery = (`in:${folder} use:folders ` + (searchQuery ?? '')).trim() - 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 - 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} - ` - - const variables = { - after: cursor, - first: limit, - query: fullQuery, - includeContent, - } - - const { data, error, mutate, size, setSize, isValidating } = useSWRInfinite( - (pageIndex, previousPageData) => { - const key = [query, variables.first, variables.query, undefined] - const previousResult = previousPageData as LibraryItemsData - if (pageIndex === 0) { - return key - } - return [ - query, - limit, - searchQuery, - pageIndex === 0 ? undefined : previousResult.search.pageInfo.endCursor, - ] - }, - (args: any[]) => { - const pageIndex = args[3] as number - return gqlFetcher(query, { ...variables, after: pageIndex }, true) - }, - { revalidateFirstPage: false } - ) - - let responseError = error - let responsePages = data as LibraryItemsData[] | undefined - - // We need to check the response errors here and return the error - // it will be nested in the data pages, if there is one error, - // we invalidate the data and return the error. We also zero out - // the response in the case of an error. - if (!error && responsePages) { - const errors = responsePages.filter( - (d) => d.search.errorCodes && d.search.errorCodes.length > 0 - ) - if (errors?.length > 0) { - responseError = errors - responsePages = undefined - } - } - - const getIndexOf = (page: LibraryItems, item: LibraryItem) => { - return page.edges.findIndex((i) => i.node.id === item.node.id) - } - - const performActionOnItem = async ( - action: LibraryItemAction, - item: LibraryItem - ) => { - console.log('performing action on items: ', action) - if (!responsePages) { - return - } - - const updateData = (mutatedItem: LibraryItem | undefined) => { - if (!responsePages) { - return - } - - for (const searchResults of responsePages) { - const itemIndex = getIndexOf(searchResults.search, item) - if (itemIndex !== -1) { - if (typeof mutatedItem === 'undefined') { - searchResults.search.edges.splice(itemIndex, 1) - } else { - searchResults.search.edges.splice(itemIndex, 1, mutatedItem) - } - break - } - } - mutate(responsePages, false) - } - - switch (action) { - case 'move-to-inbox': - updateData({ - cursor: item.cursor, - node: { - ...item.node, - folder: 'inbox', - }, - }) - - moveToFolderMutation(item.cursor, 'inbox').then((res) => { - if (res) { - showSuccessToast('Link moved', { position: 'bottom-right' }) - } else { - showErrorToast('Error moving link', { position: 'bottom-right' }) - } - }) - - mutate() - break - case 'archive': - updateData({ - cursor: item.cursor, - node: { - ...item.node, - isArchived: true, - }, - }) - - setLinkArchivedMutation({ - linkId: item.node.id, - archived: true, - }).then((res) => { - if (res) { - showSuccessToast('Link archived', { position: 'bottom-right' }) - } else { - showErrorToast('Error archiving link', { position: 'bottom-right' }) - } - }) - - mutate() - - break - case 'unarchive': - updateData({ - cursor: item.cursor, - node: { - ...item.node, - isArchived: false, - }, - }) - - setLinkArchivedMutation({ - linkId: item.node.id, - archived: false, - }).then((res) => { - if (res) { - showSuccessToast('Link unarchived', { position: 'bottom-right' }) - } else { - showErrorToast('Error unarchiving link', { - position: 'bottom-right', - }) - } - }) - mutate() - break - case 'delete': - updateData({ - cursor: item.cursor, - node: { - ...item.node, - state: State.DELETED, - }, - }) - - const pageId = item.node.id - deleteLinkMutation(pageId).then((res) => { - if (res) { - showSuccessToastWithUndo('Page deleted', async () => { - const result = await updatePageMutation({ - pageId: pageId, - state: State.SUCCEEDED, - }) - - mutate() - - if (result) { - showSuccessToast('Page recovered') - } else { - showErrorToast( - 'Error recovering page, check your deleted items' - ) - } - }) - } else { - showErrorToast('Error removing link', { position: 'bottom-right' }) - } - }) - break - case 'mark-read': - updateData({ - cursor: item.cursor, - node: { - ...item.node, - readingProgressPercent: 100, - readingProgressTopPercent: 100, - }, - }) - articleReadingProgressMutation({ - id: item.node.id, - force: true, - readingProgressPercent: 100, - readingProgressTopPercent: 100, - readingProgressAnchorIndex: 0, - }) - mutate() - break - case 'mark-unread': - updateData({ - cursor: item.cursor, - node: { - ...item.node, - readingProgressPercent: 0, - readingProgressTopPercent: 0, - readingProgressAnchorIndex: 0, - }, - }) - articleReadingProgressMutation({ - id: item.node.id, - force: true, - readingProgressPercent: 0, - readingProgressTopPercent: 0, - readingProgressAnchorIndex: 0, - }) - mutate() - break - case 'update-item': - updateData(item) - mutate() - break - case 'refresh': - await mutate() - } - } - - return { - isValidating, - itemsPages: responsePages || undefined, - itemsDataError: responseError, - isLoading: !error && !data, - performActionOnItem, - size, - setSize, - mutate, - error: !!error, - } -} - -export function useGetRawSearchItemsQuery( - { - limit, - searchQuery, - cursor, - includeContent = false, - }: LibraryItemsQueryInput, - shouldFetch = true -): LibraryItemsRawQueryResponse { - 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 variables = { - after: cursor, - first: limit, - query: searchQuery, - includeContent, - } - - const { data, error, isValidating, mutate } = useSWR( - shouldFetch ? [query, variables.first, variables.after] : null, - makeGqlFetcher(query, variables), - { - revalidateIfStale: false, - revalidateOnFocus: false, - } - ) - - const responseError = error - const responseData = data as LibraryItemsData | undefined - - // We need to check the response errors here and return the error - // it will be nested in the data pages, if there is one error, - // we invalidate the data and return the error. We also zero out - // the response in the case of an error. - if (responseData?.errorCodes) { - return { - isValidating: false, - items: [], - isLoading: false, - error: true, - } - } - - return { - isValidating, - items: responseData?.search.edges.map((edge) => edge.node) ?? [], - itemsDataError: responseError, - isLoading: !error && !data, - error: !!error, - } -} diff --git a/packages/web/lib/networking/queries/useGetSavedSearchQuery.tsx b/packages/web/lib/networking/queries/useGetSavedSearchQuery.tsx deleted file mode 100644 index f6a76f386..000000000 --- a/packages/web/lib/networking/queries/useGetSavedSearchQuery.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { gql } from 'graphql-request' -import useSWR from 'swr' -import { publicGqlFetcher } from '../networkHelpers' -import { - SavedSearch, - savedSearchFragment, -} from '../fragments/savedSearchFragment' - -type SavedSearchResponse = { - error: any - savedSearches?: SavedSearch[] - savedSearchErrors?: unknown - isLoading: boolean -} - -type SavedSearchResponseData = { - filters: { filters: SavedSearch[] } -} - -export function useGetSavedSearchQuery(): SavedSearchResponse { - const query = gql` - query SavedSearches { - filters { - ... on FiltersSuccess { - filters { - ...FiltersFragment - } - } - ... on FiltersError { - errorCodes - } - } - } - ${savedSearchFragment} - ` - - const { data, error } = useSWR(query, publicGqlFetcher) - - if (data) { - const { filters } = data as SavedSearchResponseData - - return { - error, - savedSearches: filters?.filters ?? [], - savedSearchErrors: error ?? {}, - isLoading: false, - } - } - - return { - error, - savedSearches: [], - savedSearchErrors: null, - isLoading: !error && !data, - } -} diff --git a/packages/web/lib/networking/savedsearches/gql.tsx b/packages/web/lib/networking/savedsearches/gql.tsx new file mode 100644 index 000000000..c087bf0cb --- /dev/null +++ b/packages/web/lib/networking/savedsearches/gql.tsx @@ -0,0 +1,68 @@ +import { gql } from 'graphql-request' +import { savedSearchFragment } from '../fragments/savedSearchFragment' + +export const GQL_GET_SAVED_SEARCHES = gql` + query SavedSearches { + filters { + ... on FiltersSuccess { + filters { + ...FiltersFragment + } + } + ... on FiltersError { + errorCodes + } + } + } + ${savedSearchFragment} +` + +export const GQL_DELETE_SAVED_SEARCH = gql` + mutation DeleteFilter($id: ID!) { + deleteFilter(id: $id) { + ... on DeleteFilterSuccess { + filter { + ...FiltersFragment + } + } + ... on DeleteFilterError { + errorCodes + } + } + } + ${savedSearchFragment} +` + +export const GQL_CREATE_SAVED_SEARCH = gql` + mutation SaveFilter($input: SaveFilterInput!) { + saveFilter(input: $input) { + ... on SaveFilterSuccess { + filter { + ...FiltersFragment + } + } + + ... on SaveFilterError { + errorCodes + } + } + } + ${savedSearchFragment} +` + +export const GQL_UPDATE_SAVED_SEARCH = gql` + mutation UpdateFilter($input: UpdateFilterInput!) { + updateFilter(input: $input) { + ... on UpdateFilterSuccess { + filter { + ...FiltersFragment + } + } + + ... on UpdateFilterError { + errorCodes + } + } + } + ${savedSearchFragment} +` diff --git a/packages/web/lib/networking/savedsearches/useSavedSearches.tsx b/packages/web/lib/networking/savedsearches/useSavedSearches.tsx new file mode 100644 index 000000000..e7f47e7ec --- /dev/null +++ b/packages/web/lib/networking/savedsearches/useSavedSearches.tsx @@ -0,0 +1,173 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { gqlFetcher } from '../networkHelpers' +import { + GQL_CREATE_SAVED_SEARCH, + GQL_DELETE_SAVED_SEARCH, + GQL_GET_SAVED_SEARCHES, + GQL_UPDATE_SAVED_SEARCH, +} from './gql' +import { SavedSearch } from '../fragments/savedSearchFragment' + +export function useGetSavedSearches() { + return useQuery({ + queryKey: ['filters'], + queryFn: async () => { + const response = (await gqlFetcher( + GQL_GET_SAVED_SEARCHES + )) as SavedSearchData + if (response.filters?.errorCodes?.length) { + throw new Error(response.filters.errorCodes[0]) + } + return response.filters.filters + }, + }) +} + +export const useCreateSavedSearch = () => { + const queryClient = useQueryClient() + const createSavedSearch = async (variables: { + name: string + filter: string + category: string + position: number + }) => { + const result = (await gqlFetcher(GQL_CREATE_SAVED_SEARCH, { + input: { + name: variables.name, + filter: variables.filter, + category: variables.category, + position: variables.position, + }, + })) as CreateSavedSearchData + if (result.saveFilter.errorCodes?.length) { + throw new Error(result.saveFilter.errorCodes[0]) + } + return result.saveFilter?.filter + } + return useMutation({ + mutationFn: createSavedSearch, + onSuccess: (newSavedSearch) => { + const keys = queryClient + .getQueryCache() + .findAll({ queryKey: ['filters'] }) + keys.forEach((query) => { + queryClient.setQueryData(query.queryKey, (data: SavedSearch[]) => { + return [...data, newSavedSearch] + }) + }) + }, + }) +} + +export const useUpdateSavedSearch = () => { + const queryClient = useQueryClient() + const updateSavedSearch = async (variables: { + input: UpdateSavedSearchInput + }) => { + const result = (await gqlFetcher(GQL_UPDATE_SAVED_SEARCH, { + input: { + id: variables.input.id, + name: variables.input.name, + visible: variables.input.visible, + filter: variables.input.filter, + position: variables.input.position, + }, + })) as UpdateSavedSearchData + if (result.updateFilter.errorCodes?.length) { + throw new Error(result.updateFilter.errorCodes[0]) + } + return result.updateFilter?.filter + } + return useMutation({ + mutationFn: updateSavedSearch, + onSuccess: (updatedSavedSearch) => { + if (updatedSavedSearch) { + const keys = queryClient + .getQueryCache() + .findAll({ queryKey: ['filters'] }) + keys.forEach((query) => { + queryClient.setQueryData(query.queryKey, (data: SavedSearch[]) => { + return [ + ...data.filter( + (savedSearch) => savedSearch.id !== updatedSavedSearch.id + ), + updatedSavedSearch, + ] + }) + }) + } + }, + }) +} + +export const useDeleteSavedSearch = () => { + const queryClient = useQueryClient() + const deleteSavedSearch = async (variables: { searchId: string }) => { + const result = (await gqlFetcher(GQL_DELETE_SAVED_SEARCH, { + id: variables.searchId, + })) as DeleteSavedSearchData + if (result.deleteFilter.errorCodes?.length) { + throw new Error(result.deleteFilter.errorCodes[0]) + } + return result.deleteFilter?.filter?.id + } + return useMutation({ + mutationFn: deleteSavedSearch, + onSuccess: (deletedId) => { + if (deletedId) { + const keys = queryClient + .getQueryCache() + .findAll({ queryKey: ['filters'] }) + keys.forEach((query) => { + queryClient.setQueryData(query.queryKey, (data: SavedSearch[]) => { + return data.filter((filter) => filter.id !== deletedId) + }) + }) + } + }, + }) +} + +type UpdateSavedSearchResult = { + filter?: SavedSearch + errorCodes?: string[] +} + +type UpdateSavedSearchData = { + updateFilter: UpdateSavedSearchResult +} + +type CreateSavedSearchResult = { + filter?: SavedSearch + errorCodes?: string[] +} +type CreateSavedSearchData = { + saveFilter: CreateSavedSearchResult +} + +type DeleteSavedSearchResult = { + filter?: SavedSearch + errorCodes?: string[] +} +type DeleteSavedSearchData = { + deleteFilter: DeleteSavedSearchResult +} + +type FiltersResult = { + filters?: SavedSearch[] + errorCodes?: string[] +} +type SavedSearchData = { + filters: FiltersResult +} + +export type UpdateSavedSearchInput = { + id?: string + name?: string + filter?: string + position?: number + category?: string + description?: string + visible?: boolean + folder?: string +} diff --git a/packages/web/lib/networking/shortcuts/useShortcuts.tsx b/packages/web/lib/networking/shortcuts/useShortcuts.tsx new file mode 100644 index 000000000..90c489167 --- /dev/null +++ b/packages/web/lib/networking/shortcuts/useShortcuts.tsx @@ -0,0 +1,135 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestHeaders } from '../networkHelpers' +import { fetchEndpoint } from '../../appConfig' +import { Label } from '../fragments/labelFragment' + +export type ShortcutType = 'search' | 'label' | 'newsletter' | 'feed' | 'folder' + +export type Shortcut = { + type: ShortcutType + + id: string + name: string + section: string + filter: string + + icon?: string + label?: Label + + join?: string + + children?: Shortcut[] +} + +export function useGetShortcuts() { + return useQuery({ + queryKey: ['shortcuts'], + queryFn: async () => { + return await getShortcuts() + }, + }) +} + +export const useSetShortcuts = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (variables: { shortcuts: Shortcut[] }) => { + return await setShortcuts(variables) + }, + onMutate: async (variables: { shortcuts: Shortcut[] }) => { + await queryClient.cancelQueries({ queryKey: ['shortcuts'] }) + queryClient.setQueryData(['shortcuts'], variables.shortcuts) + const previousState = { + previousItems: queryClient.getQueryData(['shortcuts']), + } + return previousState + }, + onError: (error, variables, context) => { + if (context?.previousItems) { + queryClient.setQueryData(['shortcuts'], context.previousItems) + } + }, + }) +} + +export const useResetShortcuts = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async () => { + return await resetShortcuts() + }, + onMutate: async () => { + const previousState = { + previousItems: queryClient.getQueryData(['shortcuts']), + } + return previousState + }, + onError: (error, variables, context) => { + if (context?.previousItems) { + queryClient.setQueryData(['shortcuts'], context.previousItems) + } + }, + onSuccess: (data, variables, context) => { + queryClient.setQueryData(['shortcuts'], data) + }, + }) +} + +async function getShortcuts(): Promise { + const url = new URL(`/api/shortcuts`, fetchEndpoint) + try { + const response = await fetch(url.toString(), { + method: 'GET', + headers: requestHeaders(), + credentials: 'include', + mode: 'cors', + }) + const payload = await response.json() + if ('shortcuts' in payload) { + return payload['shortcuts'] as Shortcut[] + } + return [] + } catch (err) { + console.log('error getting shortcuts: ', err) + throw err + } +} + +async function setShortcuts(variables: { + shortcuts: Shortcut[] +}): Promise { + const url = new URL(`/api/shortcuts`, fetchEndpoint) + const response = await fetch(url.toString(), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...requestHeaders(), + }, + credentials: 'include', + mode: 'cors', + body: JSON.stringify({ shortcuts: variables.shortcuts }), + }) + const payload = await response.json() + if (!('shortcuts' in payload)) { + throw new Error('Error syncing shortcuts') + } + return payload['shortcuts'] as Shortcut[] +} + +async function resetShortcuts(): Promise { + const url = new URL(`/api/shortcuts`, fetchEndpoint) + const response = await fetch(url.toString(), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...requestHeaders(), + }, + credentials: 'include', + mode: 'cors', + }) + const payload = await response.json() + if (!('shortcuts' in payload)) { + throw new Error('Error syncing shortcuts') + } + return payload['shortcuts'] as Shortcut[] +} diff --git a/packages/web/lib/toastHelpers.tsx b/packages/web/lib/toastHelpers.tsx index 5f0157cb2..b73806f84 100644 --- a/packages/web/lib/toastHelpers.tsx +++ b/packages/web/lib/toastHelpers.tsx @@ -114,6 +114,8 @@ const showToastWithAction = ( action: () => Promise, options?: ToastOptions ) => { + console.trace('show success: ', message) + return toast( ({ id }) => ( @@ -124,7 +126,6 @@ const showToastWithAction = ( style="ctaLightGray" onClick={(event) => { event.preventDefault() - toast.dismiss(id) ;(async () => { await action() diff --git a/packages/web/next.config.js b/packages/web/next.config.js index e556c8223..148e2a34a 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -49,6 +49,34 @@ const moduleExports = { source: '/collect/:match*', destination: 'https://app.posthog.com/:match*', }) + rewrites.push({ + source: '/home', + destination: '/l/home', + }) + rewrites.push({ + source: '/library', + destination: '/l/library', + }) + rewrites.push({ + source: '/subscriptions', + destination: '/l/subscriptions', + }) + rewrites.push({ + source: '/highlights', + destination: '/l/highlights', + }) + rewrites.push({ + source: '/subscriptions', + destination: '/l/subscriptions', + }) + rewrites.push({ + source: '/archive', + destination: '/l/archive', + }) + rewrites.push({ + source: '/trash', + destination: '/l/trash', + }) return rewrites }, async headers() { diff --git a/packages/web/package.json b/packages/web/package.json index e6d50b316..383d603dd 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-switch": "^1.0.1", "@sentry/nextjs": "^7.42.0", "@stitches/react": "^1.2.5", + "@tanstack/react-query": "^5.51.21", "allotment": "^1.20.2", "antd": "4.24.3", "axios": "^1.2.0", @@ -108,4 +109,4 @@ "volta": { "extends": "../../package.json" } -} \ No newline at end of file +} diff --git a/packages/web/pages/[username]/[slug]/debug.tsx b/packages/web/pages/[username]/[slug]/debug.tsx index 0691eb37b..2e74926fa 100644 --- a/packages/web/pages/[username]/[slug]/debug.tsx +++ b/packages/web/pages/[username]/[slug]/debug.tsx @@ -1,5 +1,4 @@ import { useRouter } from 'next/router' -import { useGetArticleQuery } from '../../../lib/networking/queries/useGetArticleQuery' import { applyStoredTheme } from '../../../lib/themeUpdater' import { useMemo } from 'react' import { @@ -7,6 +6,7 @@ import { HStack, SpanBox, } from '../../../components/elements/LayoutPrimitives' +import { useGetLibraryItemContent } from '../../../lib/networking/library_items/useLibraryItems' type ArticleAttribute = { name: string @@ -15,11 +15,10 @@ type ArticleAttribute = { export default function Debug(): JSX.Element { const router = useRouter() - const { articleData, articleFetchError, isLoading } = useGetArticleQuery({ - username: router.query.username as string, - slug: router.query.slug as string, - includeFriendsHighlights: false, - }) + const { data: article } = useGetLibraryItemContent( + router.query.username as string, + router.query.slug as string + ) applyStoredTheme() @@ -30,13 +29,11 @@ export default function Debug(): JSX.Element { // return sortedAttributes.sort((a, b) => // a.createdAt.localeCompare(b.createdAt) // ) - if (!articleData?.article.article) { + if (!article) { return [] } const result: ArticleAttribute[] = [] - const article = articleData.article.article - result.push({ name: 'id', value: article.id }) result.push({ name: 'linkId', value: article.linkId }) @@ -57,8 +54,6 @@ export default function Debug(): JSX.Element { result.push({ name: 'savedAt', value: article.savedAt }) result.push({ name: 'createdAt', value: article.createdAt }) result.push({ name: 'publishedAt', value: article.publishedAt ?? 'null' }) - - result.push({ name: 'isArchived', value: article.isArchived.toString() }) result.push({ name: 'description', value: article.description ?? 'null' }) result.push({ @@ -173,7 +168,7 @@ export default function Debug(): JSX.Element { // recommendations?: Recommendation[] return result - }, [articleData]) + }, [article]) return ( <> diff --git a/packages/web/pages/[username]/[slug]/index.tsx b/packages/web/pages/[username]/[slug]/index.tsx index 559f4f8d0..90107e997 100644 --- a/packages/web/pages/[username]/[slug]/index.tsx +++ b/packages/web/pages/[username]/[slug]/index.tsx @@ -1,10 +1,6 @@ import { PrimaryLayout } from '../../../components/templates/PrimaryLayout' import { LoadingView } from '../../../components/patterns/LoadingView' import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery' -import { - removeItemFromCache, - useGetArticleQuery, -} from '../../../lib/networking/queries/useGetArticleQuery' import { useRouter } from 'next/router' import { VStack } from './../../../components/elements/LayoutPrimitives' import { @@ -15,37 +11,37 @@ import { PdfArticleContainerProps } from './../../../components/templates/articl import { useCallback, useEffect, useState } from 'react' import dynamic from 'next/dynamic' import { Toaster } from 'react-hot-toast' -import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation' -import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation' -import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeHighlightMutation' -import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation' -import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation' import Script from 'next/script' import { ArticleActionsMenu } from '../../../components/templates/article/ArticleActionsMenu' -import { setLinkArchivedMutation } from '../../../lib/networking/mutations/setLinkArchivedMutation' import { Label } from '../../../lib/networking/fragments/labelFragment' -import { useSWRConfig } from 'swr' -import { - showErrorToast, - showSuccessToast, - showSuccessToastWithUndo, -} from '../../../lib/toastHelpers' +import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers' import { SetLabelsModal } from '../../../components/templates/article/SetLabelsModal' import { DisplaySettingsModal } from '../../../components/templates/article/DisplaySettingsModal' import { useReaderSettings } from '../../../lib/hooks/useReaderSettings' import { SkeletonArticleContainer } from '../../../components/templates/article/SkeletonArticleContainer' import { useRegisterActions } from 'kbar' -import { deleteLinkMutation } from '../../../lib/networking/mutations/deleteLinkMutation' import { ReaderHeader } from '../../../components/templates/reader/ReaderHeader' import { EditArticleModal } from '../../../components/templates/homeFeed/EditItemModals' import { VerticalArticleActionsMenu } from '../../../components/templates/article/VerticalArticleActions' import { PdfHeaderSpacer } from '../../../components/templates/article/PdfHeaderSpacer' import { EpubContainerProps } from '../../../components/templates/article/EpubContainer' import { useSetPageLabels } from '../../../lib/hooks/useSetPageLabels' -import { updatePageMutation } from '../../../lib/networking/mutations/updatePageMutation' -import { State } from '../../../lib/networking/fragments/articleFragment' import { posthog } from 'posthog-js' import { PDFDisplaySettingsModal } from '../../../components/templates/article/PDFDisplaySettingsModal' +import { + ArticleReadingProgressMutationInput, + useArchiveItem, + useDeleteItem, + useGetLibraryItemContent, + useUpdateItemReadStatus, +} from '../../../lib/networking/library_items/useLibraryItems' +import { + CreateHighlightInput, + useCreateHighlight, + useDeleteHighlight, + useMergeHighlight, + useUpdateHighlight, +} from '../../../lib/networking/highlights/useItemHighlights' const PdfArticleContainerNoSSR = dynamic( () => import('./../../../components/templates/article/PdfArticleContainer'), @@ -57,26 +53,31 @@ const EpubContainerNoSSR = dynamic( { ssr: false } ) -export default function Home(): JSX.Element { +export default function Reader(): JSX.Element { const router = useRouter() - const { cache, mutate } = useSWRConfig() const [showEditModal, setShowEditModal] = useState(false) const [showHighlightsModal, setShowHighlightsModal] = useState(false) const { viewerData } = useGetViewerQuery() const readerSettings = useReaderSettings() + const archiveItem = useArchiveItem() + const deleteItem = useDeleteItem() + const updateItemReadStatus = useUpdateItemReadStatus() + const createHighlight = useCreateHighlight() + const deleteHighlight = useDeleteHighlight() + const updateHighlight = useUpdateHighlight() + const mergeHighlight = useMergeHighlight() - const { articleData, articleFetchError } = useGetArticleQuery({ - username: router.query.username as string, - slug: router.query.slug as string, - includeFriendsHighlights: false, - }) - const article = articleData?.article.article + const { data: libraryItem, error: articleFetchError } = + useGetLibraryItemContent( + router.query.username as string, + router.query.slug as string + ) useEffect(() => { dispatchLabels({ type: 'RESET', - labels: article?.labels ?? [], + labels: libraryItem?.labels ?? [], }) - }, [articleData?.article.article]) + }, [libraryItem]) const goNextOrHome = useCallback(() => { // const listStr = localStorage.getItem('library-slug-list') @@ -97,7 +98,7 @@ export default function Home(): JSX.Element { const query = window.sessionStorage.getItem('q') router.push(`/home?${query}`) - }, [router, viewerData, article]) + }, [router, viewerData, libraryItem]) const goPreviousOrHome = useCallback(() => { // const listStr = localStorage.getItem('library-slug-list') @@ -113,78 +114,88 @@ export default function Home(): JSX.Element { const query = window.sessionStorage.getItem('q') router.push(`/home?${query}`) // router.push(`/home`) - }, [router, viewerData, article]) + }, [router, viewerData, libraryItem]) const actionHandler = useCallback( async (action: string, arg?: unknown) => { + if (!libraryItem) { + return + } switch (action) { - case 'unarchive': - if (article) { - removeItemFromCache(cache, mutate, article.id) - - setLinkArchivedMutation({ - linkId: article.id, - archived: false, - }).then((res) => { - if (res) { - showSuccessToast('Link unarchived', { - position: 'bottom-right', - }) - } else { - showErrorToast('Error unarchiving link', { - position: 'bottom-right', - }) - } - }) - goNextOrHome() - } - break case 'archive': - if (article) { - removeItemFromCache(cache, mutate, article.id) - - await setLinkArchivedMutation({ - linkId: article.id, - archived: true, - }).then((res) => { - if (!res) { - showErrorToast('Error archiving', { - position: 'bottom-right', - }) - } else { - goNextOrHome() - showSuccessToast('Page archived', { - position: 'bottom-right', - }) - } + case 'unarchive': + try { + await archiveItem.mutateAsync({ + itemId: libraryItem.id, + slug: libraryItem.slug, + input: { + linkId: libraryItem.id, + archived: action == 'archive', + }, }) + } catch { + showErrorToast(`Error ${action}ing item`, { + position: 'bottom-right', + }) + return } + showSuccessToast(`Item ${action}d`, { + position: 'bottom-right', + }) + goNextOrHome() break case 'mark-read': - if (article) { - articleReadingProgressMutation({ - id: article.id, - force: true, - readingProgressPercent: 100, - readingProgressTopPercent: 100, - readingProgressAnchorIndex: 0, - }).then((res) => { - if (!res) { - // todo: revalidate or put back in cache? - showErrorToast('Error marking as read', { - position: 'bottom-right', - }) - } else { - goNextOrHome() - } + case 'mark-unread': + const desc = action == 'mark-read' ? 'read' : 'unread' + const values = + action == 'mark-read' + ? { + readingProgressPercent: 100, + readingProgressTopPercent: 100, + readingProgressAnchorIndex: 0, + } + : { + readingProgressPercent: 0, + readingProgressTopPercent: 0, + readingProgressAnchorIndex: 0, + } + try { + await updateItemReadStatus.mutateAsync({ + itemId: libraryItem.id, + slug: libraryItem.slug, + input: { + id: libraryItem.id, + force: true, + ...values, + }, }) + } catch { + showErrorToast(`Error marking as ${desc}`, { + position: 'bottom-right', + }) + return } + goNextOrHome() break case 'delete': - await deleteCurrentItem() + try { + await deleteItem.mutateAsync({ + itemId: libraryItem.id, + slug: libraryItem.slug, + }) + } catch { + showErrorToast(`Error deleting item`, { + position: 'bottom-right', + }) + return + } + showSuccessToast(`Item deleted`, { + position: 'bottom-right', + }) + goNextOrHome() break case 'openOriginalArticle': - const url = article?.url + const url = libraryItem?.url if (url) { window.open(url, '_blank') } @@ -206,7 +217,15 @@ export default function Home(): JSX.Element { break } }, - [article, viewerData, cache, mutate, router, readerSettings] + [ + libraryItem, + viewerData, + router, + readerSettings, + archiveItem, + deleteItem, + updateItemReadStatus, + ] ) useEffect(() => { @@ -224,6 +243,10 @@ export default function Home(): JSX.Element { actionHandler('mark-read') } + const markUnread = () => { + actionHandler('mark-unread') + } + const showEditModal = () => { actionHandler('showEditModal') } @@ -231,6 +254,7 @@ export default function Home(): JSX.Element { document.addEventListener('archive', archive) document.addEventListener('delete', deletePage) document.addEventListener('mark-read', markRead) + document.addEventListener('mark-unread', markUnread) document.addEventListener('openOriginalArticle', openOriginalArticle) document.addEventListener('showEditModal', showEditModal) @@ -240,6 +264,8 @@ export default function Home(): JSX.Element { return () => { document.removeEventListener('archive', archive) document.removeEventListener('mark-read', markRead) + document.removeEventListener('mark-unread', markUnread) + document.removeEventListener('delete', deletePage) document.removeEventListener('openOriginalArticle', openOriginalArticle) document.removeEventListener('showEditModal', showEditModal) @@ -249,42 +275,15 @@ export default function Home(): JSX.Element { }, [actionHandler, goNextOrHome, goPreviousOrHome]) useEffect(() => { - if (article && viewerData?.me) { + if (libraryItem && viewerData?.me) { posthog.capture('link_read', { - link: article.id, - slug: article.slug, - reader: article.contentReader, - url: article.originalArticleUrl, + link: libraryItem.id, + slug: libraryItem.slug, + reader: libraryItem.contentReader, + url: libraryItem.originalArticleUrl, }) } - }, [article, viewerData]) - - const deleteCurrentItem = useCallback(async () => { - if (article) { - const pageId = article.id - removeItemFromCache(cache, mutate, pageId) - await deleteLinkMutation(pageId).then((res) => { - if (res) { - showSuccessToastWithUndo('Page deleted', async () => { - const result = await updatePageMutation({ - pageId: pageId, - state: State.SUCCEEDED, - }) - document.dispatchEvent(new Event('revalidateLibrary')) - if (result) { - showSuccessToast('Page recovered') - } else { - showErrorToast('Error recovering page, check your deleted items') - } - }) - } else { - // todo: revalidate or put back in cache? - showErrorToast('Error deleting page', { position: 'bottom-right' }) - } - }) - goNextOrHome() - } - }, [article, cache, mutate, router]) + }, [libraryItem, viewerData]) useRegisterActions( [ @@ -360,6 +359,15 @@ export default function Home(): JSX.Element { document.dispatchEvent(new Event('mark-read')) }, }, + { + id: 'mark_unread', + section: 'Article', + name: 'Mark current item as unread', + shortcut: ['-'], + perform: () => { + document.dispatchEvent(new Event('mark-unread')) + }, + }, { id: 'full_screen', section: 'Article', @@ -457,10 +465,16 @@ export default function Home(): JSX.Element { ) const [labels, dispatchLabels] = useSetPageLabels( - articleData?.article.article?.id + libraryItem?.id, + libraryItem?.slug ) - if (articleFetchError && articleFetchError.indexOf('NOT_FOUND') > -1) { + new Error() + if ( + articleFetchError && + 'message' in articleFetchError && + articleFetchError['message'] === 'NOT_FOUND' + ) { router.push('/404') return } @@ -470,18 +484,18 @@ export default function Home(): JSX.Element { pageTestId="home-page-tag" headerToolbarControl={ } - alwaysDisplayToolbar={article?.contentReader == 'PDF'} + alwaysDisplayToolbar={libraryItem?.contentReader == 'PDF'} pageMetaDataProps={{ - title: article?.title ?? '', + title: libraryItem?.title ?? '', path: router.pathname, - description: article?.description ?? '', + description: libraryItem?.description ?? '', }} >