mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1711 from omnivore-app/feat/recent-emails-ux
Initial work on the recent emails feature
This commit is contained in:
commit
bd52eef2a9
7 changed files with 418 additions and 53 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
78
packages/web/lib/networking/queries/useGetRecentEmails.tsx
Normal file
78
packages/web/lib/networking/queries/useGetRecentEmails.tsx
Normal 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: () => {},
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
226
packages/web/pages/settings/emails/recent.tsx
Normal file
226
packages/web/pages/settings/emails/recent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue