From adfb96c7ce5ca47e2d4a5e71a0ebcf3df0699f05 Mon Sep 17 00:00:00 2001 From: Weiko Date: Wed, 29 Jul 2026 16:52:14 +0200 Subject: [PATCH] Keep email participant avatar colors consistent (#23444) ## Summary - Fixes issues where the same record didn't share the same avatar in multiple places of the inbox. - Add a shared avatar color seed resolver for email participants. - Apply consistent placeholder colors across participant chips and email thread previews. Review in cubic --- .../activities/components/ParticipantChip.tsx | 4 ++ .../emails/components/EmailThreadPreview.tsx | 58 +++++++++++++------ ...getEmailParticipantAvatarColorSeed.test.ts | 48 +++++++++++++++ .../getEmailParticipantAvatarColorSeed.ts | 19 ++++++ 4 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/emails/utils/__tests__/getEmailParticipantAvatarColorSeed.test.ts create mode 100644 packages/twenty-front/src/modules/activities/emails/utils/getEmailParticipantAvatarColorSeed.ts diff --git a/packages/twenty-front/src/modules/activities/components/ParticipantChip.tsx b/packages/twenty-front/src/modules/activities/components/ParticipantChip.tsx index 0bd0ff2e5d..f95a3565ec 100644 --- a/packages/twenty-front/src/modules/activities/components/ParticipantChip.tsx +++ b/packages/twenty-front/src/modules/activities/components/ParticipantChip.tsx @@ -2,6 +2,7 @@ import { styled } from '@linaria/react'; import React from 'react'; import { getDisplayNameFromParticipant } from '@/activities/emails/utils/getDisplayNameFromParticipant'; +import { getEmailParticipantAvatarColorSeed } from '@/activities/emails/utils/getEmailParticipantAvatarColorSeed'; import { CoreObjectNameSingular } from 'twenty-shared/types'; import { RecordChip } from '@/object-record/components/RecordChip'; import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl'; @@ -73,6 +74,9 @@ export const ParticipantChip = ({ avatarUrl={getAbsoluteImageUrl(avatarUrl)} type="rounded" placeholder={displayName} + placeholderColorSeed={getEmailParticipantAvatarColorSeed( + participant, + )} size="sm" /> diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx index 3bd7cadf9e..bd9d975b2c 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx @@ -2,6 +2,7 @@ import { styled } from '@linaria/react'; import { ActivityRow } from '@/activities/components/ActivityRow'; import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared'; +import { getEmailParticipantAvatarColorSeed } from '@/activities/emails/utils/getEmailParticipantAvatarColorSeed'; import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel'; import { useContext } from 'react'; @@ -78,6 +79,13 @@ type EmailThreadPreviewProps = { thread: TimelineThread; }; +type LastAvatar = { + displayedName: string | undefined; + avatarUrl: string | undefined; + isCountIcon: boolean; + placeholderColorSeed: string | undefined; +}; + export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => { const { theme } = useContext(ThemeContext); const { openRecordInSidePanel } = useOpenRecordInSidePanel(); @@ -93,14 +101,29 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => { ? `, ${thread.lastTwoParticipants?.[1]?.displayName}` : ''); - const [finalDisplayedName, finalAvatarUrl, isCountIcon] = + const lastParticipant = thread?.lastTwoParticipants?.[1]; + + const { + displayedName, + avatarUrl, + isCountIcon, + placeholderColorSeed, + }: LastAvatar = thread.participantCount > 3 - ? [`${thread.participantCount}`, '', true] - : [ - thread?.lastTwoParticipants?.[1]?.displayName, - thread?.lastTwoParticipants?.[1]?.avatarUrl, - false, - ]; + ? { + displayedName: `${thread.participantCount}`, + avatarUrl: '', + isCountIcon: true, + placeholderColorSeed: undefined, + } + : { + displayedName: lastParticipant?.displayName, + avatarUrl: lastParticipant?.avatarUrl, + isCountIcon: false, + placeholderColorSeed: isDefined(lastParticipant) + ? getEmailParticipantAvatarColorSeed(lastParticipant) + : undefined, + }; const handleThreadClick = () => { const canOpen = @@ -122,10 +145,9 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => { {isDefined(thread?.lastTwoParticipants?.[0]) && ( @@ -135,19 +157,19 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => { thread.lastTwoParticipants[0].avatarUrl, )} placeholder={thread.lastTwoParticipants[0].displayName} - placeholderColorSeed={ - thread.lastTwoParticipants[0].workspaceMemberId || - thread.lastTwoParticipants[0].personId - } + placeholderColorSeed={getEmailParticipantAvatarColorSeed( + thread.lastTwoParticipants[0], + )} type="rounded" /> )} - {finalDisplayedName && ( + {displayedName && ( { + it('uses the person ID consistently across participant shapes', () => { + expect( + getEmailParticipantAvatarColorSeed({ + personId: 'person-id', + workspaceMemberId: 'workspace-member-id', + }), + ).toBe('person-id'); + + expect( + getEmailParticipantAvatarColorSeed({ + person: { id: 'person-id' }, + workspaceMember: { id: 'workspace-member-id' }, + }), + ).toBe('person-id'); + }); + + it('falls back to the workspace member ID', () => { + expect( + getEmailParticipantAvatarColorSeed({ + workspaceMemberId: 'workspace-member-id', + }), + ).toBe('workspace-member-id'); + + expect( + getEmailParticipantAvatarColorSeed({ + workspaceMember: { id: 'workspace-member-id' }, + }), + ).toBe('workspace-member-id'); + }); + + it('falls back to the normalized handle, then the display name', () => { + expect( + getEmailParticipantAvatarColorSeed({ + handle: ' Person@Example.com ', + displayName: 'Person', + }), + ).toBe('person@example.com'); + + expect( + getEmailParticipantAvatarColorSeed({ + displayName: 'Person', + }), + ).toBe('Person'); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/utils/getEmailParticipantAvatarColorSeed.ts b/packages/twenty-front/src/modules/activities/emails/utils/getEmailParticipantAvatarColorSeed.ts new file mode 100644 index 0000000000..c31fa08110 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/utils/getEmailParticipantAvatarColorSeed.ts @@ -0,0 +1,19 @@ +type EmailParticipantAvatarColorSeedInput = { + personId?: string | null; + workspaceMemberId?: string | null; + person?: { id: string } | null; + workspaceMember?: { id: string } | null; + handle?: string | null; + displayName?: string | null; +}; + +export const getEmailParticipantAvatarColorSeed = ( + participant: EmailParticipantAvatarColorSeedInput, +) => + participant.personId || + participant.person?.id || + participant.workspaceMemberId || + participant.workspaceMember?.id || + participant.handle?.trim().toLowerCase() || + participant.displayName || + '';