feat: make record avatar/icon resolution data-driven via a configurable image identifier field (#22644)

## Summary

Today the avatar/icon shown for a record is hardcoded per object —
Company pulls a favicon from its domain link, Person uses `avatarUrl`,
etc. This PR replaces that hardcoding with a generic, data-driven
abstraction based on a configurable **image identifier field** on each
object's metadata (mirroring the existing **label identifier** concept).

An object's image identifier can point to:
- a **`FILES`** field → the uploaded image is used directly (rounded
avatar), or
- a **`LINKS`** field → a favicon is derived from the primary URL via
the Twenty icons service (squared avatar), gated by
`ALLOW_REQUESTS_TO_TWENTY_ICONS`.

This lets any object type (Opportunity, a custom "Listing", etc.) define
its own avatar/icon without code changes, and makes the field
configurable/overridable for standard objects.


##  Open question: also allow `TEXT` → direct image URL?
Right now the image identifier is restricted to `FILES` (uploaded file)
and `LINKS` (favicon). We deliberately left out `TEXT` → **direct image
URL** (e.g. an imported/synced photo URL stored in a text field).
There's precedent for it — Person's avatar was originally a `TEXT`
`avatarUrl`, and WorkspaceMember still is — and it's unambiguous (a
`TEXT` field has no favicon-vs-image ambiguity, and selecting it as the
image identifier is itself the declaration of intent). It's a small,
clean extension:
- add `TEXT` to the allowed image-identifier types,
- add an explicit `TEXT → raw URL` case
- `getAvatarType`: `TEXT → rounded`.
Caveats: it relies on admin assertion that the text values are image
URLs (no data-level guarantee), and external image URLs load third-party
content in the browser (IP-leak/hotlinking, same as favicons — a
proxy/cache would be the more robust long-term answer).

###  Resolution
Decision: **we will not support `TEXT` as an image identifier.** Image
identifiers stay restricted to `FILES` and `LINKS`, and any other type
fails closed (returns no avatar) on both the frontend and backend.
Instead, the legacy items that still rely on a `TEXT` avatar — Person's
deprecated `avatarUrl` and WorkspaceMember's `avatarUrl` — will be
migrated to `FILE` fields in a follow-up PR. Until then, WorkspaceMember
remains an exception (its `avatarUrl` still resolves through the
existing CorePicture path), and legacy Person `avatarUrl` values that
haven't been migrated will show initials placeholders.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22644?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:
Abdul Rahman
2026-07-15 19:15:47 +05:30
committed by GitHub
parent e31e6c7794
commit f4ff234db8
58 changed files with 1986 additions and 430 deletions
@@ -3,6 +3,7 @@
exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record gql fields from object 1`] = `
{
"accountOwner": {
"avatarUrl": true,
"id": true,
"name": true,
},
@@ -88,7 +88,7 @@ export const MultipleRecordPickerMenuItemContent = ({
placeholderColorSeed={searchRecord.recordId}
placeholder={displayText}
size="md"
type={getAvatarType(objectMetadataItem.nameSingular) ?? 'rounded'}
type={getAvatarType(objectMetadataItem)}
/>
}
text={displayText}
@@ -1,3 +1,6 @@
import { useMemo } from 'react';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
@@ -7,13 +10,13 @@ import { type RecordPickerPickableMorphItem } from '@/object-record/record-picke
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type SingleRecordPickerMenuItemProps = {
morphItem: RecordPickerPickableMorphItem;
@@ -51,6 +54,18 @@ export const SingleRecordPickerMenuItem = ({
recordPickerComponentInstanceId,
);
const objectMetadataItem = useMemo(
() =>
singleRecordPickerSearchableObjectMetadataItems.find(
(searchableObjectMetadataItem: EnrichedObjectMetadataItem) =>
searchableObjectMetadataItem.id === morphItem.objectMetadataId,
),
[
singleRecordPickerSearchableObjectMetadataItems,
morphItem.objectMetadataId,
],
);
if (!isDefined(searchRecordStore)) {
return null;
}
@@ -78,9 +93,7 @@ export const SingleRecordPickerMenuItem = ({
placeholderColorSeed={morphItem.recordId}
placeholder={searchRecordStore.label}
size="md"
type={
getAvatarType(searchRecordStore.objectNameSingular) ?? 'rounded'
}
type={getAvatarType(objectMetadataItem)}
/>
}
contextualText={
@@ -1,10 +1,9 @@
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { usePersonAvatarUpload } from '@/object-record/record-show/hooks/usePersonAvatarUpload';
import { useRecordImageIdentifierUpload } from '@/object-record/record-show/hooks/useRecordImageIdentifierUpload';
import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions';
import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
@@ -49,7 +48,10 @@ export const SummaryCard = ({
objectNameSingular,
});
const { onUploadPicture } = usePersonAvatarUpload(objectRecordId);
const { onUploadPicture } = useRecordImageIdentifierUpload({
objectNameSingular,
recordId: objectRecordId,
});
const isMobile = useIsMobile() || isInSidePanel;
@@ -115,11 +117,7 @@ export const SummaryCard = ({
</FieldContext.Provider>
}
avatarType={recordIdentifier?.avatarType ?? 'rounded'}
onUploadPicture={
objectNameSingular === CoreObjectNameSingular.Person
? onUploadPicture
: undefined
}
onUploadPicture={onUploadPicture}
/>
);
};
@@ -1,59 +0,0 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useApolloClient, useMutation } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import {
FieldMetadataType,
UploadFilesFieldFileDocument,
} from '~/generated-metadata/graphql';
export const usePersonAvatarUpload = (personRecordId: string) => {
const apolloClient = useApolloClient();
const [uploadFilesFieldFile] = useMutation(UploadFilesFieldFileDocument, {
client: apolloClient,
});
const { updateOneRecord } = useUpdateOneRecord();
const { objectMetadataItem: personMetadata } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Person,
});
const avatarFileFieldMetadataId = personMetadata.fields.find(
(field) =>
field.type === FieldMetadataType.FILES && field.name === 'avatarFile',
)?.id;
const onUploadPicture = async (file: File) => {
assertIsDefinedOrThrow(
avatarFileFieldMetadataId,
new Error(t`Avatar file field not found for person object`),
);
const result = await uploadFilesFieldFile({
variables: { file, fieldMetadataId: avatarFileFieldMetadataId },
});
const uploadedFile = result?.data?.uploadFilesFieldFile;
if (!isDefined(uploadedFile)) {
return;
}
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
idToUpdate: personRecordId,
updateOneRecordInput: {
avatarFile: [
{
fileId: uploadedFile.id,
label: file.name,
},
],
},
});
};
return { onUploadPicture };
};
@@ -0,0 +1,82 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
import { useApolloClient, useMutation } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import {
FieldMetadataType,
UploadFilesFieldFileDocument,
} from '~/generated-metadata/graphql';
type UseRecordImageIdentifierUploadParams = {
objectNameSingular: string;
recordId: string;
};
export const useRecordImageIdentifierUpload = ({
objectNameSingular,
recordId,
}: UseRecordImageIdentifierUploadParams) => {
const apolloClient = useApolloClient();
const [uploadFilesFieldFile] = useMutation(UploadFilesFieldFileDocument, {
client: apolloClient,
});
const { updateOneRecord } = useUpdateOneRecord();
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const imageIdentifierFieldMetadataItem =
getImageIdentifierFieldMetadataItem(objectMetadataItem);
const filesImageIdentifierFieldMetadataItem =
imageIdentifierFieldMetadataItem?.type === FieldMetadataType.FILES
? imageIdentifierFieldMetadataItem
: undefined;
const isImageIdentifierFieldReadOnly = useIsRecordFieldReadOnly({
recordId,
objectMetadataId: objectMetadataItem.id,
fieldMetadataId: filesImageIdentifierFieldMetadataItem?.id ?? '',
});
const canUploadImageIdentifier =
isDefined(filesImageIdentifierFieldMetadataItem) &&
!isImageIdentifierFieldReadOnly;
const onUploadPicture = async (file: File) => {
if (!isDefined(filesImageIdentifierFieldMetadataItem)) {
return;
}
const result = await uploadFilesFieldFile({
variables: {
file,
fieldMetadataId: filesImageIdentifierFieldMetadataItem.id,
},
});
const uploadedFile = result?.data?.uploadFilesFieldFile;
if (!isDefined(uploadedFile)) {
return;
}
await updateOneRecord({
objectNameSingular,
idToUpdate: recordId,
updateOneRecordInput: {
[filesImageIdentifierFieldMetadataItem.name]: [
{
fileId: uploadedFile.id,
label: file.name,
},
],
},
});
};
return {
onUploadPicture: canUploadImageIdentifier ? onUploadPicture : undefined,
};
};
@@ -5,6 +5,7 @@ import {
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { getAvatarUrl } from '@/object-metadata/utils/getAvatarUrl';
import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldValue } from '@/object-metadata/utils/getLabelIdentifierFieldValue';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
@@ -72,13 +73,9 @@ export const getRecordChipGenerators = (
getLabelIdentifierFieldMetadataItem(objectMetadataItemToUse);
const imageIdentifierFieldMetadataToUse =
objectMetadataItemToUse.fields.find(
(field) =>
field.id ===
objectMetadataItemToUse.imageIdentifierFieldMetadataId,
);
getImageIdentifierFieldMetadataItem(objectMetadataItemToUse);
const avatarType = getAvatarType(objectNameSingularToFind);
const avatarType = getAvatarType(objectMetadataItemToUse);
return [
fieldMetadataItem.name,