From c0cfcf8a5aad14a8b18b7cd928dbcdb75ffca675 Mon Sep 17 00:00:00 2001 From: Marek Vesely <xvesely4@fi.muni.cz> Date: Mon, 30 Sep 2024 13:14:24 +0200 Subject: [PATCH] feat: highlight emails tabs with unread emails --- .../TeamEmails/ThreadLog/ThreadLogCards.tsx | 96 +++++------------- .../src/email/TeamEmails/ThreadLog/index.tsx | 56 +++++++++-- .../src/email/TeamEmails/ThreadLog/typing.ts | 6 ++ .../ThreadLog/useFilteredThreads.tsx | 99 +++++++++++++++++++ shared/notification/NotificationDropdown.tsx | 17 +++- 5 files changed, 195 insertions(+), 79 deletions(-) create mode 100644 frontend/src/email/TeamEmails/ThreadLog/typing.ts create mode 100644 frontend/src/email/TeamEmails/ThreadLog/useFilteredThreads.tsx diff --git a/frontend/src/email/TeamEmails/ThreadLog/ThreadLogCards.tsx b/frontend/src/email/TeamEmails/ThreadLog/ThreadLogCards.tsx index 8be26a6f1..92f1606e8 100644 --- a/frontend/src/email/TeamEmails/ThreadLog/ThreadLogCards.tsx +++ b/frontend/src/email/TeamEmails/ThreadLog/ThreadLogCards.tsx @@ -1,13 +1,13 @@ -import { EmailSelection, compareDates } from '@/analyst/utilities' +import { EmailSelection } from '@/analyst/utilities' import { Card, CardList } from '@blueprintjs/core' -import type { Email } from '@inject/graphql/fragments/Email.generated' import type { EmailThread } from '@inject/graphql/fragments/EmailThread.generated' -import { useMemo, type FC } from 'react' +import { type FC } from 'react' import ThreadLogCard from './ThreadLogCard' +import type { ExtendedEmaiLThread } from './typing' interface ThreadLogCardsProps { teamId: string - emailThreads: EmailThread[] + emailThreads: ExtendedEmaiLThread[] selectedTab: EmailSelection.RECEIVED | EmailSelection.SENT selectedEmailThreadId?: string onClick: (emailThread: EmailThread) => void @@ -21,70 +21,28 @@ const ThreadLogCards: FC<ThreadLogCardsProps> = ({ selectedEmailThreadId, onClick, inAnalyst = false, -}) => { - const emailThreadsWithLastEmails = useMemo( - () => - emailThreads - .reduce< - { - emailThread: EmailThread - lastEmail: Email - }[] - >((accumulator, emailThread) => { - const lastEmail = emailThread.emails - .filter(email => - selectedTab === EmailSelection.RECEIVED - ? email.sender.team?.id !== teamId - : email.sender.team?.id === teamId - ) - .sort( - (a, b) => - -compareDates(new Date(a.timestamp), new Date(b.timestamp)) - ) - .at(0) - - if (lastEmail) { - accumulator.push({ - emailThread, - lastEmail, - }) - } - - return accumulator - }, []) - .sort( - (a, b) => - -compareDates( - new Date(a.lastEmail.timestamp), - new Date(b.lastEmail.timestamp) - ) - ), - [emailThreads, selectedTab, teamId] - ) - - return ( - <CardList bordered={false}> - {emailThreadsWithLastEmails.length > 0 ? ( - emailThreadsWithLastEmails.map(element => ( - <ThreadLogCard - key={element.emailThread.id} - emailThread={element.emailThread} - lastEmail={element.lastEmail} - teamId={teamId} - isSelected={element.emailThread.id === selectedEmailThreadId} - onClick={() => onClick(element.emailThread)} - inAnalyst={inAnalyst} - /> - )) - ) : ( - <Card>{`No emails${ - selectedTab === undefined - ? '' - : ` ${selectedTab === EmailSelection.SENT ? 'sent' : 'received'}` - }`}</Card> - )} - </CardList> - ) -} +}) => ( + <CardList bordered={false}> + {emailThreads.length > 0 ? ( + emailThreads.map(emailThread => ( + <ThreadLogCard + key={emailThread.id} + emailThread={emailThread} + lastEmail={emailThread.lastEmail} + teamId={teamId} + isSelected={emailThread.id === selectedEmailThreadId} + onClick={() => onClick(emailThread)} + inAnalyst={inAnalyst} + /> + )) + ) : ( + <Card>{`No emails${ + selectedTab === undefined + ? '' + : ` ${selectedTab === EmailSelection.SENT ? 'sent' : 'received'}` + }`}</Card> + )} + </CardList> +) export default ThreadLogCards diff --git a/frontend/src/email/TeamEmails/ThreadLog/index.tsx b/frontend/src/email/TeamEmails/ThreadLog/index.tsx index 24d0fcf33..bf4e1acaa 100644 --- a/frontend/src/email/TeamEmails/ThreadLog/index.tsx +++ b/frontend/src/email/TeamEmails/ThreadLog/index.tsx @@ -6,12 +6,28 @@ import { OPEN_REPLY_EVENT_TYPE, } from '@/email/EmailFormOverlay' import { useNavigate } from '@/router' -import { Button, ButtonGroup, Divider } from '@blueprintjs/core' +import { + Button, + ButtonGroup, + Classes, + Colors, + Divider, +} from '@blueprintjs/core' +import { Inbox, SendMessage } from '@blueprintjs/icons' import { css, cx } from '@emotion/css' import type { EmailThread } from '@inject/graphql/fragments/EmailThread.generated' -import { type FC } from 'react' +import { useCallback, type FC } from 'react' import DraftLogCards from './DraftLogCards' import ThreadLogCards from './ThreadLogCards' +import useFilteredThreads from './useFilteredThreads' + +const unreadIcon = css` + /* override the muted color */ + color: ${Colors.BLACK} !important; + .${Classes.DARK} & { + color: ${Colors.WHITE} !important; + } +` const threadLog = css` display: flex; @@ -74,10 +90,26 @@ const ThreadLog: FC<ThreadLogProps> = ({ }) => { const nav = useNavigate() + const { received, sent } = useFilteredThreads({ emailThreads, teamId }) + + const getChildren = useCallback( + (tab: EmailSelection, unread: number) => { + const text = `${tab === EmailSelection.RECEIVED ? 'Received' : 'Sent'}\ + ${inInstructor ? ' by team' : ''}${unread ? ` (${unread})` : ''}` + + if (unread) { + return <b>{text}</b> + } + return text + }, + [inInstructor] + ) + return ( <div className={threadLog}> <div className={header}> <ButtonGroup + alignText='left' minimal className={cx({ [buttons(inInstructor)]: true, @@ -88,8 +120,10 @@ const ThreadLog: FC<ThreadLogProps> = ({ <LinkButton link={receivedLink} button={{ - text: `Received${inInstructor ? ' by team' : ''}`, - icon: 'inbox', + children: getChildren(EmailSelection.RECEIVED, received.unread), + icon: ( + <Inbox className={cx({ [unreadIcon]: received.unread > 0 })} /> + ), active: selectedTab === EmailSelection.RECEIVED, }} /> @@ -97,8 +131,12 @@ const ThreadLog: FC<ThreadLogProps> = ({ <LinkButton link={sentLink} button={{ - text: `Sent${inInstructor ? ' by team' : ''}`, - icon: 'send-message', + children: getChildren(EmailSelection.SENT, sent.unread), + icon: ( + <SendMessage + className={cx({ [unreadIcon]: sent.unread > 0 })} + /> + ), active: selectedTab === EmailSelection.SENT, }} /> @@ -157,7 +195,11 @@ const ThreadLog: FC<ThreadLogProps> = ({ ) : ( <ThreadLogCards teamId={teamId} - emailThreads={emailThreads} + emailThreads={ + selectedTab === EmailSelection.RECEIVED + ? received.emailThreads + : sent.emailThreads + } selectedTab={selectedTab} onClick={onClick} inAnalyst={inAnalyst} diff --git a/frontend/src/email/TeamEmails/ThreadLog/typing.ts b/frontend/src/email/TeamEmails/ThreadLog/typing.ts new file mode 100644 index 000000000..b3ab97b63 --- /dev/null +++ b/frontend/src/email/TeamEmails/ThreadLog/typing.ts @@ -0,0 +1,6 @@ +import type { Email } from '@inject/graphql/fragments/Email.generated' +import type { EmailThread } from '@inject/graphql/fragments/EmailThread.generated' + +export type ExtendedEmaiLThread = EmailThread & { + lastEmail: Email +} diff --git a/frontend/src/email/TeamEmails/ThreadLog/useFilteredThreads.tsx b/frontend/src/email/TeamEmails/ThreadLog/useFilteredThreads.tsx new file mode 100644 index 000000000..ac6889981 --- /dev/null +++ b/frontend/src/email/TeamEmails/ThreadLog/useFilteredThreads.tsx @@ -0,0 +1,99 @@ +import { compareDates } from '@/analyst/utilities' +import type { EmailThread } from '@inject/graphql/fragments/EmailThread.generated' +import { useMemo } from 'react' +import type { ExtendedEmaiLThread } from './typing' + +interface FilteredThreads { + received: ExtendedEmaiLThread[] + sent: ExtendedEmaiLThread[] +} + +interface FilteredThreadsWithUnread { + sent: { emailThreads: ExtendedEmaiLThread[]; unread: number } + received: { emailThreads: ExtendedEmaiLThread[]; unread: number } +} + +const getLastEmail = ( + emailThread: EmailThread, + teamId: string, + type: 'sent' | 'received' +) => + emailThread.emails + .filter(email => + type === 'sent' + ? email.sender.team?.id === teamId + : email.sender.team?.id !== teamId + ) + .sort((a, b) => -compareDates(new Date(a.timestamp), new Date(b.timestamp))) + .at(0) + +const sortFn = (a: ExtendedEmaiLThread, b: ExtendedEmaiLThread) => + -compareDates( + new Date(a.lastEmail.timestamp), + new Date(b.lastEmail.timestamp) + ) + +const useFilteredThreads = ({ + emailThreads, + teamId, +}: { + emailThreads: EmailThread[] + teamId: string +}): FilteredThreadsWithUnread => { + const { received, sent } = useMemo( + () => + emailThreads.reduce( + (accumulator: FilteredThreads, emailThread: EmailThread) => { + const lastEmailReceived = getLastEmail( + emailThread, + teamId, + 'received' + ) + const lastEmailSent = getLastEmail(emailThread, teamId, 'sent') + + return { + received: [ + ...accumulator.received, + ...(lastEmailReceived + ? [{ ...emailThread, lastEmail: lastEmailReceived }] + : []), + ], + sent: [ + ...accumulator.sent, + ...(lastEmailSent + ? [{ ...emailThread, lastEmail: lastEmailSent }] + : []), + ], + } + }, + { sent: [], received: [] } + ), + [emailThreads, teamId] + ) + + const result = useMemo( + () => ({ + received: { + emailThreads: received.sort(sortFn), + unread: received.filter(thread => + thread.readReceipt.some( + readReceipt => readReceipt.teamId === teamId && readReceipt.isUnread + ) + ).length, + }, + sent: { + emailThreads: sent.sort(sortFn), + unread: sent.filter(thread => + thread.readReceipt.some( + readReceipt => readReceipt.teamId === teamId && readReceipt.isUnread + ) + ).length, + }, + }), + [received, sent, teamId] + ) + + return result +} + +export default useFilteredThreads diff --git a/shared/notification/NotificationDropdown.tsx b/shared/notification/NotificationDropdown.tsx index a6db189b8..ddb1dcdc4 100644 --- a/shared/notification/NotificationDropdown.tsx +++ b/shared/notification/NotificationDropdown.tsx @@ -3,7 +3,7 @@ import { Button, NonIdealState } from '@blueprintjs/core' import { css } from '@emotion/css' import type { Placement } from '@floating-ui/react' import type { FC } from 'react' -import { useContext, useEffect, useState } from 'react' +import { useContext, useEffect, useMemo, useState } from 'react' import usePopoverElement from '../popover/usePopoverElement' import Notification from './components/Notification' import NotificationListContext from './contexts/NotificationListContext' @@ -78,6 +78,16 @@ const NotificationDropdown: FC<NotificationDropdownProps> = ({ } }, [count]) + const buttonChildren = useMemo(() => { + if (hideLabel) { + return undefined + } + if (count) { + return <b>{`Notifications (${count})`}</b> + } + return 'Notifications' + }, [count, hideLabel]) + return ( <> <Button @@ -88,11 +98,12 @@ const NotificationDropdown: FC<NotificationDropdownProps> = ({ alignText='left' fill={fill} minimal - text={hideLabel ? undefined : 'Notifications'} title='Notifications' onClick={() => setOpen(prev => !prev)} {...getReferenceProps} - /> + > + {buttonChildren} + </Button> {children} </> ) -- GitLab