feat(messaging): sync draft emails and edit them in the thread composer (#22178)
Stop excluding drafts from sync across all three providers (Gmail DRAFT label, Microsoft/IMAP Drafts folder) and add an isDraft boolean field on Message so drafts are queryable by the API and AI agents. Drafts render in the thread with a Draft tag; clicking one opens the existing reply composer pre-filled with the draft's recipients, subject and body, and Send reuses the existing send-email flow. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22178?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
+5
-5
@@ -73,7 +73,7 @@ export const EmailComposerFields = ({
|
||||
<StyledToRow>
|
||||
<FormMultiTextFieldInput
|
||||
label={t`To`}
|
||||
defaultValue={composerState.defaultTo}
|
||||
defaultValue={composerState.initialTo}
|
||||
onChange={composerState.setTo}
|
||||
placeholder={t`Recipients`}
|
||||
/>
|
||||
@@ -87,13 +87,13 @@ export const EmailComposerFields = ({
|
||||
<>
|
||||
<FormMultiTextFieldInput
|
||||
label={t`Cc`}
|
||||
defaultValue=""
|
||||
defaultValue={composerState.initialCc}
|
||||
onChange={composerState.setCc}
|
||||
placeholder={t`Cc`}
|
||||
/>
|
||||
<FormMultiTextFieldInput
|
||||
label={t`Bcc`}
|
||||
defaultValue=""
|
||||
defaultValue={composerState.initialBcc}
|
||||
onChange={composerState.setBcc}
|
||||
placeholder={t`Bcc`}
|
||||
/>
|
||||
@@ -101,12 +101,12 @@ export const EmailComposerFields = ({
|
||||
)}
|
||||
<FormTextFieldInput
|
||||
label={t`Subject`}
|
||||
defaultValue={composerState.defaultSubject}
|
||||
defaultValue={composerState.initialSubject}
|
||||
onChange={composerState.setSubject}
|
||||
placeholder={t`Subject`}
|
||||
/>
|
||||
<FormAdvancedTextFieldInput
|
||||
defaultValue=""
|
||||
defaultValue={composerState.initialBody}
|
||||
onChange={composerState.setBody}
|
||||
placeholder={t`Type something or press "/" to see commands`}
|
||||
minHeight={120}
|
||||
|
||||
+60
-56
@@ -1,92 +1,96 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EmailThreadMessageBody } from '@/activities/emails/components/EmailThreadMessageBody';
|
||||
import { EmailThreadMessageBodyPreview } from '@/activities/emails/components/EmailThreadMessageBodyPreview';
|
||||
import { EmailThreadMessageLayout } from '@/activities/emails/components/EmailThreadMessageLayout';
|
||||
import { EmailThreadMessageReceivers } from '@/activities/emails/components/EmailThreadMessageReceivers';
|
||||
import { EmailThreadMessageSender } from '@/activities/emails/components/EmailThreadMessageSender';
|
||||
import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared';
|
||||
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
|
||||
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
|
||||
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
|
||||
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<{ 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]};
|
||||
`;
|
||||
|
||||
const StyledThreadMessageHeader = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledThreadMessageBody = styled.div`
|
||||
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
type EmailThreadMessageProps = {
|
||||
body: string;
|
||||
sentAt: string;
|
||||
sender: EmailThreadMessageParticipant;
|
||||
participants: EmailThreadMessageParticipant[];
|
||||
message: EmailThreadMessageWithSender;
|
||||
isExpanded?: boolean;
|
||||
hideBottomBorder?: boolean;
|
||||
onDraftClick: (message: EmailThreadMessageWithSender) => void;
|
||||
};
|
||||
|
||||
export const EmailThreadMessage = ({
|
||||
body,
|
||||
sentAt,
|
||||
sender,
|
||||
participants,
|
||||
message,
|
||||
isExpanded = false,
|
||||
hideBottomBorder = false,
|
||||
onDraftClick,
|
||||
}: EmailThreadMessageProps) => {
|
||||
const [isOpen, setIsOpen] = useState(isExpanded);
|
||||
|
||||
const receivers = participants.filter(
|
||||
const receivers = message.messageParticipants.filter(
|
||||
(participant) => participant.role !== MessageParticipantRole.FROM,
|
||||
);
|
||||
|
||||
if (!isDefined(sender) || receivers.length === 0) {
|
||||
if (
|
||||
!isDefined(message.sender) ||
|
||||
(!message.isDraft && receivers.length === 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isDraft } = message;
|
||||
|
||||
const isRestricted =
|
||||
body === FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
|
||||
message.text === FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
|
||||
|
||||
const handleRowClick = () => {
|
||||
if (isRestricted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
onDraftClick(message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
if (!isDraft && isOpen) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledThreadMessage
|
||||
<EmailThreadMessageLayout
|
||||
hideBottomBorder={hideBottomBorder}
|
||||
onClick={() => !isOpen && setIsOpen(true)}
|
||||
style={{ cursor: isOpen || isRestricted ? 'auto' : 'pointer' }}
|
||||
>
|
||||
<StyledThreadMessageHeader onClick={() => isOpen && setIsOpen(false)}>
|
||||
<EmailThreadMessageSender sender={sender} sentAt={sentAt} />
|
||||
{isOpen && <EmailThreadMessageReceivers receivers={receivers} />}
|
||||
</StyledThreadMessageHeader>
|
||||
<StyledThreadMessageBody>
|
||||
{isRestricted ? (
|
||||
<EmailThreadNotShared
|
||||
visibility={MessageChannelVisibility.METADATA}
|
||||
isRowClickable={!isRestricted && (isDraft || !isOpen)}
|
||||
isHeaderClickable={!isDraft && isOpen}
|
||||
onRowClick={handleRowClick}
|
||||
onHeaderClick={handleHeaderClick}
|
||||
header={
|
||||
<>
|
||||
<EmailThreadMessageSender
|
||||
sender={message.sender}
|
||||
sentAt={message.receivedAt}
|
||||
/>
|
||||
) : isOpen ? (
|
||||
<EmailThreadMessageBody body={body} isDisplayed />
|
||||
) : (
|
||||
<EmailThreadMessageBodyPreview body={body} />
|
||||
)}
|
||||
</StyledThreadMessageBody>
|
||||
</StyledThreadMessage>
|
||||
{!isDraft && isOpen && (
|
||||
<EmailThreadMessageReceivers receivers={receivers} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isRestricted ? (
|
||||
<EmailThreadNotShared visibility={MessageChannelVisibility.METADATA} />
|
||||
) : isDraft || !isOpen ? (
|
||||
<EmailThreadMessageBodyPreview body={message.text} />
|
||||
) : (
|
||||
<EmailThreadMessageBody body={message.text} isDisplayed />
|
||||
)}
|
||||
</EmailThreadMessageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
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]};
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.div<{ isClickable?: boolean }>`
|
||||
cursor: ${({ isClickable }) => (isClickable ? 'pointer' : 'auto')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledBody = styled.div`
|
||||
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
type EmailThreadMessageLayoutProps = {
|
||||
header: ReactNode;
|
||||
children: ReactNode;
|
||||
hideBottomBorder?: boolean;
|
||||
isRowClickable?: boolean;
|
||||
isHeaderClickable?: boolean;
|
||||
onRowClick?: () => void;
|
||||
onHeaderClick?: () => void;
|
||||
};
|
||||
|
||||
export const EmailThreadMessageLayout = ({
|
||||
header,
|
||||
children,
|
||||
hideBottomBorder = false,
|
||||
isRowClickable = false,
|
||||
isHeaderClickable = false,
|
||||
onRowClick,
|
||||
onHeaderClick,
|
||||
}: EmailThreadMessageLayoutProps) => (
|
||||
<StyledThreadMessage
|
||||
hideBottomBorder={hideBottomBorder}
|
||||
onClick={onRowClick}
|
||||
style={{ cursor: isRowClickable ? 'pointer' : 'auto' }}
|
||||
>
|
||||
<StyledHeader isClickable={isHeaderClickable} onClick={onHeaderClick}>
|
||||
{header}
|
||||
</StyledHeader>
|
||||
<StyledBody>{children}</StyledBody>
|
||||
</StyledThreadMessage>
|
||||
);
|
||||
+5
-1
@@ -5,9 +5,10 @@ import { EmailThreadNotShared } from '@/activities/emails/components/EmailThread
|
||||
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Avatar } from 'twenty-ui/data-display';
|
||||
import { Avatar, Tag } from 'twenty-ui/data-display';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import {
|
||||
MessageChannelVisibility,
|
||||
@@ -173,6 +174,9 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
|
||||
)}
|
||||
{visibility === MessageChannelVisibility.SHARE_EVERYTHING && (
|
||||
<>
|
||||
{thread.lastMessageIsDraft && (
|
||||
<Tag color="orange" text={t`Draft`} />
|
||||
)}
|
||||
<StyledSubject>{thread.subject}</StyledSubject>
|
||||
<StyledBody>{thread.lastMessageBody}</StyledBody>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ export const SEND_EMAIL = gql`
|
||||
sendEmail(input: $input) {
|
||||
success
|
||||
error
|
||||
messageThreadId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ export const fetchAllThreadMessagesOperationSignatureFactory: RecordGqlOperation
|
||||
subject: true,
|
||||
text: true,
|
||||
receivedAt: true,
|
||||
isDraft: true,
|
||||
messageThread: {
|
||||
id: true,
|
||||
},
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ export const timelineThreadFragment = gql`
|
||||
subject
|
||||
numberOfMessagesInThread
|
||||
participantCount
|
||||
lastMessageIsDraft
|
||||
}
|
||||
${participantFragment}
|
||||
`;
|
||||
|
||||
+27
-11
@@ -3,13 +3,15 @@ import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
|
||||
import { type EmailAttachment } from 'twenty-shared/types';
|
||||
|
||||
import { useSendEmail } from '@/activities/emails/hooks/useSendEmail';
|
||||
import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
|
||||
|
||||
type UseEmailComposerStateArgs = {
|
||||
connectedAccountId: string;
|
||||
draftPrefill?: EmailDraftPrefill | null;
|
||||
defaultTo?: string;
|
||||
defaultSubject?: string;
|
||||
defaultInReplyTo?: string;
|
||||
onSent?: () => void;
|
||||
onSent?: (messageThreadId: string | null) => void;
|
||||
};
|
||||
|
||||
const countRecipients = (csv: string): number =>
|
||||
@@ -20,20 +22,29 @@ const countRecipients = (csv: string): number =>
|
||||
|
||||
export const useEmailComposerState = ({
|
||||
connectedAccountId: initialConnectedAccountId,
|
||||
draftPrefill,
|
||||
defaultTo = '',
|
||||
defaultSubject = '',
|
||||
defaultInReplyTo,
|
||||
onSent,
|
||||
}: UseEmailComposerStateArgs) => {
|
||||
const initialTo = draftPrefill?.to ?? defaultTo;
|
||||
const initialCc = draftPrefill?.cc ?? '';
|
||||
const initialBcc = draftPrefill?.bcc ?? '';
|
||||
const initialSubject = draftPrefill?.subject ?? defaultSubject;
|
||||
const initialBody = draftPrefill?.body ?? '';
|
||||
|
||||
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 [to, setTo] = useState(initialTo);
|
||||
const [cc, setCc] = useState(initialCc);
|
||||
const [bcc, setBcc] = useState(initialBcc);
|
||||
const [subject, setSubject] = useState(initialSubject);
|
||||
const [body, setBody] = useState(initialBody);
|
||||
const [showCcBcc, setShowCcBcc] = useState(
|
||||
initialCc.length > 0 || initialBcc.length > 0,
|
||||
);
|
||||
const [files, setFiles] = useState<EmailAttachment[]>([]);
|
||||
|
||||
const { sendEmail, loading } = useSendEmail();
|
||||
@@ -60,7 +71,7 @@ export const useEmailComposerState = ({
|
||||
const trimmedCc = cc.trim();
|
||||
const trimmedBcc = bcc.trim();
|
||||
|
||||
const success = await sendEmail({
|
||||
const { success, messageThreadId } = await sendEmail({
|
||||
connectedAccountId,
|
||||
to: trimmedTo,
|
||||
cc: trimmedCc || undefined,
|
||||
@@ -68,11 +79,12 @@ export const useEmailComposerState = ({
|
||||
subject,
|
||||
body,
|
||||
inReplyTo: defaultInReplyTo,
|
||||
draftMessageId: draftPrefill?.messageId,
|
||||
files: files.length > 0 ? files : undefined,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
onSent?.();
|
||||
onSent?.(messageThreadId);
|
||||
}
|
||||
}, [
|
||||
connectedAccountId,
|
||||
@@ -82,6 +94,7 @@ export const useEmailComposerState = ({
|
||||
subject,
|
||||
body,
|
||||
defaultInReplyTo,
|
||||
draftPrefill?.messageId,
|
||||
files,
|
||||
sendEmail,
|
||||
onSent,
|
||||
@@ -108,8 +121,11 @@ export const useEmailComposerState = ({
|
||||
handleSend,
|
||||
loading,
|
||||
canSend,
|
||||
defaultTo,
|
||||
defaultSubject,
|
||||
initialTo,
|
||||
initialCc,
|
||||
initialBcc,
|
||||
initialSubject,
|
||||
initialBody,
|
||||
recipientCount,
|
||||
exceedsRecipientLimit,
|
||||
maxRecipients: MAX_EMAIL_RECIPIENTS,
|
||||
|
||||
@@ -32,15 +32,27 @@ export const useReplyContext = (
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const sentMessages = messages.filter((message) => !message.isDraft);
|
||||
const lastSentMessage = sentMessages[sentMessages.length - 1];
|
||||
|
||||
if (!isDefined(lastMessage)) {
|
||||
return null;
|
||||
if (!isDefined(lastSentMessage)) {
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
to: '',
|
||||
subject: '',
|
||||
inReplyTo: '',
|
||||
connectedAccountId,
|
||||
connectedAccountProvider,
|
||||
};
|
||||
}
|
||||
|
||||
const senderHandle = lastMessage.sender?.handle ?? '';
|
||||
const senderHandle = lastSentMessage.sender?.handle ?? '';
|
||||
|
||||
const rawSubject = lastMessage.subject ?? '';
|
||||
const rawSubject = lastSentMessage.subject ?? '';
|
||||
const subject = rawSubject.startsWith('Re: ')
|
||||
? rawSubject
|
||||
: `Re: ${rawSubject}`;
|
||||
@@ -49,7 +61,7 @@ export const useReplyContext = (
|
||||
loading: false,
|
||||
to: senderHandle,
|
||||
subject,
|
||||
inReplyTo: lastMessage.headerMessageId ?? '',
|
||||
inReplyTo: lastSentMessage.headerMessageId ?? '',
|
||||
connectedAccountId,
|
||||
connectedAccountProvider,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
type SendEmailMutationVariables,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type SendEmailResult = {
|
||||
success: boolean;
|
||||
messageThreadId: string | null;
|
||||
};
|
||||
|
||||
type SendEmailParams = {
|
||||
connectedAccountId: string;
|
||||
to: string;
|
||||
@@ -20,6 +25,7 @@ type SendEmailParams = {
|
||||
subject: string;
|
||||
body: string;
|
||||
inReplyTo?: string;
|
||||
draftMessageId?: string;
|
||||
files?: EmailAttachment[];
|
||||
};
|
||||
|
||||
@@ -34,7 +40,7 @@ export const useSendEmail = () => {
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const sendEmail = useCallback(
|
||||
async (params: SendEmailParams): Promise<boolean> => {
|
||||
async (params: SendEmailParams): Promise<SendEmailResult> => {
|
||||
try {
|
||||
const result = await sendEmailMutation({
|
||||
variables: {
|
||||
@@ -46,6 +52,7 @@ export const useSendEmail = () => {
|
||||
subject: params.subject,
|
||||
body: params.body,
|
||||
inReplyTo: params.inReplyTo,
|
||||
draftMessageId: params.draftMessageId,
|
||||
files: params.files,
|
||||
},
|
||||
},
|
||||
@@ -65,20 +72,23 @@ export const useSendEmail = () => {
|
||||
],
|
||||
});
|
||||
|
||||
return true;
|
||||
return {
|
||||
success: true,
|
||||
messageThreadId: result.data.sendEmail.messageThreadId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
enqueueErrorSnackBar({
|
||||
message: result.data?.sendEmail.error ?? t`Failed to send email`,
|
||||
});
|
||||
|
||||
return false;
|
||||
return { success: false, messageThreadId: null };
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to send email`,
|
||||
});
|
||||
|
||||
return false;
|
||||
return { success: false, messageThreadId: null };
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type EmailRecipients } from 'twenty-shared/workflow';
|
||||
|
||||
export type EmailDraftPrefill = EmailRecipients & {
|
||||
messageId: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
};
|
||||
@@ -10,5 +10,6 @@ export type EmailThreadMessage = {
|
||||
messageThreadId: string;
|
||||
messageParticipants: EmailThreadMessageParticipant[];
|
||||
messageThread: MessageThread;
|
||||
isDraft: boolean;
|
||||
__typename: 'EmailThreadMessage';
|
||||
};
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
|
||||
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
|
||||
import { MessageParticipantRole } from 'twenty-shared/types';
|
||||
|
||||
export const getEmailDraftPrefillFromMessage = (
|
||||
message: EmailThreadMessageWithSender,
|
||||
): EmailDraftPrefill => {
|
||||
const joinHandlesByRole = (role: MessageParticipantRole) =>
|
||||
message.messageParticipants
|
||||
.filter((participant) => participant.role === role)
|
||||
.map((participant) => participant.handle)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
messageId: message.id,
|
||||
to: joinHandlesByRole(MessageParticipantRole.TO),
|
||||
cc: joinHandlesByRole(MessageParticipantRole.CC),
|
||||
bcc: joinHandlesByRole(MessageParticipantRole.BCC),
|
||||
subject: message.subject,
|
||||
body: message.text,
|
||||
};
|
||||
};
|
||||
+22
-3
@@ -4,11 +4,15 @@ import { useCallback, 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 { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
|
||||
import { EmailThreadComposerFooterEffect } from '@/page-layout/widgets/email-thread/components/EmailThreadComposerFooterEffect';
|
||||
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
|
||||
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
|
||||
import { type SidePanelFooterCommandMenuItem } from '@/ui/layout/side-panel/types/SidePanelFooterCommandMenuItem';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconArrowBackUp, IconSend, IconX } from 'twenty-ui/icon';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { getOsControlSymbol } from 'twenty-ui/utilities';
|
||||
@@ -34,6 +38,7 @@ type EmailThreadComposerProps = {
|
||||
isInSidePanel: boolean;
|
||||
isComposerOpen: boolean;
|
||||
setIsComposerOpen: (open: boolean) => void;
|
||||
draftPrefill?: EmailDraftPrefill | null;
|
||||
};
|
||||
|
||||
export const EmailThreadComposer = ({
|
||||
@@ -41,13 +46,27 @@ export const EmailThreadComposer = ({
|
||||
isInSidePanel,
|
||||
isComposerOpen,
|
||||
setIsComposerOpen,
|
||||
draftPrefill,
|
||||
}: EmailThreadComposerProps) => {
|
||||
const handleReplySent = useCallback(() => {
|
||||
setIsComposerOpen(false);
|
||||
}, [setIsComposerOpen]);
|
||||
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
|
||||
|
||||
const handleReplySent = useCallback(
|
||||
(messageThreadId: string | null) => {
|
||||
setIsComposerOpen(false);
|
||||
|
||||
if (isDefined(messageThreadId)) {
|
||||
openRecordInSidePanel({
|
||||
recordId: messageThreadId,
|
||||
objectNameSingular: CoreObjectNameSingular.MessageThread,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setIsComposerOpen, openRecordInSidePanel],
|
||||
);
|
||||
|
||||
const composerState = useEmailComposerState({
|
||||
connectedAccountId: replyContext.connectedAccountId,
|
||||
draftPrefill,
|
||||
defaultTo: replyContext.to,
|
||||
defaultSubject: replyContext.subject,
|
||||
defaultInReplyTo: replyContext.inReplyTo,
|
||||
|
||||
+4
-4
@@ -15,8 +15,10 @@ const StyledButtonContainer = styled.div`
|
||||
|
||||
export const EmailThreadIntermediaryMessages = ({
|
||||
messages,
|
||||
onDraftClick,
|
||||
}: {
|
||||
messages: EmailThreadMessageWithSender[];
|
||||
onDraftClick: (message: EmailThreadMessageWithSender) => void;
|
||||
}) => {
|
||||
const [areMessagesOpen, setAreMessagesOpen] = useState(false);
|
||||
const messagesLength = messages.length;
|
||||
@@ -29,10 +31,8 @@ export const EmailThreadIntermediaryMessages = ({
|
||||
messages.map((message) => (
|
||||
<EmailThreadMessage
|
||||
key={message.id}
|
||||
sender={message.sender}
|
||||
participants={message.messageParticipants}
|
||||
body={message.text}
|
||||
sentAt={message.receivedAt}
|
||||
message={message}
|
||||
onDraftClick={onDraftClick}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
+55
-12
@@ -1,11 +1,14 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useState } from 'react';
|
||||
import { useCallback, 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 EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
|
||||
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
|
||||
import { getEmailDraftPrefillFromMessage } from '@/activities/emails/utils/getEmailDraftPrefillFromMessage';
|
||||
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';
|
||||
@@ -43,7 +46,36 @@ export const EmailThreadWidget = ({
|
||||
|
||||
const replyContext = useReplyContext(targetRecord.id);
|
||||
|
||||
const [isComposerOpen, setIsComposerOpen] = useState(false);
|
||||
const [composerIntent, setComposerIntent] = useState<
|
||||
'opened' | 'closed' | null
|
||||
>(null);
|
||||
const [clickedDraftPrefill, setClickedDraftPrefill] =
|
||||
useState<EmailDraftPrefill | null>(null);
|
||||
const [previousTargetRecordId, setPreviousTargetRecordId] = useState(
|
||||
targetRecord.id,
|
||||
);
|
||||
|
||||
if (previousTargetRecordId !== targetRecord.id) {
|
||||
setPreviousTargetRecordId(targetRecord.id);
|
||||
setComposerIntent(null);
|
||||
setClickedDraftPrefill(null);
|
||||
}
|
||||
|
||||
const handleComposerOpenChange = useCallback((open: boolean) => {
|
||||
setComposerIntent(open ? 'opened' : 'closed');
|
||||
|
||||
if (!open) {
|
||||
setClickedDraftPrefill(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDraftClick = useCallback(
|
||||
(message: EmailThreadMessageWithSender) => {
|
||||
setClickedDraftPrefill(getEmailDraftPrefillFromMessage(message));
|
||||
setComposerIntent('opened');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const canReply = isDefined(replyContext) && !replyContext.loading;
|
||||
|
||||
@@ -58,6 +90,16 @@ export const EmailThreadWidget = ({
|
||||
: [];
|
||||
const lastMessage = messages[messagesCount - 1];
|
||||
|
||||
const trailingDraft = lastMessage?.isDraft ? lastMessage : undefined;
|
||||
const draftPrefill =
|
||||
clickedDraftPrefill ??
|
||||
(isDefined(trailingDraft)
|
||||
? getEmailDraftPrefillFromMessage(trailingDraft)
|
||||
: null);
|
||||
const isComposerOpen =
|
||||
composerIntent === 'opened' ||
|
||||
(composerIntent === null && isDefined(trailingDraft));
|
||||
|
||||
if (threadLoading || !thread || !messages.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -74,21 +116,20 @@ export const EmailThreadWidget = ({
|
||||
{firstMessages.map((message) => (
|
||||
<EmailThreadMessage
|
||||
key={message.id}
|
||||
sender={message.sender}
|
||||
participants={message.messageParticipants}
|
||||
body={message.text}
|
||||
sentAt={message.receivedAt}
|
||||
message={message}
|
||||
onDraftClick={handleDraftClick}
|
||||
/>
|
||||
))}
|
||||
<EmailThreadIntermediaryMessages messages={intermediaryMessages} />
|
||||
<EmailThreadIntermediaryMessages
|
||||
messages={intermediaryMessages}
|
||||
onDraftClick={handleDraftClick}
|
||||
/>
|
||||
<EmailThreadMessage
|
||||
key={lastMessage.id}
|
||||
sender={lastMessage.sender}
|
||||
participants={lastMessage.messageParticipants}
|
||||
body={lastMessage.text}
|
||||
sentAt={lastMessage.receivedAt}
|
||||
message={lastMessage}
|
||||
isExpanded
|
||||
hideBottomBorder={!isComposerOpen}
|
||||
onDraftClick={handleDraftClick}
|
||||
/>
|
||||
<CustomResolverFetchMoreLoader
|
||||
loading={threadLoading}
|
||||
@@ -97,10 +138,12 @@ export const EmailThreadWidget = ({
|
||||
</StyledContainer>
|
||||
{canReply && (
|
||||
<EmailThreadComposer
|
||||
key={draftPrefill?.messageId ?? 'reply'}
|
||||
replyContext={replyContext}
|
||||
isInSidePanel={isInSidePanel}
|
||||
isComposerOpen={isComposerOpen}
|
||||
setIsComposerOpen={setIsComposerOpen}
|
||||
setIsComposerOpen={handleComposerOpenChange}
|
||||
draftPrefill={draftPrefill}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
|
||||
+1
-3
@@ -1,6 +1,5 @@
|
||||
import { getInitialEditorContent } from '@/workflow/workflow-variables/utils/getInitialEditorContent';
|
||||
import type { JSONContent } from '@tiptap/react';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
// Previous format of the email body was plain text,
|
||||
// but from now on we will save it as JSON.
|
||||
@@ -33,8 +32,7 @@ export const getInitialAdvancedTextEditorContent = (
|
||||
}
|
||||
|
||||
return json;
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
} catch {
|
||||
return getInitialEditorContent(rawContent);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user