Merge pull request #2617 from Podginator/feat/add-links-in-search

[Feat] Add Links to Search, and show "PROCESSING" Links in the Card/List Views.
This commit is contained in:
Jackson Harper 2023-08-08 10:52:39 +08:00 committed by GitHub
commit 755b5fe8c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 287 additions and 57 deletions

View file

@ -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<SetStateAction<AnimationStatus>>) => {
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 (
<Box
css={{
height: '5px',
width: '100%',
overflow: 'hidden',
backgroundColor: props.backgroundColor,
}}
>
<Box
css={{
height: '100%',
position: 'absolute',
width: `${props.percentFill ?? 10}%`,
left: `${leftOne.position}%`,
transition: leftOne.transition,
backgroundColor: props.fillColor,
}}
/>
<Box
css={{
height: '100%',
position: 'absolute',
width: `${props.percentFill ?? 10}%`,
left: `${leftTwo.position}%`,
transition: leftTwo.transition,
backgroundColor: props.fillColor,
}}
/>
</Box>
)
}

View file

@ -30,4 +30,5 @@ export type LinkedItemCardProps = {
multiSelectMode: MultiSelectMode
isHovered?: boolean
isLoading?: boolean
}

View file

@ -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 && (
<LoadingBarOverlay
width="100%"
top={95}
bottomRadius={'0px'}
fillColor={"rgba(60, 179, 113, 1)"}
/>
)
}
{(props.readingProgress ?? 0) > 0 && !props.isLoading && (
<ProgressBarOverlay
width="100%"
top={95}
@ -189,6 +198,7 @@ const LibraryGridCardContent = (props: LinkedItemCardProps): JSX.Element => {
src={props.item.image}
title={props.item.title}
readingProgress={item.readingProgressPercent}
isLoading={props.isLoading}
/>
<SpanBox
css={{

View file

@ -31,6 +31,7 @@ import { ProgressBar } from '../../elements/ProgressBar'
import { theme } from '../../tokens/stitches.config'
import { FallbackImage } from './FallbackImage'
import { useRouter } from 'next/router'
import { LoadingBar } from "../../elements/LoadingBar"
export function LibraryListCard(props: LinkedItemCardProps): JSX.Element {
const router = useRouter()
@ -128,11 +129,45 @@ export function LibraryListCard(props: LinkedItemCardProps): JSX.Element {
)
}
type LoadingBarOverlayProps = {
top: number
width: string
bottomRadius: string,
fillColor?: string,
percentFill?: number
}
type ProgressBarOverlayProps = {
top: number
width: string
value: number
bottomRadius: string
bottomRadius: string,
}
export const LoadingBarOverlay = (
props: LoadingBarOverlayProps
): JSX.Element => {
return (
<Box
css={{
position: 'absolute',
width: props.width,
top: props.top,
borderBottomLeftRadius: props.bottomRadius,
borderBottomRightRadius: props.bottomRadius,
overflow: 'clip',
zIndex: 2,
}}
>
<LoadingBar
fillColor={props.fillColor ?? theme.colors.thProgressFg.toString()}
backgroundColor="rgba(217, 217, 217, 0.65)"
borderRadius={'2px'}
percentFill={props.percentFill}
/>
</Box>
)
}
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 && (
<LoadingBarOverlay
width="55px"
top={50}
bottomRadius="4px"
fillColor={"rgba(60, 179, 113, 1)"}
percentFill={30}
/>
)
}
{(props.readingProgress ?? 0) > 0 && !props.isLoading && (
<ProgressBarOverlay
width="55px"
top={50}
@ -241,6 +287,7 @@ export function LibraryListCardContent(
src={props.item.image}
title={props.item.title}
readingProgress={item.readingProgressPercent}
isLoading={props.isLoading}
/>
</Box>
<VStack

View file

@ -16,42 +16,12 @@ import {
type AddLinkModalProps = {
onOpenChange: (open: boolean) => void
handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise<void>,
}
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(
() => (
<Box>
Link Saved
<span style={{ padding: '16px' }} />
<Button
style="ctaDarkYellow"
autoFocus
onClick={() => {
window.location.href = `/article?url=${encodeURIComponent(
link
)}`
}}
>
Read Now
</Button>
</Box>
),
{ 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 {
<ModalTitleBar title="Add Link" onOpenChange={props.onOpenChange} />
<Box css={{ width: '100%', py: '16px' }}>
<form
onSubmit={(event) => {
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)
}}
>

View file

@ -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(
() => (
<Box>
Link Saved
<span style={{ padding: '16px' }} />
<Button
style="ctaDarkYellow"
autoFocus
onClick={() => {
window.location.href = `/article?url=${encodeURIComponent(
link
)}`
}}
>
Read Now
</Button>
</Box>
),
{ 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 (
<HomeFeedGrid
items={libraryItems}
@ -728,6 +814,7 @@ export function HomeFeedContainer(): JSX.Element {
gridContainerRef={gridContainerRef}
mode={mode}
setMode={setMode}
handleLinkSubmission={handleLinkSubmission}
applySearchQuery={(searchQuery: string) => {
setQueryInputs({
...queryInputs,
@ -815,6 +902,8 @@ type HomeFeedContentProps = {
item: LibraryItem | undefined
) => Promise<void>
handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise<void>,
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 && (
<AddLinkModal onOpenChange={() => props.setShowAddLinkModal(false)} />
<AddLinkModal handleLinkSubmission={props.handleLinkSubmission} onOpenChange={() => props.setShowAddLinkModal(false)} />
)}
</HStack>
</VStack>
@ -1145,6 +1235,7 @@ function LibraryItems(props: LibraryItemsProps): JSX.Element {
<LinkedItemCard
layout={props.layout}
item={linkedItem.node}
isLoading={linkedItem.isLoading}
viewer={props.viewer}
isChecked={props.isChecked(linkedItem.node.id)}
setIsChecked={props.setIsChecked}

View file

@ -1,15 +1,16 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from "react"
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { theme } from '../../tokens/stitches.config'
import { FormInput } from '../../elements/FormElements'
import { searchBarCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts'
import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
import { locale, timeZone } from '../../../lib/dateFormatting'
import { Button, IconButton } from '../../elements/Button'
import {
CaretDown,
FunnelSimple,
MagnifyingGlass,
Prohibit,
Plus,
X,
} from 'phosphor-react'
import { LayoutType } from './HomeFeedContainer'
@ -52,6 +53,8 @@ type LibraryHeaderProps = {
setMultiSelectMode: (mode: MultiSelectMode) => void
performMultiSelectAction: (action: BulkAction, labelIds?: string[]) => void
handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise<void>,
}
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}
/>
</HStack>
)
@ -148,6 +152,7 @@ function SmallHeaderLayout(props: LibraryHeaderProps): JSX.Element {
<>
{props.multiSelectMode === 'off' && <MenuHeaderButton {...props} />}
<ControlButtonBox
handleLinkSubmission={props.handleLinkSubmission}
layout={props.layout}
updateLayout={props.updateLayout}
setShowInlineSearch={setShowInlineSearch}
@ -215,17 +220,24 @@ export type SearchBoxProps = {
compact?: boolean
onClose?: () => void
handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise<void>,
}
export function SearchBox(props: SearchBoxProps): JSX.Element {
const inputRef = useRef<HTMLInputElement | null>(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()
}}
>
<MagnifyingGlass
size={props.compact ? 15 : 20}
color={theme.colors.graySolid.toString()}
/>
{
(() => {
if (isAddAction) {
return <Plus
size={props.compact ? 15 : 20}
color={theme.colors.graySolid.toString()}
/>
}
return <MagnifyingGlass
size={props.compact ? 15 : 20}
color={theme.colors.graySolid.toString()}
/>
})()
}
</HStack>
<form
onSubmit={(event) => {
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<void>,
}
function MultiSelectControls(props: ControlButtonBoxProps): JSX.Element {

View file

@ -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<ArticleAttributes> {
const result = (await ssrFetcher(context, query, input)) as ArticleData
): Promise<ArticleAttributes | undefined> {
const result = (await gqlFetcher(query, input)) as ArticleData
if (result.article) {
return result.article.article
}
return Promise.reject()
return undefined
}
export const cacheArticle = (

View file

@ -65,6 +65,7 @@ export type LibraryItems = {
export type LibraryItem = {
cursor: string
node: LibraryItemNode
isLoading?: boolean | undefined
}
export type LibraryItemNode = {