diff --git a/packages/manager/src/components/Avatar/Avatar.stories.tsx b/packages/manager/src/components/Avatar/Avatar.stories.tsx
new file mode 100644
index 00000000000..9817951902b
--- /dev/null
+++ b/packages/manager/src/components/Avatar/Avatar.stories.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+
+import { Avatar } from 'src/components/Avatar/Avatar';
+
+import type { Meta, StoryObj } from '@storybook/react';
+import type { AvatarProps } from 'src/components/Avatar/Avatar';
+
+export const Default: StoryObj = {
+ render: (args) => ,
+};
+
+export const System: StoryObj = {
+ render: (args) => ,
+};
+
+const meta: Meta = {
+ args: {
+ color: '#0174bc',
+ height: 88,
+ sx: {},
+ username: 'MyUsername',
+ width: 88,
+ },
+ component: Avatar,
+ title: 'Components/Avatar',
+};
+export default meta;
diff --git a/packages/manager/src/components/Avatar/Avatar.test.tsx b/packages/manager/src/components/Avatar/Avatar.test.tsx
new file mode 100644
index 00000000000..e1c553d2c15
--- /dev/null
+++ b/packages/manager/src/components/Avatar/Avatar.test.tsx
@@ -0,0 +1,74 @@
+import * as React from 'react';
+
+import { profileFactory } from 'src/factories/profile';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { Avatar } from './Avatar';
+
+import type { AvatarProps } from './Avatar';
+
+const mockProps: AvatarProps = {};
+
+const queryMocks = vi.hoisted(() => ({
+ useProfile: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('src/queries/profile/profile', async () => {
+ const actual = await vi.importActual('src/queries/profile/profile');
+ return {
+ ...actual,
+ useProfile: queryMocks.useProfile,
+ };
+});
+
+describe('Avatar', () => {
+ it('should render the first letter of a username from /profile with default background color', () => {
+ queryMocks.useProfile.mockReturnValue({
+ data: profileFactory.build({ username: 'my-user' }),
+ });
+ const { getByTestId } = renderWithTheme();
+ const avatar = getByTestId('avatar');
+ const avatarStyles = getComputedStyle(avatar);
+
+ expect(getByTestId('avatar-letter')).toHaveTextContent('M');
+ expect(avatarStyles.backgroundColor).toBe('rgb(1, 116, 188)'); // theme.color.primary.dark (#0174bc)
+ });
+
+ it('should render a background color from props', () => {
+ queryMocks.useProfile.mockReturnValue({
+ data: profileFactory.build({ username: 'my-user' }),
+ });
+
+ const { getByTestId } = renderWithTheme(
+
+ );
+ const avatar = getByTestId('avatar');
+ const avatarText = getByTestId('avatar-letter');
+ const avatarStyles = getComputedStyle(avatar);
+ const avatarTextStyles = getComputedStyle(avatarText);
+
+ // Confirm background color contrasts with text color.
+ expect(avatarStyles.backgroundColor).toBe('rgb(0, 0, 0)'); // black
+ expect(avatarTextStyles.color).toBe('rgb(255, 255, 255)'); // white
+ });
+
+ it('should render the first letter of username from props', async () => {
+ const { getByTestId } = renderWithTheme(
+
+ );
+
+ expect(getByTestId('avatar-letter')).toHaveTextContent('T');
+ });
+
+ it('should render an svg instead of first letter for system users', async () => {
+ const systemUsernames = ['Linode', 'lke-service-account-123'];
+
+ systemUsernames.forEach((username, i) => {
+ const { getAllByRole, queryByTestId } = renderWithTheme(
+
+ );
+ expect(getAllByRole('img')[i]).toBeVisible();
+ expect(queryByTestId('avatar-letter')).toBe(null);
+ });
+ });
+});
diff --git a/packages/manager/src/components/Avatar/Avatar.tsx b/packages/manager/src/components/Avatar/Avatar.tsx
new file mode 100644
index 00000000000..968ec8b5834
--- /dev/null
+++ b/packages/manager/src/components/Avatar/Avatar.tsx
@@ -0,0 +1,96 @@
+import { Typography, useTheme } from '@mui/material';
+import { default as _Avatar } from '@mui/material/Avatar';
+import * as React from 'react';
+
+import AkamaiWave from 'src/assets/logo/akamai-wave.svg';
+import { usePreferences } from 'src/queries/profile/preferences';
+import { useProfile } from 'src/queries/profile/profile';
+
+import type { SxProps } from '@mui/material';
+
+export const DEFAULT_AVATAR_SIZE = 28;
+
+export interface AvatarProps {
+ /**
+ * Optional background color to override the color set in user preferences
+ * */
+ color?: string;
+ /**
+ * Optional height
+ * @default 28px
+ * */
+ height?: number;
+ /**
+ * Optional styles
+ * */
+ sx?: SxProps;
+ /**
+ * Optional username to override the profile username; will display the first letter
+ * */
+ username?: string;
+ /**
+ * Optional width
+ * @default 28px
+ * */
+ width?: number;
+}
+
+/**
+ * The Avatar component displays the first letter of a username on a solid background color.
+ * For system avatars associated with Akamai-generated events, an Akamai logo is displayed in place of a letter.
+ */
+export const Avatar = (props: AvatarProps) => {
+ const {
+ color,
+ height = DEFAULT_AVATAR_SIZE,
+ sx,
+ username,
+ width = DEFAULT_AVATAR_SIZE,
+ } = props;
+
+ const theme = useTheme();
+
+ const { data: preferences } = usePreferences();
+ const { data: profile } = useProfile();
+
+ const _username = username ?? profile?.username ?? '';
+ const isAkamai =
+ _username === 'Linode' || _username.startsWith('lke-service-account');
+
+ const savedAvatarColor =
+ isAkamai || !preferences?.avatarColor
+ ? theme.palette.primary.dark
+ : preferences.avatarColor;
+ const avatarLetter = _username[0]?.toUpperCase() ?? '';
+
+ return (
+ <_Avatar
+ sx={{
+ '& svg': {
+ height: width / 2,
+ width: width / 2,
+ },
+ bgcolor: color ?? savedAvatarColor,
+ height,
+ width,
+ ...sx,
+ }}
+ alt={`Avatar for user ${username ?? profile?.email ?? ''}`}
+ data-testid="avatar"
+ >
+ {isAkamai ? (
+
+ ) : (
+
+ {avatarLetter}
+
+ )}
+
+ );
+};
diff --git a/packages/manager/src/components/GravatarForProxy.tsx b/packages/manager/src/components/AvatarForProxy.tsx
similarity index 93%
rename from packages/manager/src/components/GravatarForProxy.tsx
rename to packages/manager/src/components/AvatarForProxy.tsx
index 5bbef1d15b1..16840219df4 100644
--- a/packages/manager/src/components/GravatarForProxy.tsx
+++ b/packages/manager/src/components/AvatarForProxy.tsx
@@ -9,7 +9,7 @@ interface Props {
width?: number;
}
-export const GravatarForProxy = ({ height = 34, width = 34 }: Props) => {
+export const AvatarForProxy = ({ height = 34, width = 34 }: Props) => {
return (
({
diff --git a/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx b/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx
new file mode 100644
index 00000000000..aba8758b071
--- /dev/null
+++ b/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+
+import { ColorPicker } from 'src/components/ColorPicker/ColorPicker';
+
+import type { ColorPickerProps } from './ColorPicker';
+import type { Meta, StoryObj } from '@storybook/react';
+
+const meta: Meta = {
+ args: {
+ defaultColor: '#0174bc',
+ label: 'Label for color picker',
+ onChange: () => undefined,
+ },
+ component: ColorPicker,
+ title: 'Components/ColorPicker',
+};
+
+export const Default: StoryObj = {
+ render: (args) => {
+ return ;
+ },
+};
+
+export default meta;
diff --git a/packages/manager/src/components/ColorPicker/ColorPicker.tsx b/packages/manager/src/components/ColorPicker/ColorPicker.tsx
new file mode 100644
index 00000000000..6b611c91ee7
--- /dev/null
+++ b/packages/manager/src/components/ColorPicker/ColorPicker.tsx
@@ -0,0 +1,54 @@
+import { useTheme } from '@mui/material';
+import React, { useState } from 'react';
+
+import type { CSSProperties } from 'react';
+
+export interface ColorPickerProps {
+ /**
+ * Optional color to specify as a default
+ * */
+ defaultColor?: string;
+ /**
+ * Optional styles for the input element
+ * */
+ inputStyles?: CSSProperties;
+ /**
+ * Visually hidden label to semantically describe the color picker for accessibility
+ * */
+ label: string;
+ /**
+ * Function to update the color based on user selection
+ * */
+ onChange: (color: string) => void;
+}
+
+/**
+ * The ColorPicker component serves as a wrapper for the native HTML input color picker.
+ */
+export const ColorPicker = (props: ColorPickerProps) => {
+ const { defaultColor, inputStyles, label, onChange } = props;
+
+ const theme = useTheme();
+ const [color, setColor] = useState(
+ defaultColor ?? theme.palette.primary.dark
+ );
+
+ return (
+ <>
+
+ {
+ setColor(e.target.value);
+ onChange(e.target.value);
+ }}
+ color={color}
+ id="color-picker"
+ style={inputStyles}
+ type="color"
+ value={color}
+ />
+ >
+ );
+};
diff --git a/packages/manager/src/components/GravatarByEmail.tsx b/packages/manager/src/components/GravatarByEmail.tsx
index ea345f14cc2..4ff64876d93 100644
--- a/packages/manager/src/components/GravatarByEmail.tsx
+++ b/packages/manager/src/components/GravatarByEmail.tsx
@@ -6,14 +6,14 @@ import { getGravatarUrl } from 'src/utilities/gravatar';
export const DEFAULT_AVATAR_SIZE = 28;
-interface Props {
+export interface GravatarByEmailProps {
className?: string;
email: string;
height?: number;
width?: number;
}
-export const GravatarByEmail = (props: Props) => {
+export const GravatarByEmail = (props: GravatarByEmailProps) => {
const {
className,
email,
diff --git a/packages/manager/src/components/GravatarByUsername.tsx b/packages/manager/src/components/GravatarByUsername.tsx
index 509a78eabc4..3adb2262aa3 100644
--- a/packages/manager/src/components/GravatarByUsername.tsx
+++ b/packages/manager/src/components/GravatarByUsername.tsx
@@ -5,18 +5,24 @@ import UserIcon from 'src/assets/icons/account.svg';
import { useAccountUser } from 'src/queries/account/users';
import { getGravatarUrl } from 'src/utilities/gravatar';
+import { Box } from './Box';
import { DEFAULT_AVATAR_SIZE } from './GravatarByEmail';
-interface Props {
+export interface GravatarByUsernameProps {
className?: string;
username: null | string;
}
-export const GravatarByUsername = (props: Props) => {
+export const GravatarByUsername = (props: GravatarByUsernameProps) => {
const { className, username } = props;
- const { data: user } = useAccountUser(username ?? '');
+ const { data: user, isLoading } = useAccountUser(username ?? '');
const url = user?.email ? getGravatarUrl(user.email) : undefined;
+ // Render placeholder instead of flashing default user icon briefly
+ if (isLoading) {
+ return ;
+ }
+
return (
{
+ const {
+ avatar,
+ gravatar,
+ height = DEFAULT_AVATAR_SIZE,
+ width = DEFAULT_AVATAR_SIZE,
+ } = props;
+ const { data: profile } = useProfile();
+ const { hasGravatar, isLoadingGravatar } = useGravatar(profile?.email);
+
+ return isLoadingGravatar ? (
+
+ ) : hasGravatar ? (
+ gravatar
+ ) : (
+ avatar
+ );
+};
diff --git a/packages/manager/src/features/Events/EventRow.styles.ts b/packages/manager/src/features/Events/EventRow.styles.ts
index 08624554348..cf2743a97ed 100644
--- a/packages/manager/src/features/Events/EventRow.styles.ts
+++ b/packages/manager/src/features/Events/EventRow.styles.ts
@@ -1,10 +1,14 @@
+// @TODO: delete file once Gravatar is sunset
import { styled } from '@mui/material/styles';
+import { fadeIn } from 'src/styles/keyframes';
+
import { GravatarByUsername } from '../../components/GravatarByUsername';
export const StyledGravatar = styled(GravatarByUsername, {
label: 'StyledGravatar',
})(({ theme }) => ({
+ animation: `${fadeIn} .2s ease-in-out forwards`,
height: theme.spacing(3),
width: theme.spacing(3),
}));
diff --git a/packages/manager/src/features/Events/EventRow.test.tsx b/packages/manager/src/features/Events/EventRow.test.tsx
index 825e4d7c09f..311b12b1a74 100644
--- a/packages/manager/src/features/Events/EventRow.test.tsx
+++ b/packages/manager/src/features/Events/EventRow.test.tsx
@@ -29,6 +29,6 @@ describe('EventRow', () => {
name: /Two-factor authentication has been enabled./i,
})
).toBeInTheDocument();
- expect(getByRole('cell', { name: 'test_user' })).toBeInTheDocument();
+ expect(getByRole('cell', { name: /test_user/i })).toBeInTheDocument();
});
});
diff --git a/packages/manager/src/features/Events/EventRow.tsx b/packages/manager/src/features/Events/EventRow.tsx
index 9fab05311cb..40acfd1a063 100644
--- a/packages/manager/src/features/Events/EventRow.tsx
+++ b/packages/manager/src/features/Events/EventRow.tsx
@@ -1,11 +1,15 @@
+import { useTheme } from '@mui/material';
import * as React from 'react';
+import { Avatar } from 'src/components/Avatar/Avatar';
import { BarPercent } from 'src/components/BarPercent';
import { Box } from 'src/components/Box';
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
+import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar';
import { Hidden } from 'src/components/Hidden';
import { TableCell } from 'src/components/TableCell';
import { TableRow } from 'src/components/TableRow';
+import { useProfile } from 'src/queries/profile/profile';
import { getEventTimestamp } from 'src/utilities/eventUtils';
import { StyledGravatar } from './EventRow.styles';
@@ -24,12 +28,14 @@ interface EventRowProps {
export const EventRow = (props: EventRowProps) => {
const { event } = props;
+ const theme = useTheme();
const timestamp = getEventTimestamp(event);
const { action, message, username } = {
action: event.action,
message: getEventMessage(event),
username: getEventUsername(event),
};
+ const { data: profile } = useProfile();
if (!message) {
return null;
@@ -54,7 +60,27 @@ export const EventRow = (props: EventRowProps) => {
-
+
+ }
+ gravatar={
+
+ }
+ height={24}
+ width={24}
+ />
{username}
diff --git a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx
index 2d600740b1b..8654d0858ad 100644
--- a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx
+++ b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx
@@ -15,6 +15,7 @@ import { APIMaintenanceBanner } from './APIMaintenanceBanner';
import { ComplianceBanner } from './ComplianceBanner';
import { ComplianceUpdateModal } from './ComplianceUpdateModal';
import { EmailBounceNotificationSection } from './EmailBounce';
+import { GravatarSunsetBanner } from './GravatarSunsetBanner';
import { RegionStatusBanner } from './RegionStatusBanner';
import { TaxCollectionBanner } from './TaxCollectionBanner';
import { DesignUpdateBanner } from './TokensUpdateBanner';
@@ -86,6 +87,7 @@ export const GlobalNotifications = () => {
Object.keys(flags.taxCollectionBanner).length > 0 ? (
) : null}
+
>
);
};
diff --git a/packages/manager/src/features/GlobalNotifications/GravatarSunsetBanner.tsx b/packages/manager/src/features/GlobalNotifications/GravatarSunsetBanner.tsx
new file mode 100644
index 00000000000..50055637fc3
--- /dev/null
+++ b/packages/manager/src/features/GlobalNotifications/GravatarSunsetBanner.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+
+import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner';
+import { Typography } from 'src/components/Typography';
+import { useGravatar } from 'src/hooks/useGravatar';
+
+interface Props {
+ email: string;
+}
+
+export const GravatarSunsetBanner = (props: Props) => {
+ const { email } = props;
+ const GRAVATAR_DEPRECATION_DATE = 'September 28th, 2024';
+
+ const hasGravatar = useGravatar(email);
+
+ if (!hasGravatar) {
+ return;
+ }
+ return (
+
+
+ {`Support for using Gravatar as your profile photo will be deprecated on ${GRAVATAR_DEPRECATION_DATE}. Your profile photo will automatically be changed to your username initial.`}
+
+
+ );
+};
diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx
index acd014cd307..1d837243fe9 100644
--- a/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx
@@ -17,7 +17,8 @@ import { Security } from './Security';
import type { LinodeCreateFormValues } from './utilities';
describe('Security', () => {
- it(
+ // TODO: Unskip once M3-8559 is addressed.
+ it.skip(
'should render a root password input',
async () => {
const { findByLabelText } = renderWithThemeAndHookFormContext({
diff --git a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx
index b5ad7c1cb0e..6a8fef5766e 100644
--- a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx
+++ b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx
@@ -1,15 +1,19 @@
+import { useTheme } from '@mui/material';
import * as React from 'react';
import { BarPercent } from 'src/components/BarPercent';
import { Box } from 'src/components/Box';
+import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar';
import { Typography } from 'src/components/Typography';
import {
formatProgressEvent,
getEventMessage,
getEventUsername,
} from 'src/features/Events/utils';
+import { useProfile } from 'src/queries/profile/profile';
import {
+ NotificationEventAvatar,
NotificationEventGravatar,
NotificationEventStyledBox,
notificationEventStyles,
@@ -25,11 +29,14 @@ interface NotificationEventProps {
export const NotificationCenterEvent = React.memo(
(props: NotificationEventProps) => {
const { event } = props;
+ const theme = useTheme();
const { classes, cx } = notificationEventStyles();
const unseenEventClass = cx({ [classes.unseenEvent]: !event.seen });
const message = getEventMessage(event);
const username = getEventUsername(event);
+ const { data: profile } = useProfile();
+
/**
* Some event types may not be handled by our system (or new types or new ones may be added that we haven't caught yet).
* Filter these out so we don't display blank messages to the user.
@@ -48,7 +55,21 @@ export const NotificationCenterEvent = React.memo(
data-qa-event-seen={event.seen}
data-testid={event.action}
>
-
+
+ }
+ gravatar={}
+ height={32}
+ width={32}
+ />
{message}
{showProgress && (
diff --git a/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts b/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts
index 2761e4d27f5..620151a4769 100644
--- a/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts
+++ b/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts
@@ -2,10 +2,12 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
import { styled } from '@mui/material';
import { makeStyles } from 'tss-react/mui';
+import { Avatar } from 'src/components/Avatar/Avatar';
import { Box } from 'src/components/Box';
import { GravatarByUsername } from 'src/components/GravatarByUsername';
import { Link } from 'src/components/Link';
import { Typography } from 'src/components/Typography';
+import { fadeIn } from 'src/styles/keyframes';
import { omittedProps } from 'src/utilities/omittedProps';
import type { NotificationCenterNotificationMessageProps } from './types';
@@ -120,6 +122,16 @@ export const NotificationEventStyledBox = styled(Box, {
export const NotificationEventGravatar = styled(GravatarByUsername, {
label: 'StyledGravatarByUsername',
+})(() => ({
+ animation: `${fadeIn} .2s ease-in-out forwards`,
+ height: 32,
+ marginTop: 2,
+ minWidth: 32,
+ width: 32,
+}));
+
+export const NotificationEventAvatar = styled(Avatar, {
+ label: 'StyledAvatar',
})(() => ({
height: 32,
marginTop: 2,
diff --git a/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx
new file mode 100644
index 00000000000..19b8557ac5d
--- /dev/null
+++ b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx
@@ -0,0 +1,43 @@
+import { fireEvent } from '@testing-library/react';
+import * as React from 'react';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { AvatarColorPickerDialog } from './AvatarColorPickerDialog';
+
+import type { AvatarColorPickerDialogProps } from './AvatarColorPickerDialog';
+
+const mockProps: AvatarColorPickerDialogProps = {
+ handleClose: vi.fn(),
+ open: true,
+};
+
+describe('AvatarColorPicker', () => {
+ it('should render a dialog with a title, color picker, and avatar components', () => {
+ const { getByLabelText, getByTestId, getByTitle } = renderWithTheme(
+
+ );
+
+ expect(getByTitle('Change Avatar Color')).toBeVisible();
+ expect(getByLabelText('Avatar color picker')).toBeVisible();
+ expect(getByTestId('avatar')).toBeVisible();
+ });
+
+ it('calls onClose when Close button is clicked', async () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ await fireEvent.click(getByText('Close'));
+ expect(mockProps.handleClose).toHaveBeenCalled();
+ });
+
+ it('closes when Save button is clicked', async () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ await fireEvent.click(getByText('Save'));
+ expect(mockProps.handleClose).toHaveBeenCalled();
+ });
+});
diff --git a/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx
new file mode 100644
index 00000000000..dd0cca24ccf
--- /dev/null
+++ b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx
@@ -0,0 +1,75 @@
+import { Typography } from '@mui/material';
+import React from 'react';
+import { useState } from 'react';
+
+import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
+import { Avatar } from 'src/components/Avatar/Avatar';
+import { ColorPicker } from 'src/components/ColorPicker/ColorPicker';
+import { Dialog } from 'src/components/Dialog/Dialog';
+import { Stack } from 'src/components/Stack';
+import {
+ useMutatePreferences,
+ usePreferences,
+} from 'src/queries/profile/preferences';
+
+export interface AvatarColorPickerDialogProps {
+ handleClose: () => void;
+ open: boolean;
+}
+
+export const AvatarColorPickerDialog = (
+ props: AvatarColorPickerDialogProps
+) => {
+ const { handleClose, open } = props;
+
+ const [avatarColor, setAvatarColor] = useState();
+
+ const { data: preferences } = usePreferences();
+ const { mutateAsync: updatePreferences } = useMutatePreferences();
+
+ return (
+
+ );
+};
diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx
index 1a1ba9ae96b..6c003f183c2 100644
--- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx
+++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx
@@ -5,21 +5,26 @@ import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { v4 } from 'uuid';
+import { Avatar } from 'src/components/Avatar/Avatar';
import { Box } from 'src/components/Box';
+import { Button } from 'src/components/Button/Button';
import { Divider } from 'src/components/Divider';
-import { GravatarByEmail } from 'src/components/GravatarByEmail';
+import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar';
import { Link } from 'src/components/Link';
import { Paper } from 'src/components/Paper';
import { SingleTextFieldForm } from 'src/components/SingleTextFieldForm/SingleTextFieldForm';
import { TooltipIcon } from 'src/components/TooltipIcon';
import { Typography } from 'src/components/Typography';
import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants';
+import { useGravatar } from 'src/hooks/useGravatar';
import { useNotificationsQuery } from 'src/queries/account/notifications';
import { useMutateProfile, useProfile } from 'src/queries/profile/profile';
+import { AvatarColorPickerDialog } from './AvatarColorPickerDialog';
import { TimezoneForm } from './TimezoneForm';
import type { ApplicationState } from 'src/store';
+import { GravatarByEmail } from 'src/components/GravatarByEmail';
export const DisplaySettings = () => {
const theme = useTheme();
@@ -34,6 +39,13 @@ export const DisplaySettings = () => {
const isProxyUser = profile?.user_type === 'proxy';
+ const { hasGravatar } = useGravatar(profile?.email);
+
+ const [
+ isColorPickerDialogOpen,
+ setAvatarColorPickerDialogOpen,
+ ] = React.useState(false);
+
React.useEffect(() => {
if (location.state?.focusEmail && emailRef.current) {
emailRef.current.focus();
@@ -89,31 +101,50 @@ export const DisplaySettings = () => {
}}
display="flex"
>
-
+ }
+ avatar={}
height={88}
width={88}
/>
- Profile photo
-
+ {hasGravatar ? 'Profile photo' : 'Avatar'}
+ {hasGravatar && (
+
+ )}
- Create, upload, and manage your globally recognized avatar from
- a single place with Gravatar.
+ {hasGravatar
+ ? 'Create, upload, and manage your globally recognized avatar from a single place with Gravatar.'
+ : 'Your avatar is automatically generated using the first character of your username.'}
-
- Manage photo
-
+ {hasGravatar ? (
+
+ Manage photo
+
+ ) : (
+
+ )}
@@ -155,6 +186,10 @@ export const DisplaySettings = () => {
/>
+ setAvatarColorPickerDialogOpen(false)}
+ open={isColorPickerDialogOpen}
+ />
);
};
diff --git a/packages/manager/src/features/Support/ExpandableTicketPanel.tsx b/packages/manager/src/features/Support/ExpandableTicketPanel.tsx
index 5cbdf6960ff..80d5ab5cd5a 100644
--- a/packages/manager/src/features/Support/ExpandableTicketPanel.tsx
+++ b/packages/manager/src/features/Support/ExpandableTicketPanel.tsx
@@ -1,18 +1,23 @@
-import { SupportReply, SupportTicket } from '@linode/api-v4';
import Avatar from '@mui/material/Avatar';
-import { Theme } from '@mui/material/styles';
+import { useTheme } from '@mui/material/styles';
import Grid from '@mui/material/Unstable_Grid2';
import * as React from 'react';
import { makeStyles } from 'tss-react/mui';
import UserIcon from 'src/assets/icons/account.svg';
+import { Avatar as NewAvatar } from 'src/components/Avatar/Avatar';
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
+import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar';
import { Typography } from 'src/components/Typography';
+import { useProfile } from 'src/queries/profile/profile';
import { Hively, shouldRenderHively } from './Hively';
import { TicketDetailText } from './TicketDetailText';
import { OFFICIAL_USERNAMES } from './ticketUtils';
+import type { SupportReply, SupportTicket } from '@linode/api-v4';
+import type { Theme } from '@mui/material/styles';
+
const useStyles = makeStyles()((theme: Theme) => ({
'@keyframes fadeIn': {
from: {
@@ -99,10 +104,14 @@ interface Data {
export const ExpandableTicketPanel = React.memo((props: Props) => {
const { classes } = useStyles();
+ const theme = useTheme();
+
const { open, parentTicket, reply, ticket, ticketUpdated } = props;
const [data, setData] = React.useState(undefined);
+ const { data: profile } = useProfile();
+
React.useEffect(() => {
if (!ticket && !reply) {
return;
@@ -137,13 +146,28 @@ export const ExpandableTicketPanel = React.memo((props: Props) => {
const renderAvatar = (id: string) => {
return (
-
-
-
+
+ }
+ gravatar={
+
+
+
+ }
+ />
);
};
diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx
index f6d9ee4c6b2..c1470ebbaaf 100644
--- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx
+++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx
@@ -1,17 +1,18 @@
-import { GlobalGrantTypes } from '@linode/api-v4/lib/account';
import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp';
-import { Theme, styled, useMediaQuery } from '@mui/material';
+import { styled, useMediaQuery } from '@mui/material';
import Popover from '@mui/material/Popover';
import Grid from '@mui/material/Unstable_Grid2';
import { useSnackbar } from 'notistack';
import * as React from 'react';
+import { Avatar } from 'src/components/Avatar/Avatar';
+import { AvatarForProxy } from 'src/components/AvatarForProxy';
import { Box } from 'src/components/Box';
import { Button } from 'src/components/Button/Button';
import { Divider } from 'src/components/Divider';
import { GravatarByEmail } from 'src/components/GravatarByEmail';
-import { GravatarForProxy } from 'src/components/GravatarForProxy';
+import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar';
import { Hidden } from 'src/components/Hidden';
import { Link } from 'src/components/Link';
import { Stack } from 'src/components/Stack';
@@ -29,6 +30,9 @@ import { getStorage, setStorage } from 'src/utilities/storage';
import { getCompanyNameOrEmail } from './utils';
+import type { GlobalGrantTypes } from '@linode/api-v4/lib/account';
+import type { Theme } from '@mui/material';
+
interface MenuLink {
display: string;
hide?: boolean;
@@ -207,9 +211,12 @@ export const UserMenu = React.memo(() => {
+
) : (
-
+ }
+ gravatar={}
+ />
)
}
sx={(theme) => ({
diff --git a/packages/manager/src/features/Users/UserRow.tsx b/packages/manager/src/features/Users/UserRow.tsx
index 51745389d36..abe6616ddde 100644
--- a/packages/manager/src/features/Users/UserRow.tsx
+++ b/packages/manager/src/features/Users/UserRow.tsx
@@ -1,9 +1,12 @@
+import { useTheme } from '@mui/material/styles';
import React from 'react';
+import { Avatar } from 'src/components/Avatar/Avatar';
import { Box } from 'src/components/Box';
import { Chip } from 'src/components/Chip';
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
import { GravatarByEmail } from 'src/components/GravatarByEmail';
+import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar';
import { Hidden } from 'src/components/Hidden';
import { Stack } from 'src/components/Stack';
import { StatusIcon } from 'src/components/StatusIcon/StatusIcon';
@@ -24,6 +27,7 @@ interface Props {
}
export const UserRow = ({ onDelete, user }: Props) => {
+ const theme = useTheme();
const { data: grants } = useAccountUserGrants(user.username);
const { data: profile } = useProfile();
@@ -34,7 +38,19 @@ export const UserRow = ({ onDelete, user }: Props) => {
-
+
+ }
+ gravatar={}
+ />
{user.username}
{user.tfa_enabled && }
diff --git a/packages/manager/src/hooks/useGravatar.ts b/packages/manager/src/hooks/useGravatar.ts
new file mode 100644
index 00000000000..b2b99cae853
--- /dev/null
+++ b/packages/manager/src/hooks/useGravatar.ts
@@ -0,0 +1,45 @@
+import { useEffect, useState } from 'react';
+
+import { checkForGravatar, getGravatarUrl } from 'src/utilities/gravatar';
+
+// set a cache to prevent duplicate requests
+const gravatarCache = new Map();
+
+/**
+ * useGravatar
+ *
+ * @description
+ * This hook checks if a user has a Gravatar associated with their email.
+ * It uses a cache to prevent duplicate requests.
+ *
+ * @param email - The email address to check for a Gravatar.
+ * @returns - A boolean indicating whether the user has a Gravatar.
+ */
+
+export const useGravatar = (email: string | undefined) => {
+ const [hasGravatar, setHasGravatar] = useState(
+ email && gravatarCache.has(email) ? gravatarCache.get(email) : false
+ );
+ const [isLoadingGravatar, setIsLoadingGravatar] = useState(true);
+
+ useEffect(() => {
+ if (!email) {
+ setHasGravatar(false);
+ setIsLoadingGravatar(false);
+ return;
+ }
+
+ if (gravatarCache.has(email)) {
+ setHasGravatar(gravatarCache.get(email));
+ setIsLoadingGravatar(false);
+ } else {
+ checkForGravatar(getGravatarUrl(email)).then((result) => {
+ gravatarCache.set(email, result);
+ setHasGravatar(result);
+ setIsLoadingGravatar(false);
+ });
+ }
+ }, [email]);
+
+ return { hasGravatar, isLoadingGravatar };
+};
diff --git a/packages/manager/src/types/ManagerPreferences.ts b/packages/manager/src/types/ManagerPreferences.ts
index 1db40f73035..3b97d993996 100644
--- a/packages/manager/src/types/ManagerPreferences.ts
+++ b/packages/manager/src/types/ManagerPreferences.ts
@@ -15,6 +15,7 @@ export interface DismissedNotification {
}
export interface ManagerPreferences extends UserPreferences {
+ avatarColor?: string;
backups_cta_dismissed?: boolean;
desktop_sidebar_open?: boolean;
dismissed_notifications?: Record;
diff --git a/packages/manager/src/utilities/gravatar.ts b/packages/manager/src/utilities/gravatar.ts
index 1740e2c4372..989631cc7fd 100644
--- a/packages/manager/src/utilities/gravatar.ts
+++ b/packages/manager/src/utilities/gravatar.ts
@@ -11,3 +11,19 @@ export const getGravatarUrlFromHash = (hash: string): string => {
export const getGravatarUrl = (email: string): string => {
return getGravatarUrlFromHash(getEmailHash(email));
};
+
+export const checkForGravatar = async (url: string) => {
+ try {
+ const response = await fetch(url);
+
+ if (response.status === 200) {
+ return true;
+ }
+ if (response.status === 404) {
+ return false;
+ }
+ } catch (error) {
+ // The fetch to Gravatar failed. Event won't be logged.
+ }
+ return false;
+};