♻️(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:
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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)}`;
|
||||||
|
};
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './Auth';
|
export * from './Auth';
|
||||||
export * from './ButtonLogin';
|
export * from './ButtonLogin';
|
||||||
|
export * from './UserAvatar';
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user