Merge pull request #1711 from omnivore-app/feat/recent-emails-ux

Initial work on the recent emails feature
This commit is contained in:
Jackson Harper 2023-01-28 15:23:23 +08:00 committed by GitHub
commit bd52eef2a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 418 additions and 53 deletions

View file

@ -12,7 +12,6 @@ import { PrimaryLayout } from '../PrimaryLayout'
type SettingsTableProps = {
pageId: string
pageHeadline: string
pageInfoLink: string
headerTitle: string
@ -35,13 +34,16 @@ type SettingsTableRowProps = {
titleElement?: JSX.Element
extraElement?: JSX.Element
deleteTitle: string
onDelete: () => void
deleteTitle?: string
onDelete?: () => void
dropdownItems?: JSX.Element
}
type MoreOptionsProps = {
title: string
onDelete: () => void
title?: string
onDelete?: () => void
dropdownItems?: JSX.Element
}
const MoreOptions = (props: MoreOptionsProps) => (
@ -63,29 +65,32 @@ const MoreOptions = (props: MoreOptionsProps) => (
</Box>
}
>
<DropdownOption
onSelect={() => {
props.onDelete()
}}
>
<HStack alignment={'center'} distribution={'start'}>
<Trash size={24} color={theme.colors.omnivoreRed.toString()} />
<SpanBox
css={{
color: theme.colors.omnivoreRed.toString(),
marginLeft: '8px',
border: 'none',
backgroundColor: 'transparent',
'&:hover': {
{props.onDelete && props.title && (
<DropdownOption
onSelect={() => {
props.onDelete && props.onDelete()
}}
>
<HStack alignment={'center'} distribution={'start'}>
<Trash size={24} color={theme.colors.omnivoreRed.toString()} />
<SpanBox
css={{
color: theme.colors.omnivoreRed.toString(),
marginLeft: '8px',
border: 'none',
backgroundColor: 'transparent',
},
}}
>
{props.title}
</SpanBox>
</HStack>
</DropdownOption>
'&:hover': {
border: 'none',
backgroundColor: 'transparent',
},
}}
>
{props.title}
</SpanBox>
</HStack>
</DropdownOption>
)}
{props.dropdownItems && props.dropdownItems}
</Dropdown>
)
@ -183,7 +188,11 @@ export const SettingsTableRow = (props: SettingsTableRowProps): JSX.Element => {
},
}}
>
<MoreOptions title={props.deleteTitle} onDelete={props.onDelete} />
<MoreOptions
title={props.deleteTitle}
onDelete={props.onDelete}
dropdownItems={props.dropdownItems}
/>
</Box>
</HStack>
{props.extraElement}
@ -198,7 +207,11 @@ export const SettingsTableRow = (props: SettingsTableRowProps): JSX.Element => {
},
}}
>
<MoreOptions title={props.deleteTitle} onDelete={props.onDelete} />
<MoreOptions
title={props.deleteTitle}
onDelete={props.onDelete}
dropdownItems={props.dropdownItems}
/>
</Box>
</HStack>
</Box>
@ -261,6 +274,7 @@ export const SettingsTable = (props: SettingsTableProps): JSX.Element => {
display: 'flex',
alignItems: 'center',
marginBottom: '10px',
height: '60px',
}}
>
{props.createAction && props.createTitle && (

View file

@ -0,0 +1,35 @@
import { gqlFetcher } from '../networkHelpers'
type MarkEmailAsItemDataResponseData = {
markEmailAsItem?: MarkEmailAsItemData
}
type MarkEmailAsItemData = {
success: boolean
errorCodes?: unknown[]
}
export async function markEmailAsItemMutation(
recentEmailId: string
): Promise<void> {
const mutation = `
mutation MarkRecentEmailAsItem($recentEmailId: ID!) {
markEmailAsItem(recentEmailId:$recentEmailId) {
... on MarkEmailAsItemError {
errorCodes
}
... on MarkEmailAsItemSuccess {
success
}
}
}`
const data = await gqlFetcher(mutation, { recentEmailId })
console.log('recentEmailId: ', data)
const output = data as MarkEmailAsItemDataResponseData | undefined
const error = output?.markEmailAsItem?.errorCodes?.find(() => true)
console.log('error: ', error)
if (error) {
throw error
}
}

View file

@ -0,0 +1,78 @@
import { gql } from 'graphql-request'
import useSWR from 'swr'
import { publicGqlFetcher } from '../networkHelpers'
export interface RecentEmail {
id: string
from: string
to: string
subject: string
type: string
text: string
createdAt: string
}
interface RecentEmailsResponse {
isValidating: boolean
recentEmails: RecentEmail[]
revalidate: () => void
}
interface RecentEmailsResponseData {
recentEmails: RecentEmailsData
}
interface RecentEmailsData {
recentEmails: RecentEmail[]
}
export function useGetRecentEmailsQuery(): RecentEmailsResponse {
const query = gql`
query GetRecentEmails {
recentEmails {
... on RecentEmailsSuccess {
recentEmails {
id
from
to
subject
type
text
createdAt
}
}
... on RecentEmailsError {
errorCodes
}
}
}
`
const { data, mutate, error, isValidating } = useSWR(query, publicGqlFetcher)
try {
if (error) {
throw error
}
if (data) {
const result = data as RecentEmailsResponseData
const recentEmails = result.recentEmails.recentEmails as RecentEmail[]
return {
isValidating,
recentEmails,
revalidate: () => {
mutate()
},
}
}
} catch (error) {
console.log('error', error)
}
return {
isValidating: true,
recentEmails: [],
// eslint-disable-next-line @typescript-eslint/no-empty-function
revalidate: () => {},
}
}

View file

@ -114,7 +114,6 @@ export default function Api(): JSX.Element {
return (
<SettingsTable
pageId="api-keys"
pageHeadline="API Keys"
pageInfoLink="https://docs.omnivore.app/integrations/api.html"
headerTitle="API Keys"
createTitle="Generate API Key"

View file

@ -1,28 +1,27 @@
import { Button } from '../../components/elements/Button'
import { useGetNewsletterEmailsQuery } from '../../lib/networking/queries/useGetNewsletterEmailsQuery'
import { createNewsletterEmailMutation } from '../../lib/networking/mutations/createNewsletterEmailMutation'
import { deleteNewsletterEmailMutation } from '../../lib/networking/mutations/deleteNewsletterEmailMutation'
import { MoreOptionsIcon } from '../../components/elements/images/MoreOptionsIcon'
import { Trash, Copy } from 'phosphor-react'
import { Button } from '../../../components/elements/Button'
import { useGetNewsletterEmailsQuery } from '../../../lib/networking/queries/useGetNewsletterEmailsQuery'
import { createNewsletterEmailMutation } from '../../../lib/networking/mutations/createNewsletterEmailMutation'
import { deleteNewsletterEmailMutation } from '../../../lib/networking/mutations/deleteNewsletterEmailMutation'
import { Copy } from 'phosphor-react'
import { theme, styled } from '../../../components/tokens/stitches.config'
import {
Dropdown,
DropdownOption,
} from '../../components/elements/DropdownElements'
import { theme, styled } from '../../components/tokens/stitches.config'
import { Box, HStack } from '../../components/elements/LayoutPrimitives'
import { useCopyLink } from '../../lib/hooks/useCopyLink'
Box,
HStack,
SpanBox,
} from '../../../components/elements/LayoutPrimitives'
import { useCopyLink } from '../../../lib/hooks/useCopyLink'
import { useCallback, useMemo, useState } from 'react'
import { StyledText } from '../../components/elements/StyledText'
import { applyStoredTheme } from '../../lib/themeUpdater'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
import { formattedShortDate } from '../../lib/dateFormatting'
import { StyledText } from '../../../components/elements/StyledText'
import { applyStoredTheme } from '../../../lib/themeUpdater'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { formattedShortDate } from '../../../lib/dateFormatting'
import Link from 'next/link'
import {
EmptySettingsRow,
SettingsTable,
SettingsTableRow,
} from '../../components/templates/settings/SettingsTable'
import { ConfirmationModal } from '../../components/patterns/ConfirmationModal'
} from '../../../components/templates/settings/SettingsTable'
import { ConfirmationModal } from '../../../components/patterns/ConfirmationModal'
enum TextType {
EmailAddress,
@ -81,8 +80,9 @@ function CopyTextButton(props: CopyTextButtonProps): JSX.Element {
export default function EmailsPage(): JSX.Element {
const { emailAddresses, revalidate, isValidating } =
useGetNewsletterEmailsQuery()
const [confirmDeleteEmailId, setConfirmDeleteEmailId] =
useState<undefined | string>(undefined)
const [confirmDeleteEmailId, setConfirmDeleteEmailId] = useState<
undefined | string
>(undefined)
applyStoredTheme(false)
@ -117,7 +117,6 @@ export default function EmailsPage(): JSX.Element {
<>
<SettingsTable
pageId="settings-emails-tag"
pageHeadline="Email Addresses"
pageInfoLink="/help/newsletters"
headerTitle="Address"
createTitle="Create a new email address"
@ -214,6 +213,20 @@ export default function EmailsPage(): JSX.Element {
text={isValidating ? '-' : 'No Email Addresses Found'}
/>
)}
<SpanBox
css={{
pt: '15px',
fontSize: '12px',
marginLeft: 'auto',
a: {
color: '$omnivoreCtaYellow',
},
}}
>
<Link href="/settings/emails/recent">
View recently received emails
</Link>
</SpanBox>
</SettingsTable>
{confirmDeleteEmailId ? (

View file

@ -0,0 +1,226 @@
import { useMemo, useState } from 'react'
import { applyStoredTheme } from '../../../lib/themeUpdater'
import {
EmptySettingsRow,
SettingsTable,
SettingsTableRow,
} from '../../../components/templates/settings/SettingsTable'
import { StyledText } from '../../../components/elements/StyledText'
import {
RecentEmail,
useGetRecentEmailsQuery,
} from '../../../lib/networking/queries/useGetRecentEmails'
import {
Box,
HStack,
SpanBox,
VStack,
} from '../../../components/elements/LayoutPrimitives'
import { DropdownOption } from '../../../components/elements/DropdownElements'
import { theme } from '../../../components/tokens/stitches.config'
import {
ModalContent,
ModalOverlay,
ModalRoot,
ModalTitleBar,
} from '../../../components/elements/ModalPrimitives'
import { markEmailAsItemMutation } from '../../../lib/networking/mutations/markEmailAsItemMutation'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
type TypeChipProps = {
type: string
}
const TypeChip = (props: TypeChipProps): JSX.Element => {
const backgroundColor = props.type == 'article' ? '$omnivoreCtaYellow' : 'red'
return (
<SpanBox
css={{
color: 'black',
display: 'inline-table',
marginTop: '5px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 'bold',
padding: '5px 10px 5px 10px',
whiteSpace: 'nowrap',
cursor: 'pointer',
backgroundClip: 'padding-box',
backgroundColor,
}}
>
{props.type}
</SpanBox>
)
}
type MoreOptionItemProps = {
text: string
action: () => void
}
const MoreOptionItem = (props: MoreOptionItemProps): JSX.Element => {
return (
<DropdownOption
onSelect={() => {
props.action()
}}
>
<HStack alignment={'center'} distribution={'start'}>
<SpanBox
css={{
color: theme.colors.grayTextContrast.toString(),
marginLeft: '8px',
border: 'none',
backgroundColor: 'transparent',
'&:hover': {
border: 'none',
backgroundColor: 'transparent',
},
}}
>
{props.text}
</SpanBox>
</HStack>
</DropdownOption>
)
}
type ViewRecentEmailModalProps = {
recentEmail: RecentEmail
onOpenChange: (open: boolean) => void
}
const ViewRecentEmailModal = (
props: ViewRecentEmailModalProps
): JSX.Element => {
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
<ModalOverlay />
<ModalContent
css={{
bg: '$grayBg',
px: '24px',
overflowY: 'auto',
height: '100%',
width: '100%',
maxWidth: '650px',
}}
onInteractOutside={() => {
// remove focus from modal
;(document.activeElement as HTMLElement).blur()
}}
>
<VStack distribution="start">
<ModalTitleBar title="View Email" onOpenChange={props.onOpenChange} />
<Box
css={{
width: '100%',
height: '100%',
fontSize: '12px',
overflowY: 'scroll',
}}
>
{props.recentEmail.text}
</Box>
</VStack>
</ModalContent>
</ModalRoot>
)
}
export default function RecentEmails(): JSX.Element {
const { recentEmails, isValidating } = useGetRecentEmailsQuery()
const [viewingEmail, setViewingEmail] = useState<RecentEmail | undefined>(
undefined
)
applyStoredTheme(false)
const sortedRecentEmails = useMemo(() => {
return recentEmails.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
}, [recentEmails])
return (
<SettingsTable
pageId="api-keys"
pageInfoLink="https://docs.omnivore.app/using/inbox.html"
headerTitle="Recently Received Emails"
>
{sortedRecentEmails.length > 0 ? (
sortedRecentEmails.map((recentEmail: RecentEmail, i) => {
return (
<SettingsTableRow
key={recentEmail.id}
title={recentEmail.from}
isLast={i === sortedRecentEmails.length - 1}
sublineElement={
<VStack>
<StyledText
css={{
my: '5px',
fontSize: '14px',
}}
>
{recentEmail.subject}
</StyledText>
<StyledText
css={{
my: '0px',
fontSize: '11px',
a: {
color: '$omnivoreCtaYellow',
},
}}
>
<TypeChip type={recentEmail.type} />
</StyledText>
</VStack>
}
dropdownItems={
<>
<MoreOptionItem
text="View Text"
action={() => {
console.log('viewing text: ', recentEmail)
setViewingEmail(recentEmail)
}}
/>
{recentEmail.type != 'article' && (
<MoreOptionItem
text="Mark as article"
action={async () => {
console.log('marking as email', recentEmail)
showSuccessToast('Marking email as article')
try {
await markEmailAsItemMutation(recentEmail.id)
} catch (err) {
console.log('error marking as article: ', err)
showErrorToast('Error marking item as article')
return
}
showSuccessToast('Email added to library')
}}
/>
)}
</>
}
/>
)
})
) : (
<EmptySettingsRow
text={isValidating ? '-' : 'No recent emails Found'}
/>
)}
{viewingEmail && (
<ViewRecentEmailModal
recentEmail={viewingEmail}
onOpenChange={() => setViewingEmail(undefined)}
/>
)}
</SettingsTable>
)
}

View file

@ -15,8 +15,9 @@ import { formattedShortDate } from '../../lib/dateFormatting'
export default function SubscriptionsPage(): JSX.Element {
const { subscriptions, revalidate, isValidating } = useGetSubscriptionsQuery()
const [confirmUnsubscribeName, setConfirmUnsubscribeName] =
useState<string | null>(null)
const [confirmUnsubscribeName, setConfirmUnsubscribeName] = useState<
string | null
>(null)
applyStoredTheme(false)
@ -40,7 +41,6 @@ export default function SubscriptionsPage(): JSX.Element {
return (
<SettingsTable
pageId="settings-subscriptions-tag"
pageHeadline="Subscriptions"
pageInfoLink="/help/newsletters"
headerTitle="Subscriptions"
>