Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [M3-8158] - Begin to sunset Gravatar #10859

Merged
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
0115e51
Swap out gravatar for colored avatar on Profile > Display
mjac0bs Aug 19, 2024
36cc621
Add utils to determine letter color
mjac0bs Aug 19, 2024
24296ca
Bring back checkForGravatar event and fix hasGravatar boolean
mjac0bs Aug 28, 2024
c2bd8c7
Hide tooltip if user unless user hasGravatar
mjac0bs Aug 28, 2024
e99f755
Add GravatarSunsetBanner.tsx
mjac0bs Aug 28, 2024
c904496
Use util for banner and profile page
mjac0bs Aug 28, 2024
c76b89b
Add Akamai wave icon for Akamai-generated user events
mjac0bs Aug 29, 2024
86bf9fc
Clean up of theme colors and conditional rendering
mjac0bs Aug 29, 2024
242f570
Style ColorPicker
mjac0bs Aug 29, 2024
0885902
Try to handle constrast ratio
mjac0bs Aug 29, 2024
1136b91
Address UX feedback: show avatar preview in dialog
mjac0bs Aug 29, 2024
2b9df00
Add new avatar to EventRow on Event Landing page
mjac0bs Sep 4, 2024
9c520d4
Conditionally render Gravatar in EventRow until sunset
mjac0bs Sep 4, 2024
5d65177
Replace gravatar conditionally in UserRow of Users Landing
mjac0bs Sep 4, 2024
a708fb6
Conditionally render styled Avatar in UserSSHKeyPanel
mjac0bs Sep 4, 2024
33a9b94
Conditionally render Avatar in TopMenu; rename GravatarForProxy
mjac0bs Sep 4, 2024
9e7ed75
Conditionally render styled Avatar in NotificationCenterEvent
mjac0bs Sep 4, 2024
357bd7e
Fix sunset date
mjac0bs Sep 4, 2024
b3c0fe7
Use MUI theme function to get contrasting text color
mjac0bs Sep 4, 2024
d460d59
Clean up; change color default to darker color
mjac0bs Sep 4, 2024
b26814d
Clean up Avatar, ColorPicker; add stories
mjac0bs Sep 5, 2024
f093384
Clean up and add unit tests
mjac0bs Sep 5, 2024
ccea1bd
Clean up; fix test
mjac0bs Sep 5, 2024
edb2fb1
Forgot to push last changes for Support; default color fix
mjac0bs Sep 5, 2024
65f9271
Add changesets
mjac0bs Sep 5, 2024
a1c35ce
Fix an accidentally skipped test
mjac0bs Sep 5, 2024
bef6088
Address UX feedback: use 'Avatar' over 'Profile photo'
mjac0bs Sep 6, 2024
bb98f35
Address feedback: avoid regex
mjac0bs Sep 6, 2024
10f0742
Fix bug: NotificationCenterEvent missing Linode system avatar
mjac0bs Sep 6, 2024
95e2a76
Use hook throughout gravatar replacement
mjac0bs Sep 6, 2024
702cab4
improve useGravatar hook
abailly-akamai Sep 6, 2024
a62cc2d
Fix: showing new system/support avatars when user has Gravatar enabled
mjac0bs Sep 6, 2024
148c471
Experiment with component for loading/gravatar/avatar
mjac0bs Sep 9, 2024
665915c
Handle loading state and fade per-component to fix flickering
mjac0bs Sep 9, 2024
0d3f0ef
Fix username for support tickets; clean up
mjac0bs Sep 9, 2024
b0f7a7d
Fix avatar color for additional account users
mjac0bs Sep 9, 2024
10399db
Merge branch 'develop' into M3-8158-replace-gravatar-with-new-avatars
mjac0bs Sep 9, 2024
130aec7
Switch over to single GravatarOrAvatar component for rendering
mjac0bs Sep 9, 2024
d48352b
Clean up commented code
mjac0bs Sep 9, 2024
f4d32f3
Use const for default avatar size
mjac0bs Sep 9, 2024
6a3eca7
Address feedback: use Map
mjac0bs Sep 10, 2024
e8a6afa
Merge branch 'develop' into M3-8158-replace-gravatar-with-new-avatars
mjac0bs Sep 10, 2024
c373a53
Does not using the deprecated function fix the unit tests in CI?
mjac0bs Sep 10, 2024
2e56cb8
Revert "Does not using the deprecated function fix the unit tests in …
mjac0bs Sep 10, 2024
b9d597b
Skip failing test with JSDom issues for now
mjac0bs Sep 10, 2024
04c0b6b
Improve fading behavior
mjac0bs Sep 10, 2024
1e589a8
Revert "Improve fading behavior" because it causes other issues
mjac0bs Sep 10, 2024
5835140
Add GravatarByUsername loading placeholder; adjust other fades
mjac0bs Sep 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-10859-added-1725550540714.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

Gravatar sunset banner for existing Gravatar users ([#10859](https://github.com/linode/manager/pull/10859))
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Changed
---

Avatars for users without Gravatars ([#10859](https://github.com/linode/manager/pull/10859))
3 changes: 3 additions & 0 deletions packages/manager/src/assets/logo/akamai-wave.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 35 additions & 5 deletions packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Theme } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import * as React from 'react';
import { makeStyles } from 'tss-react/mui';

Expand All @@ -16,12 +16,17 @@ import { CreateSSHKeyDrawer } from 'src/features/Profile/SSHKeys/CreateSSHKeyDra
import { usePagination } from 'src/hooks/usePagination';
import { useAccountUsers } from 'src/queries/account/users';
import { useProfile, useSSHKeysQuery } from 'src/queries/profile/profile';
import { fadeIn } from 'src/styles/keyframes';
import { truncateAndJoinList } from 'src/utilities/stringUtils';

import { Avatar } from '../Avatar/Avatar';
import { GravatarByEmail } from '../GravatarByEmail';
import { GravatarOrAvatar } from '../GravatarOrAvatar';
import { PaginationFooter } from '../PaginationFooter/PaginationFooter';
import { TableRowLoading } from '../TableRowLoading/TableRowLoading';

import type { Theme } from '@mui/material/styles';

export const MAX_SSH_KEYS_DISPLAY = 25;

const useStyles = makeStyles()((theme: Theme) => ({
Expand All @@ -34,6 +39,7 @@ const useStyles = makeStyles()((theme: Theme) => ({
width: '30%',
},
gravatar: {
animation: `${fadeIn} .2s ease-out forwards`,
borderRadius: '50%',
height: 24,
marginRight: theme.spacing(1),
Expand All @@ -57,6 +63,7 @@ interface Props {

const UserSSHKeyPanel = (props: Props) => {
const { classes } = useStyles();
const theme = useTheme();
const { authorizedUsers, disabled, setAuthorizedUsers } = props;

const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState<boolean>(
Expand Down Expand Up @@ -145,9 +152,14 @@ const UserSSHKeyPanel = (props: Props) => {
</TableCell>
<TableCell className={classes.cellUser}>
<div className={classes.userWrapper}>
<GravatarByEmail
className={classes.gravatar}
email={profile.email}
<GravatarOrAvatar
gravatar={
<GravatarByEmail
className={classes.gravatar}
email={profile.email}
/>
}
avatar={<Avatar sx={{ borderRadius: '50%', marginRight: 1 }} />}
/>
{profile.username}
</div>
Expand Down Expand Up @@ -177,7 +189,25 @@ const UserSSHKeyPanel = (props: Props) => {
</TableCell>
<TableCell className={classes.cellUser}>
<div className={classes.userWrapper}>
<GravatarByEmail className={classes.gravatar} email={user.email} />
<GravatarOrAvatar
avatar={
<Avatar
color={
user.username !== profile?.username
? theme.palette.primary.dark
: undefined
}
sx={{ borderRadius: '50%', marginRight: 1 }}
username={user.username}
/>
}
gravatar={
<GravatarByEmail
className={classes.gravatar}
email={user.email}
/>
}
/>
{user.username}
</div>
</TableCell>
Expand Down
27 changes: 27 additions & 0 deletions packages/manager/src/components/Avatar/Avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<AvatarProps> = {
render: (args) => <Avatar {...args} />,
};

export const System: StoryObj<AvatarProps> = {
render: (args) => <Avatar {...args} username="Linode" />,
};

const meta: Meta<AvatarProps> = {
args: {
color: '#0174bc',
height: 88,
sx: {},
username: 'MyUsername',
width: 88,
},
component: Avatar,
title: 'Components/Avatar',
};
export default meta;
74 changes: 74 additions & 0 deletions packages/manager/src/components/Avatar/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Avatar {...mockProps} />);
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(
<Avatar {...mockProps} color="#000000" />
);
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(
<Avatar {...mockProps} username="test" />
);

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(
<Avatar {...mockProps} username={username} />
);
expect(getAllByRole('img')[i]).toBeVisible();
expect(queryByTestId('avatar-letter')).toBe(null);
});
});
});
96 changes: 96 additions & 0 deletions packages/manager/src/components/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +61 to +63
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avatars from system-generated events have a default color and use the Akamai wave logo. If the user has not set a background color, we default to the same color and their initial. Otherwise, we'll use their saved preference.

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 ? (
<AkamaiWave />
) : (
<Typography
sx={{
color: theme.palette.getContrastText(color ?? savedAvatarColor),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice πŸ”₯ 🎨

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, I forgot to update you - I was very excited by this line! πŸ™ŒπŸΌ

fontSize: width / 2,
}}
data-testid="avatar-letter"
>
{avatarLetter}
</Typography>
)}
</_Avatar>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Box
sx={(theme) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ColorPickerProps> = {
args: {
defaultColor: '#0174bc',
label: 'Label for color picker',
onChange: () => undefined,
},
component: ColorPicker,
title: 'Components/ColorPicker',
};

export const Default: StoryObj<ColorPickerProps> = {
render: (args) => {
return <ColorPicker {...args} />;
},
};

export default meta;
54 changes: 54 additions & 0 deletions packages/manager/src/components/ColorPicker/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(
defaultColor ?? theme.palette.primary.dark
);

return (
<>
<label className="visually-hidden" htmlFor="color-picker">
{label}
</label>
<input
onChange={(e) => {
setColor(e.target.value);
onChange(e.target.value);
}}
color={color}
id="color-picker"
style={inputStyles}
type="color"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Native color picker πŸ”₯

value={color}
/>
</>
);
};
Loading
Loading