Decouple twenty-ui Avatar from app server-URL config (#21968)

Makes `twenty-ui`'s `Avatar` render the `avatarUrl` it receives instead
of building it from `window._env_`/`window.location` at module load, so
the library no longer depends on the app environment. URL resolution
moves to `twenty-front` via a `getAbsoluteImageUrl` helper applied at
the call sites.

Part of making twenty-ui a standalone library.
This commit is contained in:
Raphaël Bosi
2026-06-22 18:38:00 +02:00
committed by GitHub
parent e59e102448
commit 5f22908588
47 changed files with 198 additions and 115 deletions
@@ -3,6 +3,7 @@ import { isTimelineCalendarEventParticipant } from '@/activities/calendar/types/
import { isDefined } from 'twenty-shared/utils';
import { Avatar, AvatarGroup } from 'twenty-ui/data-display';
import { type TimelineCalendarEventParticipant } from '~/generated/graphql';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type CalendarEventParticipantsAvatarGroupProps = {
participants: CalendarEventParticipant[] | TimelineCalendarEventParticipant[];
@@ -51,7 +52,7 @@ export const CalendarEventParticipantsAvatarGroup = ({
key={[participant.workspaceMemberId, participant.displayName]
.filter(isDefined)
.join('-')}
avatarUrl={participant.avatarUrl}
avatarUrl={getAbsoluteImageUrl(participant.avatarUrl)}
placeholder={
participant.firstName && participant.lastName
? `${participant.firstName} ${participant.lastName}`
@@ -4,6 +4,7 @@ import React from 'react';
import { getDisplayNameFromParticipant } from '@/activities/emails/utils/getDisplayNameFromParticipant';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { RecordChip } from '@/object-record/components/RecordChip';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { Avatar } from 'twenty-ui/data-display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -69,7 +70,7 @@ export const ParticipantChip = ({
<StyledChip>
<StyledAvatarContainer>
<Avatar
avatarUrl={avatarUrl}
avatarUrl={getAbsoluteImageUrl(avatarUrl)}
type="rounded"
placeholder={displayName}
size="sm"
@@ -14,6 +14,7 @@ import {
type TimelineThread,
} from '~/generated/graphql';
import { formatToHumanReadableDate } from '~/utils/date-utils';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledHeading = styled.div<{ unread: boolean }>`
display: flex;
@@ -118,7 +119,7 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
<StyledHeading unread={!thread.read}>
<StyledParticipantsContainer>
<Avatar
avatarUrl={thread?.firstParticipant?.avatarUrl}
avatarUrl={getAbsoluteImageUrl(thread?.firstParticipant?.avatarUrl)}
placeholder={thread.firstParticipant.displayName}
placeholderColorSeed={
thread.firstParticipant.workspaceMemberId ||
@@ -129,7 +130,9 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
{isDefined(thread?.lastTwoParticipants?.[0]) && (
<StyledAvatarWrapper>
<Avatar
avatarUrl={thread.lastTwoParticipants[0].avatarUrl}
avatarUrl={getAbsoluteImageUrl(
thread.lastTwoParticipants[0].avatarUrl,
)}
placeholder={thread.lastTwoParticipants[0].displayName}
placeholderColorSeed={
thread.lastTwoParticipants[0].workspaceMemberId ||
@@ -142,7 +145,7 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
{finalDisplayedName && (
<StyledAvatarWrapper>
<Avatar
avatarUrl={finalAvatarUrl}
avatarUrl={getAbsoluteImageUrl(finalAvatarUrl)}
placeholder={finalDisplayedName}
type="rounded"
color={isCountIcon ? theme.grayScale.gray11 : undefined}
@@ -1,5 +1,6 @@
import { useApplicationChipData } from '@/applications/hooks/useApplicationChipData';
import { styled } from '@linaria/react';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { Avatar, type AvatarSize } from 'twenty-ui/data-display';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -44,7 +45,7 @@ export const AppChip = ({
<Avatar
type="app"
size={size}
avatarUrl={applicationChipData.logo}
avatarUrl={getAbsoluteImageUrl(applicationChipData.logo)}
placeholder={applicationChipData.name}
placeholderColorSeed={applicationChipData.seed}
color={applicationChipData.colors?.color}
@@ -34,6 +34,7 @@ import {
GetWorkspaceCreationDefaultsDocument,
} from '~/generated-metadata/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledWorkspaceContainer = styled.div`
background-color: ${themeCssVariables.background.secondary};
@@ -172,9 +173,9 @@ export const SignInUpGlobalScopeForm = () => {
<StyledWorkspaceContent>
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO
}
avatarUrl={getAbsoluteImageUrl(
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
size="lg"
/>
<StyledWorkspaceTextContainer>
@@ -1,6 +1,7 @@
import { type MouseEvent } from 'react';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { Avatar } from 'twenty-ui/data-display';
import { MenuItemSuggestion } from 'twenty-ui/navigation';
@@ -40,7 +41,7 @@ export const MentionMenuListItem = ({
<Avatar
placeholder={label}
placeholderColorSeed={recordId}
avatarUrl={imageUrl}
avatarUrl={getAbsoluteImageUrl(imageUrl)}
type={getAvatarType(objectNameSingular) ?? 'rounded'}
size="sm"
/>
@@ -1,4 +1,5 @@
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import {
@@ -59,7 +60,7 @@ export const MentionRecordChip = ({
placeholder={label}
placeholderColorSeed={recordId}
avatarType="rounded"
avatarUrl={imageUrl}
avatarUrl={getAbsoluteImageUrl(imageUrl)}
/>
}
/>
@@ -5,6 +5,7 @@ import { MenuItemSuggestion } from 'twenty-ui/navigation';
import type { MentionSearchResult } from '@/mention/types/MentionSearchResult';
import type { MentionSuggestionMenuProps } from '@/mention/types/MentionSuggestionMenuProps';
import { SuggestionMenu } from '@/ui/suggestion/components/SuggestionMenu';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const getItemKey = (item: MentionSearchResult) =>
`${item.objectNameSingular}-${item.recordId}`;
@@ -20,7 +21,7 @@ const renderItem = (
placeholder={item.label}
placeholderColorSeed={item.recordId}
avatarType="rounded"
avatarUrl={item.imageUrl}
avatarUrl={getAbsoluteImageUrl(item.imageUrl)}
/>
)}
text={item.label}
@@ -20,6 +20,7 @@ import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandard
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
export const NavigationMenuItemIcon = ({
navigationMenuItem,
@@ -155,7 +156,7 @@ export const NavigationMenuItemIcon = ({
type={recordIdentifier?.avatarType ?? 'icon'}
Icon={iconToUse}
iconColor={iconColorToUse}
avatarUrl={recordIdentifier?.avatarUrl ?? ''}
avatarUrl={getAbsoluteImageUrl(recordIdentifier?.avatarUrl ?? '')}
placeholder={labelIdentifier}
placeholderColorSeed={navigationMenuItem.targetRecordId ?? undefined}
/>
@@ -30,6 +30,7 @@ import { Avatar } from 'twenty-ui/data-display';
import { IconLock, useIcons } from 'twenty-ui/icon';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
export type NavigationDrawerItemForObjectMetadataItemProps = {
objectMetadataItem: EnrichedObjectMetadataItem;
@@ -150,7 +151,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
? 'squared'
: 'rounded'
}
avatarUrl={recordIdentifier?.avatarUrl}
avatarUrl={getAbsoluteImageUrl(recordIdentifier?.avatarUrl)}
placeholderColorSeed={navigationMenuItem!.targetRecordId ?? undefined}
placeholder={itemLabel}
/>
@@ -14,6 +14,7 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { SidePanelItemWithAddToNavigationDrag } from '@/side-panel/components/SidePanelItemWithAddToNavigationDrag';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type SearchRecord = {
recordId: string;
@@ -90,7 +91,7 @@ export const SidePanelNewSidebarItemRecordItem = ({
? 'squared'
: 'rounded'
}
avatarUrl={record.imageUrl}
avatarUrl={getAbsoluteImageUrl(record.imageUrl)}
placeholderColorSeed={record.recordId}
placeholder={record.label}
/>
@@ -7,6 +7,7 @@ import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { canOpenObjectInSidePanel } from '@/object-record/utils/canOpenObjectInSidePanel';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ViewOpenRecordIn } from '~/generated-metadata/graphql';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { t } from '@lingui/core/macro';
import { type MouseEvent } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -98,7 +99,7 @@ export const RecordChip = ({
placeholder={recordChipData.name}
placeholderColorSeed={record.id}
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
avatarUrl={getAbsoluteImageUrl(recordChipData.avatarUrl ?? '')}
/>
)
}
@@ -120,7 +121,7 @@ export const RecordChip = ({
placeholder={recordChipData.name}
placeholderColorSeed={record.id}
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
avatarUrl={getAbsoluteImageUrl(recordChipData.avatarUrl ?? '')}
/>
)
}
@@ -3,6 +3,7 @@ import { styled } from '@linaria/react';
import { Avatar } from 'twenty-ui/data-display';
import { MenuItemMultiSelectAvatar } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledPinnedItemsContainer = styled.div`
display: flex;
@@ -31,7 +32,7 @@ export const ObjectFilterDropdownRecordPinnedItems = (props: {
avatar={
selectableItem.avatarUrl ? (
<Avatar
avatarUrl={selectableItem.avatarUrl}
avatarUrl={getAbsoluteImageUrl(selectableItem.avatarUrl)}
placeholderColorSeed={selectableItem.id}
placeholder={selectableItem.name}
type={selectableItem.avatarType}
@@ -18,6 +18,7 @@ import { MenuItemMultiSelectAvatar } from 'twenty-ui/navigation';
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
import { type SearchRecord } from '~/generated/graphql';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type MultipleRecordPickerMenuItemContentProps = {
searchRecord: SearchRecord;
@@ -83,7 +84,7 @@ export const MultipleRecordPickerMenuItemContent = ({
selected={isRecordSelectedWithObjectItem}
avatar={
<Avatar
avatarUrl={searchRecord.imageUrl}
avatarUrl={getAbsoluteImageUrl(searchRecord.imageUrl)}
placeholderColorSeed={searchRecord.recordId}
placeholder={displayText}
size="md"
@@ -10,6 +10,7 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
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 { capitalize, isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
@@ -73,7 +74,7 @@ export const SingleRecordPickerMenuItem = ({
focused={isSelectedItemId}
avatar={
<Avatar
avatarUrl={searchRecordStore.imageUrl}
avatarUrl={getAbsoluteImageUrl(searchRecordStore.imageUrl)}
placeholderColorSeed={morphItem.recordId}
placeholder={searchRecordStore.label}
size="md"
@@ -10,6 +10,7 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { t } from '@lingui/core/macro';
import { Avatar } from 'twenty-ui/data-display';
import { MenuItem, MenuItemMultiSelectAvatar } from 'twenty-ui/navigation';
@@ -107,7 +108,7 @@ export const MultipleSelectDropdown = ({
text={item.name}
avatar={
<Avatar
avatarUrl={item.avatarUrl}
avatarUrl={getAbsoluteImageUrl(item.avatarUrl)}
placeholderColorSeed={item.id}
placeholder={item.name}
size="md"
@@ -29,6 +29,7 @@ import {
AdminPanelRecentUsersDocument,
AdminPanelTopWorkspacesDocument,
} from '~/generated-admin/graphql';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledEmptyState = styled.div`
color: ${themeCssVariables.font.color.tertiary};
@@ -136,7 +137,7 @@ export const SettingsAdminGeneral = () => {
overflow="hidden"
>
<Avatar
avatarUrl={user.avatarUrl}
avatarUrl={getAbsoluteImageUrl(user.avatarUrl)}
placeholder={
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
user.email
@@ -160,7 +161,7 @@ export const SettingsAdminGeneral = () => {
{user.workspaceId ? (
<>
<Avatar
avatarUrl={user.workspaceLogo}
avatarUrl={getAbsoluteImageUrl(user.workspaceLogo)}
placeholder={user.workspaceName || ''}
placeholderColorSeed={user.workspaceId}
size="sm"
@@ -232,7 +233,7 @@ export const SettingsAdminGeneral = () => {
overflow="hidden"
>
<Avatar
avatarUrl={workspace.logoUrl}
avatarUrl={getAbsoluteImageUrl(workspace.logoUrl)}
placeholder={workspace.name || ''}
placeholderColorSeed={workspace.id}
size="md"
@@ -12,7 +12,6 @@ import { useContext } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import {
formatUpgradeCommandName,
getImageAbsoluteURI,
getSettingsPath,
isDefined,
} from 'twenty-shared/utils';
@@ -30,8 +29,8 @@ import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { H2Title } from 'twenty-ui/typography';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
type SettingsAdminWorkspaceContentProps = {
@@ -83,14 +82,11 @@ export const SettingsAdminWorkspaceContent = ({
})}
leftComponent={
<AvatarOrIcon
avatarUrl={
getImageAbsoluteURI({
imageUrl: isNonEmptyString(activeWorkspace?.logo)
? activeWorkspace?.logo
: DEFAULT_WORKSPACE_LOGO,
baseUrl: REACT_APP_SERVER_BASE_URL,
}) ?? ''
}
avatarUrl={getAbsoluteImageUrl(
isNonEmptyString(activeWorkspace?.logo)
? activeWorkspace?.logo
: DEFAULT_WORKSPACE_LOGO,
)}
/>
}
/>
@@ -12,6 +12,7 @@ import { AppTooltip, TooltipDelay } from 'twenty-ui/surfaces';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type RoleWithPartialMembers } from '@/settings/roles/types/RoleWithPartialMembers';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledAssignedText = styled.div`
color: ${themeCssVariables.font.color.secondary};
@@ -102,7 +103,7 @@ export const SettingsRolesTableRow = ({ role }: SettingsRolesTableRowProps) => {
<React.Fragment key={workspaceMember.id}>
<div id={`avatar-${workspaceMember.id}`}>
<Avatar
avatarUrl={workspaceMember.avatarUrl}
avatarUrl={getAbsoluteImageUrl(workspaceMember.avatarUrl)}
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName ?? ''}
type="rounded"
@@ -7,6 +7,7 @@ import { t } from '@lingui/core/macro';
import { Avatar } from 'twenty-ui/data-display';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledSettingsCardContainer = styled.div`
margin-top: ${themeCssVariables.spacing[6]};
@@ -41,7 +42,9 @@ export const SettingsRoleAssignmentConfirmationModalSubtitle = ({
title={selectedRoleTarget.role?.label || ''}
Icon={
<Avatar
avatarUrl={enrichedSelectedWorkspaceMember?.avatarUrl}
avatarUrl={getAbsoluteImageUrl(
enrichedSelectedWorkspaceMember?.avatarUrl,
)}
placeholderColorSeed={enrichedSelectedWorkspaceMember?.id}
placeholder={workspaceMemberName}
size="md"
@@ -12,6 +12,7 @@ import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type Agent, type ApiKeyForRole } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { formatDateString } from '~/utils/string/formatDateString';
import { type PartialWorkspaceMember } from '@/settings/roles/types/RoleWithPartialMembers';
@@ -63,7 +64,7 @@ export const SettingsRoleAssignmentTableRow = ({
);
return (
<Avatar
avatarUrl={enrichedWorkspaceMember?.avatarUrl}
avatarUrl={getAbsoluteImageUrl(enrichedWorkspaceMember?.avatarUrl)}
placeholderColorSeed={enrichedWorkspaceMember?.id}
placeholder={enrichedWorkspaceMember?.name.firstName ?? ''}
type="rounded"
@@ -2,6 +2,7 @@ import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandard
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { Avatar } from 'twenty-ui/data-display';
@@ -46,7 +47,7 @@ export const SidePanelContextRecordChipAvatars = ({
<Icon color={IconColor} size={theme.icon.size.sm} />
) : (
<Avatar
avatarUrl={recordChipData.avatarUrl}
avatarUrl={getAbsoluteImageUrl(recordChipData.avatarUrl)}
placeholderColorSeed={recordChipData.recordId}
placeholder={recordChipData.name}
type={recordChipData.avatarType}
@@ -25,6 +25,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { SidePanelPageInfoLayout } from './SidePanelPageInfoLayout';
const StyledClickableTitle = styled.div`
@@ -140,7 +141,7 @@ export const SidePanelRecordInfo = ({
icon={
recordIdentifier ? (
<Avatar
avatarUrl={recordIdentifier.avatarUrl}
avatarUrl={getAbsoluteImageUrl(recordIdentifier.avatarUrl)}
placeholder={recordIdentifier.name}
placeholderColorSeed={objectRecordId}
size="md"
@@ -5,6 +5,7 @@ import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { useSidePanelSearchRecords } from '@/side-panel/pages/search/hooks/useSidePanelSearchRecords';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types';
@@ -70,7 +71,7 @@ export const SidePanelSearchRecordsPage = () => {
LeftComponent={
<Avatar
type={item.avatarType}
avatarUrl={item.imageUrl}
avatarUrl={getAbsoluteImageUrl(item.imageUrl)}
placeholderColorSeed={item.recordId}
placeholder={item.label}
/>
@@ -4,6 +4,7 @@ import { useContext } from 'react';
import { type BackgroundMockCompany } from '@/sign-in-background-mock/constants/BackgroundMockCompanies';
import { BACKGROUND_MOCK_COLUMN_WIDTHS } from '@/sign-in-background-mock/constants/BackgroundMockColumnWidths';
import { BACKGROUND_MOCK_TABLE_DIMENSIONS } from '@/sign-in-background-mock/constants/BackgroundMockTableDimensions';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import {
Avatar,
Chip,
@@ -125,7 +126,7 @@ export const BackgroundMockTableRow = ({
leftComponent={
<Avatar
type="squared"
avatarUrl={logoUrl}
avatarUrl={getAbsoluteImageUrl(logoUrl)}
placeholder={company.name}
placeholderColorSeed={company.id}
size="md"
@@ -18,6 +18,7 @@ import {
IconWebhook,
type IconComponent,
} from 'twenty-ui/icon';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type ActorDisplayProps = Partial<FieldActorValue> & {
avatarUrl?: string | null;
@@ -96,7 +97,7 @@ export const ActorDisplay = ({
avatarType={workspaceMemberId ? 'rounded' : 'squared'}
placeholder={name}
Icon={LeftIcon}
avatarUrl={avatarUrl ?? undefined}
avatarUrl={getAbsoluteImageUrl(avatarUrl ?? undefined)}
/>
}
/>
@@ -15,6 +15,7 @@ import {
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type ShowPageSummaryCardProps = {
avatarPlaceholder: string;
@@ -148,7 +149,7 @@ export const ShowPageSummaryCard = ({
hasIcon={isDefined(icon)}
>
<Avatar
avatarUrl={logoOrAvatar}
avatarUrl={getAbsoluteImageUrl(logoOrAvatar)}
onClick={onUploadPicture ? handleAvatarClick : undefined}
size="xl"
placeholderColorSeed={id}
@@ -3,12 +3,19 @@ import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
export const TabAvatar = ({ tab }: { tab: SingleTabProps }) => {
const { theme } = useContext(ThemeContext);
if (isDefined(tab.logo)) {
return <Avatar avatarUrl={tab.logo} size="md" placeholder={tab.title} />;
return (
<Avatar
avatarUrl={getAbsoluteImageUrl(tab.logo)}
size="md"
placeholder={tab.title}
/>
);
}
return (
tab.Icon && (
@@ -9,6 +9,7 @@ import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigat
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { useContext } from 'react';
import { Avatar } from 'twenty-ui/data-display';
import { ThemeContext } from 'twenty-ui/theme-constants';
@@ -34,7 +35,9 @@ export const MultiWorkspaceDropdownClickableComponent = ({
>
<Avatar
placeholder={currentWorkspace?.displayName || ''}
avatarUrl={currentWorkspace?.logo ?? DEFAULT_WORKSPACE_LOGO}
avatarUrl={getAbsoluteImageUrl(
currentWorkspace?.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
/>
<StyledLabelWrapper>
<NavigationDrawerAnimatedCollapseWrapper>
@@ -45,6 +45,7 @@ import {
} from 'twenty-ui/navigation';
import { type AvailableWorkspace } from '~/generated-metadata/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledDescription = styled.div`
color: ${themeCssVariables.font.color.light};
@@ -100,7 +101,9 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
Avatar={
<Avatar
placeholder={currentWorkspace?.displayName || ''}
avatarUrl={currentWorkspace?.logo ?? DEFAULT_WORKSPACE_LOGO}
avatarUrl={getAbsoluteImageUrl(
currentWorkspace?.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
/>
}
/>
@@ -161,9 +164,9 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
avatar={
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO
}
avatarUrl={getAbsoluteImageUrl(
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
/>
}
selected={false}
@@ -4,6 +4,7 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta
import { type AvailableWorkspace } from '~/generated-metadata/graphql';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { getAvailableWorkspacePathAndSearchParams } from '@/auth/utils/availableWorkspacesUtils';
import { t } from '@lingui/core/macro';
import React from 'react';
@@ -49,7 +50,9 @@ export const AvailableWorkspaceItem = ({
avatar={
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO}
avatarUrl={getAbsoluteImageUrl(
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
/>
}
selected={isSelected}
@@ -15,6 +15,7 @@ import {
IconSettings,
IconWorld,
} from 'twenty-ui/icon';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { SettingsApplicationRegistrationConfigTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationConfigTab';
import { SettingsApplicationRegistrationOAuthTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab';
import { SettingsApplicationRegistrationDistributionTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab';
@@ -108,7 +109,7 @@ export const SettingsAdminApplicationRegistrationDetail = () => {
<Avatar
type="app"
size="md"
avatarUrl={registration.logoUrl ?? undefined}
avatarUrl={getAbsoluteImageUrl(registration.logoUrl ?? undefined)}
placeholder={registration.name}
placeholderColorSeed={registration.name}
/>
@@ -45,6 +45,7 @@ import { H2Title } from 'twenty-ui/typography';
import { Button, Toggle } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import {
type FeatureFlagKey,
type GetAdminWorkspaceChatThreadsQuery,
@@ -267,7 +268,7 @@ export const SettingsAdminWorkspaceDetail = () => {
overflow="hidden"
>
<Avatar
avatarUrl={user.avatarUrl}
avatarUrl={getAbsoluteImageUrl(user.avatarUrl)}
placeholder={
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
user.email
@@ -3,6 +3,7 @@ import { useContext } from 'react';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { isDefined } from 'twenty-shared/utils';
import { Avatar, getIconTileColorShades } from 'twenty-ui/data-display';
import {
@@ -98,7 +99,7 @@ export const SettingsToolIcon = ({
if (isDefined(application) && isDefined(marketplaceApp?.logo)) {
return (
<Avatar
avatarUrl={marketplaceApp?.logo ?? null}
avatarUrl={getAbsoluteImageUrl(marketplaceApp?.logo ?? null)}
placeholder={application.name}
placeholderColorSeed={application.name}
type="squared"
@@ -20,6 +20,7 @@ import { SettingsApplicationRegistrationDistributionTab } from '~/pages/settings
import { SettingsApplicationRegistrationGeneralTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationGeneralTab';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -99,7 +100,7 @@ export const SettingsApplicationRegistrationDetails = () => {
<Avatar
type="app"
size="md"
avatarUrl={registration.logoUrl ?? undefined}
avatarUrl={getAbsoluteImageUrl(registration.logoUrl ?? undefined)}
placeholder={registration.name}
placeholderColorSeed={registration.name}
/>
@@ -33,6 +33,7 @@ import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Section } from 'twenty-ui/layout';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsApplicationRegistrationShareLinkButtons } from '~/pages/settings/applications/components/SettingsApplicationRegistrationShareLinkButtons';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledSourceRow = styled.div`
align-items: center;
@@ -119,7 +120,9 @@ export const SettingsApplicationRegistrationGeneralInfo = ({
leftComponent={
<AvatarOrIcon
avatarType="rounded"
avatarUrl={ownerWorkspace?.logo ?? undefined}
avatarUrl={getAbsoluteImageUrl(
ownerWorkspace?.logo ?? undefined,
)}
/>
}
label={ownerWorkspace.displayName}
@@ -13,6 +13,7 @@ import { Card } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type MarketplaceApp } from '~/generated-metadata/graphql';
import { getApplicationDescriptionSummary } from '~/pages/settings/applications/utils/getApplicationDescriptionSummary';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type SettingsAvailableApplicationCardProps = {
application: MarketplaceApp;
@@ -60,7 +61,7 @@ export const SettingsAvailableApplicationCard = ({
<Card rounded fullWidth>
<StyledSettingsCardContent alignItems="flex-start" fullHeight>
<Avatar
avatarUrl={application.logo || null}
avatarUrl={getAbsoluteImageUrl(application.logo || null)}
placeholder={application.name}
placeholderColorSeed={application.name}
size="lg"
@@ -38,6 +38,7 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const StyledTableContainer = styled.div<{ hasMoreRows?: boolean }>`
@@ -248,7 +249,9 @@ export const SettingsWorkspaceMembersTeamTab = () => {
<TableCell>
<StyledIconWrapper>
<Avatar
avatarUrl={workspaceMember.avatarUrl}
avatarUrl={getAbsoluteImageUrl(
workspaceMember.avatarUrl,
)}
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName ?? ''}
type="rounded"
@@ -0,0 +1,31 @@
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
jest.mock('~/config', () => ({
REACT_APP_SERVER_BASE_URL: 'https://example.com',
}));
describe('getAbsoluteImageUrl', () => {
it('returns undefined for nullish or empty input', () => {
expect(getAbsoluteImageUrl(undefined)).toBeUndefined();
expect(getAbsoluteImageUrl(null)).toBeUndefined();
expect(getAbsoluteImageUrl('')).toBeUndefined();
});
it('returns absolute http(s) URLs unchanged', () => {
expect(getAbsoluteImageUrl('https://cdn.example.com/avatar.png')).toBe(
'https://cdn.example.com/avatar.png',
);
expect(getAbsoluteImageUrl('http://cdn.example.com/avatar.png')).toBe(
'http://cdn.example.com/avatar.png',
);
});
it('resolves relative paths against the server base URL under /files', () => {
expect(getAbsoluteImageUrl('avatars/avatar.png')).toBe(
'https://example.com/files/avatars/avatar.png',
);
expect(getAbsoluteImageUrl('/avatars/avatar.png')).toBe(
'https://example.com/files/avatars/avatar.png',
);
});
});
@@ -0,0 +1,11 @@
import { isNonEmptyString } from '@sniptt/guards';
import { getImageAbsoluteURI } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
export const getAbsoluteImageUrl = (
imageUrl?: string | null,
): string | undefined =>
isNonEmptyString(imageUrl)
? getImageAbsoluteURI({ imageUrl, baseUrl: REACT_APP_SERVER_BASE_URL })
: undefined;
@@ -14,6 +14,44 @@ describe('getImageAbsoluteURI', () => {
expect(result).toBe(imageUrl);
});
it('should return http absolute url unchanged', () => {
const imageUrl = 'http://XXX/pic.png';
const baseUrl = 'http://localhost:3000';
const result = getImageAbsoluteURI({ imageUrl, baseUrl });
expect(result).toBe(imageUrl);
});
it('should treat schemes case-insensitively and return them unchanged', () => {
const baseUrl = 'http://localhost:3000';
expect(getImageAbsoluteURI({ imageUrl: 'HTTPS://XXX', baseUrl })).toBe(
'HTTPS://XXX',
);
expect(
getImageAbsoluteURI({ imageUrl: 'Data:image/png;base64,AAAA', baseUrl }),
).toBe('Data:image/png;base64,AAAA');
});
it('should return data URIs unchanged', () => {
const imageUrl = 'data:image/png;base64,iVBORw0KGgo=';
const baseUrl = 'http://localhost:3000';
const result = getImageAbsoluteURI({ imageUrl, baseUrl });
expect(result).toBe(imageUrl);
});
it('should return blob URLs unchanged', () => {
const imageUrl = 'blob:http://localhost:3000/123e4567-e89b-12d3';
const baseUrl = 'http://localhost:3000';
const result = getImageAbsoluteURI({ imageUrl, baseUrl });
expect(result).toBe(imageUrl);
});
it('should return protocol-relative URLs unchanged', () => {
const imageUrl = '//cdn.example.com/pic.png';
const baseUrl = 'http://localhost:3000';
const result = getImageAbsoluteURI({ imageUrl, baseUrl });
expect(result).toBe(imageUrl);
});
it('should return fully formed url if imageUrl is a relative url starting with /', () => {
const imageUrl = '/path/pic.png';
const baseUrl = 'http://localhost:3000';
@@ -7,7 +7,13 @@ export const getImageAbsoluteURI = ({
imageUrl,
baseUrl,
}: getImageAbsoluteURIProps): string => {
if (imageUrl.startsWith('https:') || imageUrl.startsWith('http:')) {
const lowerCaseImageUrl = imageUrl.toLowerCase();
const isAlreadyAbsoluteUri =
['http:', 'https:', 'data:', 'blob:'].some((scheme) =>
lowerCaseImageUrl.startsWith(scheme),
) || imageUrl.startsWith('//');
if (isAlreadyAbsoluteUri) {
return imageUrl;
}
@@ -8,9 +8,7 @@ import { type AvatarType } from '@ui/data-display/Avatar/types/AvatarType';
import { type IconComponent } from '@ui/icon/types/IconComponent';
import { ThemeContext } from '@ui/theme-constants';
import { stringToThemeColorP3String } from '@ui/utilities';
import { REACT_APP_SERVER_BASE_URL } from '@ui/utilities/config';
import { type Nullable } from '@ui/utilities/types/Nullable';
import { getImageAbsoluteURI } from '@ui/utilities/utils/getImageAbsoluteURI';
import styles from './Avatar.module.scss';
@@ -49,12 +47,7 @@ export const Avatar = ({
string | null
>(null);
const avatarImageURI = isNonEmptyString(avatarUrl)
? getImageAbsoluteURI({
imageUrl: avatarUrl,
baseUrl: REACT_APP_SERVER_BASE_URL,
})
: null;
const avatarImageURI = isNonEmptyString(avatarUrl) ? avatarUrl : null;
const placeholderFirstChar = placeholder?.trim()?.charAt(0);
const isPlaceholderFirstCharEmpty =
-7
View File
@@ -1,7 +0,0 @@
declare global {
interface Window {
_env_?: Record<string, string>;
}
}
export {};
@@ -1,21 +0,0 @@
const getDefaultUrl = () => {
if (
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1'
) {
// In development environment front and backend usually run on separate ports
// we set the default value to localhost:3000.
// In dev context, we use env vars to overwrite it
return 'http://localhost:3000';
} else {
// Outside of localhost we assume that they run on the same port
// because the backend will serve the frontend
// In prod context, we use index.html + window var to ovewrite it
return `${window.location.protocol}//${window.location.hostname}${
window.location.port ? `:${window.location.port}` : ''
}`;
}
};
export const REACT_APP_SERVER_BASE_URL =
window._env_?.REACT_APP_SERVER_BASE_URL || getDefaultUrl();
@@ -14,7 +14,6 @@ export {
stringToThemeColorP3String,
} from './color/utils/stringToThemeColorP3String';
export { themeColorSchema } from './color/utils/themeColorSchema';
export { REACT_APP_SERVER_BASE_URL } from './config';
export { getOsControlSymbol } from './device/getOsControlSymbol';
export { getOsShortcutSeparator } from './device/getOsShortcutSeparator';
export { getUserDevice } from './device/getUserDevice';
@@ -27,7 +26,6 @@ export { useScreenSize } from './screen-size/hooks/useScreenSize';
export type { ClickOutsideAttributes } from './types/ClickOutsideAttributes';
export type { Nullable } from './types/Nullable';
export { getDisplayValueByUrlType } from './utils/getDisplayValueByUrlType';
export { getImageAbsoluteURI } from './utils/getImageAbsoluteURI';
export { getSafeUrl } from './utils/getSafeUrl';
export { isDefined } from './utils/isDefined';
export type { LinkifyMatch } from './utils/linkifyText';
@@ -1,19 +0,0 @@
type getImageAbsoluteURIProps = {
imageUrl: string;
baseUrl: string;
};
export const getImageAbsoluteURI = ({
imageUrl,
baseUrl,
}: getImageAbsoluteURIProps): string => {
if (imageUrl.startsWith('https:') || imageUrl.startsWith('http:')) {
return imageUrl;
}
if (imageUrl.startsWith('/')) {
return new URL(`/files${imageUrl}`, baseUrl).toString();
}
return new URL(`/files/${imageUrl}`, baseUrl).toString();
};