diff --git a/packages/web/components/elements/LoadingBar.tsx b/packages/web/components/elements/LoadingBar.tsx new file mode 100644 index 000000000..6b92e7bf8 --- /dev/null +++ b/packages/web/components/elements/LoadingBar.tsx @@ -0,0 +1,77 @@ +import { Box } from './../elements/LayoutPrimitives' +import { Dispatch, SetStateAction, useEffect, useState } from "react" + +type LoadingBarProps = { + fillColor: string + backgroundColor: string + borderRadius: string + percentFill?: number +} + +type AnimationStatus = { + position: number, + transition: string +} + +export function LoadingBar(props: LoadingBarProps): JSX.Element { + const [leftOne, setLeftOne] = useState({ position: 0, transition: 'left 0.5s linear' }) + const [leftTwo, setLeftTwo] = useState({ position: -100, transition: 'left 0.5s linear' }) + + const calculateNewValue = (currVal: AnimationStatus, setNextVal: Dispatch>) => { + const position = currVal.position >= 100 ? -100 : currVal.position + 25; + const transition = currVal.position >= 100 ? 'left 0s linear' : 'left 0.5s linear'; + setNextVal({ position, transition }) + } + + useEffect(() => { + const interval = setTimeout(() => { + calculateNewValue(leftOne, setLeftOne) + }, 500); + + return () => { + clearTimeout(interval) + } + }, [leftOne]) + + useEffect(() => { + const interval = setTimeout(() => { + calculateNewValue(leftTwo, setLeftTwo) + }, 500); + + return () => { + clearTimeout(interval) + } + }, [leftTwo]) + + return ( + + + + + ) +} diff --git a/packages/web/components/patterns/LibraryCards/CardTypes.tsx b/packages/web/components/patterns/LibraryCards/CardTypes.tsx index 799509cb2..ab9d9dfcb 100644 --- a/packages/web/components/patterns/LibraryCards/CardTypes.tsx +++ b/packages/web/components/patterns/LibraryCards/CardTypes.tsx @@ -30,4 +30,5 @@ export type LinkedItemCardProps = { multiSelectMode: MultiSelectMode isHovered?: boolean + isLoading?: boolean } diff --git a/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx b/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx index 60072a376..167fdbd05 100644 --- a/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx +++ b/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx @@ -5,7 +5,6 @@ import { CoverImage } from '../../elements/CoverImage' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { useCallback, useState } from 'react' -import Link from 'next/link' import { AuthorInfoStyle, CardCheckbox, @@ -28,7 +27,7 @@ import { import { CardMenu } from '../CardMenu' import { DotsThree } from 'phosphor-react' import { isTouchScreenDevice } from '../../../lib/deviceType' -import { ProgressBarOverlay } from './LibraryListCard' +import { LoadingBarOverlay, ProgressBarOverlay } from "./LibraryListCard" import { FallbackImage } from './FallbackImage' import { useRouter } from 'next/router' @@ -88,7 +87,6 @@ export function LibraryGridCard(props: LinkedItemCardProps): JSX.Element { setIsHovered(false) }} onClick={(event) => { - console.log('click event: ', event) if (event.metaKey || event.ctrlKey) { window.open( `/${props.viewer.profile.username}/${props.item.slug}`, @@ -133,6 +131,7 @@ type GridImageProps = { src?: string title?: string readingProgress?: number + isLoading?: boolean } const GridImage = (props: GridImageProps): JSX.Element => { @@ -140,7 +139,17 @@ const GridImage = (props: GridImageProps): JSX.Element => { return ( <> - {(props.readingProgress ?? 0) > 0 && ( + { + props.isLoading && ( + + ) + } + {(props.readingProgress ?? 0) > 0 && !props.isLoading && ( { src={props.item.image} title={props.item.title} readingProgress={item.readingProgressPercent} + isLoading={props.isLoading} /> { + return ( + + + + ) } export const ProgressBarOverlay = ( @@ -164,14 +199,25 @@ type ListImageProps = { src?: string title?: string readingProgress?: number + isLoading?: boolean } const ListImage = (props: ListImageProps): JSX.Element => { const [displayFallback, setDisplayFallback] = useState(props.src == undefined) return ( - <> - {(props.readingProgress ?? 0) > 0 && ( + <>{ + props.isLoading && ( + + ) + } + {(props.readingProgress ?? 0) > 0 && !props.isLoading && ( void + handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise, } export function AddLinkModal(props: AddLinkModalProps): JSX.Element { const [link, setLink] = useState('') - const handleLinkSubmission = useCallback( - async (link: string, timezone: string, locale: string) => { - const result = await saveUrlMutation(link, timezone, locale) - if (result) { - toast( - () => ( - - Link Saved - - - - ), - { position: 'bottom-right' } - ) - } else { - showErrorToast('Error saving link', { position: 'bottom-right' }) - } - }, - [link] - ) - const validateLink = useCallback( (link: string) => { try { @@ -81,7 +51,7 @@ export function AddLinkModal(props: AddLinkModalProps): JSX.Element {
{ + onSubmit={async (event) => { event.preventDefault() let submitLink = link @@ -96,7 +66,7 @@ export function AddLinkModal(props: AddLinkModalProps): JSX.Element { setLink(newLink) submitLink = newLink } - handleLinkSubmission(submitLink, timeZone, locale) + await props.handleLinkSubmission(submitLink, timeZone, locale) props.onOpenChange(false) }} > diff --git a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx index 75bb983dc..ae89e8f0f 100644 --- a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx +++ b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx @@ -2,7 +2,7 @@ import { Action, createAction, useKBar, useRegisterActions } from 'kbar' import debounce from 'lodash/debounce' import { useRouter } from 'next/router' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Toaster } from 'react-hot-toast' +import toast, { Toaster } from "react-hot-toast" import TopBarProgress from 'react-topbar-progress-indicator' import { useFetchMore } from '../../../lib/hooks/useFetchMoreScroll' import { usePersistedState } from '../../../lib/hooks/usePersistedState' @@ -48,6 +48,8 @@ import { } 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" export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT' export type LibraryMode = 'reads' | 'highlights' @@ -65,6 +67,11 @@ 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 = [1000, 2000, 2500, 3500, 5000, 10000, 60000]; + export function HomeFeedContainer(): JSX.Element { const { viewerData } = useGetViewerQuery() const router = useRouter() @@ -144,10 +151,11 @@ export function HomeFeedContainer(): JSX.Element { useEffect(() => { if (!router.isReady) return const q = router.query['q'] - let qs = '' + let qs = 'in:inbox' // Default to in:inbox search term. if (q && typeof q === 'string') { qs = q } + if (qs !== (queryInputs.searchQuery || '')) { setQueryInputs({ ...queryInputs, searchQuery: qs }) performActionOnItem('refresh', undefined as unknown as any) @@ -171,14 +179,61 @@ export function HomeFeedContainer(): JSX.Element { return itemsPages[itemsPages.length - 1].search.pageInfo.hasNextPage }, [itemsPages]) + const libraryItems = useMemo(() => { const items = itemsPages?.flatMap((ad) => { - return ad.search.edges + return ad.search.edges.map(it => ({ ...it, isLoading: it.node.state === 'PROCESSING'})); }) || [] return items }, [itemsPages, performActionOnItem]) + useEffect(() => { + const timeout : NodeJS.Timeout[] = [] + + 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; + 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.slug, 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 @@ -714,6 +769,37 @@ export function HomeFeedContainer(): JSX.Element { [itemsPages, multiSelectMode, checkedItems] ) + const handleLinkSubmission = + async (link: string, timezone: string, locale: string) => { + const result = await saveUrlMutation(link, timezone, locale) + if (result) { + toast( + () => ( + + Link Saved + + + + ), + { position: 'bottom-right' } + ) + const id = result.url?.match(/[^/]+$/)?.[0] ?? ""; + performActionOnItem('refresh', undefined as unknown as any) + } else { + showErrorToast('Error saving link', { position: 'bottom-right' }) + } + }; + return ( { setQueryInputs({ ...queryInputs, @@ -815,6 +902,8 @@ type HomeFeedContentProps = { item: LibraryItem | undefined ) => Promise + handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise, + setIsChecked: (itemId: string, set: boolean) => void itemIsChecked: (itemId: string) => boolean @@ -857,6 +946,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element { applySearchQuery={(searchQuery: string) => { props.applySearchQuery(searchQuery) }} + handleLinkSubmission={props.handleLinkSubmission} allowSelectMultiple={props.mode !== 'highlights'} alwaysShowHeader={props.mode == 'highlights'} showFilterMenu={showFilterMenu} @@ -896,7 +986,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element { )} {props.showAddLinkModal && ( - props.setShowAddLinkModal(false)} /> + props.setShowAddLinkModal(false)} /> )} @@ -1145,6 +1235,7 @@ function LibraryItems(props: LibraryItemsProps): JSX.Element { void performMultiSelectAction: (action: BulkAction, labelIds?: string[]) => void + + handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise, } export function LibraryHeader(props: LibraryHeaderProps): JSX.Element { @@ -110,6 +113,7 @@ function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element { searchTerm={props.searchTerm} applySearchQuery={props.applySearchQuery} allowSelectMultiple={props.allowSelectMultiple} + handleLinkSubmission={props.handleLinkSubmission} /> ) @@ -148,6 +152,7 @@ function SmallHeaderLayout(props: LibraryHeaderProps): JSX.Element { <> {props.multiSelectMode === 'off' && } void + handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise, } export function SearchBox(props: SearchBoxProps): JSX.Element { const inputRef = useRef(null) const [focused, setFocused] = useState(false) const [searchTerm, setSearchTerm] = useState(props.searchTerm ?? '') + const [isAddAction, setIsAddAction] = useState(false); + const IS_URL_REGEX = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; useEffect(() => { setSearchTerm(props.searchTerm ?? '') }, [props.searchTerm]) + useEffect(() => { + setIsAddAction(IS_URL_REGEX.test(searchTerm)) + }, [searchTerm, props.searchTerm]) + useKeyboardShortcuts( searchBarCommands((action) => { if (action === 'focusSearchBar' && inputRef.current) { @@ -272,15 +284,34 @@ export function SearchBox(props: SearchBoxProps): JSX.Element { e.preventDefault() }} > - + { + (() => { + if (isAddAction) { + return + } + + return + })() + } + { + onSubmit={async (event) => { event.preventDefault() - props.applySearchQuery(searchTerm || '') + + if (!isAddAction) { + props.applySearchQuery(searchTerm || '') + } else { + await props.handleLinkSubmission(searchTerm, timeZone, locale) + setSearchTerm(props.searchTerm ?? "") + props.applySearchQuery(props.searchTerm ?? "") + } inputRef.current?.blur() if (props.onClose) { props.onClose() @@ -376,6 +407,8 @@ type ControlButtonBoxProps = { searchTerm: string | undefined applySearchQuery: (searchQuery: string) => void + + handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise, } function MultiSelectControls(props: ControlButtonBoxProps): JSX.Element { diff --git a/packages/web/lib/networking/queries/useGetArticleQuery.tsx b/packages/web/lib/networking/queries/useGetArticleQuery.tsx index f9f9df7cf..6fcb758ff 100644 --- a/packages/web/lib/networking/queries/useGetArticleQuery.tsx +++ b/packages/web/lib/networking/queries/useGetArticleQuery.tsx @@ -1,6 +1,6 @@ import { gql } from 'graphql-request' import useSWRImmutable, { Cache } from 'swr' -import { makeGqlFetcher, RequestContext, ssrFetcher } from '../networkHelpers' +import { gqlFetcher, makeGqlFetcher, RequestContext, ssrFetcher } from "../networkHelpers" import { articleFragment, ContentReader, @@ -135,15 +135,15 @@ export function useGetArticleQuery({ } export async function articleQuery( - context: RequestContext, input: ArticleQueryInput -): Promise { - const result = (await ssrFetcher(context, query, input)) as ArticleData +): Promise { + + const result = (await gqlFetcher(query, input)) as ArticleData if (result.article) { return result.article.article } - return Promise.reject() + return undefined } export const cacheArticle = ( diff --git a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx index 4a0535c28..cddf3d6f4 100644 --- a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx +++ b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx @@ -65,6 +65,7 @@ export type LibraryItems = { export type LibraryItem = { cursor: string node: LibraryItemNode + isLoading?: boolean | undefined } export type LibraryItemNode = {