♻️(frontend) add user avatar to thread comments

We extracted the UserAvatar component from the
doc-share feature and integrated it into
the users feature. It will be used in the
thread comments feature as well.
This commit is contained in:
Anthony LC
2025-09-12 15:45:39 +02:00
parent 48e1370ba3
commit 1bf810d596
11 changed files with 176 additions and 79 deletions

View File

@@ -51,6 +51,9 @@ test.describe('Doc Comments', () => {
await thread.locator('[data-test="addreaction"]').first().click(); await thread.locator('[data-test="addreaction"]').first().click();
await page.getByRole('button', { name: '👍' }).click(); await page.getByRole('button', { name: '👍' }).click();
await expect(
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeVisible(); await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible(); await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1'); await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1');
@@ -88,6 +91,9 @@ test.describe('Doc Comments', () => {
await otherThread.locator('[data-test="save"]').click(); await otherThread.locator('[data-test="save"]').click();
// We check that the second user can see the comment he just made // We check that the second user can see the comment he just made
await expect(
otherThread.getByRole('img', { name: `E2E ${otherBrowserName}` }).first(),
).toBeVisible();
await expect( await expect(
otherThread.getByText('This is a comment from the other user').first(), otherThread.getByText('This is a comment from the other user').first(),
).toBeVisible(); ).toBeVisible();

View File

@@ -98,8 +98,8 @@ const dsfrTheme = {
}, },
font: { font: {
families: { families: {
base: 'Marianne', base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
accent: 'Marianne', accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
}, },
}, },
}, },

View File

@@ -556,8 +556,10 @@
--c--theme--logo--widthHeader: 110px; --c--theme--logo--widthHeader: 110px;
--c--theme--logo--widthFooter: 220px; --c--theme--logo--widthFooter: 220px;
--c--theme--logo--alt: gouvernement logo; --c--theme--logo--alt: gouvernement logo;
--c--theme--font--families--base: marianne; --c--theme--font--families--base:
--c--theme--font--families--accent: marianne; marianne, inter, roboto flex variable, sans-serif;
--c--theme--font--families--accent:
marianne, inter, roboto flex variable, sans-serif;
--c--components--la-gaufre: true; --c--components--la-gaufre: true;
--c--components--home-proconnect: true; --c--components--home-proconnect: true;
--c--components--favicon--ico: /assets/favicon-dsfr.ico; --c--components--favicon--ico: /assets/favicon-dsfr.ico;

View File

@@ -436,7 +436,12 @@ export const tokens = {
widthFooter: '220px', widthFooter: '220px',
alt: 'Gouvernement Logo', alt: 'Gouvernement Logo',
}, },
font: { families: { base: 'Marianne', accent: 'Marianne' } }, font: {
families: {
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
}, },
components: { components: {
'la-gaufre': true, 'la-gaufre': true,

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Box, BoxType } from '@/components';
type AvatarSvgProps = {
initials: string;
background: string;
fontFamily?: string;
} & BoxType;
export const AvatarSvg: React.FC<AvatarSvgProps> = ({
initials,
background,
fontFamily,
...props
}) => (
<Box
as="svg"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
{...props}
>
<rect
x="0.5"
y="0.5"
width="23"
height="23"
rx="11.5"
ry="11.5"
fill={background}
stroke="rgba(255,255,255,0.5)"
strokeWidth="1"
/>
<text
x="50%"
y="50%"
dy="0.35em"
textAnchor="middle"
fontSize="10"
fontWeight="600"
fill="rgba(255,255,255,0.9)"
fontFamily={fontFamily || 'Arial'}
>
{initials}
</text>
</Box>
);

View File

@@ -0,0 +1,70 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { tokens } from '@/cunningham';
import { AvatarSvg } from './AvatarSvg';
const colors = tokens.themes.default.theme.colors;
const avatarsColors = [
colors['blue-500'],
colors['brown-500'],
colors['cyan-500'],
colors['gold-500'],
colors['green-500'],
colors['olive-500'],
colors['orange-500'],
colors['pink-500'],
colors['purple-500'],
colors['yellow-500'],
];
const getColorFromName = (name: string) => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return avatarsColors[Math.abs(hash) % avatarsColors.length];
};
const getInitialFromName = (name: string) => {
const splitName = name?.split(' ');
return (splitName[0]?.charAt(0) || '?') + (splitName?.[1]?.charAt(0) || '');
};
type UserAvatarProps = {
fullName?: string;
background?: string;
};
export const UserAvatar = ({ fullName, background }: UserAvatarProps) => {
const name = fullName?.trim() || '?';
return (
<AvatarSvg
className="--docs--user-avatar"
initials={getInitialFromName(name).toUpperCase()}
background={background || getColorFromName(name)}
/>
);
};
export const avatarUrlFromName = (
fullName?: string,
fontFamily?: string,
): string => {
const name = fullName?.trim() || '?';
const initials = getInitialFromName(name).toUpperCase();
const background = getColorFromName(name);
const svgMarkup = renderToStaticMarkup(
<AvatarSvg
className="--docs--user-avatar"
initials={initials}
background={background}
fontFamily={fontFamily}
/>,
);
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgMarkup)}`;
};

View File

@@ -1,2 +1,3 @@
export * from './Auth'; export * from './Auth';
export * from './ButtonLogin'; export * from './ButtonLogin';
export * from './UserAvatar';

View File

@@ -12,14 +12,15 @@ import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css'; import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react'; import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider'; import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { Box, TextErrors } from '@/components'; import { Box, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, useProviderStore } from '@/docs/doc-management'; import { Doc, useProviderStore } from '@/docs/doc-management';
import { useAuth } from '@/features/auth'; import { avatarUrlFromName, useAuth } from '@/features/auth';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { import {
@@ -82,6 +83,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { setEditor } = useEditorStore(); const { setEditor } = useEditorStore();
const { t } = useTranslation(); const { t } = useTranslation();
const { themeTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore(); const { isDesktop } = useResponsiveStore();
const { isSynced: isConnectedToCollabServer } = useProviderStore(); const { isSynced: isConnectedToCollabServer } = useProviderStore();
const refEditorContainer = useRef<HTMLDivElement>(null); const refEditorContainer = useRef<HTMLDivElement>(null);
@@ -93,18 +95,25 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { uploadFile, errorAttachment } = useUploadFile(doc.id); const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const collabName = user?.full_name || user?.email || t('Anonymous'); const collabName = user?.full_name || user?.email;
const cursorName = collabName || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity'; const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
const threadStore = useComments(doc.id, canSeeComment, user); const threadStore = useComments(doc.id, canSeeComment, user);
const currentUserAvatarUrl = useMemo(() => {
if (canSeeComment) {
return avatarUrlFromName(collabName, themeTokens?.font?.families?.base);
}
}, [canSeeComment, collabName, themeTokens?.font?.families?.base]);
const editor: DocsBlockNoteEditor = useCreateBlockNote( const editor: DocsBlockNoteEditor = useCreateBlockNote(
{ {
collaboration: { collaboration: {
provider: provider, provider: provider,
fragment: provider.document.getXmlFragment('document-store'), fragment: provider.document.getXmlFragment('document-store'),
user: { user: {
name: collabName, name: cursorName,
color: randomColor(), color: randomColor(),
}, },
/** /**
@@ -159,7 +168,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
return { return {
id: encodedURIUserId, id: encodedURIUserId,
username: fullName || t('Anonymous'), username: fullName || t('Anonymous'),
avatarUrl: 'https://i.pravatar.cc/300', avatarUrl: avatarUrlFromName(
fullName,
themeTokens?.font?.families?.base,
),
}; };
}), }),
); );
@@ -173,7 +185,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
uploadFile, uploadFile,
schema: blockNoteSchema, schema: blockNoteSchema,
}, },
[collabName, lang, provider, uploadFile, threadStore], [cursorName, lang, provider, uploadFile, threadStore],
); );
useHeadings(editor); useHeadings(editor);
@@ -195,7 +207,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
ref={refEditorContainer} ref={refEditorContainer}
$css={css` $css={css`
${cssEditor}; ${cssEditor};
${cssComments(canSeeComment)} ${cssComments(canSeeComment, currentUserAvatarUrl)}
`} `}
> >
{errorAttachment && ( {errorAttachment && (

View File

@@ -1,6 +1,9 @@
import { css } from 'styled-components'; import { css } from 'styled-components';
export const cssComments = (canSeeComment: boolean) => css` export const cssComments = (
canSeeComment: boolean,
currentUserAvatarUrl?: string,
) => css`
& .--docs--main-editor, & .--docs--main-editor,
& .--docs--main-editor .ProseMirror { & .--docs--main-editor .ProseMirror {
// Comments marks in the editor // Comments marks in the editor
@@ -151,6 +154,19 @@ export const cssComments = (canSeeComment: boolean) => css`
.bn-container.bn-comment-editor { .bn-container.bn-comment-editor {
min-width: 0; min-width: 0;
} }
&::before {
content: '';
width: 26px;
height: 26px;
flex: 0 0 26px;
background-image: ${currentUserAvatarUrl
? `url("${currentUserAvatarUrl}")`
: 'none'};
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
} }
// Actions button send comment // Actions button send comment

View File

@@ -4,9 +4,7 @@ import {
QuickSearchItemContentProps, QuickSearchItemContentProps,
} from '@/components/quick-search'; } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { User } from '@/features/auth'; import { User, UserAvatar } from '@/features/auth';
import { UserAvatar } from './UserAvatar';
type Props = { type Props = {
user: User; user: User;
@@ -36,7 +34,7 @@ export const SearchUserRow = ({
className="--docs--search-user-row" className="--docs--search-user-row"
> >
<UserAvatar <UserAvatar
user={user} fullName={user.full_name || user.email}
background={ background={
isInvitation ? colorsTokens['greyscale-400'] : undefined isInvitation ? colorsTokens['greyscale-400'] : undefined
} }

View File

@@ -1,62 +0,0 @@
import { css } from 'styled-components';
import { Text } from '@/components';
import { tokens } from '@/cunningham';
import { User } from '@/features/auth';
const colors = tokens.themes.default.theme.colors;
const avatarsColors = [
colors['blue-500'],
colors['brown-500'],
colors['cyan-500'],
colors['gold-500'],
colors['green-500'],
colors['olive-500'],
colors['orange-500'],
colors['pink-500'],
colors['purple-500'],
colors['yellow-500'],
];
const getColorFromName = (name: string) => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return avatarsColors[Math.abs(hash) % avatarsColors.length];
};
type Props = {
user: User;
background?: string;
};
export const UserAvatar = ({ user, background }: Props) => {
const name = user.full_name || user.email || '?';
const splitName = name?.split(' ');
return (
<Text
className="--docs--user-avatar"
$align="center"
$color="rgba(255, 255, 255, 0.9)"
$justify="center"
$background={background || getColorFromName(name)}
$width="24px"
$height="24px"
$radius="50%"
$size="10px"
$textAlign="center"
$textTransform="uppercase"
$weight={600}
$css={css`
border: 1px solid rgba(255, 255, 255, 0.5);
contain: content;
`}
>
{splitName[0]?.charAt(0)}
{splitName?.[1]?.charAt(0)}
</Text>
);
};