Merge pull request #2692 from omnivore-app/feat/web-empty-library-improvements

Improve the empty search message
This commit is contained in:
Jackson Harper 2023-08-29 14:40:26 +08:00 committed by GitHub
commit 130e1c5eff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 414 additions and 135 deletions

View file

@ -0,0 +1,43 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class ArrowRightIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
>
<g>
<path
d="M3.33398 8H12.6673"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 10.6667L12.6667 8"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 5.33203L12.6667 7.9987"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -1,8 +1,4 @@
import {
VStack,
HStack,
SpanBox,
} from '../elements/LayoutPrimitives'
import { VStack, HStack, SpanBox } from '../elements/LayoutPrimitives'
import { StyledText } from '../elements/StyledText'
import Link from 'next/link'
import { Button } from '../elements/Button'
@ -22,11 +18,14 @@ export function ErrorLayout(props: ErrorLayoutProps): JSX.Element {
return (
<VStack alignment="center" distribution="start" css={{ height: '100%' }}>
<HStack alignment="center" css={{ mt: '64px', verticalAlign: 'middle' }}>
<StyledText style="headline" css={{
marginRight: '25px',
padding: '32px',
borderRight: '1px solid $grayText',
}}>
<StyledText
style="headline"
css={{
marginRight: '25px',
padding: '32px',
borderRight: '1px solid $grayText',
}}
>
{props.statusCode}
</StyledText>
<StyledText style="body">
@ -34,9 +33,11 @@ export function ErrorLayout(props: ErrorLayoutProps): JSX.Element {
</StyledText>
</HStack>
<SpanBox css={{ height: '64px' }} />
<Link passHref href={viewerData?.me ? "/home" : "/login"}>
<Button style="ctaDarkYellow">{viewerData?.me ? "Go Home" : "Login"}</Button>
<Link passHref href={viewerData?.me ? '/home' : '/login'}>
<Button style="ctaDarkYellow">
{viewerData?.me ? 'Go Home' : 'Login'}
</Button>
</Link>
</VStack>
)
}
}

View file

@ -1,51 +1,237 @@
import Link from 'next/link'
import { Book } from 'phosphor-react'
import { Button } from '../../elements/Button'
import { VStack } from '../../elements/LayoutPrimitives'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { theme } from '../../tokens/stitches.config'
import { useMemo } from 'react'
import { searchQuery } from '../../../lib/networking/queries/search'
import { LIBRARY_LEFT_MENU_WIDTH } from './LibraryFilterMenu'
import { LayoutType } from './HomeFeedContainer'
import { ArrowRightIcon } from '../../elements/icons/ArrowRightIcon'
type EmptyLibraryProps = {
searchTerm: string | undefined
onAddLinkClicked: () => void
layoutType: LayoutType
}
export function EmptyLibrary(props: EmptyLibraryProps): JSX.Element {
type MessageType = 'feed' | 'newsletter' | 'library'
type HelpMessageProps = {
type: MessageType
}
const HelpMessage = (props: HelpMessageProps) => {
switch (props.type) {
case 'library':
return (
<>
You can add a link or read more about Omnivore&apos;s{' '}
<a
href="https://docs.omnivore.app/using/search.html"
target="_blank"
rel="noreferrer"
>
advanced search
</a>
.
</>
)
case 'feed':
return (
<>
You can subscribe to RSS feeds using the{' '}
<Link href="/settings/feeds" passHref>
feeds page
</Link>
. Learn more about feeds at &apos;s{' '}
<a
href="https://docs.omnivore.app/using/feeds.html"
target="_blank"
rel="noreferrer"
>
docs.omnivore.app/using/feeds.html
</a>
.
</>
)
case 'newsletter':
return (
<>
Create email addresses that can be used to subscribe to newsletters on
the{' '}
<Link href="/settings/emails" passHref>
emails page
</Link>
. Learn more about reading newsletters in Omnivore at &apos;s{' '}
<a
href="https://docs.omnivore.app/using/inbox.html"
target="_blank"
rel="noreferrer"
>
docs.omnivore.app/using/inbox.html
</a>
.
</>
)
}
return <></>
}
export const ErrorBox = (props: HelpMessageProps) => {
const errorTitle = useMemo(() => {
switch (props.type) {
case 'feed':
return 'You do not have any feed items matching this query.'
case 'newsletter':
return 'You do not have any newsletter item matching this query.'
}
return 'No results found for this query.'
}, [props.type])
return (
<VStack
alignment="center"
distribution="center"
<Box
css={{
color: '$grayTextContrast',
textAlign: 'center',
paddingTop: '88px',
flex: '1',
width: 'fit-content',
borderRadius: '5px',
background: 'rgba(255, 59, 48, 0.3)',
fontSize: '15px',
fontFamily: '$inter',
fontWeight: '500',
color: '$thTextContrast',
padding: '10px',
'@smDown': {
width: '100%',
},
'@xlgDown': {
justifyContent: 'flex-start',
},
}}
>
<Book size={44} color={theme.colors.grayTextContrast.toString()} />
<StyledText style="fixedHeadline" css={{ color: '$grayTextContrast' }}>
No results found.
</StyledText>
<StyledText style="footnote" css={{ color: '$grayTextContrast' }}>
You can add a link or read more about Omnivore&apos;s{' '}
<a
href="https://docs.omnivore.app/using/search.html"
target="_blank"
rel="noreferrer"
>
advanced search
</a>
.
</StyledText>
<Button
style="ctaDarkYellow"
onClick={() => {
props.onAddLinkClicked()
}}
>
Add Link
</Button>
</VStack>
{errorTitle}
</Box>
)
}
export const SuggestionBox = (props: HelpMessageProps) => {
const helpMessage = useMemo(() => {
switch (props.type) {
case 'feed':
return 'Want to add an RSS or Atom Subscription?'
case 'newsletter':
return 'Create an Omnivore email address and subscribe to newsletters.'
}
return "Add a link or read more about Omnivore's Advanced Search."
}, [props.type])
const helpTarget = useMemo(() => {
switch (props.type) {
case 'feed':
return '/settings/feeds'
case 'newsletter':
return '/settings/emails'
}
return 'https://docs.omnivore.app/'
}, [props.type])
return (
<HStack
css={{
gap: '10px',
width: 'fit-content',
borderRadius: '5px',
background: '$thBackground3',
fontSize: '15px',
fontFamily: '$inter',
fontWeight: '500',
color: '$thTextContrast',
padding: '10px',
justifyContent: 'flex-start',
'@smDown': {
flexDirection: 'column',
alignItems: 'center',
width: '100%',
},
}}
>
{helpMessage}
<SpanBox css={{ cursor: 'pointer' }}>
<Link href={helpTarget} passHref>
<SpanBox
css={{
display: 'flex',
alignItems: 'center',
color: '$omnivoreCtaYellow',
gap: '2px',
'&:hover': {
textDecoration: 'underline',
},
}}
>
<>Click Here</>
<ArrowRightIcon
size={25}
color={theme.colors.omnivoreCtaYellow.toString()}
/>
</SpanBox>
</Link>
</SpanBox>
</HStack>
)
}
export const EmptyLibrary = (props: EmptyLibraryProps) => {
const type = useMemo<MessageType>(() => {
if (props.searchTerm) {
switch (props.searchTerm) {
case 'label:RSS':
return 'feed'
case 'label:Newsletter':
return 'newsletter'
}
}
return 'library'
}, [props])
return (
<Box
css={{
display: 'inline-flex',
color: '$grayTextContrast',
gap: '10px',
pl: '0px',
width: '100%',
'@media (max-width: 1300px)': {
flexDirection: 'column',
},
'@media (max-width: 768px)': {
p: '15px',
},
'@media (min-width: 768px)': {
pl: '15px',
width: `calc(100vw - ${LIBRARY_LEFT_MENU_WIDTH})`,
},
'@media (min-width: 930px)': {
pl: '0px',
width: props.layoutType == 'GRID_LAYOUT' ? '660px' : '640px',
},
'@media (min-width: 1280px)': {
pl: '0px',
width: '1000px',
},
'@media (min-width: 1600px)': {
pl: '0px',
width: '1340px',
},
}}
>
<ErrorBox type={type} />
<SuggestionBox type={type} />
</Box>
)
}

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 toast, { 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,8 +48,9 @@ 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"
import { saveUrlMutation } from '../../../lib/networking/mutations/saveUrlMutation'
import { articleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
import { searchQuery } from '../../../lib/networking/queries/search'
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
export type LibraryMode = 'reads' | 'highlights'
@ -70,7 +71,7 @@ const debouncedFetchSearchResults = debounce((query, cb) => {
// 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];
const TIMEOUT_DELAYS = [1000, 2000, 2500, 3500, 5000, 10000, 60000]
export function HomeFeedContainer(): JSX.Element {
const { viewerData } = useGetViewerQuery()
@ -179,58 +180,69 @@ 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.map(it => ({ ...it, isLoading: it.node.state === 'PROCESSING'}));
return ad.search.edges.map((it) => ({
...it,
isLoading: it.node.state === 'PROCESSING',
}))
}) || []
return items
}, [itemsPages, performActionOnItem])
useEffect(() => {
const timeout : NodeJS.Timeout[] = []
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);
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;
let startIdx = 0
const seeIfUpdated = async () => {
if (startIdx > TIMEOUT_DELAYS.length) {
item.node.state = State.FAILED;
item.node.state = State.FAILED
return
}
const username = viewerData?.me?.profile.username
const itemsToUpdate = libraryItems.filter(it => it.isLoading);
const itemsToUpdate = libraryItems.filter((it) => it.isLoading)
if (itemsToUpdate.length > 0) {
const link = await articleQuery({ username, slug: item.node.slug, includeFriendsHighlights: false })
const link = await articleQuery({
username,
slug: item.node.slug,
includeFriendsHighlights: false,
})
if (link && link.state != "PROCESSING") {
const updatedArticle = { ...item };
if (link && link.state != 'PROCESSING') {
const updatedArticle = { ...item }
updatedArticle.node = { ...item.node, ...link }
updatedArticle.isLoading = false;
updatedArticle.isLoading = false
console.log(`Updating Metadata of ${item.node.slug}.`)
performActionOnItem('update-item', updatedArticle);
return;
performActionOnItem('update-item', updatedArticle)
return
}
console.log(`Trying to get the metadata of item ${item.node.slug}... Retry ${startIdx} of 5`);
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();
});
await seeIfUpdated()
})
return () => {
timeout.forEach(clearTimeout);
timeout.forEach(clearTimeout)
}
}, [itemsPages])
@ -769,36 +781,39 @@ 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' })
}
};
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
@ -902,7 +917,11 @@ type HomeFeedContentProps = {
item: LibraryItem | undefined
) => Promise<void>
handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise<void>,
handleLinkSubmission: (
link: string,
timezone: string,
locale: string
) => Promise<void>
setIsChecked: (itemId: string, set: boolean) => void
itemIsChecked: (itemId: string) => boolean
@ -986,7 +1005,10 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
)}
{props.showAddLinkModal && (
<AddLinkModal handleLinkSubmission={props.handleLinkSubmission} onOpenChange={() => props.setShowAddLinkModal(false)} />
<AddLinkModal
handleLinkSubmission={props.handleLinkSubmission}
onOpenChange={() => props.setShowAddLinkModal(false)}
/>
)}
</HStack>
</VStack>
@ -1044,6 +1066,8 @@ function LibraryItemsLayout(props: LibraryItemsLayoutProps): JSX.Element {
>
{!props.isValidating && props.items.length == 0 ? (
<EmptyLibrary
layoutType={props.layout}
searchTerm={props.searchTerm}
onAddLinkClicked={() => {
props.setShowAddLinkModal(true)
}}

View file

@ -92,7 +92,7 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
term: 'in:inbox sort:read-desc is:unread',
},
{
name: 'Read Later',
name: 'Non-Feed Items',
term: 'in:library',
},
{
@ -170,10 +170,6 @@ function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
[subscriptions]
)
if (!subscriptions || subscriptions.length < 1) {
return <></>
}
return (
<MenuPanel
title="Subscriptions"
@ -181,18 +177,33 @@ function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
editFunc={() => {
window.location.href = '/settings/subscriptions'
}}
viewAll={() => {
setViewAll(true)
}}
>
{subscriptions.slice(0, viewAll ? undefined : 4).map((item) => {
return (
{viewAll ? (
<>
<FilterButton filterTerm={`label:RSS`} text="Feeds" {...props} />
<FilterButton
key={item.id}
filterTerm={`subscription:\"${item.name}\"`}
text={item.name}
filterTerm={`label:Newsletter`}
text="Newsletters"
{...props}
/>
)
})}
<ViewAllButton state={viewAll} setState={setViewAll} />
{(subscriptions ?? []).map((item) => {
return (
<FilterButton
key={item.id}
filterTerm={`subscription:\"${item.name}\"`}
text={item.name}
{...props}
/>
)
})}
<ViewAllButton state={viewAll} setState={setViewAll} />
</>
) : (
<SpanBox css={{ mb: '10px' }} />
)}
</MenuPanel>
)
}
@ -233,6 +244,7 @@ type MenuPanelProps = {
editFunc?: () => void
editTitle?: string
hideBottomBorder?: boolean
viewAll?: () => void
}
function MenuPanel(props: MenuPanelProps): JSX.Element {
@ -258,14 +270,15 @@ function MenuPanel(props: MenuPanelProps): JSX.Element {
lineHeight: '125%',
color: '$thLibraryMenuPrimary',
pl: '10px',
my: '20px',
mt: '20px',
mb: '10px',
}}
>
{props.title}
</StyledText>
<SpanBox
css={{
my: '15px',
mt: '15px',
marginLeft: 'auto',
height: '100%',
verticalAlign: 'middle',
@ -296,6 +309,16 @@ function MenuPanel(props: MenuPanelProps): JSX.Element {
</Box>
}
>
{props.viewAll && (
<DropdownOption
title="View All"
onSelect={() => {
if (props.viewAll) {
props.viewAll()
}
}}
/>
)}
<DropdownOption
title={props.editTitle}
onSelect={() => {

View file

@ -8,7 +8,7 @@ import { ConfirmationModal } from '../../../components/patterns/ConfirmationModa
import {
EmptySettingsRow,
SettingsTable,
SettingsTableRow
SettingsTableRow,
} from '../../../components/templates/settings/SettingsTable'
import { theme } from '../../../components/tokens/stitches.config'
import { formattedDateTime } from '../../../lib/dateFormatting'
@ -17,7 +17,7 @@ import { updateSubscriptionMutation } from '../../../lib/networking/mutations/up
import {
SubscriptionStatus,
SubscriptionType,
useGetSubscriptionsQuery
useGetSubscriptionsQuery,
} from '../../../lib/networking/queries/useGetSubscriptionsQuery'
import { applyStoredTheme } from '../../../lib/themeUpdater'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
@ -90,17 +90,15 @@ export default function Rss(): JSX.Element {
return (
<SettingsTable
pageId={'feeds'}
pageInfoLink={''} // TODO: https://docs.omnivore.app/settings/feeds.html
headerTitle={'Subscribed feeds'}
createTitle={'Add feed'}
pageInfoLink="https://docs.omnivore.app/settings/feeds.html"
headerTitle="Subscribed feeds"
createTitle="Add feed"
createAction={() => {
router.push('/settings/feeds/add')
}}
>
{subscriptions.length === 0 ? (
<EmptySettingsRow
text={isValidating ? '-' : 'No feeds subscribed'}
/>
<EmptySettingsRow text={isValidating ? '-' : 'No feeds subscribed'} />
) : (
subscriptions.map((subscription, i) => {
return (
@ -218,9 +216,7 @@ export default function Rss(): JSX.Element {
{onDeleteId && (
<ConfirmationModal
message={
'Feed will be unsubscribed. This action cannot be undone.'
}
message={'Feed will be unsubscribed. This action cannot be undone.'}
onAccept={async () => {
await onDelete(onDeleteId)
setOnDeleteId('')

View file

@ -12,10 +12,16 @@ export default {
},
} as ComponentMeta<typeof EmptyLibrary>
export const EmptyLibraryStory: ComponentStory<typeof EmptyLibrary> = (args: any) => {
export const EmptyLibraryStory: ComponentStory<typeof EmptyLibrary> = (
args: any
) => {
return (
<EmptyLibrary onAddLinkClicked={() => {
console.log('onAddLinkClicked')
}} />
<EmptyLibrary
layoutType="GRID_LAYOUT"
searchTerm=""
onAddLinkClicked={() => {
console.log('onAddLinkClicked')
}}
/>
)
}