feat: Send email from UI — inline reply composer & SendEmail mutation (#19363)

## Summary

- **Inline email reply**: Replace external email client redirects
(Gmail/Outlook deeplinks) with an in-app email composer. Users can reply
to email threads directly from the email thread widget or via the
command menu.
- **SendEmail GraphQL mutation**: New backend mutation that reuses
`EmailComposerService` for body sanitization, recipient validation, and
SMTP dispatch via the existing outbound messaging infrastructure.
- **Side panel compose page**: Command menu "Reply" action now opens a
side-panel compose email page with pre-filled To, Subject, and
In-Reply-To fields.

### Backend
- `SendEmailResolver` with `SendEmailInput` / `SendEmailOutputDTO`
- `SendEmailModule` wired into `CoreEngineModule`
- Reuses `EmailComposerService` + `MessagingMessageOutboundService`

### Frontend
- `EmailComposer` / `EmailComposerFields` components
- `useSendEmail`, `useReplyContext`, `useEmailComposerState` hooks
- `useOpenComposeEmailInSidePanel` + `SidePanelComposeEmailPage`
- `EmailThreadWidget` inline Reply bar with toggle composer
- `ReplyToEmailThreadCommand` now opens side-panel instead of external
links

### Seeds
- Added `handle` field to message participant seeds for realistic email
addresses
- Seed `connectedAccount` and `messageChannel` in correct batch order

## Test plan

- [ ] Open an email thread on a person/company record → verify
"Reply..." bar appears below the last message
- [ ] Click "Reply..." → composer opens inline with pre-filled To and
Subject
- [ ] Type a message and click Send → email is sent via SMTP, composer
closes
- [ ] Use command menu Reply action → side panel opens with compose
email page
- [ ] Verify Send/Cancel buttons work correctly in side panel
- [ ] Test with Cc/Bcc toggle in composer fields
- [ ] Verify error handling: invalid recipients, missing connected
account


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-07 08:43:48 +02:00
committed by GitHub
parent aec43da1e2
commit 83d30f8b76
68 changed files with 2466 additions and 579 deletions
@@ -0,0 +1,91 @@
import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const ComposeEmailButton = () => {
const targetRecord = useTargetRecord();
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
const navigateSettings = useNavigateSettings();
const { connectedAccountId, loading: accountLoading } =
useFirstConnectedAccount();
const isPerson =
targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Person;
const isCompany =
targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Company;
const isOpportunity =
targetRecord.targetObjectNameSingular ===
CoreObjectNameSingular.Opportunity;
const { record: personRecord } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
objectRecordId: targetRecord.id,
recordGqlFields: { id: true, emails: { primaryEmail: true } },
skip: !isPerson,
});
const { records: companyPeople } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Person,
filter: { companyId: { eq: targetRecord.id } },
recordGqlFields: { id: true, emails: { primaryEmail: true } },
limit: 1,
skip: !isCompany,
});
const { record: opportunityRecord } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.Opportunity,
objectRecordId: targetRecord.id,
recordGqlFields: {
id: true,
pointOfContact: { id: true, emails: { primaryEmail: true } },
company: { id: true },
},
skip: !isOpportunity,
});
const resolveDefaultTo = (): string => {
if (isPerson) {
return personRecord?.emails?.primaryEmail ?? '';
}
if (isCompany) {
return companyPeople[0]?.emails?.primaryEmail ?? '';
}
if (isOpportunity) {
return opportunityRecord?.pointOfContact?.emails?.primaryEmail ?? '';
}
return '';
};
const handleClick = () => {
if (!isDefined(connectedAccountId)) {
navigateSettings(SettingsPath.NewAccount);
return;
}
openComposeEmailInSidePanel({
connectedAccountId,
defaultTo: resolveDefaultTo(),
});
};
if (accountLoading) {
return null;
}
return (
<LightIconButton
Icon={IconPlus}
accent="tertiary"
size="small"
onClick={handleClick}
/>
);
};
@@ -0,0 +1,81 @@
import { styled } from '@linaria/react';
import { EmailComposerFields } from '@/activities/emails/components/EmailComposerFields';
import { useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
import { t } from '@lingui/core/macro';
import { IconArrowBackUp } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledComposerContainer = styled.div`
background: ${themeCssVariables.background.primary};
display: flex;
flex-direction: column;
`;
const StyledFooter = styled.div`
align-items: center;
border-top: 1px solid ${themeCssVariables.border.color.light};
display: flex;
justify-content: flex-end;
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[4]};
`;
const StyledFooterActions = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
type EmailComposerProps = {
connectedAccountId: string;
defaultTo?: string;
defaultSubject?: string;
defaultInReplyTo?: string;
onClose?: () => void;
onSent?: () => void;
};
export const EmailComposer = ({
connectedAccountId,
defaultTo = '',
defaultSubject = '',
defaultInReplyTo,
onClose,
onSent,
}: EmailComposerProps) => {
const composerState = useEmailComposerState({
connectedAccountId,
defaultTo,
defaultSubject,
defaultInReplyTo,
onSent,
});
return (
<StyledComposerContainer>
<EmailComposerFields composerState={composerState} />
<StyledFooter>
<StyledFooterActions>
{onClose && (
<Button
size="small"
variant="secondary"
title={t`Cancel`}
onClick={onClose}
/>
)}
<Button
size="small"
variant="primary"
accent="blue"
title={t`Send`}
Icon={IconArrowBackUp}
onClick={composerState.handleSend}
disabled={!composerState.canSend}
/>
</StyledFooterActions>
</StyledFooter>
</StyledComposerContainer>
);
};
@@ -0,0 +1,117 @@
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { type EmailComposerState } from '@/activities/emails/types/EmailComposerState';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { FormMultiTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { Select } from '@/ui/input/components/Select';
import { t } from '@lingui/core/macro';
import { type SelectOption } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledFieldsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[2]};
`;
const StyledToRow = styled.div`
display: flex;
flex-direction: column;
position: relative;
`;
const StyledCcBccToggle = styled.button`
all: unset;
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
font-size: ${themeCssVariables.font.size.xs};
position: absolute;
right: 0;
top: 0;
&:hover {
color: ${themeCssVariables.font.color.secondary};
}
`;
type EmailComposerFieldsProps = {
composerState: EmailComposerState;
};
export const EmailComposerFields = ({
composerState,
}: EmailComposerFieldsProps) => {
const { data: accountsData } = useQuery<{
myConnectedAccounts: { id: string; handle: string }[];
}>(GET_MY_CONNECTED_ACCOUNTS);
const accountOptions: SelectOption<string>[] =
accountsData?.myConnectedAccounts?.map((account) => ({
label: account.handle,
value: account.id,
})) ?? [];
const hasMultipleAccounts = accountOptions.length > 1;
return (
<StyledFieldsContainer>
{hasMultipleAccounts && (
<Select
dropdownId="email-composer-from-account"
label={t`From`}
fullWidth
value={composerState.connectedAccountId}
options={accountOptions}
onChange={(value) => composerState.setConnectedAccountId(value)}
/>
)}
<StyledToRow>
<FormMultiTextFieldInput
label={t`To`}
defaultValue={composerState.defaultTo}
onChange={composerState.setTo}
placeholder={t`Recipients`}
/>
{!composerState.showCcBcc && (
<StyledCcBccToggle onClick={() => composerState.setShowCcBcc(true)}>
{t`Cc/Bcc`}
</StyledCcBccToggle>
)}
</StyledToRow>
{composerState.showCcBcc && (
<>
<FormMultiTextFieldInput
label={t`Cc`}
defaultValue=""
onChange={composerState.setCc}
placeholder={t`Cc`}
/>
<FormMultiTextFieldInput
label={t`Bcc`}
defaultValue=""
onChange={composerState.setBcc}
placeholder={t`Bcc`}
/>
</>
)}
<FormTextFieldInput
label={t`Subject`}
defaultValue={composerState.defaultSubject}
onChange={composerState.setSubject}
placeholder={t`Subject`}
/>
<FormAdvancedTextFieldInput
defaultValue=""
onChange={composerState.setBody}
placeholder={t`Type something or press "/" to see commands`}
minHeight={120}
maxWidth={600}
contentType="json"
/>
</StyledFieldsContainer>
);
};
@@ -8,13 +8,16 @@ import { EmailThreadMessageSender } from '@/activities/emails/components/EmailTh
import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared';
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { MessageParticipantRole } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { MessageChannelVisibility } from '~/generated/graphql';
const StyledThreadMessage = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
const StyledThreadMessage = styled.div<{ hideBottomBorder?: boolean }>`
border-bottom: ${({ hideBottomBorder }) =>
hideBottomBorder
? 'none'
: `1px solid ${themeCssVariables.border.color.light}`};
display: flex;
flex-direction: column;
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[0]};
@@ -26,11 +29,11 @@ const StyledThreadMessageHeader = styled.div`
flex-direction: column;
justify-content: space-between;
margin-bottom: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[6]};
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
const StyledThreadMessageBody = styled.div`
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[6]};
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
type EmailThreadMessageProps = {
@@ -39,6 +42,7 @@ type EmailThreadMessageProps = {
sender: EmailThreadMessageParticipant;
participants: EmailThreadMessageParticipant[];
isExpanded?: boolean;
hideBottomBorder?: boolean;
};
export const EmailThreadMessage = ({
@@ -47,6 +51,7 @@ export const EmailThreadMessage = ({
sender,
participants,
isExpanded = false,
hideBottomBorder = false,
}: EmailThreadMessageProps) => {
const [isOpen, setIsOpen] = useState(isExpanded);
@@ -63,6 +68,7 @@ export const EmailThreadMessage = ({
return (
<StyledThreadMessage
hideBottomBorder={hideBottomBorder}
onClick={() => !isOpen && setIsOpen(true)}
style={{ cursor: isOpen || isRestricted ? 'auto' : 'pointer' }}
>
@@ -3,6 +3,7 @@ import { styled } from '@linaria/react';
import { ActivityList } from '@/activities/components/ActivityList';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { ComposeEmailButton } from '@/activities/emails/components/ComposeEmailButton';
import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview';
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
@@ -38,6 +39,12 @@ const StyledContainer = styled.div`
${themeCssVariables.spacing[2]};
`;
const StyledHeaderRow = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledH1TitleWrapper = styled.div`
> h2 {
display: flex;
@@ -49,6 +56,11 @@ const StyledEmailCount = styled.span`
color: ${themeCssVariables.font.color.light};
`;
const StyledComposeButtonRow = styled.div`
display: flex;
justify-content: flex-end;
`;
export const EmailsCard = () => {
const targetRecord = useTargetRecord();
@@ -89,37 +101,47 @@ export const EmailsCard = () => {
if (!firstQueryLoading && !timelineThreads?.length) {
return (
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="emptyInbox" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
<Trans>Empty Inbox</Trans>
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
<Trans>No email exchange has occurred with this record yet.</Trans>
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
<StyledContainer>
<StyledComposeButtonRow>
<ComposeEmailButton />
</StyledComposeButtonRow>
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="emptyInbox" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
<Trans>Empty Inbox</Trans>
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
<Trans>
No email exchange has occurred with this record yet.
</Trans>
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
</StyledContainer>
);
}
return (
<StyledContainer>
<Section>
<StyledH1TitleWrapper>
<H1Title
title={
<>
<Trans>Inbox</Trans>{' '}
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
</>
}
fontColor={H1TitleFontColor.Primary}
/>
</StyledH1TitleWrapper>
<StyledHeaderRow>
<StyledH1TitleWrapper>
<H1Title
title={
<>
<Trans>Inbox</Trans>{' '}
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
</>
}
fontColor={H1TitleFontColor.Primary}
/>
</StyledH1TitleWrapper>
<ComposeEmailButton />
</StyledHeaderRow>
{!firstQueryLoading && (
<ActivityList>
{timelineThreads?.map((thread: TimelineThread) => (
@@ -0,0 +1,10 @@
import gql from 'graphql-tag';
export const SEND_EMAIL = gql`
mutation SendEmail($input: SendEmailInput!) {
sendEmail(input: $input) {
success
error
}
}
`;
@@ -0,0 +1,90 @@
import { useCallback, useState } from 'react';
import { useSendEmail } from '@/activities/emails/hooks/useSendEmail';
type UseEmailComposerStateArgs = {
connectedAccountId: string;
defaultTo?: string;
defaultSubject?: string;
defaultInReplyTo?: string;
onSent?: () => void;
};
export const useEmailComposerState = ({
connectedAccountId: initialConnectedAccountId,
defaultTo = '',
defaultSubject = '',
defaultInReplyTo,
onSent,
}: UseEmailComposerStateArgs) => {
const [connectedAccountId, setConnectedAccountId] = useState(
initialConnectedAccountId,
);
const [to, setTo] = useState(defaultTo);
const [cc, setCc] = useState('');
const [bcc, setBcc] = useState('');
const [subject, setSubject] = useState(defaultSubject);
const [body, setBody] = useState('');
const [showCcBcc, setShowCcBcc] = useState(false);
const { sendEmail, loading } = useSendEmail();
const canSend =
to.trim().length > 0 && connectedAccountId.length > 0 && !loading;
const handleSend = useCallback(async () => {
if (!to.trim() || !connectedAccountId) {
return;
}
const trimmedTo = to.trim();
const trimmedCc = cc.trim();
const trimmedBcc = bcc.trim();
const success = await sendEmail({
connectedAccountId,
to: trimmedTo,
cc: trimmedCc || undefined,
bcc: trimmedBcc || undefined,
subject,
body,
inReplyTo: defaultInReplyTo,
});
if (success) {
onSent?.();
}
}, [
connectedAccountId,
to,
cc,
bcc,
subject,
body,
defaultInReplyTo,
sendEmail,
onSent,
]);
return {
connectedAccountId,
setConnectedAccountId,
to,
setTo,
cc,
setCc,
bcc,
setBcc,
subject,
setSubject,
body,
setBody,
showCcBcc,
setShowCcBcc,
handleSend,
loading,
canSend,
defaultTo,
defaultSubject,
};
};
@@ -1,6 +1,6 @@
import { useQuery } from '@apollo/client/react';
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';
@@ -10,7 +10,9 @@ import { type MessageChannelMessageAssociation } from '@/activities/emails/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 { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import {
type ConnectedAccountProvider,
CoreObjectNameSingular,
MessageParticipantRole,
} from 'twenty-shared/types';
@@ -19,9 +21,6 @@ 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>({
@@ -66,6 +65,14 @@ export const useEmailThread = (threadId: string | null) => {
}
}, [fetchMoreRecords, messagesLoading, hasNextPage]);
// When all messages fit in the first page, fetchMoreMessages is never called,
// so we need to mark fetch as complete here to unblock downstream queries
useEffect(() => {
if (!messagesLoading && !hasNextPage) {
setIsMessagesFetchComplete(true);
}
}, [messagesLoading, hasNextPage]);
useEffect(() => {
if (messages.length > 0 && isMessagesFetchComplete) {
const lastMessage = messages[messages.length - 1];
@@ -116,34 +123,6 @@ export const useEmailThread = (threadId: string | null) => {
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
@@ -152,8 +131,6 @@ export const useEmailThread = (threadId: string | null) => {
messageChannelMessageAssociationData.length > 0
? messageChannelMessageAssociationData[0].messageExternalId
: null;
const connectedAccountHandle =
messageChannelData.length > 0 ? messageChannelData[0].handle : null;
const messagesWithSender: EmailThreadMessageWithSender[] = messages
.map((message) => {
@@ -172,21 +149,32 @@ export const useEmailThread = (threadId: string | null) => {
})
.filter(isDefined);
const connectedAccount =
messageChannelData.length > 0
? messageChannelData[0]?.connectedAccount
: null;
const connectedAccountProvider = connectedAccount?.provider ?? null;
const connectedAccountConnectionParameters =
connectedAccount?.connectionParameters;
// connectedAccount and messageChannel live in the core schema,
// so we resolve the account from the core myConnectedAccounts query
// rather than the workspace-level messageChannel records.
const { data: myConnectedAccountsData, loading: messageChannelLoading } =
useQuery<{
myConnectedAccounts: {
id: string;
handle: string;
provider: ConnectedAccountProvider;
}[];
}>(GET_MY_CONNECTED_ACCOUNTS);
const resolvedConnectedAccount =
myConnectedAccountsData?.myConnectedAccounts[0] ?? null;
const connectedAccountId = resolvedConnectedAccount?.id ?? null;
const connectedAccountHandle = resolvedConnectedAccount?.handle ?? null;
const connectedAccountProvider = resolvedConnectedAccount?.provider ?? null;
return {
thread,
messages: messagesWithSender,
messageThreadExternalId,
connectedAccountId,
connectedAccountHandle,
connectedAccountProvider,
connectedAccountConnectionParameters,
threadLoading: messagesLoading,
messageChannelLoading,
lastMessageExternalId,
@@ -0,0 +1,17 @@
import { useQuery } from '@apollo/client/react';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
export const useFirstConnectedAccount = () => {
const { data, loading } = useQuery<{
myConnectedAccounts: { id: string; handle: string }[];
}>(GET_MY_CONNECTED_ACCOUNTS);
const firstAccount = data?.myConnectedAccounts?.[0] ?? null;
return {
connectedAccountId: firstAccount?.id ?? null,
connectedAccountHandle: firstAccount?.handle ?? null,
loading,
};
};
@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import {
type ReplyContext,
type ReplyContextReady,
} from '@/activities/emails/types/ReplyContext';
import { isDefined } from 'twenty-shared/utils';
export type { ReplyContext, ReplyContextReady };
export const useReplyContext = (
threadId: string | null,
): ReplyContext | null => {
const {
messages,
connectedAccountId,
connectedAccountProvider,
messageChannelLoading,
threadLoading,
} = useEmailThread(threadId);
return useMemo(() => {
if (
!isDefined(connectedAccountId) ||
!isDefined(connectedAccountProvider)
) {
if (messageChannelLoading || threadLoading) {
return { loading: true };
}
return null;
}
const lastMessage = messages[messages.length - 1];
if (!isDefined(lastMessage)) {
return null;
}
const senderHandle = lastMessage.sender?.handle ?? '';
const rawSubject = lastMessage.subject ?? '';
const subject = rawSubject.startsWith('Re: ')
? rawSubject
: `Re: ${rawSubject}`;
return {
loading: false,
to: senderHandle,
subject,
inReplyTo: lastMessage.headerMessageId ?? '',
connectedAccountId,
connectedAccountProvider,
};
}, [
messages,
connectedAccountId,
connectedAccountProvider,
messageChannelLoading,
threadLoading,
]);
};
@@ -0,0 +1,94 @@
import { useMutation } from '@apollo/client/react';
import { useCallback } from 'react';
import { SEND_EMAIL } from '@/activities/emails/graphql/mutations/sendEmail';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId';
import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro';
import {
type SendEmailMutation,
type SendEmailMutationVariables,
} from '~/generated-metadata/graphql';
type SendEmailParams = {
connectedAccountId: string;
to: string;
cc?: string;
bcc?: string;
subject: string;
body: string;
inReplyTo?: string;
};
export const useSendEmail = () => {
const apolloCoreClient = useApolloCoreClient();
const [sendEmailMutation, { loading }] = useMutation<
SendEmailMutation,
SendEmailMutationVariables
>(SEND_EMAIL);
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const sendEmail = useCallback(
async (params: SendEmailParams): Promise<boolean> => {
try {
const result = await sendEmailMutation({
variables: {
input: {
connectedAccountId: params.connectedAccountId,
to: params.to,
cc: params.cc,
bcc: params.bcc,
subject: params.subject,
body: params.body,
inReplyTo: params.inReplyTo,
},
},
});
if (result.data?.sendEmail.success) {
enqueueSuccessSnackBar({
message: t`Email sent successfully`,
});
await apolloCoreClient.refetchQueries({
include: [
getTimelineThreadsFromCompanyId,
getTimelineThreadsFromPersonId,
getTimelineThreadsFromOpportunityId,
'FindManyMessages',
'FindManyMessageParticipants',
'FindManyMessageChannelMessageAssociations',
],
});
return true;
}
enqueueErrorSnackBar({
message: result.data?.sendEmail.error ?? t`Failed to send email`,
});
return false;
} catch {
enqueueErrorSnackBar({
message: t`Failed to send email`,
});
return false;
}
},
[
sendEmailMutation,
enqueueSuccessSnackBar,
enqueueErrorSnackBar,
apolloCoreClient,
],
);
return { sendEmail, loading };
};
@@ -0,0 +1,3 @@
import { type useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
export type EmailComposerState = ReturnType<typeof useEmailComposerState>;
@@ -6,6 +6,7 @@ export type EmailThreadMessage = {
text: string;
receivedAt: string;
subject: string;
headerMessageId: string;
messageThreadId: string;
messageParticipants: EmailThreadMessageParticipant[];
messageThread: MessageThread;
@@ -0,0 +1,16 @@
import { type ConnectedAccountProvider } from 'twenty-shared/types';
type ReplyContextLoading = {
loading: true;
};
export type ReplyContextReady = {
loading: false;
to: string;
subject: string;
inReplyTo: string;
connectedAccountId: string;
connectedAccountProvider: ConnectedAccountProvider;
};
export type ReplyContext = ReplyContextLoading | ReplyContextReady;
@@ -4,8 +4,13 @@ import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuCont
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { getSidePanelCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getSidePanelCommandMenuDropdownIdFromCommandMenuId';
import { OptionsDropdownMenu } from '@/ui/layout/dropdown/components/OptionsDropdownMenu';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
import { HorizontalSeparator } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
export const RecordPageSidePanelCommandMenuDropdown = () => {
const { commandMenuItems } = useContext(CommandMenuContext);
@@ -17,13 +22,24 @@ export const RecordPageSidePanelCommandMenuDropdown = () => {
const dropdownId =
getSidePanelCommandMenuDropdownIdFromCommandMenuId(commandMenuId);
const { closeDropdown } = useCloseDropdown();
const sidePanelWidgetFooterActions = useAtomStateValue(
sidePanelWidgetFooterActionsState,
);
const dropdownWidgetActions = sidePanelWidgetFooterActions.filter(
(action) => action.isPinned === false,
);
const recordSelectionActions = commandMenuItems.filter(
(action) => action.scope === CommandMenuItemScope.RecordSelection,
);
const selectableItemIdArray = recordSelectionActions.map(
(action) => action.key,
);
const selectableItemIdArray = [
...dropdownWidgetActions.map((action) => action.key),
...recordSelectionActions.map((action) => action.key),
];
return (
<OptionsDropdownMenu
@@ -31,6 +47,19 @@ export const RecordPageSidePanelCommandMenuDropdown = () => {
selectableListId={commandMenuId}
selectableItemIdArray={selectableItemIdArray}
>
{dropdownWidgetActions.map((action) => (
<MenuItem
key={action.key}
text={action.label}
LeftIcon={action.Icon}
onClick={() => {
closeDropdown(dropdownId);
action.onClick();
}}
/>
))}
{dropdownWidgetActions.length > 0 &&
recordSelectionActions.length > 0 && <HorizontalSeparator noMargin />}
{recordSelectionActions.map((action) => (
<CommandMenuItemComponent action={action} key={action.key} />
))}
@@ -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 { ComposeEmailCommand } from '@/command-menu-item/engine-command/global/components/ComposeEmailCommand';
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';
@@ -242,6 +243,7 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record<
<HeadlessFrontComponentRendererEngineCommand />
),
[EngineComponentKey.REPLY_TO_EMAIL_THREAD]: <ReplyToEmailThreadCommand />,
[EngineComponentKey.COMPOSE_EMAIL]: <ComposeEmailCommand />,
// Deprecated keys kept for backward compatibility until migration runs
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,
@@ -0,0 +1,31 @@
import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const ComposeEmailCommand = () => {
const { connectedAccountId, loading } = useFirstConnectedAccount();
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
const navigateSettings = useNavigateSettings();
const handleExecute = () => {
if (!isDefined(connectedAccountId)) {
navigateSettings(SettingsPath.NewAccount);
return;
}
openComposeEmailInSidePanel({
connectedAccountId,
});
};
return (
<HeadlessEngineCommandWrapperEffect
execute={handleExecute}
ready={!loading}
/>
);
};
@@ -15,10 +15,13 @@ export const NavigateToNextRecordSingleRecordCommand = () => {
);
}
const { navigateToNextRecord } = useRecordShowPagePagination(
objectMetadataItem.nameSingular,
recordId,
);
const { navigateToNextRecord, isLoadingPagination } =
useRecordShowPagePagination(objectMetadataItem.nameSingular, recordId);
return <HeadlessEngineCommandWrapperEffect execute={navigateToNextRecord} />;
return (
<HeadlessEngineCommandWrapperEffect
execute={navigateToNextRecord}
ready={!isLoadingPagination}
/>
);
};
@@ -15,12 +15,13 @@ export const NavigateToPreviousRecordSingleRecordCommand = () => {
);
}
const { navigateToPreviousRecord } = useRecordShowPagePagination(
objectMetadataItem.nameSingular,
recordId,
);
const { navigateToPreviousRecord, isLoadingPagination } =
useRecordShowPagePagination(objectMetadataItem.nameSingular, recordId);
return (
<HeadlessEngineCommandWrapperEffect execute={navigateToPreviousRecord} />
<HeadlessEngineCommandWrapperEffect
execute={navigateToPreviousRecord}
ready={!isLoadingPagination}
/>
);
};
@@ -1,73 +1,31 @@
import { useMemo } from 'react';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { useReplyContext } from '@/activities/emails/hooks/useReplyContext';
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 { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
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 replyContext = useReplyContext(threadId);
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
const handleExecute = () => {
if (!canReply) {
if (!isDefined(replyContext) || replyContext.loading) {
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;
}
openComposeEmailInSidePanel({
threadId: threadId ?? undefined,
connectedAccountId: replyContext.connectedAccountId,
defaultTo: replyContext.to,
defaultSubject: replyContext.subject,
defaultInReplyTo: replyContext.inReplyTo,
});
};
const isReady = !messageChannelLoading && canReply;
const isReady = isDefined(replyContext) && !replyContext.loading;
return (
<HeadlessEngineCommandWrapperEffect
@@ -20,7 +20,14 @@ export const CommandMenuContextProviderServerItems = ({
containerType,
children,
}: CommandMenuContextProviderServerItemsProps) => {
const commandMenuContextApi = useCommandMenuContextApi();
const commandMenuContextApiFromHook = useCommandMenuContextApi();
// SidePanelRecordPage shadows the outer ContextStore provider with a
// per-page instance ID, so useCommandMenuContextApi derives isInSidePanel
// as false. The explicit prop from the caller is the source of truth.
const commandMenuContextApi = isInSidePanel
? { ...commandMenuContextApiFromHook, isInSidePanel: true as const }
: commandMenuContextApiFromHook;
const currentObjectNameSingular =
commandMenuContextApi.objectMetadataItem.nameSingular;
@@ -10,9 +10,12 @@ import { usePageLayoutIdForRecord } from '@/page-layout/hooks/usePageLayoutIdFor
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { type TargetRecordIdentifier } from '@/ui/layout/contexts/TargetRecordIdentifier';
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
import { styled } from '@linaria/react';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { PageLayoutType } from '~/generated-metadata/graphql';
@@ -55,6 +58,16 @@ export const PageLayoutRecordPageRenderer = ({
targetObjectNameSingular: targetRecordIdentifier.targetObjectNameSingular,
});
const sidePanelWidgetFooterActions = useAtomStateValue(
sidePanelWidgetFooterActionsState,
);
const pinnedWidgetActions = sidePanelWidgetFooterActions.filter(
(action) => action.isPinned !== false,
);
const hasPinnedWidgetActions = pinnedWidgetActions.length > 0;
return (
<>
<RecordShowEffect
@@ -101,13 +114,30 @@ export const PageLayoutRecordPageRenderer = ({
{isInSidePanel && (
<SidePanelFooter
actions={[
<RecordPageSidePanelCommandMenu />,
<RecordShowSidePanelOpenRecordButton
objectNameSingular={
targetRecordIdentifier.targetObjectNameSingular
}
recordId={targetRecordIdentifier.id}
/>,
<RecordPageSidePanelCommandMenu key="options" />,
...(hasPinnedWidgetActions
? pinnedWidgetActions.map((action) => (
<Button
key={action.key}
size="small"
variant={action.isPrimaryCTA ? 'primary' : 'secondary'}
accent={action.isPrimaryCTA ? 'blue' : 'default'}
title={action.label}
Icon={action.Icon}
hotkeys={action.hotkeys}
onClick={action.onClick}
disabled={action.disabled}
/>
))
: [
<RecordShowSidePanelOpenRecordButton
key="open"
objectNameSingular={
targetRecordIdentifier.targetObjectNameSingular
}
recordId={targetRecordIdentifier.id}
/>,
]),
]}
/>
)}
@@ -0,0 +1,137 @@
import { styled } from '@linaria/react';
import { useCallback, useEffect, useMemo } from 'react';
import { EmailComposerFields } from '@/activities/emails/components/EmailComposerFields';
import { useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
import { type ReplyContextReady } from '@/activities/emails/hooks/useReplyContext';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
import { type SidePanelFooterAction } from '@/ui/layout/side-panel/types/SidePanelFooterAction';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { t } from '@lingui/core/macro';
import { IconArrowBackUp, IconSend, IconX } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getOsControlSymbol } from 'twenty-ui/utilities';
const StyledReplyBar = styled.button`
align-items: center;
all: unset;
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
display: flex;
font-size: ${themeCssVariables.font.size.md};
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
&:hover {
background: ${themeCssVariables.background.transparent.light};
color: ${themeCssVariables.font.color.secondary};
}
`;
type EmailThreadComposerProps = {
replyContext: ReplyContextReady;
isInSidePanel: boolean;
isComposerOpen: boolean;
setIsComposerOpen: (open: boolean) => void;
};
export const EmailThreadComposer = ({
replyContext,
isInSidePanel,
isComposerOpen,
setIsComposerOpen,
}: EmailThreadComposerProps) => {
const handleReplySent = useCallback(() => {
setIsComposerOpen(false);
}, [setIsComposerOpen]);
const composerState = useEmailComposerState({
connectedAccountId: replyContext.connectedAccountId,
defaultTo: replyContext.to,
defaultSubject: replyContext.subject,
defaultInReplyTo: replyContext.inReplyTo,
onSent: handleReplySent,
});
const setSidePanelWidgetFooterActions = useSetAtomState(
sidePanelWidgetFooterActionsState,
);
const footerActions = useMemo((): SidePanelFooterAction[] => {
if (!isComposerOpen) {
return [
{
key: 'reply',
label: t`Reply`,
Icon: IconArrowBackUp,
isPrimaryCTA: true,
onClick: () => setIsComposerOpen(true),
},
];
}
return [
{
key: 'cancel-reply',
label: t`Cancel reply`,
Icon: IconX,
isPinned: false,
onClick: () => setIsComposerOpen(false),
},
{
key: 'send',
label: t`Send`,
Icon: IconSend,
isPrimaryCTA: true,
hotkeys: [getOsControlSymbol(), '⏎'],
onClick: composerState.handleSend,
disabled: !composerState.canSend,
},
];
}, [
isComposerOpen,
composerState.handleSend,
composerState.canSend,
setIsComposerOpen,
]);
useEffect(() => {
if (!isInSidePanel) {
return;
}
setSidePanelWidgetFooterActions(footerActions);
return () => setSidePanelWidgetFooterActions([]);
}, [isInSidePanel, footerActions, setSidePanelWidgetFooterActions]);
const handleSendHotkey = useCallback(() => {
if (isComposerOpen && composerState.canSend) {
composerState.handleSend();
}
}, [isComposerOpen, composerState.canSend, composerState.handleSend]);
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: handleSendHotkey,
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [handleSendHotkey],
});
if (!isComposerOpen) {
if (isInSidePanel) {
return null;
}
return (
<StyledReplyBar onClick={() => setIsComposerOpen(true)}>
<IconArrowBackUp size={16} />
{t`Reply...`}
</StyledReplyBar>
);
}
return <EmailComposerFields composerState={composerState} />;
};
@@ -1,13 +1,18 @@
import { styled } from '@linaria/react';
import { useState } from '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 { useReplyContext } from '@/activities/emails/hooks/useReplyContext';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { EmailThreadComposer } from '@/page-layout/widgets/email-thread/components/EmailThreadComposer';
import { EmailThreadIntermediaryMessages } from '@/page-layout/widgets/email-thread/components/EmailThreadIntermediaryMessages';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
const StyledWrapper = styled.div`
display: flex;
@@ -30,11 +35,18 @@ export const EmailThreadWidget = ({
widget: _widget,
}: EmailThreadWidgetProps) => {
const targetRecord = useTargetRecord();
const { isInSidePanel } = useLayoutRenderingContext();
const { thread, messages, fetchMoreMessages, threadLoading } = useEmailThread(
targetRecord.id,
);
const replyContext = useReplyContext(targetRecord.id);
const [isComposerOpen, setIsComposerOpen] = useState(false);
const canReply = isDefined(replyContext) && !replyContext.loading;
const messagesCount = messages.length;
const is5OrMoreMessages = messagesCount >= 5;
const firstMessages = messages.slice(
@@ -59,33 +71,38 @@ export const EmailThreadWidget = ({
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}
/>
</>
}
{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
hideBottomBorder={!isComposerOpen}
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</StyledContainer>
{canReply && (
<EmailThreadComposer
replyContext={replyContext}
isInSidePanel={isInSidePanel}
isComposerOpen={isComposerOpen}
setIsComposerOpen={setIsComposerOpen}
/>
)}
</StyledWrapper>
);
};
@@ -14,14 +14,28 @@ import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { Trans } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { styled } from '@linaria/react';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { SidePanelPageInfoLayout } from './SidePanelPageInfoLayout';
const StyledClickableTitle = styled.div`
cursor: pointer;
a {
color: inherit;
text-decoration: none;
}
`;
export const SidePanelRecordInfo = ({
sidePanelPageInstanceId,
}: {
@@ -85,6 +99,11 @@ export const SidePanelRecordInfo = ({
objectNameSingular,
});
const recordShowPagePath = getAppPath(AppPath.RecordShowPage, {
objectNameSingular,
objectRecordId,
});
const fieldDefinition = {
type: labelIdentifierFieldMetadataItem?.type ?? FieldMetadataType.TEXT,
iconName: '',
@@ -97,6 +116,25 @@ export const SidePanelRecordInfo = ({
defaultValue: labelIdentifierFieldMetadataItem?.defaultValue,
};
const titleContent = (
<FieldContext.Provider
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isCentered: false,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isTitleReadOnly,
}}
>
<RecordTitleCell
sizeVariant="sm"
containerType={RecordTitleCellContainerType.PageHeader}
/>
</FieldContext.Provider>
);
return (
<SidePanelPageInfoLayout
icon={
@@ -111,22 +149,15 @@ export const SidePanelRecordInfo = ({
) : undefined
}
title={
<FieldContext.Provider
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isCentered: false,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isTitleReadOnly,
}}
>
<RecordTitleCell
sizeVariant="sm"
containerType={RecordTitleCellContainerType.PageHeader}
/>
</FieldContext.Provider>
isTitleReadOnly ? (
<StyledClickableTitle>
<UndecoratedLink to={recordShowPagePath}>
{titleContent}
</UndecoratedLink>
</StyledClickableTitle>
) : (
titleContent
)
}
label={
beautifiedCreatedAt ? (
@@ -5,6 +5,7 @@ import { SidePanelNewSidebarItemPage } from '@/navigation-menu-item/edit/side-pa
import { SidePanelAIChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/components/SidePanelAIChatThreadsPage';
import { SidePanelAskAIPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAIPage';
import { SidePanelCalendarEventPage } from '@/side-panel/pages/calendar-event/components/SidePanelCalendarEventPage';
import { SidePanelComposeEmailPage } from '@/side-panel/pages/compose-email/components/SidePanelComposeEmailPage';
import { SidePanelFrontComponentPage } from '@/side-panel/pages/front-component/components/SidePanelFrontComponentPage';
import { SidePanelPageLayoutChartSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutChartSettings';
import { SidePanelPageLayoutFieldSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldSettings';
@@ -17,7 +18,6 @@ import { SidePanelMergeRecordPage } from '@/side-panel/pages/record-page/compone
import { SidePanelRecordPage } from '@/side-panel/pages/record-page/components/SidePanelRecordPage';
import { SidePanelUpdateMultipleRecords } from '@/side-panel/pages/record-page/components/SidePanelUpdateMultipleRecords';
import { SidePanelEditRichTextPage } from '@/side-panel/pages/rich-text-page/components/SidePanelEditRichTextPage';
import { SidePanelRootPage } from '@/side-panel/pages/root/components/SidePanelRootPage';
import { SidePanelSearchRecordsPage } from '@/side-panel/pages/search/components/SidePanelSearchRecordsPage';
import { SidePanelWorkflowCreateStep } from '@/side-panel/pages/workflow/step/create/components/SidePanelWorkflowCreateStep';
import { SidePanelWorkflowEditStep } from '@/side-panel/pages/workflow/step/edit/components/SidePanelWorkflowEditStep';
@@ -82,5 +82,6 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
],
[SidePanelPages.NavigationMenuAddItem, <SidePanelNewSidebarItemPage />],
[SidePanelPages.CommandMenuEdit, <SidePanelCommandMenuItemEditPage />],
[SidePanelPages.ComposeEmail, <SidePanelComposeEmailPage />],
],
);
@@ -0,0 +1,78 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { SidePanelPages } from 'twenty-shared/types';
import {
type IconComponent,
IconArrowBackUp,
IconMail,
} from 'twenty-ui/display';
import { v4 } from 'uuid';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { composeEmailConnectedAccountIdComponentState } from '@/side-panel/pages/compose-email/states/composeEmailConnectedAccountIdComponentState';
import { composeEmailDefaultInReplyToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultInReplyToComponentState';
import { composeEmailDefaultSubjectComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultSubjectComponentState';
import { composeEmailDefaultToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultToComponentState';
import { t } from '@lingui/core/macro';
type OpenComposeEmailParams = {
threadId?: string;
connectedAccountId: string;
defaultTo?: string;
defaultSubject?: string;
defaultInReplyTo?: string;
pageTitle?: string;
pageIcon?: IconComponent;
};
export const useOpenComposeEmailInSidePanel = () => {
const store = useStore();
const { navigateSidePanelMenu } = useSidePanelMenu();
const openComposeEmailInSidePanel = useCallback(
(params: OpenComposeEmailParams) => {
const pageId = v4();
const isReply = !!params.defaultInReplyTo;
store.set(
composeEmailConnectedAccountIdComponentState.atomFamily({
instanceId: pageId,
}),
params.connectedAccountId,
);
store.set(
composeEmailDefaultToComponentState.atomFamily({
instanceId: pageId,
}),
params.defaultTo ?? '',
);
store.set(
composeEmailDefaultSubjectComponentState.atomFamily({
instanceId: pageId,
}),
params.defaultSubject ?? '',
);
store.set(
composeEmailDefaultInReplyToComponentState.atomFamily({
instanceId: pageId,
}),
params.defaultInReplyTo ?? '',
);
navigateSidePanelMenu({
page: SidePanelPages.ComposeEmail,
pageTitle: params.pageTitle ?? (isReply ? t`Reply` : t`New Email`),
pageIcon: params.pageIcon ?? (isReply ? IconArrowBackUp : IconMail),
pageId,
});
},
[navigateSidePanelMenu, store],
);
return { openComposeEmailInSidePanel };
};
@@ -0,0 +1,103 @@
import { useCallback } from 'react';
import { EmailComposerFields } from '@/activities/emails/components/EmailComposerFields';
import { useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { composeEmailConnectedAccountIdComponentState } from '@/side-panel/pages/compose-email/states/composeEmailConnectedAccountIdComponentState';
import { composeEmailDefaultInReplyToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultInReplyToComponentState';
import { composeEmailDefaultSubjectComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultSubjectComponentState';
import { composeEmailDefaultToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultToComponentState';
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { IconSend } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol } from 'twenty-ui/utilities';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
overflow-y: auto;
`;
export const SidePanelComposeEmailPage = () => {
const composeEmailConnectedAccountId = useAtomComponentStateValue(
composeEmailConnectedAccountIdComponentState,
);
const composeEmailDefaultTo = useAtomComponentStateValue(
composeEmailDefaultToComponentState,
);
const composeEmailDefaultSubject = useAtomComponentStateValue(
composeEmailDefaultSubjectComponentState,
);
const composeEmailDefaultInReplyTo = useAtomComponentStateValue(
composeEmailDefaultInReplyToComponentState,
);
const { goBackFromSidePanel } = useSidePanelHistory();
const composerState = useEmailComposerState({
connectedAccountId: composeEmailConnectedAccountId ?? '',
defaultTo: composeEmailDefaultTo ?? '',
defaultSubject: composeEmailDefaultSubject ?? '',
defaultInReplyTo: composeEmailDefaultInReplyTo ?? undefined,
onSent: goBackFromSidePanel,
});
const handleSendHotkey = useCallback(() => {
if (composerState.canSend) {
composerState.handleSend();
}
}, [composerState.canSend, composerState.handleSend]);
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: handleSendHotkey,
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [handleSendHotkey],
});
if (!composeEmailConnectedAccountId) {
return null;
}
return (
<StyledContainer>
<StyledContent>
<EmailComposerFields composerState={composerState} />
</StyledContent>
<SidePanelFooter
actions={[
<Button
key="cancel"
size="small"
variant="secondary"
title={t`Cancel`}
onClick={goBackFromSidePanel}
/>,
<Button
key="send"
size="small"
variant="primary"
accent="blue"
title={t`Send`}
Icon={IconSend}
hotkeys={[getOsControlSymbol(), '⏎']}
onClick={composerState.handleSend}
disabled={!composerState.canSend}
/>,
]}
/>
</StyledContainer>
);
};
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeEmailConnectedAccountIdComponentState =
createAtomComponentState<string>({
key: 'side-panel/compose-email-connected-account-id',
defaultValue: '',
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeEmailDefaultInReplyToComponentState =
createAtomComponentState<string>({
key: 'side-panel/compose-email-default-in-reply-to',
defaultValue: '',
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeEmailDefaultSubjectComponentState =
createAtomComponentState<string>({
key: 'side-panel/compose-email-default-subject',
defaultValue: '',
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeEmailDefaultToComponentState =
createAtomComponentState<string>({
key: 'side-panel/compose-email-default-to',
defaultValue: '',
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { type SidePanelFooterAction } from '@/ui/layout/side-panel/types/SidePanelFooterAction';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const sidePanelWidgetFooterActionsState = createAtomState<
SidePanelFooterAction[]
>({
key: 'side-panel/widgetFooterActionsState',
defaultValue: [],
});
@@ -0,0 +1,12 @@
import { type IconComponent } from 'twenty-ui/display';
export type SidePanelFooterAction = {
key: string;
label: string;
Icon?: IconComponent;
isPrimaryCTA?: boolean;
isPinned?: boolean;
onClick: () => void;
disabled?: boolean;
hotkeys?: string[];
};