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,
});
}
};
@@ -0,0 +1,195 @@
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 EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { type MessageChannelMessageAssociation } from '@/activities/emails/types/MessageChannelMessageAssociation';
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 { isDefined } from 'twenty-shared/utils';
export const useEmailThread = (threadId: string | null) => {
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
const [lastMessageChannelId, setLastMessageChannelId] = useState<
string | null
>(null);
const [isMessagesFetchComplete, setIsMessagesFetchComplete] = useState(false);
const { record: thread } = useFindOneRecord<EmailThread>({
objectNameSingular: CoreObjectNameSingular.MessageThread,
objectRecordId: threadId ?? '',
recordGqlFields: {
id: true,
},
});
useEffect(() => {
if (thread) {
upsertRecordsInStore({ partialRecords: [thread] });
}
}, [thread, upsertRecordsInStore]);
const FETCH_ALL_MESSAGES_OPERATION_SIGNATURE =
fetchAllThreadMessagesOperationSignatureFactory({
messageThreadId: threadId,
});
const {
records: messages,
loading: messagesLoading,
fetchMoreRecords,
hasNextPage,
} = useFindManyRecords<EmailThreadMessage>({
limit: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.limit,
filter: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.filter,
objectNameSingular:
FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.objectNameSingular,
orderBy: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.orderBy,
recordGqlFields: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.fields,
skip: !threadId,
});
const fetchMoreMessages = useCallback(() => {
if (!messagesLoading && hasNextPage) {
fetchMoreRecords();
} else if (!hasNextPage) {
setIsMessagesFetchComplete(true);
}
}, [fetchMoreRecords, messagesLoading, hasNextPage]);
useEffect(() => {
if (messages.length > 0 && isMessagesFetchComplete) {
const lastMessage = messages[messages.length - 1];
setLastMessageId(lastMessage.id);
}
}, [messages, isMessagesFetchComplete]);
const { records: messageSenders } =
useFindManyRecords<EmailThreadMessageParticipant>({
filter: {
messageId: {
in: messages.map(({ id }) => id),
},
role: {
eq: MessageParticipantRole.FROM,
},
},
objectNameSingular: CoreObjectNameSingular.MessageParticipant,
recordGqlFields: {
id: true,
role: true,
displayName: true,
messageId: true,
handle: true,
person: true,
workspaceMember: true,
},
skip: messages.length === 0,
});
const { records: messageChannelMessageAssociationData } =
useFindManyRecords<MessageChannelMessageAssociation>({
filter: {
messageId: {
eq: lastMessageId ?? '',
},
},
objectNameSingular:
CoreObjectNameSingular.MessageChannelMessageAssociation,
recordGqlFields: {
id: true,
messageId: true,
messageChannelId: true,
messageThreadExternalId: true,
messageExternalId: true,
},
skip: !lastMessageId || !isMessagesFetchComplete,
});
useEffect(() => {
if (messageChannelMessageAssociationData.length > 0) {
setLastMessageChannelId(
messageChannelMessageAssociationData[0].messageChannelId,
);
}
}, [messageChannelMessageAssociationData]);
const { records: messageChannelData, loading: messageChannelLoading } =
useFindManyRecords<MessageChannel>({
filter: {
id: {
eq: lastMessageChannelId ?? '',
},
},
objectNameSingular: CoreObjectNameSingular.MessageChannel,
recordGqlFields: {
id: true,
handle: true,
connectedAccount: {
id: true,
provider: true,
connectionParameters: true,
},
},
skip: !lastMessageChannelId,
});
const messageThreadExternalId =
messageChannelMessageAssociationData.length > 0
? messageChannelMessageAssociationData[0].messageThreadExternalId
: null;
const lastMessageExternalId =
messageChannelMessageAssociationData.length > 0
? messageChannelMessageAssociationData[0].messageExternalId
: null;
const connectedAccountHandle =
messageChannelData.length > 0 ? messageChannelData[0].handle : null;
const messagesWithSender: EmailThreadMessageWithSender[] = messages
.map((message) => {
const sender = messageSenders.find(
(messageSender) => messageSender.messageId === message.id,
);
if (!sender) {
return null;
}
return {
...message,
sender,
};
})
.filter(isDefined);
const connectedAccount =
messageChannelData.length > 0
? messageChannelData[0]?.connectedAccount
: null;
const connectedAccountProvider = connectedAccount?.provider ?? null;
const connectedAccountConnectionParameters =
connectedAccount?.connectionParameters;
return {
thread,
messages: messagesWithSender,
messageThreadExternalId,
connectedAccountHandle,
connectedAccountProvider,
connectedAccountConnectionParameters,
threadLoading: messagesLoading,
messageChannelLoading,
lastMessageExternalId,
fetchMoreMessages,
};
};
@@ -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,
});
}
};