Add email thread widget and message thread record page layout (#19351)

## Summary
- Move email thread display from side panel to a dedicated record page
with a new `EMAIL_THREAD` widget type
- Add message thread as a standard object with page layout, subject
field, and backfill command
- Add reply-to-email command menu item for message thread records
- Remove old side panel message thread components in favor of the new
widget-based approach

## Type fixes
- Add `EMAIL_THREAD` to `WidgetConfigurationType`, `WidgetType`, and all
configuration/validator maps
- Create `EmailThreadConfigurationDTO` and shared
`EmailThreadConfiguration` type
- Register EMAIL_THREAD in widget type validators, configuration
resolvers, and standard widget mappings

## Test plan
- [ ] Verify message thread record pages render with the email thread
widget
- [ ] Verify email thread preview navigates to the record page instead
of opening side panel
- [ ] Verify reply-to-email command appears for message thread records
- [ ] Verify typecheck passes for both twenty-front and twenty-server
- [ ] Run existing test suites to check for regressions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-06 12:02:04 +02:00
committed by GitHub
parent 4aa1d71b12
commit 8acfacc69c
56 changed files with 2441 additions and 2371 deletions
@@ -2,9 +2,10 @@ import { styled } from '@linaria/react';
import { ActivityRow } from '@/activities/components/ActivityRow';
import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared';
import { useOpenEmailThreadInSidePanel } from '@/side-panel/hooks/useOpenEmailThreadInSidePanel';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { useContext } from 'react';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
@@ -77,7 +78,7 @@ type EmailThreadPreviewProps = {
export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
const { theme } = useContext(ThemeContext);
const { openEmailThreadInSidePanel } = useOpenEmailThreadInSidePanel();
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
const visibility = thread.visibility;
@@ -104,7 +105,10 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
thread.visibility === MessageChannelVisibility.SHARE_EVERYTHING;
if (canOpen) {
openEmailThreadInSidePanel(thread.id);
openRecordInSidePanel({
recordId: thread.id,
objectNameSingular: CoreObjectNameSingular.MessageThread,
});
}
};
@@ -1,29 +1,22 @@
import { useCallback, useEffect, useState } from 'react';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { fetchAllThreadMessagesOperationSignatureFactory } from '@/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory';
import { type EmailThread } from '@/activities/emails/types/EmailThread';
import { type EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { type MessageChannelMessageAssociation } from '@/activities/emails/types/MessageChannelMessageAssociation';
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import {
CoreObjectNameSingular,
MessageParticipantRole,
} from 'twenty-shared/types';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
// to improve - https://github.com/twentyhq/twenty/issues/12190
export const useEmailThreadInSidePanel = () => {
const viewableRecordId = useAtomComponentStateValue(
viewableRecordIdComponentState,
);
export const useEmailThread = (threadId: string | null) => {
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
const [lastMessageChannelId, setLastMessageChannelId] = useState<
@@ -33,7 +26,7 @@ export const useEmailThreadInSidePanel = () => {
const { record: thread } = useFindOneRecord<EmailThread>({
objectNameSingular: CoreObjectNameSingular.MessageThread,
objectRecordId: viewableRecordId ?? '',
objectRecordId: threadId ?? '',
recordGqlFields: {
id: true,
},
@@ -47,7 +40,7 @@ export const useEmailThreadInSidePanel = () => {
const FETCH_ALL_MESSAGES_OPERATION_SIGNATURE =
fetchAllThreadMessagesOperationSignatureFactory({
messageThreadId: viewableRecordId,
messageThreadId: threadId,
});
const {
@@ -62,7 +55,7 @@ export const useEmailThreadInSidePanel = () => {
FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.objectNameSingular,
orderBy: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.orderBy,
recordGqlFields: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.fields,
skip: !viewableRecordId,
skip: !threadId,
});
const fetchMoreMessages = useCallback(() => {
@@ -76,11 +69,11 @@ export const useEmailThreadInSidePanel = () => {
useEffect(() => {
if (messages.length > 0 && isMessagesFetchComplete) {
const lastMessage = messages[messages.length - 1];
setLastMessageId(lastMessage.id);
}
}, [messages, isMessagesFetchComplete]);
// TODO: introduce nested filters so we can retrieve the message sender directly from the message query
const { records: messageSenders } =
useFindManyRecords<EmailThreadMessageParticipant>({
filter: {
@@ -167,9 +160,11 @@ export const useEmailThreadInSidePanel = () => {
const sender = messageSenders.find(
(messageSender) => messageSender.messageId === message.id,
);
if (!sender) {
return null;
}
return {
...message,
sender,
@@ -3,7 +3,7 @@ import { styled } from '@linaria/react';
import { type EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
import { EventCardMessageBodyNotShared } from '@/activities/timeline-activities/rows/message/components/EventCardMessageBodyNotShared';
import { EventCardMessageForbidden } from '@/activities/timeline-activities/rows/message/components/EventCardMessageForbidden';
import { useOpenEmailThreadInSidePanel } from '@/side-panel/hooks/useOpenEmailThreadInSidePanel';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -62,7 +62,7 @@ export const EventCardMessage = ({
authorFullName: string;
}) => {
const { t } = useLingui();
const { openEmailThreadInSidePanel } = useOpenEmailThreadInSidePanel();
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
const {
record: message,
@@ -133,7 +133,10 @@ export const EventCardMessage = ({
const handleClick = () => {
if (canOpen && isDefined(message.messageThreadId)) {
openEmailThreadInSidePanel(message.messageThreadId);
openRecordInSidePanel({
recordId: message.messageThreadId,
objectNameSingular: CoreObjectNameSingular.MessageThread,
});
}
};
@@ -40,6 +40,7 @@ import { TestWorkflowSingleRecordCommand } from '@/command-menu-item/engine-comm
import { TidyUpWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand';
import { HeadlessFrontComponentRendererEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessFrontComponentRendererEngineCommand';
import { TriggerWorkflowVersionEngineCommand } from '@/command-menu-item/engine-command/record/components/TriggerWorkflowVersionEngineCommand';
import { ReplyToEmailThreadCommand } from '@/command-menu-item/engine-command/record/single-record/message-thread/components/ReplyToEmailThreadCommand';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { msg } from '@lingui/core/macro';
import { AppPath, SettingsPath, SidePanelPages } from 'twenty-shared/types';
@@ -240,6 +241,7 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record<
[EngineComponentKey.FRONT_COMPONENT_RENDERER]: (
<HeadlessFrontComponentRendererEngineCommand />
),
[EngineComponentKey.REPLY_TO_EMAIL_THREAD]: <ReplyToEmailThreadCommand />,
// Deprecated keys kept for backward compatibility until migration runs
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,
@@ -0,0 +1,78 @@
import { useMemo } from 'react';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const ALLOWED_REPLY_PROVIDERS = [
ConnectedAccountProvider.GOOGLE,
ConnectedAccountProvider.MICROSOFT,
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
];
export const ReplyToEmailThreadCommand = () => {
const { selectedRecords } = useHeadlessCommandContextApi();
const threadId = selectedRecords[0]?.id ?? null;
const {
messageThreadExternalId,
connectedAccountHandle,
connectedAccountProvider,
lastMessageExternalId,
connectedAccountConnectionParameters,
messageChannelLoading,
} = useEmailThread(threadId);
const canReply = useMemo(() => {
return (
isDefined(connectedAccountHandle) &&
isDefined(connectedAccountProvider) &&
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
isDefined(messageThreadExternalId)
);
}, [
connectedAccountConnectionParameters,
connectedAccountHandle,
connectedAccountProvider,
messageThreadExternalId,
]);
const handleExecute = () => {
if (!canReply) {
return;
}
switch (connectedAccountProvider) {
case ConnectedAccountProvider.MICROSOFT: {
const url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
window.open(url, '_blank');
break;
}
case ConnectedAccountProvider.GOOGLE: {
const url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
window.open(url, '_blank');
break;
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case null:
return;
default:
return;
}
};
const isReady = !messageChannelLoading && canReply;
return (
<HeadlessEngineCommandWrapperEffect
execute={handleExecute}
ready={isReady}
/>
);
};
@@ -0,0 +1,61 @@
import { DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultMessageThreadRecordPageLayoutId';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
export const DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT: PageLayout = {
__typename: 'PageLayout',
id: DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID,
name: 'Default Message Thread Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'message-thread-tab-home',
title: 'Home',
icon: 'IconHome',
position: 100,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID,
isOverridden: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'message-thread-widget-email-thread',
pageLayoutTabId: 'message-thread-tab-home',
title: 'Thread',
type: WidgetType.EMAIL_THREAD,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 12,
columnSpan: 12,
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
isOverridden: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
],
};
@@ -0,0 +1,2 @@
export const DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID =
'default-message-thread-page-layout';
@@ -159,6 +159,9 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
... on EmailsConfiguration {
configurationType
}
... on EmailThreadConfiguration {
configurationType
}
... on FieldConfiguration {
configurationType
fieldDisplayMode
@@ -1,5 +1,7 @@
import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT } from '@/page-layout/constants/DefaultCompanyRecordPageLayout';
import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultCompanyRecordPageLayoutId';
import { DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT } from '@/page-layout/constants/DefaultMessageThreadRecordPageLayout';
import { DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultMessageThreadRecordPageLayoutId';
import { DEFAULT_NOTE_RECORD_PAGE_LAYOUT } from '@/page-layout/constants/DefaultNoteRecordPageLayout';
import { DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultNoteRecordPageLayoutId';
import { DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT } from '@/page-layout/constants/DefaultOpportunityRecordPageLayout';
@@ -42,6 +44,8 @@ const getDefaultLayoutById = (layoutId: string): PageLayout => {
return DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT;
case DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID:
return DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT;
case DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID:
return DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT;
case DEFAULT_RECORD_PAGE_LAYOUT_ID:
default:
return DEFAULT_RECORD_PAGE_LAYOUT;
@@ -57,7 +61,8 @@ const isDefaultLayoutId = (layoutId: string): boolean =>
layoutId === DEFAULT_TASK_RECORD_PAGE_LAYOUT_ID ||
layoutId === DEFAULT_WORKFLOW_PAGE_LAYOUT_ID ||
layoutId === DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT_ID ||
layoutId === DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID;
layoutId === DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID ||
layoutId === DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID;
export const useBasePageLayout = (
pageLayoutId: string,
@@ -1,5 +1,6 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultCompanyRecordPageLayoutId';
import { DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultMessageThreadRecordPageLayoutId';
import { DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultNoteRecordPageLayoutId';
import { DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultOpportunityRecordPageLayoutId';
import { DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPersonRecordPageLayoutId';
@@ -20,6 +21,8 @@ const OBJECT_NAME_TO_DEFAULT_LAYOUT_ID: Record<string, string> = {
[CoreObjectNameSingular.WorkflowVersion]:
DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.WorkflowRun]: DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.MessageThread]:
DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID,
};
export const getDefaultRecordPageLayoutId = ({
@@ -1,5 +1,6 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { CalendarWidget } from '@/page-layout/widgets/calendar/components/CalendarWidget';
import { EmailThreadWidget } from '@/page-layout/widgets/email-thread/components/EmailThreadWidget';
import { EmailWidget } from '@/page-layout/widgets/emails/components/EmailWidget';
import { FieldRichTextWidgetRenderer } from '@/page-layout/widgets/field-rich-text/components/FieldRichTextWidgetRenderer';
import { FieldWidget } from '@/page-layout/widgets/field/components/FieldWidget';
@@ -77,6 +78,9 @@ export const WidgetContentRenderer = ({
case WidgetType.RECORD_TABLE:
return <RecordTableWidgetRenderer widget={widget} />;
case WidgetType.EMAIL_THREAD:
return <EmailThreadWidget widget={widget} />;
default:
return null;
}
@@ -111,11 +111,14 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
// TODO: when we have more widgets without headers, we should use a more generic approach to hide the header
// each widget type could have metadata (e.g., hasHeader: boolean or headerMode: 'always' | 'editOnly' | 'never')
const isRichTextWidget = widget.type === WidgetType.STANDALONE_RICH_TEXT;
const hideRichTextHeader = isRichTextWidget && !isPageLayoutInEditMode;
const isHeaderHiddenInViewMode =
widget.type === WidgetType.STANDALONE_RICH_TEXT ||
widget.type === WidgetType.EMAIL_THREAD;
const hideHeaderInViewMode =
isHeaderHiddenInViewMode && !isPageLayoutInEditMode;
const showHeader =
layoutMode !== PageLayoutTabLayoutMode.CANVAS && !hideRichTextHeader;
layoutMode !== PageLayoutTabLayoutMode.CANVAS && !hideHeaderInViewMode;
const handleClick = () => {
handleEditWidget({
@@ -3,23 +3,25 @@ import { useState } from 'react';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { Button } from 'twenty-ui/input';
import { t } from '@lingui/core/macro';
import { IconArrowsVertical } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledButtonContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
padding: 16px 24px;
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[6]};
`;
export const SidePanelMessageThreadIntermediaryMessages = ({
export const EmailThreadIntermediaryMessages = ({
messages,
}: {
messages: EmailThreadMessageWithSender[];
}) => {
const [areMessagesOpen, setAreMessagesOpen] = useState(false);
const messagesLength = messages.length;
if (messages.length === 0) {
if (messagesLength === 0) {
return null;
}
@@ -37,7 +39,7 @@ export const SidePanelMessageThreadIntermediaryMessages = ({
<StyledButtonContainer>
<Button
Icon={IconArrowsVertical}
title={`${messages.length} email${messages.length > 1 ? 's' : ''}`}
title={t`${messagesLength} emails`}
size="small"
onClick={() => setAreMessagesOpen(true)}
/>
@@ -0,0 +1,91 @@
import { styled } from '@linaria/react';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { EmailThreadIntermediaryMessages } from '@/page-layout/widgets/email-thread/components/EmailThreadIntermediaryMessages';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { t } from '@lingui/core/macro';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow-y: auto;
`;
type EmailThreadWidgetProps = {
widget: PageLayoutWidget;
};
export const EmailThreadWidget = ({
widget: _widget,
}: EmailThreadWidgetProps) => {
const targetRecord = useTargetRecord();
const { thread, messages, fetchMoreMessages, threadLoading } = useEmailThread(
targetRecord.id,
);
const messagesCount = messages.length;
const is5OrMoreMessages = messagesCount >= 5;
const firstMessages = messages.slice(
0,
is5OrMoreMessages ? 2 : messagesCount - 1,
);
const intermediaryMessages = is5OrMoreMessages
? messages.slice(2, messagesCount - 1)
: [];
const lastMessage = messages[messagesCount - 1];
if (threadLoading || !thread || !messages.length) {
return (
<StyledWrapper>
<StyledContainer>
<EmailLoader loadingText={t`Loading thread`} />
</StyledContainer>
</StyledWrapper>
);
}
return (
<StyledWrapper>
<StyledContainer>
{
<>
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<EmailThreadIntermediaryMessages messages={intermediaryMessages} />
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</>
}
</StyledContainer>
</StyledWrapper>
);
};
@@ -6,7 +6,6 @@ import { SidePanelAIChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/c
import { SidePanelAskAIPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAIPage';
import { SidePanelCalendarEventPage } from '@/side-panel/pages/calendar-event/components/SidePanelCalendarEventPage';
import { SidePanelFrontComponentPage } from '@/side-panel/pages/front-component/components/SidePanelFrontComponentPage';
import { SidePanelMessageThreadPage } from '@/side-panel/pages/message-thread/components/SidePanelMessageThreadPage';
import { SidePanelPageLayoutChartSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutChartSettings';
import { SidePanelPageLayoutFieldSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldSettings';
import { SidePanelPageLayoutFieldsSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldsSettings';
@@ -37,7 +36,6 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
[SidePanelPages.ViewRecord, <SidePanelRecordPage />],
[SidePanelPages.MergeRecords, <SidePanelMergeRecordPage />],
[SidePanelPages.UpdateRecords, <SidePanelUpdateMultipleRecords />],
[SidePanelPages.ViewEmailThread, <SidePanelMessageThreadPage />],
[SidePanelPages.ViewCalendarEvent, <SidePanelCalendarEventPage />],
[SidePanelPages.EditRichText, <SidePanelEditRichTextPage />],
[
@@ -1,89 +0,0 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { useOpenEmailThreadInSidePanel } from '@/side-panel/hooks/useOpenEmailThreadInSidePanel';
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { SidePanelPages } from 'twenty-shared/types';
import { IconMail } from 'twenty-ui/display';
import { getJestMetadataAndApolloMocksAndCommandMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndCommandMenuWrapper';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('mocked-uuid'),
}));
const mockNavigateSidePanel = jest.fn();
jest.mock('@/side-panel/hooks/useNavigateSidePanel', () => ({
useNavigateSidePanel: () => ({
navigateSidePanel: mockNavigateSidePanel,
}),
}));
const personMockObjectMetadataItem =
getTestEnrichedObjectMetadataItemsMock().find(
(item) => item.nameSingular === 'person',
)!;
const wrapper = getJestMetadataAndApolloMocksAndCommandMenuWrapper({
apolloMocks: [],
componentInstanceId: SIDE_PANEL_COMPONENT_INSTANCE_ID,
contextStoreCurrentObjectMetadataNameSingular:
personMockObjectMetadataItem.nameSingular,
contextStoreCurrentViewId: 'my-view-id',
contextStoreTargetedRecordsRule: {
mode: 'selection',
selectedRecordIds: [],
},
contextStoreNumberOfSelectedRecords: 0,
contextStoreCurrentViewType: ContextStoreViewType.Table,
});
const renderHooks = () => {
const { result } = renderHook(
() => {
const { openEmailThreadInSidePanel } = useOpenEmailThreadInSidePanel();
const viewableRecordId = useAtomComponentStateValue(
viewableRecordIdComponentState,
'mocked-uuid',
);
return {
openEmailThreadInSidePanel,
viewableRecordId,
};
},
{
wrapper,
},
);
return { result };
};
describe('useOpenEmailThreadInSidePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should set the correct states and navigate to the email thread page', () => {
const { result } = renderHooks();
const emailThreadId = 'email-thread-123';
act(() => {
result.current.openEmailThreadInSidePanel(emailThreadId);
});
expect(result.current.viewableRecordId).toBe(emailThreadId);
expect(mockNavigateSidePanel).toHaveBeenCalledWith({
page: SidePanelPages.ViewEmailThread,
pageTitle: 'Email Thread',
pageIcon: IconMail,
pageId: 'mocked-uuid',
});
});
});
@@ -1,63 +0,0 @@
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { IconMail } from 'twenty-ui/display';
import { v4 } from 'uuid';
import { useStore } from 'jotai';
export const useOpenEmailThreadInSidePanel = () => {
const store = useStore();
const { navigateSidePanel } = useNavigateSidePanel();
const openEmailThreadInSidePanel = useCallback(
(emailThreadId: string) => {
const pageComponentInstanceId = v4();
store.set(
viewableRecordIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
emailThreadId,
);
// TODO: Uncomment this once we need to show the thread title in the navigation
// const objectMetadataItem = snapshot
// .getLoadable(objectMetadataItemsSelector)
// .getValue()
// .find(
// ({ nameSingular }) =>
// nameSingular === CoreObjectNameSingular.MessageThread,
// );
// set(
// commandMenuNavigationMorphItemsState,
// new Map([
// ...snapshot
// .getLoadable(commandMenuNavigationMorphItemsState)
// .getValue(),
// [
// pageComponentInstanceId,
// {
// objectMetadataId: objectMetadataItem?.id,
// recordId: emailThreadId,
// },
// ],
// ]),
// );
navigateSidePanel({
page: SidePanelPages.ViewEmailThread,
pageTitle: t`Email Thread`,
pageIcon: IconMail,
pageId: pageComponentInstanceId,
});
},
[navigateSidePanel, store],
);
return {
openEmailThreadInSidePanel,
};
};
@@ -1,183 +0,0 @@
import { styled } from '@linaria/react';
import { useEffect, useMemo } from 'react';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadHeader } from '@/activities/emails/components/EmailThreadHeader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { SidePanelMessageThreadIntermediaryMessages } from '@/side-panel/pages/message-thread/components/SidePanelMessageThreadIntermediaryMessages';
import { useEmailThreadInSidePanel } from '@/side-panel/pages/message-thread/hooks/useEmailThreadInSidePanel';
import { messageThreadComponentState } from '@/side-panel/pages/message-thread/states/messageThreadComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { t } from '@lingui/core/macro';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { IconArrowBackUp } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
height: 85%;
overflow-y: auto;
`;
const StyledButtonContainer = styled.div`
background: ${themeCssVariables.background.secondary};
border-top: 1px solid ${themeCssVariables.border.color.light};
box-sizing: border-box;
display: flex;
justify-content: flex-end;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const ALLOWED_REPLY_PROVIDERS = [
ConnectedAccountProvider.GOOGLE,
ConnectedAccountProvider.MICROSOFT,
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
];
export const SidePanelMessageThreadPage = () => {
const setMessageThread = useSetAtomComponentState(
messageThreadComponentState,
);
const {
thread,
messages,
fetchMoreMessages,
threadLoading,
messageThreadExternalId,
connectedAccountHandle,
messageChannelLoading,
connectedAccountProvider,
lastMessageExternalId,
connectedAccountConnectionParameters,
} = useEmailThreadInSidePanel();
useEffect(() => {
if (!isDefined(messages[0]?.messageThread)) {
return;
}
setMessageThread(messages[0]?.messageThread);
}, [messages, setMessageThread]);
const messagesCount = messages.length;
const is5OrMoreMessages = messagesCount >= 5;
const firstMessages = messages.slice(
0,
is5OrMoreMessages ? 2 : messagesCount - 1,
);
const intermediaryMessages = is5OrMoreMessages
? messages.slice(2, messagesCount - 1)
: [];
const lastMessage = messages[messagesCount - 1];
const subject = messages[0]?.subject;
const canReply = useMemo(() => {
return (
isDefined(connectedAccountHandle) &&
isDefined(connectedAccountProvider) &&
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
isDefined(lastMessage) &&
messageThreadExternalId != null
);
}, [
connectedAccountConnectionParameters,
connectedAccountHandle,
connectedAccountProvider,
lastMessage,
messageThreadExternalId,
]);
const handleReplyClick = () => {
if (!canReply) {
return;
}
let url: string;
switch (connectedAccountProvider) {
case ConnectedAccountProvider.MICROSOFT:
url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.GOOGLE:
url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case null:
return;
default:
assertUnreachable(connectedAccountProvider);
}
};
if (!thread || !messages.length) {
return null;
}
return (
<StyledWrapper>
<StyledContainer>
{threadLoading ? (
<EmailLoader loadingText={t`Loading thread`} />
) : (
<>
<EmailThreadHeader
subject={subject}
lastMessageSentAt={lastMessage.receivedAt}
/>
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<SidePanelMessageThreadIntermediaryMessages
messages={intermediaryMessages}
/>
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</>
)}
</StyledContainer>
{!messageChannelLoading && (
<StyledButtonContainer>
<Button
size="small"
onClick={handleReplyClick}
title={t`Reply`}
Icon={IconArrowBackUp}
disabled={!canReply}
/>
</StyledButtonContainer>
)}
</StyledWrapper>
);
};
@@ -1,255 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { fetchAllThreadMessagesOperationSignatureFactory } from '@/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory';
import { useEmailThreadInSidePanel } from '@/side-panel/pages/message-thread/hooks/useEmailThreadInSidePanel';
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery';
import gql from 'graphql-tag';
import {
QUERY_DEFAULT_LIMIT_RECORDS,
QUERY_MAX_RECORDS,
} from 'twenty-shared/constants';
import { MessageParticipantRole } from 'twenty-shared/types';
import { generateMockRecordNode } from '~/testing/utils/generateMockRecordNode';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
const messageMetadataItem = getMockObjectMetadataItemOrThrow('message');
const messageParticipantMetadataItem =
getMockObjectMetadataItemOrThrow('messageParticipant');
const messageOperationSignature =
fetchAllThreadMessagesOperationSignatureFactory({
messageThreadId: '1',
});
const findManyMessagesQuery = generateFindManyRecordsQuery({
objectMetadataItem: messageMetadataItem,
objectMetadataItems: getTestEnrichedObjectMetadataItemsMock(),
recordGqlFields: messageOperationSignature.fields,
objectPermissionsByObjectMetadataId: {},
});
const findManyMessageParticipantsQuery = generateFindManyRecordsQuery({
objectMetadataItem: messageParticipantMetadataItem,
objectMetadataItems: getTestEnrichedObjectMetadataItemsMock(),
recordGqlFields: {
id: true,
role: true,
displayName: true,
messageId: true,
handle: true,
person: true,
workspaceMember: true,
},
objectPermissionsByObjectMetadataId: {},
});
const mocks = [
{
request: {
query: gql`
query FindOneMessageThread($objectRecordId: UUID!) {
messageThread(filter: { id: { eq: $objectRecordId } }) {
__typename
id
}
}
`,
variables: { objectRecordId: '1' },
},
result: jest.fn(() => ({
data: {
messageThread: {
id: '1',
__typename: 'MessageThread',
},
},
})),
},
{
request: {
query: findManyMessagesQuery,
variables: {
filter: { messageThreadId: { eq: '1' } },
orderBy: [{ receivedAt: 'AscNullsLast' }],
lastCursor: undefined,
limit: QUERY_MAX_RECORDS,
},
},
result: jest.fn(() => ({
data: {
messages: {
edges: [
{
node: generateMockRecordNode({
objectNameSingular: 'message',
input: {
id: '1',
text: 'Message 1',
createdAt: '2024-10-03T10:20:10.145Z',
},
}),
cursor: '1',
},
{
node: generateMockRecordNode({
objectNameSingular: 'message',
input: {
id: '2',
text: 'Message 2',
createdAt: '2024-10-03T10:20:10.145Z',
},
}),
cursor: '2',
},
],
totalCount: 2,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '1',
endCursor: '2',
},
},
},
})),
},
{
request: {
query: findManyMessageParticipantsQuery,
variables: {
filter: {
messageId: { in: ['1', '2'] },
role: { eq: MessageParticipantRole.FROM },
},
orderBy: undefined,
lastCursor: undefined,
limit: QUERY_DEFAULT_LIMIT_RECORDS,
},
},
result: jest.fn(() => ({
data: {
messageParticipants: {
edges: [
{
node: generateMockRecordNode({
objectNameSingular: 'messageParticipant',
input: {
id: 'messageParticipant-1',
role: MessageParticipantRole.FROM,
messageId: '1',
},
}),
cursor: '1',
},
{
node: generateMockRecordNode({
objectNameSingular: 'messageParticipant',
input: {
id: 'messageParticipant-2',
role: MessageParticipantRole.FROM,
messageId: '2',
},
}),
cursor: '2',
},
],
totalCount: 2,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '1',
endCursor: '2',
},
},
},
})),
},
];
const Wrapper = ({ children }: { children: React.ReactNode }) => {
const MetadataWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
onInitializeJotaiStore: (store) => {
store.set(
viewableRecordIdComponentState.atomFamily({
instanceId: 'test-instance',
}),
'1',
);
},
});
return (
<MetadataWrapper>
<SidePanelPageComponentInstanceContext.Provider
value={{ instanceId: 'test-instance' }}
>
{children}
</SidePanelPageComponentInstanceContext.Provider>
</MetadataWrapper>
);
};
describe('useEmailThreadInSidePanel', () => {
it('should return correct values', async () => {
const mockMessages = [
{
__typename: 'Message',
createdAt: '2024-10-03T10:20:10.145Z',
headerMessageId: '',
id: '1',
messageParticipants: [],
messageThread: null,
receivedAt: null,
sender: {
__typename: 'MessageParticipant',
displayName: '',
handle: '',
id: 'messageParticipant-1',
messageId: '1',
person: null,
role: MessageParticipantRole.FROM,
workspaceMember: null,
},
subject: '',
text: 'Message 1',
},
{
__typename: 'Message',
createdAt: '2024-10-03T10:20:10.145Z',
headerMessageId: '',
id: '2',
messageParticipants: [],
messageThread: null,
receivedAt: null,
sender: {
__typename: 'MessageParticipant',
displayName: '',
handle: '',
id: 'messageParticipant-2',
messageId: '2',
person: null,
role: MessageParticipantRole.FROM,
workspaceMember: null,
},
subject: '',
text: 'Message 2',
},
];
const { result } = renderHook(() => useEmailThreadInSidePanel(), {
wrapper: Wrapper,
});
await waitFor(() => {
expect(result.current.thread).toBeDefined();
expect(result.current.messages).toEqual(mockMessages);
expect(result.current.threadLoading).toBeFalsy();
expect(result.current.fetchMoreMessages).toBeInstanceOf(Function);
});
});
});
@@ -1,10 +0,0 @@
import { type MessageThread } from '@/activities/emails/types/MessageThread';
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const messageThreadComponentState =
createAtomComponentState<MessageThread | null>({
key: 'messageThreadComponentState',
defaultValue: null,
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -3,6 +3,7 @@ import {
type AggregateChartConfiguration,
type BarChartConfiguration,
type CalendarConfiguration,
type EmailThreadConfiguration,
type EmailsConfiguration,
type FieldRichTextConfiguration,
type FieldsConfiguration,
@@ -44,6 +45,12 @@ type WidgetConfigurationTypenameMap = {
> & {
configurationType: WidgetConfigurationType.FRONT_COMPONENT;
};
EmailThreadConfiguration: Omit<
EmailThreadConfiguration,
'configurationType'
> & {
configurationType: WidgetConfigurationType.EMAIL_THREAD;
};
EmailsConfiguration: Omit<EmailsConfiguration, 'configurationType'> & {
configurationType: WidgetConfigurationType.EMAILS;
};