Build message threads (#3593)

* Adding message thread component

* Add state and mocks

* Rename components and use local state for messages

---------

Co-authored-by: Thomas Trompette <thomast@twenty.com>
This commit is contained in:
Thomas Trompette
2024-01-24 14:32:57 +01:00
committed by GitHub
parent afc36c7329
commit e85f65a195
20 changed files with 404 additions and 71 deletions

View File

@ -3,10 +3,11 @@ import styled from '@emotion/styled';
import { IconMail } from '@/ui/display/icon'; import { IconMail } from '@/ui/display/icon';
import { Tag } from '@/ui/display/tag/components/Tag'; import { Tag } from '@/ui/display/tag/components/Tag';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
interface ThreadHeaderProps {
type EmailThreadHeaderProps = {
subject: string; subject: string;
lastMessageSentAt: Date; lastMessageSentAt: string;
} };
const StyledContainer = styled.div` const StyledContainer = styled.div`
align-items: flex-start; align-items: flex-start;
@ -36,10 +37,10 @@ const StyledContent = styled.span`
width: 100%; width: 100%;
`; `;
export const ThreadHeader = ({ export const EmailThreadHeader = ({
subject, subject,
lastMessageSentAt, lastMessageSentAt,
}: ThreadHeaderProps) => { }: EmailThreadHeaderProps) => {
return ( return (
<StyledContainer> <StyledContainer>
<Tag Icon={IconMail} color="gray" text="Email" onClick={() => {}} /> <Tag Icon={IconMail} color="gray" text="Email" onClick={() => {}} />

View File

@ -0,0 +1,56 @@
import React, { useState } from 'react';
import styled from '@emotion/styled';
import { EmailThreadMessageBody } from '@/activities/emails/components/EmailThreadMessageBody';
import { EmailThreadMessageBodyPreview } from '@/activities/emails/components/EmailThreadMessageBodyPreview';
import { EmailThreadMessageSender } from '@/activities/emails/components/EmailThreadMessageSender';
import { MockedEmailUser } from '@/activities/emails/mocks/mockedEmailThreads';
const StyledThreadMessage = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
cursor: pointer;
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(4, 6)};
`;
const StyledThreadMessageHeader = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
type EmailThreadMessageProps = {
id: string;
body: string;
sentAt: string;
from: MockedEmailUser;
to: MockedEmailUser[];
};
export const EmailThreadMessage = ({
body,
sentAt,
from,
}: EmailThreadMessageProps) => {
const { displayName, avatarUrl } = from;
const [isOpen, setIsOpen] = useState(false);
return (
<StyledThreadMessage onClick={() => setIsOpen(!isOpen)}>
<StyledThreadMessageHeader>
<EmailThreadMessageSender
displayName={displayName}
avatarUrl={avatarUrl}
sentAt={sentAt}
/>
</StyledThreadMessageHeader>
{isOpen ? (
<EmailThreadMessageBody body={body} />
) : (
<EmailThreadMessageBodyPreview body={body} />
)}
</StyledThreadMessage>
);
};

View File

@ -0,0 +1,20 @@
import React from 'react';
import styled from '@emotion/styled';
const StyledThreadMessageBody = styled.div`
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: column;
margin-top: ${({ theme }) => theme.spacing(4)};
white-space: pre-line;
`;
type EmailThreadMessageBodyProps = {
body: string;
};
export const EmailThreadMessageBody = ({
body,
}: EmailThreadMessageBodyProps) => {
return <StyledThreadMessageBody>{body}</StyledThreadMessageBody>;
};

View File

@ -0,0 +1,23 @@
import React from 'react';
import styled from '@emotion/styled';
const StyledThreadMessageBodyPreview = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: ${({ theme }) => theme.font.size.sm};
`;
type EmailThreadMessageBodyPreviewProps = {
body: string;
};
export const EmailThreadMessageBodyPreview = ({
body,
}: EmailThreadMessageBodyPreviewProps) => {
return (
<StyledThreadMessageBodyPreview>{body}</StyledThreadMessageBodyPreview>
);
};

View File

@ -0,0 +1,61 @@
import React from 'react';
import styled from '@emotion/styled';
import { Avatar } from '@/users/components/Avatar';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
const StyledEmailThreadMessageSender = styled.div`
display: flex;
justify-content: space-between;
`;
const StyledEmailThreadMessageSenderUser = styled.div`
align-items: flex-start;
display: flex;
`;
const StyledAvatar = styled(Avatar)`
margin: ${({ theme }) => theme.spacing(0, 1)};
`;
const StyledSenderName = styled.span`
font-size: ${({ theme }) => theme.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
`;
const StyledThreadMessageSentAt = styled.div`
align-items: flex-end;
display: flex;
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
type EmailThreadMessageSenderProps = {
displayName: string;
avatarUrl: string;
sentAt: string;
};
export const EmailThreadMessageSender = ({
displayName,
avatarUrl,
sentAt,
}: EmailThreadMessageSenderProps) => {
return (
<StyledEmailThreadMessageSender>
<StyledEmailThreadMessageSenderUser>
<StyledAvatar
avatarUrl={avatarUrl}
type="rounded"
placeholder={displayName}
size="sm"
/>
<StyledSenderName>{displayName}</StyledSenderName>
</StyledEmailThreadMessageSenderUser>
<StyledThreadMessageSentAt>
{beautifyPastDateRelativeToNow(sentAt)}
</StyledThreadMessageSentAt>
</StyledEmailThreadMessageSender>
);
};

View File

@ -1,6 +1,5 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useOpenThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenThreadRightDrawer';
import { CardContent } from '@/ui/layout/card/components/CardContent'; import { CardContent } from '@/ui/layout/card/components/CardContent';
import { Avatar } from '@/users/components/Avatar'; import { Avatar } from '@/users/components/Avatar';
import { TimelineThread } from '~/generated/graphql'; import { TimelineThread } from '~/generated/graphql';
@ -78,19 +77,19 @@ const StyledReceivedAt = styled.div`
padding: ${({ theme }) => theme.spacing(0, 1)}; padding: ${({ theme }) => theme.spacing(0, 1)};
`; `;
type ThreadPreviewProps = { type EmailThreadPreviewProps = {
divider?: boolean; divider?: boolean;
thread: TimelineThread; thread: TimelineThread;
onClick: () => void;
}; };
export const ThreadPreview = ({ divider, thread }: ThreadPreviewProps) => { export const EmailThreadPreview = ({
const openMessageThreadRightDrawer = useOpenThreadRightDrawer(); divider,
thread,
onClick,
}: EmailThreadPreviewProps) => {
return ( return (
<StyledCardContent <StyledCardContent onClick={() => onClick()} divider={divider}>
onClick={() => openMessageThreadRightDrawer()}
divider={divider}
>
<StyledHeading unread={!thread.read}> <StyledHeading unread={!thread.read}>
<StyledAvatar <StyledAvatar
avatarUrl={thread.senderPictureUrl} avatarUrl={thread.senderPictureUrl}

View File

@ -1,7 +1,12 @@
import { useQuery } from '@apollo/client'; import { useQuery } from '@apollo/client';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ThreadPreview } from '@/activities/emails/components/ThreadPreview'; import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import {
mockedEmailThreads,
MockedThread,
} from '@/activities/emails/mocks/mockedEmailThreads';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId'; import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId';
import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId'; import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
@ -12,7 +17,6 @@ import {
} from '@/ui/display/typography/components/H1Title'; } from '@/ui/display/typography/components/H1Title';
import { Card } from '@/ui/layout/card/components/Card'; import { Card } from '@/ui/layout/card/components/Card';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { TimelineThread } from '~/generated/graphql';
const StyledContainer = styled.div` const StyledContainer = styled.div`
display: flex; display: flex;
@ -30,7 +34,13 @@ const StyledEmailCount = styled.span`
color: ${({ theme }) => theme.font.color.light}; color: ${({ theme }) => theme.font.color.light};
`; `;
export const Threads = ({ entity }: { entity: ActivityTargetableObject }) => { export const EmailThreads = ({
entity,
}: {
entity: ActivityTargetableObject;
}) => {
const { openEmailThread } = useEmailThread();
const threadQuery = const threadQuery =
entity.targetObjectNameSingular === CoreObjectNameSingular.Person entity.targetObjectNameSingular === CoreObjectNameSingular.Person
? getTimelineThreadsFromPersonId ? getTimelineThreadsFromPersonId
@ -49,12 +59,16 @@ export const Threads = ({ entity }: { entity: ActivityTargetableObject }) => {
return; return;
} }
const timelineThreads: TimelineThread[] = // To use once the id is returned by the query
threads.data[
entity.targetObjectNameSingular === CoreObjectNameSingular.Person // const fetchedTimelineThreads: TimelineThread[] =
? 'getTimelineThreadsFromPersonId' // threads.data[
: 'getTimelineThreadsFromCompanyId' // entity.targetObjectNameSingular === CoreObjectNameSingular.Person
]; // ? 'getTimelineThreadsFromPersonId'
// : 'getTimelineThreadsFromCompanyId'
// ];
const timelineThreads = mockedEmailThreads;
return ( return (
<StyledContainer> <StyledContainer>
@ -72,11 +86,12 @@ export const Threads = ({ entity }: { entity: ActivityTargetableObject }) => {
/> />
<Card> <Card>
{timelineThreads && {timelineThreads &&
timelineThreads.map((thread: TimelineThread, index: number) => ( timelineThreads.map((thread: MockedThread, index: number) => (
<ThreadPreview <EmailThreadPreview
key={index} key={index}
divider={index < timelineThreads.length - 1} divider={index < timelineThreads.length - 1}
thread={thread} thread={thread}
onClick={() => openEmailThread(thread)}
/> />
))} ))}
</Card> </Card>

View File

@ -0,0 +1,21 @@
import { useRecoilState } from 'recoil';
import { MockedThread } from '@/activities/emails/mocks/mockedEmailThreads';
import { useOpenEmailThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer';
import { viewableEmailThreadState } from '@/activities/emails/state/viewableEmailThreadState';
export const useEmailThread = () => {
const [, setViewableEmailThread] = useRecoilState(viewableEmailThreadState);
const openEmailThredRightDrawer = useOpenEmailThreadRightDrawer();
const openEmailThread = (thread: MockedThread) => {
openEmailThredRightDrawer();
setViewableEmailThread(thread);
};
return {
openEmailThread,
};
};

View File

@ -0,0 +1,110 @@
import { DateTime } from 'luxon';
import { Scalars, TimelineThread } from '~/generated/graphql';
export type MockedThread = {
id: string;
} & TimelineThread;
export type MockedEmailUser = {
avatarUrl: string;
displayName: string;
workspaceMemberId?: string;
personId?: string;
};
export type MockedMessage = {
id: string;
from: MockedEmailUser;
to: MockedEmailUser[];
subject: string;
body: string;
sentAt: string;
};
export const mockedEmailThreads: MockedThread[] = [
{
__typename: 'TimelineThread',
id: '1',
body: 'This is a test email' as Scalars['String'],
numberOfMessagesInThread: 5 as Scalars['Float'],
read: true as Scalars['Boolean'],
receivedAt: new Date().toISOString() as Scalars['DateTime'],
senderName: 'Thom Trp' as Scalars['String'],
senderPictureUrl: '' as Scalars['String'],
subject: 'Test email' as Scalars['String'],
},
{
__typename: 'TimelineThread',
id: '2',
body: 'This is a second test email' as Scalars['String'],
numberOfMessagesInThread: 5 as Scalars['Float'],
read: true as Scalars['Boolean'],
receivedAt: new Date().toISOString() as Scalars['DateTime'],
senderName: 'Coco Den' as Scalars['String'],
senderPictureUrl: '' as Scalars['String'],
subject: 'Test email number 2' as Scalars['String'],
},
];
export const mockedMessagesByThread: Map<string, MockedMessage[]> = new Map([
[
'1',
Array.from({ length: 5 }).map((_, i) => ({
id: `id${i + 1}`,
from: {
avatarUrl: '',
displayName: `User ${i + 1}`,
workspaceMemberId: `workspaceMemberId${i + 1}`,
personId: `personId${i + 1}`,
},
to: [
{
avatarUrl: 'https://favicon.twenty.com/qonto.com',
displayName: `User ${i + 2}`,
workspaceMemberId: `workspaceMemberId${i + 1}`,
personId: `personId${i + 2}`,
},
],
subject: `Subject ${i + 1}`,
body: `Body ${
i + 1
}. I am testing a very long body. I am adding more text.
I also want to test a new line. To see if it works.
I am adding a new paragraph.
Thomas`,
sentAt: DateTime.fromFormat('2021-03-12', 'yyyy-MM-dd').toISO() ?? '',
})),
],
[
'2',
Array.from({ length: 5 }).map((_, i) => ({
id: `id${i + 10}`,
from: {
avatarUrl: '',
displayName: `Other user ${i + 1}`,
workspaceMemberId: `workspaceMemberId${i + 1}`,
personId: `personId${i + 1}`,
},
to: [
{
avatarUrl: 'https://favicon.twenty.com/qonto.com',
displayName: `Other user ${i + 2}`,
workspaceMemberId: `workspaceMemberId${i + 1}`,
personId: `personId${i + 2}`,
},
],
subject: `Subject ${i + 1}`,
body: `Body ${
i + 1
}. Hello, I am testing a very long body. I am adding more text.
I am adding a new paragraph.
Thomas`,
sentAt: DateTime.fromFormat('2021-03-12', 'yyyy-MM-dd').toISO() ?? '',
})),
],
]);

View File

@ -0,0 +1,48 @@
import React from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { EmailThreadHeader } from '@/activities/emails/components/EmailThreadHeader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { mockedMessagesByThread } from '@/activities/emails/mocks/mockedEmailThreads';
import { viewableEmailThreadState } from '@/activities/emails/state/viewableEmailThreadState';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
justify-content: flex-start;
overflow-y: auto;
position: relative;
`;
export const RightDrawerEmailThread = () => {
const viewableEmailThread = useRecoilValue(viewableEmailThreadState);
if (!viewableEmailThread) {
return null;
}
const mockedMessages =
mockedMessagesByThread.get(viewableEmailThread.id) ?? [];
return (
<StyledContainer>
<EmailThreadHeader
subject={viewableEmailThread.subject}
lastMessageSentAt={viewableEmailThread.receivedAt}
/>
{mockedMessages.map((message) => (
<EmailThreadMessage
key={message.id}
id={message.id}
from={message.from}
to={message.to}
body={message.body}
sentAt={message.sentAt}
/>
))}
</StyledContainer>
);
};

View File

@ -10,7 +10,7 @@ const StyledTopBarWrapper = styled.div`
display: flex; display: flex;
`; `;
export const RightDrawerThreadTopBar = () => { export const RightDrawerEmailThreadTopBar = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
return ( return (

View File

@ -1,29 +0,0 @@
import React from 'react';
import styled from '@emotion/styled';
import { ThreadHeader } from '@/activities/emails/components/ThreadHeader';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
overflow-y: auto;
position: relative;
`;
export const RightDrawerThread = () => {
const mockedThread = {
subject: 'Tes with long subject, very long subject, very long subject',
receivedAt: new Date(),
};
return (
<StyledContainer>
<ThreadHeader
subject={mockedThread.subject}
lastMessageSentAt={mockedThread.receivedAt}
/>
</StyledContainer>
);
};

View File

@ -1,12 +1,12 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { RightDrawerThreadTopBar } from '@/activities/emails/right-drawer/components/RightDrawerThreadTopBar'; import { RightDrawerEmailThreadTopBar } from '@/activities/emails/right-drawer/components/RightDrawerEmailThreadTopBar';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<typeof RightDrawerThreadTopBar> = { const meta: Meta<typeof RightDrawerEmailThreadTopBar> = {
title: 'Modules/Activities/Emails/RightDrawer/RightDrawerThreadTopBar', title: 'Modules/Activities/Emails/RightDrawer/RightDrawerEmailThreadTopBar',
component: RightDrawerThreadTopBar, component: RightDrawerEmailThreadTopBar,
decorators: [ decorators: [
(Story) => ( (Story) => (
<div style={{ width: '500px' }}> <div style={{ width: '500px' }}>
@ -21,6 +21,6 @@ const meta: Meta<typeof RightDrawerThreadTopBar> = {
}; };
export default meta; export default meta;
type Story = StoryObj<typeof RightDrawerThreadTopBar>; type Story = StoryObj<typeof RightDrawerEmailThreadTopBar>;
export const Default: Story = {}; export const Default: Story = {};

View File

@ -3,12 +3,12 @@ import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDraw
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
export const useOpenThreadRightDrawer = () => { export const useOpenEmailThreadRightDrawer = () => {
const { openRightDrawer } = useRightDrawer(); const { openRightDrawer } = useRightDrawer();
const setHotkeyScope = useSetHotkeyScope(); const setHotkeyScope = useSetHotkeyScope();
return () => { return () => {
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
openRightDrawer(RightDrawerPages.ViewThread); openRightDrawer(RightDrawerPages.ViewEmailThread);
}; };
}; };

View File

@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { MockedThread } from '@/activities/emails/mocks/mockedEmailThreads';
export const viewableEmailThreadState = atom<MockedThread | null>({
key: 'viewableEmailThreadState',
default: null,
});

View File

@ -1,8 +1,8 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { RightDrawerThread } from '@/activities/emails/right-drawer/components/RightDrawerThread'; import { RightDrawerEmailThread } from '@/activities/emails/right-drawer/components/RightDrawerEmailThread';
import { RightDrawerThreadTopBar } from '@/activities/emails/right-drawer/components/RightDrawerThreadTopBar'; import { RightDrawerEmailThreadTopBar } from '@/activities/emails/right-drawer/components/RightDrawerEmailThreadTopBar';
import { RightDrawerCreateActivity } from '@/activities/right-drawer/components/create/RightDrawerCreateActivity'; import { RightDrawerCreateActivity } from '@/activities/right-drawer/components/create/RightDrawerCreateActivity';
import { RightDrawerEditActivity } from '@/activities/right-drawer/components/edit/RightDrawerEditActivity'; import { RightDrawerEditActivity } from '@/activities/right-drawer/components/edit/RightDrawerEditActivity';
@ -42,9 +42,9 @@ export const RightDrawerRouter = () => {
page = <RightDrawerEditActivity />; page = <RightDrawerEditActivity />;
topBar = <RightDrawerActivityTopBar />; topBar = <RightDrawerActivityTopBar />;
break; break;
case RightDrawerPages.ViewThread: case RightDrawerPages.ViewEmailThread:
page = <RightDrawerThread />; page = <RightDrawerEmailThread />;
topBar = <RightDrawerThreadTopBar />; topBar = <RightDrawerEmailThreadTopBar />;
break; break;
default: default:
break; break;

View File

@ -1,5 +1,5 @@
export enum RightDrawerPages { export enum RightDrawerPages {
CreateActivity = 'create-activity', CreateActivity = 'create-activity',
EditActivity = 'edit-activity', EditActivity = 'edit-activity',
ViewThread = 'view-thread', ViewEmailThread = 'view-email-thread',
} }

View File

@ -1,7 +1,7 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Threads } from '@/activities/emails/components/Threads'; import { EmailThreads } from '@/activities/emails/components/EmailThreads';
import { Attachments } from '@/activities/files/components/Attachments'; import { Attachments } from '@/activities/files/components/Attachments';
import { Notes } from '@/activities/notes/components/Notes'; import { Notes } from '@/activities/notes/components/Notes';
import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks'; import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks';
@ -115,7 +115,7 @@ export const ShowPageRightContainer = ({
{activeTabId === 'files' && ( {activeTabId === 'files' && (
<Attachments targetableObject={targetableObject} /> <Attachments targetableObject={targetableObject} />
)} )}
{activeTabId === 'emails' && <Threads entity={targetableObject} />} {activeTabId === 'emails' && <EmailThreads entity={targetableObject} />}
</StyledShowPageRightContainer> </StyledShowPageRightContainer>
); );
}; };

View File

@ -212,7 +212,7 @@ export const mockedActivities: Array<MockedActivity> = [
}, },
]; ];
export const mockedThreads: TimelineThread[] = [ export const mockedEmailThreads: TimelineThread[] = [
{ {
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras dignissim nisi eu tellus dapibus, egestas placerat risus placerat. Praesent eget arcu consectetur, efficitur felis.', body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras dignissim nisi eu tellus dapibus, egestas placerat risus placerat. Praesent eget arcu consectetur, efficitur felis.',
numberOfMessagesInThread: 4, numberOfMessagesInThread: 4,