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.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23444?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Weiko
2026-07-29 16:52:14 +02:00
committed by GitHub
parent 133b3375b6
commit adfb96c7ce
4 changed files with 111 additions and 18 deletions
@@ -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"
/>
</StyledAvatarContainer>
@@ -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) => {
<Avatar
avatarUrl={getAbsoluteImageUrl(thread?.firstParticipant?.avatarUrl)}
placeholder={thread.firstParticipant.displayName}
placeholderColorSeed={
thread.firstParticipant.workspaceMemberId ||
thread.firstParticipant.personId
}
placeholderColorSeed={getEmailParticipantAvatarColorSeed(
thread.firstParticipant,
)}
type="rounded"
/>
{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"
/>
</StyledAvatarWrapper>
)}
{finalDisplayedName && (
{displayedName && (
<StyledAvatarWrapper>
<Avatar
avatarUrl={getAbsoluteImageUrl(finalAvatarUrl)}
placeholder={finalDisplayedName}
avatarUrl={getAbsoluteImageUrl(avatarUrl)}
placeholder={displayedName}
placeholderColorSeed={placeholderColorSeed}
type="rounded"
color={isCountIcon ? theme.grayScale.gray11 : undefined}
backgroundColor={
@@ -0,0 +1,48 @@
import { getEmailParticipantAvatarColorSeed } from '@/activities/emails/utils/getEmailParticipantAvatarColorSeed';
describe('getEmailParticipantAvatarColorSeed', () => {
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');
});
});
@@ -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 ||
'';