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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 { Tag } from '@/ui/display/tag/components/Tag';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
interface ThreadHeaderProps {
type EmailThreadHeaderProps = {
subject: string;
lastMessageSentAt: Date;
}
lastMessageSentAt: string;
};
const StyledContainer = styled.div`
align-items: flex-start;
@ -36,10 +37,10 @@ const StyledContent = styled.span`
width: 100%;
`;
export const ThreadHeader = ({
export const EmailThreadHeader = ({
subject,
lastMessageSentAt,
}: ThreadHeaderProps) => {
}: EmailThreadHeaderProps) => {
return (
<StyledContainer>
<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 { useOpenThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenThreadRightDrawer';
import { CardContent } from '@/ui/layout/card/components/CardContent';
import { Avatar } from '@/users/components/Avatar';
import { TimelineThread } from '~/generated/graphql';
@ -78,19 +77,19 @@ const StyledReceivedAt = styled.div`
padding: ${({ theme }) => theme.spacing(0, 1)};
`;
type ThreadPreviewProps = {
type EmailThreadPreviewProps = {
divider?: boolean;
thread: TimelineThread;
onClick: () => void;
};
export const ThreadPreview = ({ divider, thread }: ThreadPreviewProps) => {
const openMessageThreadRightDrawer = useOpenThreadRightDrawer();
export const EmailThreadPreview = ({
divider,
thread,
onClick,
}: EmailThreadPreviewProps) => {
return (
<StyledCardContent
onClick={() => openMessageThreadRightDrawer()}
divider={divider}
>
<StyledCardContent onClick={() => onClick()} divider={divider}>
<StyledHeading unread={!thread.read}>
<StyledAvatar
avatarUrl={thread.senderPictureUrl}

View File

@ -1,7 +1,12 @@
import { useQuery } from '@apollo/client';
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 { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
@ -12,7 +17,6 @@ import {
} from '@/ui/display/typography/components/H1Title';
import { Card } from '@/ui/layout/card/components/Card';
import { Section } from '@/ui/layout/section/components/Section';
import { TimelineThread } from '~/generated/graphql';
const StyledContainer = styled.div`
display: flex;
@ -30,7 +34,13 @@ const StyledEmailCount = styled.span`
color: ${({ theme }) => theme.font.color.light};
`;
export const Threads = ({ entity }: { entity: ActivityTargetableObject }) => {
export const EmailThreads = ({
entity,
}: {
entity: ActivityTargetableObject;
}) => {
const { openEmailThread } = useEmailThread();
const threadQuery =
entity.targetObjectNameSingular === CoreObjectNameSingular.Person
? getTimelineThreadsFromPersonId
@ -49,12 +59,16 @@ export const Threads = ({ entity }: { entity: ActivityTargetableObject }) => {
return;
}
const timelineThreads: TimelineThread[] =
threads.data[
entity.targetObjectNameSingular === CoreObjectNameSingular.Person
? 'getTimelineThreadsFromPersonId'
: 'getTimelineThreadsFromCompanyId'
];
// To use once the id is returned by the query
// const fetchedTimelineThreads: TimelineThread[] =
// threads.data[
// entity.targetObjectNameSingular === CoreObjectNameSingular.Person
// ? 'getTimelineThreadsFromPersonId'
// : 'getTimelineThreadsFromCompanyId'
// ];
const timelineThreads = mockedEmailThreads;
return (
<StyledContainer>
@ -72,11 +86,12 @@ export const Threads = ({ entity }: { entity: ActivityTargetableObject }) => {
/>
<Card>
{timelineThreads &&
timelineThreads.map((thread: TimelineThread, index: number) => (
<ThreadPreview
timelineThreads.map((thread: MockedThread, index: number) => (
<EmailThreadPreview
key={index}
divider={index < timelineThreads.length - 1}
thread={thread}
onClick={() => openEmailThread(thread)}
/>
))}
</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;
`;
export const RightDrawerThreadTopBar = () => {
export const RightDrawerEmailThreadTopBar = () => {
const isMobile = useIsMobile();
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 { RightDrawerThreadTopBar } from '@/activities/emails/right-drawer/components/RightDrawerThreadTopBar';
import { RightDrawerEmailThreadTopBar } from '@/activities/emails/right-drawer/components/RightDrawerEmailThreadTopBar';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<typeof RightDrawerThreadTopBar> = {
title: 'Modules/Activities/Emails/RightDrawer/RightDrawerThreadTopBar',
component: RightDrawerThreadTopBar,
const meta: Meta<typeof RightDrawerEmailThreadTopBar> = {
title: 'Modules/Activities/Emails/RightDrawer/RightDrawerEmailThreadTopBar',
component: RightDrawerEmailThreadTopBar,
decorators: [
(Story) => (
<div style={{ width: '500px' }}>
@ -21,6 +21,6 @@ const meta: Meta<typeof RightDrawerThreadTopBar> = {
};
export default meta;
type Story = StoryObj<typeof RightDrawerThreadTopBar>;
type Story = StoryObj<typeof RightDrawerEmailThreadTopBar>;
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 { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
export const useOpenThreadRightDrawer = () => {
export const useOpenEmailThreadRightDrawer = () => {
const { openRightDrawer } = useRightDrawer();
const setHotkeyScope = useSetHotkeyScope();
return () => {
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 { useRecoilState } from 'recoil';
import { RightDrawerThread } from '@/activities/emails/right-drawer/components/RightDrawerThread';
import { RightDrawerThreadTopBar } from '@/activities/emails/right-drawer/components/RightDrawerThreadTopBar';
import { RightDrawerEmailThread } from '@/activities/emails/right-drawer/components/RightDrawerEmailThread';
import { RightDrawerEmailThreadTopBar } from '@/activities/emails/right-drawer/components/RightDrawerEmailThreadTopBar';
import { RightDrawerCreateActivity } from '@/activities/right-drawer/components/create/RightDrawerCreateActivity';
import { RightDrawerEditActivity } from '@/activities/right-drawer/components/edit/RightDrawerEditActivity';
@ -42,9 +42,9 @@ export const RightDrawerRouter = () => {
page = <RightDrawerEditActivity />;
topBar = <RightDrawerActivityTopBar />;
break;
case RightDrawerPages.ViewThread:
page = <RightDrawerThread />;
topBar = <RightDrawerThreadTopBar />;
case RightDrawerPages.ViewEmailThread:
page = <RightDrawerEmailThread />;
topBar = <RightDrawerEmailThreadTopBar />;
break;
default:
break;

View File

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

View File

@ -1,7 +1,7 @@
import styled from '@emotion/styled';
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 { Notes } from '@/activities/notes/components/Notes';
import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks';
@ -115,7 +115,7 @@ export const ShowPageRightContainer = ({
{activeTabId === 'files' && (
<Attachments targetableObject={targetableObject} />
)}
{activeTabId === 'emails' && <Threads entity={targetableObject} />}
{activeTabId === 'emails' && <EmailThreads entity={targetableObject} />}
</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.',
numberOfMessagesInThread: 4,