diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 5923755afb..3fba58bffc 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -83,6 +83,7 @@ "@tiptap/react": "3.4.2", "@types/marked": "^6.0.0", "@xyflow/react": "^12.4.2", + "addressparser": "1.0.1", "ai": "6.0.97", "apollo-link-rest": "^0.10.0-rc.2", "apollo-upload-client": "^19.0.0", @@ -176,6 +177,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@tiptap/suggestion": "3.4.2", + "@types/addressparser": "^1.0.3", "@types/deep-equal": "^1.0.1", "@types/file-saver": "^2.0.7", "@types/jest": "^30.0.0", diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailComposerFields.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailComposerFields.tsx index 52c91f1b82..445693d254 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailComposerFields.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailComposerFields.tsx @@ -2,9 +2,11 @@ import { useQuery } from '@apollo/client/react'; import { styled } from '@linaria/react'; import { EmailAttachmentsField } from '@/activities/emails/components/EmailAttachmentsField'; +import { EmailRecipientsFieldInput } from '@/activities/emails/recipients/components/EmailRecipientsFieldInput'; +import { type EmailComposerContextRecord } from '@/activities/emails/recipients/types/EmailComposerContextRecord'; +import { getEmailRecipientKey } from '@/activities/emails/recipients/utils/getEmailRecipientKey'; 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'; @@ -39,12 +41,19 @@ const StyledCcBccToggle = styled.button` } `; +const StyledRecipientLimitWarning = styled.div` + color: ${themeCssVariables.color.red}; + font-size: ${themeCssVariables.font.size.xs}; +`; + type EmailComposerFieldsProps = { composerState: EmailComposerState; + contextRecord?: EmailComposerContextRecord | null; }; export const EmailComposerFields = ({ composerState, + contextRecord, }: EmailComposerFieldsProps) => { const { data: accountsData } = useQuery<{ myConnectedAccounts: { id: string; handle: string }[]; @@ -58,6 +67,12 @@ export const EmailComposerFields = ({ const hasMultipleAccounts = accountOptions.length > 1; + const allRecipientKeys = [ + ...composerState.to, + ...composerState.cc, + ...composerState.bcc, + ].map((recipient) => getEmailRecipientKey(recipient.address)); + return ( {hasMultipleAccounts && ( @@ -71,11 +86,14 @@ export const EmailComposerFields = ({ /> )} - {!composerState.showCcBcc && ( composerState.setShowCcBcc(true)}> @@ -85,20 +103,31 @@ export const EmailComposerFields = ({ {composerState.showCcBcc && ( <> - - )} + {composerState.exceedsRecipientLimit && ( + + {t`Too many recipients (${composerState.recipientCount}/${composerState.maxRecipients}).`} + + )} { openComposeEmailInSidePanel({ connectedAccountId, defaultTo, + contextRecord: { + objectNameSingular: targetRecord.targetObjectNameSingular, + recordId: targetRecord.id, + }, }); }; diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useEmailComposerState.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useEmailComposerState.ts index cebac50e67..a444c7654e 100644 --- a/packages/twenty-front/src/modules/activities/emails/hooks/useEmailComposerState.ts +++ b/packages/twenty-front/src/modules/activities/emails/hooks/useEmailComposerState.ts @@ -3,6 +3,10 @@ import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants'; import { type EmailAttachment } from 'twenty-shared/types'; import { useSendEmail } from '@/activities/emails/hooks/useSendEmail'; +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; +import { isValidEmailRecipientAddress } from '@/activities/emails/recipients/utils/isValidEmailRecipientAddress'; +import { parseEmailRecipients } from '@/activities/emails/recipients/utils/parseEmailRecipients'; +import { serializeEmailRecipients } from '@/activities/emails/recipients/utils/serializeEmailRecipients'; import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill'; type UseEmailComposerStateArgs = { @@ -14,11 +18,10 @@ type UseEmailComposerStateArgs = { onSent?: (messageThreadId: string | null) => void; }; -const countRecipients = (csv: string): number => - csv - .split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0).length; +const hasInvalidRecipient = (recipients: EmailRecipient[]): boolean => + recipients.some( + (recipient) => !isValidEmailRecipientAddress(recipient.address), + ); export const useEmailComposerState = ({ connectedAccountId: initialConnectedAccountId, @@ -37,9 +40,15 @@ export const useEmailComposerState = ({ const [connectedAccountId, setConnectedAccountId] = useState( initialConnectedAccountId, ); - const [to, setTo] = useState(initialTo); - const [cc, setCc] = useState(initialCc); - const [bcc, setBcc] = useState(initialBcc); + const [to, setTo] = useState(() => + parseEmailRecipients(initialTo), + ); + const [cc, setCc] = useState(() => + parseEmailRecipients(initialCc), + ); + const [bcc, setBcc] = useState(() => + parseEmailRecipients(initialBcc), + ); const [subject, setSubject] = useState(initialSubject); const [body, setBody] = useState(initialBody); const [showCcBcc, setShowCcBcc] = useState( @@ -49,33 +58,38 @@ export const useEmailComposerState = ({ const { sendEmail, loading } = useSendEmail(); - const recipientCount = useMemo( - () => countRecipients(to) + countRecipients(cc) + countRecipients(bcc), - [to, cc, bcc], - ); + const recipientCount = to.length + cc.length + bcc.length; const exceedsRecipientLimit = recipientCount > MAX_EMAIL_RECIPIENTS; + const hasInvalidRecipients = useMemo( + () => + hasInvalidRecipient(to) || + hasInvalidRecipient(cc) || + hasInvalidRecipient(bcc), + [to, cc, bcc], + ); + const canSend = - to.trim().length > 0 && + to.length > 0 && connectedAccountId.length > 0 && !loading && - !exceedsRecipientLimit; + !exceedsRecipientLimit && + !hasInvalidRecipients; const handleSend = useCallback(async () => { - if (!to.trim() || !connectedAccountId || exceedsRecipientLimit) { + if (!canSend) { return; } - const trimmedTo = to.trim(); - const trimmedCc = cc.trim(); - const trimmedBcc = bcc.trim(); + const serializedCc = serializeEmailRecipients(cc); + const serializedBcc = serializeEmailRecipients(bcc); const { success, messageThreadId } = await sendEmail({ connectedAccountId, - to: trimmedTo, - cc: trimmedCc || undefined, - bcc: trimmedBcc || undefined, + to: serializeEmailRecipients(to), + cc: serializedCc || undefined, + bcc: serializedBcc || undefined, subject, body, inReplyTo: defaultInReplyTo, @@ -87,6 +101,7 @@ export const useEmailComposerState = ({ onSent?.(messageThreadId); } }, [ + canSend, connectedAccountId, to, cc, @@ -98,7 +113,6 @@ export const useEmailComposerState = ({ files, sendEmail, onSent, - exceedsRecipientLimit, ]); return { @@ -121,9 +135,6 @@ export const useEmailComposerState = ({ handleSend, loading, canSend, - initialTo, - initialCc, - initialBcc, initialSubject, initialBody, recipientCount, diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useReplyContext.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useReplyContext.ts index a54535b6aa..c7e09b6bfc 100644 --- a/packages/twenty-front/src/modules/activities/emails/hooks/useReplyContext.ts +++ b/packages/twenty-front/src/modules/activities/emails/hooks/useReplyContext.ts @@ -1,6 +1,8 @@ +import { isNonEmptyString } from '@sniptt/guards'; import { useMemo } from 'react'; import { useEmailThread } from '@/activities/emails/hooks/useEmailThread'; +import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient'; import { type ReplyContext, type ReplyContextReady, @@ -51,6 +53,12 @@ export const useReplyContext = ( } const senderHandle = lastSentMessage.sender?.handle ?? ''; + const replyTo = isNonEmptyString(senderHandle) + ? formatEmailRecipient({ + address: senderHandle, + displayName: lastSentMessage.sender?.displayName, + }) + : ''; const rawSubject = lastSentMessage.subject ?? ''; const subject = rawSubject.startsWith('Re: ') @@ -59,7 +67,7 @@ export const useReplyContext = ( return { loading: false, - to: senderHandle, + to: replyTo, subject, inReplyTo: lastSentMessage.headerMessageId ?? '', connectedAccountId, diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientChipMenuContent.tsx b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientChipMenuContent.tsx new file mode 100644 index 0000000000..2b3c475d9f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientChipMenuContent.tsx @@ -0,0 +1,158 @@ +import { useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { IconCopy, IconPencil, IconTrash, IconUserPlus } from 'twenty-ui/icon'; +import { MenuItem, MenuItemAvatar } from 'twenty-ui/navigation'; + +import { type EmailRecipientResolution } from '@/activities/emails/recipients/hooks/useEmailRecipientsResolution'; +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; +import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; +import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl'; + +type EmailRecipientChipMenuContentProps = { + dropdownId: string; + recipient: EmailRecipient; + resolution: EmailRecipientResolution | undefined; + isInvalid: boolean; + onEdit: () => void; + onRemove: () => void; +}; + +export const EmailRecipientChipMenuContent = ({ + dropdownId, + recipient, + resolution, + isInvalid, + onEdit, + onRemove, +}: EmailRecipientChipMenuContentProps) => { + const { t } = useLingui(); + const { closeDropdown } = useCloseDropdown(); + const { copyToClipboard } = useCopyToClipboard(); + const { enqueueSuccessSnackBar } = useSnackBar(); + + const { createOneRecord: createPerson } = useCreateOneRecord({ + objectNameSingular: CoreObjectNameSingular.Person, + }); + + const workspaceMember = resolution?.workspaceMember; + const person = resolution?.person; + + const workspaceMemberFullName = isDefined(workspaceMember) + ? `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim() + : ''; + const personFullName = isDefined(person) + ? `${person.firstName} ${person.lastName}`.trim() + : ''; + + const handleAddAsPerson = async () => { + closeDropdown(dropdownId); + + const [firstName = '', ...lastNameParts] = ( + recipient.displayName ?? '' + ).split(' '); + + const createdPerson = await createPerson({ + emails: { primaryEmail: recipient.address, additionalEmails: [] }, + name: { firstName, lastName: lastNameParts.join(' ') }, + }); + + if (isDefined(createdPerson)) { + enqueueSuccessSnackBar({ message: t`Person created` }); + } + }; + + const handleCopy = () => { + closeDropdown(dropdownId); + copyToClipboard(recipient.address, t`Email copied to clipboard`); + }; + + const handleEdit = () => { + closeDropdown(dropdownId); + onEdit(); + }; + + const handleRemove = () => { + closeDropdown(dropdownId); + onRemove(); + }; + + const showAddAsPerson = + !isDefined(person) && !isDefined(workspaceMember) && !isInvalid; + + return ( + + {(isDefined(person) || isDefined(workspaceMember) || showAddAsPerson) && ( + <> + + {isDefined(workspaceMember) ? ( + + ) : isDefined(person) ? ( + + ) : ( + + )} + + + + )} + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientSuggestionMenuItem.tsx b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientSuggestionMenuItem.tsx new file mode 100644 index 0000000000..01863de4fb --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientSuggestionMenuItem.tsx @@ -0,0 +1,47 @@ +import { Avatar } from 'twenty-ui/data-display'; +import { MenuItemSelectAvatar } from 'twenty-ui/navigation'; + +import { type EmailRecipientSuggestion } from '@/activities/emails/recipients/hooks/useEmailRecipientSuggestions'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState'; +import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; +import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl'; + +type EmailRecipientSuggestionMenuItemProps = { + suggestion: EmailRecipientSuggestion; + selectableListInstanceId: string; + onPick: (suggestion: EmailRecipientSuggestion) => void; +}; + +export const EmailRecipientSuggestionMenuItem = ({ + suggestion, + selectableListInstanceId, + onPick, +}: EmailRecipientSuggestionMenuItemProps) => { + const isSelectedItemId = useAtomComponentFamilyStateValue( + isSelectedItemIdComponentFamilyState, + suggestion.suggestionId, + selectableListInstanceId, + ); + + return ( + + onPick(suggestion)} + text={suggestion.label} + contextualText={suggestion.secondaryText} + selected={false} + focused={isSelectedItemId} + avatar={ + + } + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientSuggestionsDropdownContent.tsx b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientSuggestionsDropdownContent.tsx new file mode 100644 index 0000000000..e46ba5120c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientSuggestionsDropdownContent.tsx @@ -0,0 +1,53 @@ +import { useLingui } from '@lingui/react/macro'; +import { MenuItem } from 'twenty-ui/navigation'; + +import { EmailRecipientSuggestionMenuItem } from '@/activities/emails/recipients/components/EmailRecipientSuggestionMenuItem'; +import { type EmailRecipientSuggestion } from '@/activities/emails/recipients/hooks/useEmailRecipientSuggestions'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; + +type EmailRecipientSuggestionsDropdownContentProps = { + suggestions: EmailRecipientSuggestion[]; + selectableListInstanceId: string; + focusId: string; + onPick: (suggestion: EmailRecipientSuggestion) => void; +}; + +export const EmailRecipientSuggestionsDropdownContent = ({ + suggestions, + selectableListInstanceId, + focusId, + onPick, +}: EmailRecipientSuggestionsDropdownContentProps) => { + const { t } = useLingui(); + + return ( +
event.preventDefault()}> + + + {suggestions.length === 0 ? ( + + ) : ( + suggestion.suggestionId, + )} + > + {suggestions.map((suggestion) => ( + + ))} + + )} + + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientsFieldChip.tsx b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientsFieldChip.tsx new file mode 100644 index 0000000000..ce87253ebc --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientsFieldChip.tsx @@ -0,0 +1,107 @@ +import { useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +import { Avatar } from 'twenty-ui/data-display'; + +import { EmailRecipientChipMenuContent } from '@/activities/emails/recipients/components/EmailRecipientChipMenuContent'; +import { type EmailRecipientResolution } from '@/activities/emails/recipients/hooks/useEmailRecipientsResolution'; +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; +import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient'; +import { BaseChip } from '@/object-record/record-field/ui/form-types/components/BaseChip'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl'; + +const CHIP_MAX_LABEL_WIDTH = 240; + +type EmailRecipientsFieldChipProps = { + chipId: string; + dropdownId: string; + recipient: EmailRecipient; + resolution: EmailRecipientResolution | undefined; + isInvalid: boolean; + selected: boolean; + isFlashing: boolean; + onEdit: () => void; + onRemove: () => void; +}; + +export const EmailRecipientsFieldChip = ({ + chipId, + dropdownId, + recipient, + resolution, + isInvalid, + selected, + isFlashing, + onEdit, + onRemove, +}: EmailRecipientsFieldChipProps) => { + const { t } = useLingui(); + + const workspaceMember = resolution?.workspaceMember; + const person = resolution?.person; + + const workspaceMemberFullName = isDefined(workspaceMember) + ? `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim() + : ''; + const personFullName = isDefined(person) + ? `${person.firstName} ${person.lastName}`.trim() + : ''; + + const resolvedLabel = + [workspaceMemberFullName, personFullName, recipient.displayName ?? ''].find( + isNonEmptyString, + ) ?? recipient.address; + + const avatar = + isDefined(workspaceMember) || isDefined(person) ? ( + + ) : undefined; + + return ( + { + event.stopPropagation(); + onRemove(); + }} + removeAriaLabel={t`Remove ${recipient.address}`} + /> + } + dropdownComponents={ + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientsFieldInput.tsx b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientsFieldInput.tsx new file mode 100644 index 0000000000..17763cad87 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientsFieldInput.tsx @@ -0,0 +1,558 @@ +import { styled } from '@linaria/react'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useStore } from 'jotai'; +import { + type ClipboardEvent, + type KeyboardEvent, + type MouseEvent, + type ReactNode, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import { flushSync } from 'react-dom'; +import { isDefined } from 'twenty-shared/utils'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { useDebouncedCallback } from 'use-debounce'; + +import { EmailRecipientsFieldChip } from '@/activities/emails/recipients/components/EmailRecipientsFieldChip'; +import { EmailRecipientSuggestionsDropdownContent } from '@/activities/emails/recipients/components/EmailRecipientSuggestionsDropdownContent'; +import { useEmailRecipientsField } from '@/activities/emails/recipients/hooks/useEmailRecipientsField'; +import { useEmailRecipientsResolution } from '@/activities/emails/recipients/hooks/useEmailRecipientsResolution'; +import { + type EmailRecipientSuggestion, + useEmailRecipientSuggestions, +} from '@/activities/emails/recipients/hooks/useEmailRecipientSuggestions'; +import { type EmailComposerContextRecord } from '@/activities/emails/recipients/types/EmailComposerContextRecord'; +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; +import { getEmailRecipientKey } from '@/activities/emails/recipients/utils/getEmailRecipientKey'; +import { isValidEmailRecipientAddress } from '@/activities/emails/recipients/utils/isValidEmailRecipientAddress'; +import { parseEmailRecipients } from '@/activities/emails/recipients/utils/parseEmailRecipients'; +import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer'; +import { FORM_FIELD_PLACEHOLDER_STYLES } from '@/object-record/record-field/ui/form-types/constants/FormFieldPlaceholderStyles'; +import { InputLabel } from '@/ui/input/components/InputLabel'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; +import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; +import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; + +const SUGGESTIONS_SEARCH_DEBOUNCE_MS = 300; + +const StyledRowContainer = styled.div` + align-content: flex-start; + align-items: center; + background-color: ${themeCssVariables.background.transparent.lighter}; + border: 1px solid ${themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.md}; + box-sizing: border-box; + cursor: text; + display: flex; + flex-wrap: wrap; + gap: ${themeCssVariables.spacing[1]}; + max-height: 96px; + min-height: 32px; + overflow-y: auto; + padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]}; + width: 100%; +`; + +const StyledInput = styled.input` + background: transparent; + border: none; + color: ${themeCssVariables.font.color.primary}; + flex: 1 1 60px; + font-family: inherit; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.regular}; + height: 20px; + min-width: 60px; + outline: none; + padding: 0; + + &::placeholder { + ${FORM_FIELD_PLACEHOLDER_STYLES} + } +`; + +type EmailRecipientsFieldInputProps = { + label: string; + placeholder: string; + recipients: EmailRecipient[]; + onChange: (recipients: EmailRecipient[]) => void; + onSubmit?: () => void; + excludedSuggestionKeys?: string[]; + contextRecord?: EmailComposerContextRecord | null; +}; + +export const EmailRecipientsFieldInput = ({ + label, + placeholder, + recipients, + onChange, + onSubmit, + excludedSuggestionKeys = [], + contextRecord, +}: EmailRecipientsFieldInputProps) => { + const instanceId = useId(); + const focusId = `email-recipients-field-${instanceId}`; + const suggestionsDropdownId = `${focusId}-suggestions`; + + const inputRef = useRef(null); + + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + const { removeFocusItemFromFocusStackById } = + useRemoveFocusItemFromFocusStackById(); + const { openDropdown } = useOpenDropdown(); + const { closeDropdown } = useCloseDropdown(); + const { resetSelectedItem } = useSelectableList(suggestionsDropdownId); + + const store = useStore(); + const selectedItemIdAtom = useAtomComponentStateCallbackState( + selectedItemIdComponentState, + suggestionsDropdownId, + ); + + const isDropdownOpen = useAtomComponentStateValue( + isDropdownOpenComponentState, + suggestionsDropdownId, + ); + + const { + inputValue, + setInputValue, + editingIndex, + isEditing, + selectedChipIndex, + chipFlash, + commitInput, + addRecipient, + addRecipients, + beginEditingChip, + cancelEditing, + removeRecipientAtIndex, + removeRecipientWithKeyboard, + clearChipSelection, + moveChipSelection, + } = useEmailRecipientsField({ recipients, onChange }); + + const [suggestionsSearchInput, setSuggestionsSearchInput] = useState(''); + const debouncedSetSuggestionsSearchInput = useDebouncedCallback( + setSuggestionsSearchInput, + SUGGESTIONS_SEARCH_DEBOUNCE_MS, + ); + + const resetSuggestionsSearchInput = () => { + debouncedSetSuggestionsSearchInput.cancel(); + setSuggestionsSearchInput(''); + }; + + const { resolutionByRecipientKey } = useEmailRecipientsResolution({ + recipients, + }); + + const { suggestions } = useEmailRecipientSuggestions({ + searchInput: isEditing ? '' : suggestionsSearchInput, + excludedRecipientKeys: excludedSuggestionKeys, + contextRecord, + }); + + const invalidRecipientKeys = useMemo( + () => + new Set( + recipients + .filter( + (recipient) => !isValidEmailRecipientAddress(recipient.address), + ) + .map((recipient) => getEmailRecipientKey(recipient.address)), + ), + [recipients], + ); + + const openSuggestions = () => { + if (!isDropdownOpen) { + openDropdown({ + dropdownComponentInstanceIdFromProps: suggestionsDropdownId, + globalHotkeysConfig: { enableGlobalHotkeysWithModifiers: true }, + }); + } + }; + + const closeSuggestions = () => { + if (isDropdownOpen) { + closeDropdown(suggestionsDropdownId); + } + }; + + const getChipId = (chipIndex: number) => `${focusId}-chip-${chipIndex}`; + + const scrollChipIntoView = (chipIndex: number | null) => { + if (chipIndex === null) { + return; + } + + document + .getElementById(getChipId(chipIndex)) + ?.scrollIntoView({ block: 'nearest' }); + }; + + const focusInput = () => inputRef.current?.focus(); + + const handleRowMouseDown = (event: MouseEvent) => { + if (event.target === event.currentTarget) { + event.preventDefault(); + focusInput(); + } + }; + + const handleInputFocus = () => { + pushFocusItemToFocusStack({ + focusId, + component: { + type: FocusComponentType.FORM_FIELD_INPUT, + instanceId: focusId, + }, + globalHotkeysConfig: { + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }); + }; + + const handleInputClick = () => { + if (!isEditing && inputValue.length === 0 && suggestions.length > 0) { + openSuggestions(); + } + }; + + const commitInputAndCloseSuggestions = () => { + commitInput(); + resetSuggestionsSearchInput(); + closeSuggestions(); + }; + + const handleInputBlur = () => { + removeFocusItemFromFocusStackById({ focusId }); + commitInputAndCloseSuggestions(); + clearChipSelection(); + }; + + const handleInputChange = (value: string) => { + setInputValue(value); + clearChipSelection(); + resetSelectedItem(); + + if (isEditing) { + return; + } + + debouncedSetSuggestionsSearchInput(value); + + if (value.trim().length > 0) { + openSuggestions(); + } else { + resetSuggestionsSearchInput(); + closeSuggestions(); + } + }; + + const handlePickSuggestion = (suggestion: EmailRecipientSuggestion) => { + addRecipient(suggestion.recipient); + resetSuggestionsSearchInput(); + closeSuggestions(); + focusInput(); + }; + + const handleInputPaste = (event: ClipboardEvent) => { + if (isEditing) { + return; + } + + const pastedText = event.clipboardData.getData('text/plain'); + const parsedRecipients = parseEmailRecipients(pastedText); + + const shouldCommitAsChips = + parsedRecipients.length > 1 || + (parsedRecipients.length === 1 && + (isNonEmptyString(parsedRecipients[0].displayName) || + isValidEmailRecipientAddress(parsedRecipients[0].address))); + + if (!shouldCommitAsChips) { + return; + } + + event.preventDefault(); + addRecipients(parsedRecipients, null); + }; + + const handleChipEdit = (chipIndex: number) => { + flushSync(() => { + beginEditingChip(chipIndex); + resetSuggestionsSearchInput(); + closeSuggestions(); + }); + + const inputElement = inputRef.current; + + if (!isDefined(inputElement)) { + return; + } + + inputElement.focus(); + inputElement.setSelectionRange( + inputElement.value.length, + inputElement.value.length, + ); + }; + + const handleChipRemove = (chipIndex: number) => { + removeRecipientAtIndex(chipIndex); + focusInput(); + }; + + const handleSubmitHotkey = () => { + if (inputValue.length > 0) { + commitInputAndCloseSuggestions(); + } else { + onSubmit?.(); + } + }; + + useHotkeysOnFocusedElement({ + keys: ['ctrl+Enter,meta+Enter'], + callback: handleSubmitHotkey, + focusId, + dependencies: [handleSubmitHotkey], + }); + + useHotkeysOnFocusedElement({ + keys: ['ctrl+Enter,meta+Enter'], + callback: handleSubmitHotkey, + focusId: suggestionsDropdownId, + dependencies: [handleSubmitHotkey], + }); + + const handleInputKeyDown = (event: KeyboardEvent) => { + if (event.ctrlKey || event.metaKey) { + return; + } + + const inputElement = event.currentTarget; + const bufferIsEmpty = inputValue.length === 0; + const caretAtStart = + inputElement.selectionStart === 0 && inputElement.selectionEnd === 0; + + switch (event.key) { + case 'Enter': { + event.preventDefault(); + + if (!isEditing && isDropdownOpen && suggestions.length > 0) { + const selectedItemId = store.get(selectedItemIdAtom); + const selectedSuggestion = suggestions.find( + (suggestion) => suggestion.suggestionId === selectedItemId, + ); + const suggestionsMatchBuffer = + suggestionsSearchInput === inputValue.trim(); + const topSuggestion = suggestionsMatchBuffer + ? suggestions[0] + : undefined; + const pickedSuggestion = selectedSuggestion ?? topSuggestion; + + if (isDefined(pickedSuggestion)) { + handlePickSuggestion(pickedSuggestion); + return; + } + } + + if (bufferIsEmpty && selectedChipIndex !== null) { + handleChipEdit(selectedChipIndex); + return; + } + + commitInputAndCloseSuggestions(); + return; + } + case 'Tab': { + if (!bufferIsEmpty) { + commitInputAndCloseSuggestions(); + } + return; + } + case ',': + case ';': { + event.preventDefault(); + + if (!bufferIsEmpty) { + commitInputAndCloseSuggestions(); + } + return; + } + case ' ': { + if (isValidEmailRecipientAddress(inputValue.trim())) { + event.preventDefault(); + commitInputAndCloseSuggestions(); + } + return; + } + case 'Backspace': { + if (!bufferIsEmpty || isEditing) { + return; + } + + event.preventDefault(); + + if (selectedChipIndex !== null) { + removeRecipientWithKeyboard(); + return; + } + + scrollChipIntoView(moveChipSelection(-1)); + return; + } + case 'Delete': { + if (bufferIsEmpty && selectedChipIndex !== null) { + event.preventDefault(); + removeRecipientWithKeyboard(); + } + return; + } + case 'ArrowDown': { + if (!isEditing && !isDropdownOpen && suggestions.length > 0) { + event.preventDefault(); + openSuggestions(); + } + return; + } + case 'ArrowLeft': { + if (!caretAtStart || isEditing || recipients.length === 0) { + return; + } + + event.preventDefault(); + scrollChipIntoView(moveChipSelection(-1)); + return; + } + case 'ArrowRight': { + if (selectedChipIndex === null) { + return; + } + + event.preventDefault(); + scrollChipIntoView(moveChipSelection(1)); + return; + } + case 'Escape': { + if (isDropdownOpen) { + closeSuggestions(); + return; + } + + if (isEditing) { + event.preventDefault(); + cancelEditing(); + return; + } + + if (selectedChipIndex !== null) { + event.preventDefault(); + clearChipSelection(); + } + return; + } + default: + return; + } + }; + + const recipientsInput = ( + handleInputChange(event.target.value)} + onKeyDown={handleInputKeyDown} + onPaste={handleInputPaste} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + onClick={handleInputClick} + /> + ); + + const rowChildren: ReactNode[] = recipients.map((recipient, chipIndex) => { + if (chipIndex === editingIndex) { + return recipientsInput; + } + + const chipKey = getEmailRecipientKey(recipient.address); + const flashNonce = + chipFlash !== null && chipFlash.chipKey === chipKey + ? chipFlash.nonce + : null; + + return ( +
event.preventDefault()} + > + handleChipEdit(chipIndex)} + onRemove={() => handleChipRemove(chipIndex)} + /> +
+ ); + }); + + if (!isEditing) { + rowChildren.push(recipientsInput); + } + + return ( + + {label} + + {rowChildren} + + } + dropdownComponents={ + + } + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/constants/EmailRecipientMemberSuggestionsLimit.ts b/packages/twenty-front/src/modules/activities/emails/recipients/constants/EmailRecipientMemberSuggestionsLimit.ts new file mode 100644 index 0000000000..042242d688 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/constants/EmailRecipientMemberSuggestionsLimit.ts @@ -0,0 +1 @@ +export const EMAIL_RECIPIENT_MEMBER_SUGGESTIONS_LIMIT = 3; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/constants/EmailRecipientPeopleSuggestionsLimit.ts b/packages/twenty-front/src/modules/activities/emails/recipients/constants/EmailRecipientPeopleSuggestionsLimit.ts new file mode 100644 index 0000000000..5c0d762d74 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/constants/EmailRecipientPeopleSuggestionsLimit.ts @@ -0,0 +1 @@ +export const EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT = 8; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/hooks/__tests__/useEmailRecipientsField.test.ts b/packages/twenty-front/src/modules/activities/emails/recipients/hooks/__tests__/useEmailRecipientsField.test.ts new file mode 100644 index 0000000000..385f67e6ce --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/hooks/__tests__/useEmailRecipientsField.test.ts @@ -0,0 +1,171 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useEmailRecipientsField } from '@/activities/emails/recipients/hooks/useEmailRecipientsField'; +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; + +const setup = (initialRecipients: EmailRecipient[] = []) => { + const onChange = jest.fn(); + + const view = renderHook( + ({ recipients }: { recipients: EmailRecipient[] }) => + useEmailRecipientsField({ recipients, onChange }), + { initialProps: { recipients: initialRecipients } }, + ); + + return { view, onChange }; +}; + +describe('useEmailRecipientsField', () => { + it('should commit typed input as parsed recipients', () => { + const { view, onChange } = setup(); + + act(() => { + view.result.current.setInputValue('Jane Doe '); + }); + act(() => { + view.result.current.commitInput(); + }); + + expect(onChange).toHaveBeenCalledWith([ + { address: 'jane@example.com', displayName: 'Jane Doe' }, + ]); + expect(view.result.current.inputValue).toBe(''); + }); + + it('should not call onChange when committing an empty buffer', () => { + const { view, onChange } = setup(); + + act(() => { + view.result.current.commitInput(); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should flash the existing chip instead of adding a duplicate', () => { + const { view, onChange } = setup([{ address: 'jane@example.com' }]); + + act(() => { + view.result.current.setInputValue('JANE@example.com'); + }); + act(() => { + view.result.current.commitInput(); + }); + + expect(onChange).toHaveBeenCalledWith([{ address: 'jane@example.com' }]); + expect(view.result.current.chipFlash?.chipKey).toBe('jane@example.com'); + }); + + it('should replace the edited chip on commit', () => { + const { view, onChange } = setup([ + { address: 'a@example.com' }, + { address: 'b@example.com' }, + ]); + + act(() => { + view.result.current.beginEditingChip(0); + }); + + expect(view.result.current.inputValue).toBe('a@example.com'); + + act(() => { + view.result.current.setInputValue('c@example.com'); + }); + act(() => { + view.result.current.commitInput(); + }); + + expect(onChange).toHaveBeenCalledWith([ + { address: 'c@example.com', displayName: undefined }, + { address: 'b@example.com' }, + ]); + expect(view.result.current.editingIndex).toBeNull(); + }); + + it('should remove the edited chip when committed empty', () => { + const { view, onChange } = setup([ + { address: 'a@example.com' }, + { address: 'b@example.com' }, + ]); + + act(() => { + view.result.current.beginEditingChip(0); + }); + act(() => { + view.result.current.setInputValue(' '); + }); + act(() => { + view.result.current.commitInput(); + }); + + expect(onChange).toHaveBeenCalledWith([{ address: 'b@example.com' }]); + }); + + it('should restore the chip untouched when editing is cancelled', () => { + const { view, onChange } = setup([{ address: 'a@example.com' }]); + + act(() => { + view.result.current.beginEditingChip(0); + }); + act(() => { + view.result.current.setInputValue('changed@example.com'); + }); + act(() => { + view.result.current.cancelEditing(); + }); + + expect(onChange).not.toHaveBeenCalled(); + expect(view.result.current.editingIndex).toBeNull(); + expect(view.result.current.inputValue).toBe(''); + }); + + it('should select the last chip and keep selection on the previous chip after keyboard removal', () => { + const { view, onChange } = setup([ + { address: 'a@example.com' }, + { address: 'b@example.com' }, + ]); + + act(() => { + view.result.current.moveChipSelection(-1); + }); + + expect(view.result.current.selectedChipIndex).toBe(1); + + act(() => { + view.result.current.removeRecipientWithKeyboard(); + }); + + expect(onChange).toHaveBeenCalledWith([{ address: 'a@example.com' }]); + expect(view.result.current.selectedChipIndex).toBe(0); + }); + + it('should clear the selection when moving right past the last chip', () => { + const { view } = setup([{ address: 'a@example.com' }]); + + act(() => { + view.result.current.moveChipSelection(-1); + }); + act(() => { + view.result.current.moveChipSelection(1); + }); + + expect(view.result.current.selectedChipIndex).toBeNull(); + }); + + it('should add a picked suggestion as a recipient', () => { + const { view, onChange } = setup([{ address: 'a@example.com' }]); + + act(() => { + view.result.current.addRecipient({ + address: 'jane@example.com', + displayName: 'Jane Doe', + }); + }); + + expect(onChange).toHaveBeenCalledWith([ + { address: 'a@example.com' }, + { address: 'jane@example.com', displayName: 'Jane Doe' }, + ]); + expect(view.result.current.inputValue).toBe(''); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientSuggestions.ts b/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientSuggestions.ts new file mode 100644 index 0000000000..31fdbace8a --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientSuggestions.ts @@ -0,0 +1,249 @@ +import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { EMAIL_RECIPIENT_MEMBER_SUGGESTIONS_LIMIT } from '@/activities/emails/recipients/constants/EmailRecipientMemberSuggestionsLimit'; +import { EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT } from '@/activities/emails/recipients/constants/EmailRecipientPeopleSuggestionsLimit'; +import { type EmailComposerContextRecord } from '@/activities/emails/recipients/types/EmailComposerContextRecord'; +import { type EmailRecipientPerson } from '@/activities/emails/recipients/types/EmailRecipientPerson'; +import { getEmailRecipientKey } from '@/activities/emails/recipients/utils/getEmailRecipientKey'; +import { getEmailRecipientPersonFromRecord } from '@/activities/emails/recipients/utils/getEmailRecipientPersonFromRecord'; +import { isValidEmailRecipientAddress } from '@/activities/emails/recipients/utils/isValidEmailRecipientAddress'; +import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { filterBySearchQuery } from '~/utils/filterBySearchQuery'; + +export type EmailRecipientSuggestion = { + suggestionId: string; + recipient: { address: string; displayName?: string }; + label: string; + secondaryText: string; + avatarUrl: string | null; + avatarColorSeed: string; +}; + +type UseEmailRecipientSuggestionsArgs = { + searchInput: string; + excludedRecipientKeys: string[]; + contextRecord?: EmailComposerContextRecord | null; +}; + +const getSuggestion = ({ + suggestionId, + fullName, + address, + secondaryText, + avatarUrl, + avatarColorSeed, +}: { + suggestionId: string; + fullName: string; + address: string; + secondaryText: string; + avatarUrl: string | null; + avatarColorSeed: string; +}): EmailRecipientSuggestion => ({ + suggestionId, + recipient: { + address, + displayName: isNonEmptyString(fullName) ? fullName : undefined, + }, + label: isNonEmptyString(fullName) ? fullName : address, + secondaryText, + avatarUrl, + avatarColorSeed, +}); + +const getPersonSuggestion = ( + person: EmailRecipientPerson, +): EmailRecipientSuggestion => + getSuggestion({ + suggestionId: `person-${person.id}`, + fullName: `${person.firstName} ${person.lastName}`.trim(), + address: person.primaryEmail, + secondaryText: person.primaryEmail, + avatarUrl: person.avatarUrl, + avatarColorSeed: person.id, + }); + +export const useEmailRecipientSuggestions = ({ + searchInput, + excludedRecipientKeys, + contextRecord, +}: UseEmailRecipientSuggestionsArgs) => { + const currentWorkspaceMembers = useAtomStateValue( + currentWorkspaceMembersState, + ); + + const trimmedSearchInput = searchInput.trim(); + const hasSearchInput = trimmedSearchInput.length > 0; + + const isCompanyContext = + contextRecord?.objectNameSingular === CoreObjectNameSingular.Company; + const isPersonContext = + contextRecord?.objectNameSingular === CoreObjectNameSingular.Person; + const isOpportunityContext = + contextRecord?.objectNameSingular === CoreObjectNameSingular.Opportunity; + + const { record: contextPerson } = useFindOneRecord({ + objectNameSingular: CoreObjectNameSingular.Person, + objectRecordId: contextRecord?.recordId ?? '', + recordGqlFields: { id: true, companyId: true }, + skip: !isPersonContext, + }); + + const { record: contextOpportunity } = useFindOneRecord({ + objectNameSingular: CoreObjectNameSingular.Opportunity, + objectRecordId: contextRecord?.recordId ?? '', + recordGqlFields: { id: true, companyId: true }, + skip: !isOpportunityContext, + }); + + const contextCompanyId = isCompanyContext + ? (contextRecord?.recordId ?? null) + : (contextPerson?.companyId ?? contextOpportunity?.companyId ?? null); + + const { records: contextPeopleRecords } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.Person, + filter: { companyId: { eq: contextCompanyId ?? '' } }, + recordGqlFields: { id: true, name: true, avatarUrl: true, emails: true }, + limit: EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT, + skip: !isDefined(contextCompanyId), + }); + + const { searchRecords } = useObjectRecordSearchRecords({ + objectNameSingulars: [CoreObjectNameSingular.Person], + searchInput: hasSearchInput ? trimmedSearchInput : undefined, + limit: EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT, + }); + + const searchedPersonIds = searchRecords.map( + (searchRecord) => searchRecord.recordId, + ); + + const { records: searchedPeopleRecords } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.Person, + filter: { id: { in: searchedPersonIds } }, + recordGqlFields: { id: true, name: true, avatarUrl: true, emails: true }, + limit: EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT, + skip: !hasSearchInput || searchedPersonIds.length === 0, + }); + + const excludedKeySet = new Set(excludedRecipientKeys); + + const isSuggestablePerson = (person: EmailRecipientPerson) => + isNonEmptyString(person.primaryEmail) && + !excludedKeySet.has(getEmailRecipientKey(person.primaryEmail)); + + const contextPeople = contextPeopleRecords.map( + getEmailRecipientPersonFromRecord, + ); + + const searchedPeopleById = new Map( + searchedPeopleRecords.map((personRecord) => [ + personRecord.id, + getEmailRecipientPersonFromRecord(personRecord), + ]), + ); + const orderedSearchedPeople = searchedPersonIds + .map((personId) => searchedPeopleById.get(personId)) + .filter(isDefined); + + const contextPersonIds = new Set(contextPeople.map((person) => person.id)); + + const orderedPeople = hasSearchInput + ? [ + ...orderedSearchedPeople.filter((person) => + contextPersonIds.has(person.id), + ), + ...orderedSearchedPeople.filter( + (person) => !contextPersonIds.has(person.id), + ), + ] + : contextPeople; + + const peopleSuggestions = orderedPeople + .filter(isSuggestablePerson) + .map(getPersonSuggestion); + + const memberSuggestions: EmailRecipientSuggestion[] = hasSearchInput + ? filterBySearchQuery({ + items: currentWorkspaceMembers.filter( + (workspaceMember) => + isNonEmptyString(workspaceMember.userEmail) && + !excludedKeySet.has( + getEmailRecipientKey(workspaceMember.userEmail), + ), + ), + searchQuery: trimmedSearchInput, + getSearchableValues: (workspaceMember) => [ + `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim(), + workspaceMember.userEmail, + ], + }) + .slice(0, EMAIL_RECIPIENT_MEMBER_SUGGESTIONS_LIMIT) + .map((workspaceMember) => + getSuggestion({ + suggestionId: `workspace-member-${workspaceMember.id}`, + fullName: + `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim(), + address: workspaceMember.userEmail, + secondaryText: `${workspaceMember.userEmail} ยท ${t`Team member`}`, + avatarUrl: workspaceMember.avatarUrl ?? null, + avatarColorSeed: workspaceMember.id, + }), + ) + : []; + + const seenRecipientKeys = new Set(); + const dedupedRecordSuggestions = [ + ...peopleSuggestions, + ...memberSuggestions, + ].filter((suggestion) => { + const recipientKey = getEmailRecipientKey(suggestion.recipient.address); + + if (seenRecipientKeys.has(recipientKey)) { + return false; + } + + seenRecipientKeys.add(recipientKey); + return true; + }); + + const literalKey = getEmailRecipientKey(trimmedSearchInput); + const bufferIsAddableAddress = + hasSearchInput && + isValidEmailRecipientAddress(trimmedSearchInput) && + !excludedKeySet.has(literalKey); + + if (!bufferIsAddableAddress) { + return { suggestions: dedupedRecordSuggestions }; + } + + const exactMatchSuggestion = dedupedRecordSuggestions.find( + (suggestion) => + getEmailRecipientKey(suggestion.recipient.address) === literalKey, + ); + + const firstSuggestion: EmailRecipientSuggestion = exactMatchSuggestion ?? { + suggestionId: 'literal', + recipient: { address: trimmedSearchInput }, + label: trimmedSearchInput, + secondaryText: t`Use this email`, + avatarUrl: null, + avatarColorSeed: literalKey, + }; + + return { + suggestions: [ + firstSuggestion, + ...dedupedRecordSuggestions.filter( + (suggestion) => suggestion !== firstSuggestion, + ), + ], + }; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientsField.ts b/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientsField.ts new file mode 100644 index 0000000000..31eceac9df --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientsField.ts @@ -0,0 +1,163 @@ +import { useState } from 'react'; + +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; +import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient'; +import { mergeEmailRecipients } from '@/activities/emails/recipients/utils/mergeEmailRecipients'; +import { parseEmailRecipients } from '@/activities/emails/recipients/utils/parseEmailRecipients'; +import { toSpliced } from '~/utils/array/toSpliced'; + +type UseEmailRecipientsFieldArgs = { + recipients: EmailRecipient[]; + onChange: (recipients: EmailRecipient[]) => void; +}; + +type ChipFlash = { + chipKey: string; + nonce: number; +}; + +export const useEmailRecipientsField = ({ + recipients, + onChange, +}: UseEmailRecipientsFieldArgs) => { + const [inputValue, setInputValue] = useState(''); + const [editingIndex, setEditingIndex] = useState(null); + const [selectedChipIndex, setSelectedChipIndex] = useState( + null, + ); + const [chipFlash, setChipFlash] = useState(null); + + const isEditing = editingIndex !== null; + + const flashChip = (chipKey: string) => { + setChipFlash((previousFlash) => ({ + chipKey, + nonce: (previousFlash?.nonce ?? 0) + 1, + })); + }; + + const addRecipients = ( + addedRecipients: EmailRecipient[], + replacedIndex: number | null, + ) => { + const baseRecipients = + replacedIndex === null + ? recipients + : toSpliced(recipients, replacedIndex, 1); + + const { mergedRecipients, duplicateKeys } = mergeEmailRecipients( + baseRecipients, + addedRecipients, + replacedIndex ?? baseRecipients.length, + ); + + onChange(mergedRecipients); + + const firstDuplicateKey = duplicateKeys[0]; + if (firstDuplicateKey !== undefined) { + flashChip(firstDuplicateKey); + } + }; + + const commitRecipients = (committedRecipients: EmailRecipient[]) => { + if (editingIndex !== null && committedRecipients.length === 0) { + onChange(toSpliced(recipients, editingIndex, 1)); + } else if (committedRecipients.length > 0) { + addRecipients(committedRecipients, editingIndex); + } + + setEditingIndex(null); + setInputValue(''); + }; + + const commitInput = () => { + commitRecipients(parseEmailRecipients(inputValue)); + }; + + const addRecipient = (recipient: EmailRecipient) => { + commitRecipients([recipient]); + }; + + const beginEditingChip = (chipIndex: number) => { + setEditingIndex(chipIndex); + setInputValue(formatEmailRecipient(recipients[chipIndex])); + setSelectedChipIndex(null); + }; + + const cancelEditing = () => { + setEditingIndex(null); + setInputValue(''); + }; + + const removeRecipientAtIndex = (chipIndex: number) => { + onChange(toSpliced(recipients, chipIndex, 1)); + setSelectedChipIndex(null); + + if (editingIndex !== null && chipIndex < editingIndex) { + setEditingIndex(editingIndex - 1); + } + }; + + const removeRecipientWithKeyboard = () => { + if (selectedChipIndex === null) { + return; + } + + const removedIndex = selectedChipIndex; + onChange(toSpliced(recipients, removedIndex, 1)); + + const remainingCount = recipients.length - 1; + setSelectedChipIndex( + removedIndex > 0 && remainingCount > 0 ? removedIndex - 1 : null, + ); + }; + + const clearChipSelection = () => { + setSelectedChipIndex(null); + }; + + const moveChipSelection = (direction: -1 | 1): number | null => { + if (recipients.length === 0) { + return null; + } + + if (selectedChipIndex === null) { + if (direction === 1) { + return null; + } + + const lastIndex = recipients.length - 1; + setSelectedChipIndex(lastIndex); + return lastIndex; + } + + const nextIndex = selectedChipIndex + direction; + + if (nextIndex >= recipients.length) { + setSelectedChipIndex(null); + return null; + } + + const boundedIndex = Math.max(nextIndex, 0); + setSelectedChipIndex(boundedIndex); + return boundedIndex; + }; + + return { + inputValue, + setInputValue, + editingIndex, + isEditing, + selectedChipIndex, + chipFlash, + commitInput, + addRecipient, + addRecipients, + beginEditingChip, + cancelEditing, + removeRecipientAtIndex, + removeRecipientWithKeyboard, + clearChipSelection, + moveChipSelection, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientsResolution.ts b/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientsResolution.ts new file mode 100644 index 0000000000..244f6d620c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientsResolution.ts @@ -0,0 +1,78 @@ +import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { escapeForIlike, isDefined } from 'twenty-shared/utils'; + +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; +import { type EmailRecipientPerson } from '@/activities/emails/recipients/types/EmailRecipientPerson'; +import { getEmailRecipientKey } from '@/activities/emails/recipients/utils/getEmailRecipientKey'; +import { getEmailRecipientPersonFromRecord } from '@/activities/emails/recipients/utils/getEmailRecipientPersonFromRecord'; +import { isValidEmailRecipientAddress } from '@/activities/emails/recipients/utils/isValidEmailRecipientAddress'; +import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { type PartialWorkspaceMember } from '@/settings/roles/types/RoleWithPartialMembers'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; + +export type EmailRecipientResolution = { + person?: EmailRecipientPerson; + workspaceMember?: PartialWorkspaceMember; +}; + +export const useEmailRecipientsResolution = ({ + recipients, +}: { + recipients: EmailRecipient[]; +}) => { + const currentWorkspaceMembers = useAtomStateValue( + currentWorkspaceMembersState, + ); + + const recipientKeys = [ + ...new Set( + recipients + .filter((recipient) => isValidEmailRecipientAddress(recipient.address)) + .map((recipient) => getEmailRecipientKey(recipient.address)), + ), + ]; + + const { records: matchedPeople } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.Person, + filter: { + or: recipientKeys.map((recipientKey) => ({ + emails: { primaryEmail: { ilike: escapeForIlike(recipientKey) } }, + })), + }, + recordGqlFields: { id: true, name: true, avatarUrl: true, emails: true }, + limit: MAX_EMAIL_RECIPIENTS, + skip: recipientKeys.length === 0, + }); + + const recipientKeySet = new Set(recipientKeys); + const resolutionByRecipientKey = new Map(); + + for (const workspaceMember of currentWorkspaceMembers) { + const memberKey = getEmailRecipientKey(workspaceMember.userEmail ?? ''); + + if ( + isDefined(workspaceMember.userEmail) && + recipientKeySet.has(memberKey) + ) { + resolutionByRecipientKey.set(memberKey, { workspaceMember }); + } + } + + for (const personRecord of matchedPeople) { + const person = getEmailRecipientPersonFromRecord(personRecord); + const personKey = getEmailRecipientKey(person.primaryEmail); + + if (!recipientKeySet.has(personKey)) { + continue; + } + + resolutionByRecipientKey.set(personKey, { + ...resolutionByRecipientKey.get(personKey), + person, + }); + } + + return { resolutionByRecipientKey }; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/types/EmailComposerContextRecord.ts b/packages/twenty-front/src/modules/activities/emails/recipients/types/EmailComposerContextRecord.ts new file mode 100644 index 0000000000..14742324cb --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/types/EmailComposerContextRecord.ts @@ -0,0 +1,4 @@ +export type EmailComposerContextRecord = { + objectNameSingular: string; + recordId: string; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/types/EmailRecipient.ts b/packages/twenty-front/src/modules/activities/emails/recipients/types/EmailRecipient.ts new file mode 100644 index 0000000000..10369a652e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/types/EmailRecipient.ts @@ -0,0 +1,4 @@ +export type EmailRecipient = { + address: string; + displayName?: string; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/types/EmailRecipientPerson.ts b/packages/twenty-front/src/modules/activities/emails/recipients/types/EmailRecipientPerson.ts new file mode 100644 index 0000000000..344bd92d79 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/types/EmailRecipientPerson.ts @@ -0,0 +1,7 @@ +export type EmailRecipientPerson = { + id: string; + firstName: string; + lastName: string; + avatarUrl: string | null; + primaryEmail: string; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/__tests__/formatEmailRecipient.test.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/__tests__/formatEmailRecipient.test.ts new file mode 100644 index 0000000000..a68e3a7a43 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/__tests__/formatEmailRecipient.test.ts @@ -0,0 +1,39 @@ +import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient'; +import { parseEmailRecipients } from '@/activities/emails/recipients/utils/parseEmailRecipients'; + +describe('formatEmailRecipient', () => { + it('should return the bare address when there is no display name', () => { + expect(formatEmailRecipient({ address: 'jane@example.com' })).toBe( + 'jane@example.com', + ); + }); + + it('should format a display name with angle brackets', () => { + expect( + formatEmailRecipient({ + address: 'jane@example.com', + displayName: 'Jane Doe', + }), + ).toBe('Jane Doe '); + }); + + it('should quote display names containing special characters', () => { + expect( + formatEmailRecipient({ + address: 'jane@example.com', + displayName: 'Doe, Jane', + }), + ).toBe('"Doe, Jane" '); + }); + + it('should round trip through parseEmailRecipients', () => { + const recipient = { + address: 'jane@example.com', + displayName: 'Doe, Jane "JD"', + }; + + expect(parseEmailRecipients(formatEmailRecipient(recipient))).toEqual([ + recipient, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/__tests__/mergeEmailRecipients.test.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/__tests__/mergeEmailRecipients.test.ts new file mode 100644 index 0000000000..8cb8a75f94 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/__tests__/mergeEmailRecipients.test.ts @@ -0,0 +1,92 @@ +import { mergeEmailRecipients } from '@/activities/emails/recipients/utils/mergeEmailRecipients'; + +describe('mergeEmailRecipients', () => { + it('should append unique recipients at the given index', () => { + const { mergedRecipients, duplicateKeys } = mergeEmailRecipients( + [{ address: 'a@example.com' }], + [{ address: 'b@example.com' }], + 1, + ); + + expect(mergedRecipients).toEqual([ + { address: 'a@example.com' }, + { address: 'b@example.com' }, + ]); + expect(duplicateKeys).toEqual([]); + }); + + it('should insert at an arbitrary position', () => { + const { mergedRecipients } = mergeEmailRecipients( + [{ address: 'a@example.com' }, { address: 'c@example.com' }], + [{ address: 'b@example.com' }], + 1, + ); + + expect(mergedRecipients.map((recipient) => recipient.address)).toEqual([ + 'a@example.com', + 'b@example.com', + 'c@example.com', + ]); + }); + + it('should report duplicates case-insensitively and keep the existing entry', () => { + const { mergedRecipients, duplicateKeys } = mergeEmailRecipients( + [{ address: 'jane@example.com' }], + [{ address: 'Jane@Example.com' }], + 1, + ); + + expect(mergedRecipients).toEqual([{ address: 'jane@example.com' }]); + expect(duplicateKeys).toEqual(['jane@example.com']); + }); + + it('should upgrade the display name of an existing recipient from a duplicate', () => { + const { mergedRecipients } = mergeEmailRecipients( + [{ address: 'jane@example.com' }], + [{ address: 'jane@example.com', displayName: 'Jane Doe' }], + 1, + ); + + expect(mergedRecipients).toEqual([ + { address: 'jane@example.com', displayName: 'Jane Doe' }, + ]); + }); + + it('should not overwrite an existing display name', () => { + const { mergedRecipients } = mergeEmailRecipients( + [{ address: 'jane@example.com', displayName: 'Jane' }], + [{ address: 'jane@example.com', displayName: 'Someone Else' }], + 1, + ); + + expect(mergedRecipients).toEqual([ + { address: 'jane@example.com', displayName: 'Jane' }, + ]); + }); + + it('should dedupe within the added batch itself', () => { + const { mergedRecipients, duplicateKeys } = mergeEmailRecipients( + [], + [{ address: 'a@example.com' }, { address: 'A@example.com' }], + 0, + ); + + expect(mergedRecipients).toEqual([{ address: 'a@example.com' }]); + expect(duplicateKeys).toEqual(['a@example.com']); + }); + + it('should upgrade a display name from a later duplicate within the batch', () => { + const { mergedRecipients } = mergeEmailRecipients( + [], + [ + { address: 'a@example.com' }, + { address: 'A@example.com', displayName: 'Aline' }, + ], + 0, + ); + + expect(mergedRecipients).toEqual([ + { address: 'a@example.com', displayName: 'Aline' }, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/__tests__/parseEmailRecipients.test.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/__tests__/parseEmailRecipients.test.ts new file mode 100644 index 0000000000..20ff7387c7 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/__tests__/parseEmailRecipients.test.ts @@ -0,0 +1,75 @@ +import { parseEmailRecipients } from '@/activities/emails/recipients/utils/parseEmailRecipients'; + +describe('parseEmailRecipients', () => { + it('should parse a bare email address', () => { + expect(parseEmailRecipients('jane@example.com')).toEqual([ + { address: 'jane@example.com', displayName: undefined }, + ]); + }); + + it('should parse a display name with angle brackets', () => { + expect(parseEmailRecipients('Jane Doe ')).toEqual([ + { address: 'jane@example.com', displayName: 'Jane Doe' }, + ]); + }); + + it('should parse a quoted display name containing a comma', () => { + expect( + parseEmailRecipients('"Doe, Jane" , bob@example.com'), + ).toEqual([ + { address: 'jane@example.com', displayName: 'Doe, Jane' }, + { address: 'bob@example.com', displayName: undefined }, + ]); + }); + + it('should parse comma separated addresses', () => { + expect(parseEmailRecipients('a@example.com, b@example.com')).toEqual([ + { address: 'a@example.com', displayName: undefined }, + { address: 'b@example.com', displayName: undefined }, + ]); + }); + + it('should parse semicolon separated addresses', () => { + expect(parseEmailRecipients('a@example.com; b@example.com')).toEqual([ + { address: 'a@example.com', displayName: undefined }, + { address: 'b@example.com', displayName: undefined }, + ]); + }); + + it('should parse newline separated addresses', () => { + expect(parseEmailRecipients('a@example.com\nb@example.com')).toEqual([ + { address: 'a@example.com', displayName: undefined }, + { address: 'b@example.com', displayName: undefined }, + ]); + }); + + it('should skip empty entries between separators', () => { + expect(parseEmailRecipients('a@example.com,, ,b@example.com')).toEqual([ + { address: 'a@example.com', displayName: undefined }, + { address: 'b@example.com', displayName: undefined }, + ]); + }); + + it('should flatten address groups', () => { + expect( + parseEmailRecipients('Friends: a@example.com, b@example.com;'), + ).toEqual([ + { address: 'a@example.com', displayName: undefined }, + { address: 'b@example.com', displayName: undefined }, + ]); + }); + + it('should return an empty list for empty groups', () => { + expect(parseEmailRecipients('undisclosed-recipients:;')).toEqual([]); + }); + + it('should return an empty list for whitespace only input', () => { + expect(parseEmailRecipients(' ')).toEqual([]); + }); + + it('should keep an invalid address so validation can flag it', () => { + expect(parseEmailRecipients('not-an-email')).toEqual([ + { address: 'not-an-email', displayName: undefined }, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/formatEmailRecipient.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/formatEmailRecipient.ts new file mode 100644 index 0000000000..6c801303a4 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/formatEmailRecipient.ts @@ -0,0 +1,16 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; + +export const formatEmailRecipient = (recipient: EmailRecipient): string => { + if (!isNonEmptyString(recipient.displayName)) { + return recipient.address; + } + + const requiresQuoting = /[,;<>@"]/.test(recipient.displayName); + const formattedDisplayName = requiresQuoting + ? `"${recipient.displayName.replaceAll('"', '\\"')}"` + : recipient.displayName; + + return `${formattedDisplayName} <${recipient.address}>`; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/getEmailRecipientKey.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/getEmailRecipientKey.ts new file mode 100644 index 0000000000..105335db39 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/getEmailRecipientKey.ts @@ -0,0 +1,2 @@ +export const getEmailRecipientKey = (address: string): string => + address.trim().toLowerCase(); diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/getEmailRecipientPersonFromRecord.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/getEmailRecipientPersonFromRecord.ts new file mode 100644 index 0000000000..e7f818a830 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/getEmailRecipientPersonFromRecord.ts @@ -0,0 +1,12 @@ +import { type EmailRecipientPerson } from '@/activities/emails/recipients/types/EmailRecipientPerson'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; + +export const getEmailRecipientPersonFromRecord = ( + personRecord: ObjectRecord, +): EmailRecipientPerson => ({ + id: personRecord.id, + firstName: personRecord.name?.firstName ?? '', + lastName: personRecord.name?.lastName ?? '', + avatarUrl: personRecord.avatarUrl ?? null, + primaryEmail: personRecord.emails?.primaryEmail ?? '', +}); diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/isValidEmailRecipientAddress.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/isValidEmailRecipientAddress.ts new file mode 100644 index 0000000000..90b92d94c3 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/isValidEmailRecipientAddress.ts @@ -0,0 +1,4 @@ +import { emailSchema } from 'twenty-shared/utils'; + +export const isValidEmailRecipientAddress = (address: string): boolean => + emailSchema.safeParse(address).success; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/mergeEmailRecipients.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/mergeEmailRecipients.ts new file mode 100644 index 0000000000..84ec6c0e83 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/mergeEmailRecipients.ts @@ -0,0 +1,79 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; +import { getEmailRecipientKey } from '@/activities/emails/recipients/utils/getEmailRecipientKey'; +import { toSpliced } from '~/utils/array/toSpliced'; + +type MergeEmailRecipientsResult = { + mergedRecipients: EmailRecipient[]; + duplicateKeys: string[]; +}; + +export const mergeEmailRecipients = ( + baseRecipients: EmailRecipient[], + addedRecipients: EmailRecipient[], + insertAtIndex: number, +): MergeEmailRecipientsResult => { + const baseRecipientKeys = new Set( + baseRecipients.map((baseRecipient) => + getEmailRecipientKey(baseRecipient.address), + ), + ); + + const uniqueAddedRecipients: EmailRecipient[] = []; + const uniqueAddedRecipientsByKey = new Map(); + const duplicateKeys: string[] = []; + const displayNameUpgrades = new Map(); + + for (const addedRecipient of addedRecipients) { + const recipientKey = getEmailRecipientKey(addedRecipient.address); + + if (baseRecipientKeys.has(recipientKey)) { + duplicateKeys.push(recipientKey); + + if (isNonEmptyString(addedRecipient.displayName)) { + displayNameUpgrades.set(recipientKey, addedRecipient.displayName); + } + continue; + } + + const alreadyAddedRecipient = uniqueAddedRecipientsByKey.get(recipientKey); + + if (alreadyAddedRecipient !== undefined) { + duplicateKeys.push(recipientKey); + + if ( + isNonEmptyString(addedRecipient.displayName) && + !isNonEmptyString(alreadyAddedRecipient.displayName) + ) { + alreadyAddedRecipient.displayName = addedRecipient.displayName; + } + continue; + } + + const uniqueAddedRecipient = { ...addedRecipient }; + uniqueAddedRecipientsByKey.set(recipientKey, uniqueAddedRecipient); + uniqueAddedRecipients.push(uniqueAddedRecipient); + } + + const upgradedBaseRecipients = baseRecipients.map((baseRecipient) => { + const upgradedDisplayName = displayNameUpgrades.get( + getEmailRecipientKey(baseRecipient.address), + ); + + return upgradedDisplayName !== undefined && + !isNonEmptyString(baseRecipient.displayName) + ? { ...baseRecipient, displayName: upgradedDisplayName } + : baseRecipient; + }); + + return { + mergedRecipients: toSpliced( + upgradedBaseRecipients, + insertAtIndex, + 0, + ...uniqueAddedRecipients, + ), + duplicateKeys, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/parseEmailRecipients.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/parseEmailRecipients.ts new file mode 100644 index 0000000000..dca7bb6e22 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/parseEmailRecipients.ts @@ -0,0 +1,26 @@ +import addressparser from 'addressparser'; +import { isNonEmptyString } from '@sniptt/guards'; + +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; + +export const parseEmailRecipients = (rawText: string): EmailRecipient[] => { + const normalizedText = rawText.replace(/\r?\n/g, ','); + + try { + return addressparser(normalizedText) + .flatMap((parsedAddress) => parsedAddress.group ?? [parsedAddress]) + .map((parsedAddress) => + isNonEmptyString(parsedAddress.address) + ? { + address: parsedAddress.address, + displayName: isNonEmptyString(parsedAddress.name) + ? parsedAddress.name + : undefined, + } + : { address: parsedAddress.name.trim(), displayName: undefined }, + ) + .filter((recipient) => isNonEmptyString(recipient.address)); + } catch { + return []; + } +}; diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/serializeEmailRecipients.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/serializeEmailRecipients.ts new file mode 100644 index 0000000000..474cd8ad25 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/serializeEmailRecipients.ts @@ -0,0 +1,5 @@ +import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; + +export const serializeEmailRecipients = ( + recipients: EmailRecipient[], +): string => recipients.map((recipient) => recipient.address).join(', '); diff --git a/packages/twenty-front/src/modules/activities/emails/utils/getEmailDraftPrefillFromMessage.ts b/packages/twenty-front/src/modules/activities/emails/utils/getEmailDraftPrefillFromMessage.ts index e6960c8157..cfb72cdef7 100644 --- a/packages/twenty-front/src/modules/activities/emails/utils/getEmailDraftPrefillFromMessage.ts +++ b/packages/twenty-front/src/modules/activities/emails/utils/getEmailDraftPrefillFromMessage.ts @@ -1,3 +1,6 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient'; import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill'; import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender'; import { MessageParticipantRole } from 'twenty-shared/types'; @@ -5,17 +8,23 @@ import { MessageParticipantRole } from 'twenty-shared/types'; export const getEmailDraftPrefillFromMessage = ( message: EmailThreadMessageWithSender, ): EmailDraftPrefill => { - const joinHandlesByRole = (role: MessageParticipantRole) => + const joinRecipientsByRole = (role: MessageParticipantRole) => message.messageParticipants .filter((participant) => participant.role === role) - .map((participant) => participant.handle) + .filter((participant) => isNonEmptyString(participant.handle)) + .map((participant) => + formatEmailRecipient({ + address: participant.handle, + displayName: participant.displayName, + }), + ) .join(', '); return { messageId: message.id, - to: joinHandlesByRole(MessageParticipantRole.TO), - cc: joinHandlesByRole(MessageParticipantRole.CC), - bcc: joinHandlesByRole(MessageParticipantRole.BCC), + to: joinRecipientsByRole(MessageParticipantRole.TO), + cc: joinRecipientsByRole(MessageParticipantRole.CC), + bcc: joinRecipientsByRole(MessageParticipantRole.BCC), subject: message.subject, body: message.text, }; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/global/components/ComposeEmailCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/global/components/ComposeEmailCommand.tsx index 50f5044ce0..172b58a71d 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/global/components/ComposeEmailCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/global/components/ComposeEmailCommand.tsx @@ -66,6 +66,13 @@ export const ComposeEmailCommand = () => { openComposeEmailInSidePanel({ connectedAccountId, defaultTo, + contextRecord: + isDefined(objectNameSingular) && isDefined(singleSelectedRecordId) + ? { + objectNameSingular, + recordId: singleSelectedRecordId, + } + : undefined, }); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx index 0bb77b79db..0a2d44a34d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx @@ -1,18 +1,40 @@ import { styled } from '@linaria/react'; -import { useContext } from 'react'; +import { type MouseEvent, type ReactNode, useContext } from 'react'; import { IconX } from 'twenty-ui/icon'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; -const StyledChip = styled.div<{ deletable: boolean; danger: boolean }>` +const StyledChip = styled.div<{ + deletable: boolean; + danger: boolean; + selected: boolean; +}>` align-items: center; - background-color: ${({ danger }) => - danger ? themeCssVariables.color.red3 : themeCssVariables.color.blue3}; - border-color: ${({ danger }) => - danger ? themeCssVariables.color.red5 : themeCssVariables.color.blue5}; + background-color: ${({ danger, selected }) => + selected + ? danger + ? themeCssVariables.color.red + : themeCssVariables.color.blue + : danger + ? themeCssVariables.color.red3 + : themeCssVariables.color.blue3}; + border-color: ${({ danger, selected }) => + selected + ? danger + ? themeCssVariables.color.red + : themeCssVariables.color.blue + : danger + ? themeCssVariables.color.red5 + : themeCssVariables.color.blue5}; border-radius: ${themeCssVariables.border.radius.smRound}; border-style: solid; border-width: 1px; box-sizing: border-box; + color: ${({ danger, selected }) => + selected + ? themeCssVariables.font.color.inverted + : danger + ? themeCssVariables.color.red + : themeCssVariables.color.blue}; column-gap: ${themeCssVariables.spacing[1]}; corner-shape: round; cursor: ${({ deletable }) => (deletable ? 'pointer' : 'default')}; @@ -20,18 +42,35 @@ const StyledChip = styled.div<{ deletable: boolean; danger: boolean }>` flex-direction: row; flex-shrink: 0; height: 20px; + max-width: 100%; padding-left: ${themeCssVariables.spacing[1]}; padding-right: ${({ deletable }) => deletable ? '0' : themeCssVariables.spacing[1]}; user-select: none; white-space: nowrap; + + @keyframes base-chip-flash { + 0%, + 100% { + filter: none; + } + 50% { + filter: brightness(0.85); + } + } + + &[data-flashing='true'] { + animation: base-chip-flash 300ms ease-in-out 2; + } `; -const StyledLabel = styled.span<{ danger: boolean }>` - color: ${({ danger }) => - danger ? themeCssVariables.color.red : themeCssVariables.color.blue}; +const StyledLabel = styled.span<{ maxWidth?: number }>` line-height: 140%; + max-width: ${({ maxWidth }) => + maxWidth === undefined ? 'none' : `${maxWidth}px`}; + overflow: hidden; + text-overflow: ellipsis; `; const StyledDelete = styled.button<{ danger: boolean }>` @@ -41,8 +80,7 @@ const StyledDelete = styled.button<{ danger: boolean }>` border-bottom-right-radius: ${themeCssVariables.border.radius.smRound}; border-top-right-radius: ${themeCssVariables.border.radius.smRound}; box-sizing: border-box; - color: ${({ danger }) => - danger ? themeCssVariables.color.red : themeCssVariables.color.blue}; + color: inherit; corner-shape: round; cursor: pointer; display: flex; @@ -61,29 +99,46 @@ const StyledDelete = styled.button<{ danger: boolean }>` `; type BaseChipProps = { + chipId?: string; label: string; title?: string; - onRemove?: () => void; + onRemove?: (event: MouseEvent) => void; removeAriaLabel?: string; danger?: boolean; - leftIcon?: React.ReactNode; + selected?: boolean; + isFlashing?: boolean; + onDoubleClick?: () => void; + maxLabelWidth?: number; + leftIcon?: ReactNode; }; export const BaseChip = ({ + chipId, label, title, onRemove, removeAriaLabel = 'Remove', danger = false, + selected = false, + isFlashing = false, + onDoubleClick, + maxLabelWidth, leftIcon, }: BaseChipProps) => { const { theme } = useContext(ThemeContext); const isDeletable = onRemove !== undefined; return ( - + {leftIcon} - + {label} diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useOpenComposeEmailInSidePanel.ts b/packages/twenty-front/src/modules/side-panel/hooks/useOpenComposeEmailInSidePanel.ts index 5d0a24ed07..8a86397315 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useOpenComposeEmailInSidePanel.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useOpenComposeEmailInSidePanel.ts @@ -5,8 +5,10 @@ import { SidePanelPages } from 'twenty-shared/types'; import { type IconComponent, IconArrowBackUp, IconMail } from 'twenty-ui/icon'; import { v4 } from 'uuid'; +import { type EmailComposerContextRecord } from '@/activities/emails/recipients/types/EmailComposerContextRecord'; import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; import { composeEmailConnectedAccountIdComponentState } from '@/side-panel/pages/compose-email/states/composeEmailConnectedAccountIdComponentState'; +import { composeEmailContextRecordComponentState } from '@/side-panel/pages/compose-email/states/composeEmailContextRecordComponentState'; 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'; @@ -18,6 +20,7 @@ type OpenComposeEmailParams = { defaultTo?: string; defaultSubject?: string; defaultInReplyTo?: string; + contextRecord?: EmailComposerContextRecord; pageTitle?: string; pageIcon?: IconComponent; }; @@ -60,6 +63,13 @@ export const useOpenComposeEmailInSidePanel = () => { params.defaultInReplyTo ?? '', ); + store.set( + composeEmailContextRecordComponentState.atomFamily({ + instanceId: pageId, + }), + params.contextRecord ?? null, + ); + navigateSidePanelMenu({ page: SidePanelPages.ComposeEmail, pageTitle: params.pageTitle ?? (isReply ? t`Reply` : t`New Email`), diff --git a/packages/twenty-front/src/modules/side-panel/pages/compose-email/components/SidePanelComposeEmailPage.tsx b/packages/twenty-front/src/modules/side-panel/pages/compose-email/components/SidePanelComposeEmailPage.tsx index 5a9056bb3a..e9437b72d6 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/compose-email/components/SidePanelComposeEmailPage.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/compose-email/components/SidePanelComposeEmailPage.tsx @@ -5,6 +5,7 @@ import { useEmailComposerState } from '@/activities/emails/hooks/useEmailCompose 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 { composeEmailContextRecordComponentState } from '@/side-panel/pages/compose-email/states/composeEmailContextRecordComponentState'; 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'; @@ -43,6 +44,9 @@ export const SidePanelComposeEmailPage = () => { const composeEmailDefaultInReplyTo = useAtomComponentStateValue( composeEmailDefaultInReplyToComponentState, ); + const composeEmailContextRecord = useAtomComponentStateValue( + composeEmailContextRecordComponentState, + ); const { goBackFromSidePanel } = useSidePanelHistory(); @@ -74,7 +78,10 @@ export const SidePanelComposeEmailPage = () => { return ( - + ({ + key: 'side-panel/compose-email-context-record', + defaultValue: null, + componentInstanceContext: SidePanelPageComponentInstanceContext, + }); diff --git a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts index 7cbe43ef3d..10a44a7443 100644 --- a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts +++ b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts @@ -8,7 +8,11 @@ import { FileFolder, ObjectRecord, } from 'twenty-shared/types'; -import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils'; +import { + escapeForIlike, + getLogoUrlFromDomainName, + isDefined, +} from 'twenty-shared/utils'; import { Brackets, type ObjectLiteral } from 'typeorm'; import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; @@ -33,7 +37,6 @@ import { SearchExceptionCode, } from 'src/engine/core-modules/search/exceptions/search.exception'; import { type RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item'; -import { escapeForIlike } from 'src/engine/core-modules/search/utils/escape-for-ilike'; import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; diff --git a/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts b/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts index 1b478322d2..88bebe7d3c 100644 --- a/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts +++ b/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts @@ -180,6 +180,7 @@ export type LeafFilter = | AddressFilter | LinksFilter | ActorFilter + | EmailsFilter | PhonesFilter | ArrayFilter | RawJsonFilter diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 9a85000204..406f467f62 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -228,6 +228,7 @@ export { safeDecodeURIComponent } from './url/safeDecodeURIComponent'; export { uuidToBase36 } from './uuidToBase36'; export { assertIsDefinedOrThrow } from './validation/assertIsDefinedOrThrow'; export { emailSchema } from './validation/emailSchema'; +export { escapeForIlike } from './validation/escapeForIlike'; export { isDefined } from './validation/isDefined'; export { isEmptyObject } from './validation/isEmptyObject'; export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes'; diff --git a/packages/twenty-server/src/engine/core-modules/search/utils/__tests__/escape-for-ilike.spec.ts b/packages/twenty-shared/src/utils/validation/__tests__/escapeForIlike.test.ts similarity index 89% rename from packages/twenty-server/src/engine/core-modules/search/utils/__tests__/escape-for-ilike.spec.ts rename to packages/twenty-shared/src/utils/validation/__tests__/escapeForIlike.test.ts index 25ba0eca83..beb1ec7d0f 100644 --- a/packages/twenty-server/src/engine/core-modules/search/utils/__tests__/escape-for-ilike.spec.ts +++ b/packages/twenty-shared/src/utils/validation/__tests__/escapeForIlike.test.ts @@ -1,4 +1,4 @@ -import { escapeForIlike } from 'src/engine/core-modules/search/utils/escape-for-ilike'; +import { escapeForIlike } from '@/utils/validation/escapeForIlike'; describe('escapeForIlike', () => { it('should escape percent signs', () => { diff --git a/packages/twenty-server/src/engine/core-modules/search/utils/escape-for-ilike.ts b/packages/twenty-shared/src/utils/validation/escapeForIlike.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/search/utils/escape-for-ilike.ts rename to packages/twenty-shared/src/utils/validation/escapeForIlike.ts diff --git a/yarn.lock b/yarn.lock index 826171533e..6c3ead848c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -52928,6 +52928,7 @@ __metadata: "@tiptap/extensions": "npm:3.4.2" "@tiptap/react": "npm:3.4.2" "@tiptap/suggestion": "npm:3.4.2" + "@types/addressparser": "npm:^1.0.3" "@types/deep-equal": "npm:^1.0.1" "@types/file-saver": "npm:^2.0.7" "@types/jest": "npm:^30.0.0" @@ -52940,6 +52941,7 @@ __metadata: "@vitest/coverage-istanbul": "npm:^4.1.0" "@wyw-in-js/vite": "npm:^1.1.0" "@xyflow/react": "npm:^12.4.2" + addressparser: "npm:1.0.1" ai: "npm:6.0.97" apollo-link-rest: "npm:^0.10.0-rc.2" apollo-upload-client: "npm:^19.0.0"