refactor: split avatar component to handle displaynames

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-03-24 13:16:35 +01:00
parent 3a06f84af1
commit e97a426680
9 changed files with 88 additions and 44 deletions

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { UserInfo } from '../../../api/users/types'
import type { UserAvatarProps } from './user-avatar'
import { UserAvatar } from './user-avatar'
import React from 'react'
export interface UserAvatarForUserProps extends Omit<UserAvatarProps, 'photoUrl' | 'displayName'> {
user: UserInfo
}
/**
* Renders the avatar image of a user, optionally altogether with their name.
*
* @param user The user object with the display name and photo.
* @param props remaining avatar props
*/
export const UserAvatarForUser: React.FC<UserAvatarForUserProps> = ({ user, ...props }) => {
return <UserAvatar displayName={user.displayName} photoUrl={user.photo} {...props} />
}

View file

@ -4,15 +4,14 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { getUser } from '../../../api/users' import { getUser } from '../../../api/users'
import type { UserInfo } from '../../../api/users/types'
import { AsyncLoadingBoundary } from '../async-loading-boundary/async-loading-boundary' import { AsyncLoadingBoundary } from '../async-loading-boundary/async-loading-boundary'
import type { UserAvatarProps } from './user-avatar' import type { UserAvatarProps } from './user-avatar'
import { UserAvatar } from './user-avatar' import { UserAvatar } from './user-avatar'
import React from 'react' import React, { Fragment, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAsync } from 'react-use' import { useAsync } from 'react-use'
export interface UserAvatarForUsernameProps extends Omit<UserAvatarProps, 'user'> { export interface UserAvatarForUsernameProps extends Omit<UserAvatarProps, 'photoUrl' | 'displayName'> {
username: string | null username: string | null
} }
@ -27,20 +26,21 @@ export interface UserAvatarForUsernameProps extends Omit<UserAvatarProps, 'user'
*/ */
export const UserAvatarForUsername: React.FC<UserAvatarForUsernameProps> = ({ username, ...props }) => { export const UserAvatarForUsername: React.FC<UserAvatarForUsernameProps> = ({ username, ...props }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { error, value, loading } = useAsync(async (): Promise<UserInfo> => { const { error, value, loading } = useAsync(async (): Promise<{ displayName: string; photo?: string }> => {
if (username) { return username
return await getUser(username) ? await getUser(username)
} : {
return { displayName: t('common.guestUser')
displayName: t('common.guestUser'),
photo: `public/img/avatar.png`,
username: ''
} }
}, [username, t]) }, [username, t])
const avatar = useMemo(() => {
return !value ? <Fragment /> : <UserAvatar displayName={value.displayName} photoUrl={value.photo} {...props} />
}, [props, value])
return ( return (
<AsyncLoadingBoundary loading={loading || !value} error={error} componentName={'UserAvatarForUsername'}> <AsyncLoadingBoundary loading={loading || !value} error={error} componentName={'UserAvatarForUsername'}>
<UserAvatar user={value as UserInfo} {...props} /> {avatar}
</AsyncLoadingBoundary> </AsyncLoadingBoundary>
) )
} }

View file

@ -5,7 +5,7 @@
*/ */
import type { UserInfo } from '../../../api/users/types' import type { UserInfo } from '../../../api/users/types'
import { mockI18n } from '../../markdown-renderer/test-utils/mock-i18n' import { mockI18n } from '../../markdown-renderer/test-utils/mock-i18n'
import { UserAvatar } from './user-avatar' import { UserAvatarForUser } from './user-avatar-for-user'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
describe('UserAvatar', () => { describe('UserAvatar', () => {
@ -20,25 +20,25 @@ describe('UserAvatar', () => {
}) })
it('renders the user avatar correctly', () => { it('renders the user avatar correctly', () => {
const view = render(<UserAvatar user={user} />) const view = render(<UserAvatarForUser user={user} />)
expect(view.container).toMatchSnapshot() expect(view.container).toMatchSnapshot()
}) })
describe('renders the user avatar in size', () => { describe('renders the user avatar in size', () => {
it('sm', () => { it('sm', () => {
const view = render(<UserAvatar user={user} size={'sm'} />) const view = render(<UserAvatarForUser user={user} size={'sm'} />)
expect(view.container).toMatchSnapshot() expect(view.container).toMatchSnapshot()
}) })
it('lg', () => { it('lg', () => {
const view = render(<UserAvatar user={user} size={'lg'} />) const view = render(<UserAvatarForUser user={user} size={'lg'} />)
expect(view.container).toMatchSnapshot() expect(view.container).toMatchSnapshot()
}) })
}) })
it('adds additionalClasses props to wrapping span', () => { it('adds additionalClasses props to wrapping span', () => {
const view = render(<UserAvatar user={user} additionalClasses={'testClass'} />) const view = render(<UserAvatarForUser user={user} additionalClasses={'testClass'} />)
expect(view.container).toMatchSnapshot() expect(view.container).toMatchSnapshot()
}) })
it('does not show names if showName prop is false', () => { it('does not show names if showName prop is false', () => {
const view = render(<UserAvatar user={user} showName={false} />) const view = render(<UserAvatarForUser user={user} showName={false} />)
expect(view.container).toMatchSnapshot() expect(view.container).toMatchSnapshot()
}) })
}) })

View file

@ -3,7 +3,6 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { UserInfo } from '../../../api/users/types'
import { ShowIf } from '../show-if/show-if' import { ShowIf } from '../show-if/show-if'
import defaultAvatar from './default-avatar.png' import defaultAvatar from './default-avatar.png'
import styles from './user-avatar.module.scss' import styles from './user-avatar.module.scss'
@ -16,7 +15,8 @@ export interface UserAvatarProps {
size?: 'sm' | 'lg' size?: 'sm' | 'lg'
additionalClasses?: string additionalClasses?: string
showName?: boolean showName?: boolean
user: UserInfo photoUrl?: string
displayName: string
} }
/** /**
@ -27,7 +27,13 @@ export interface UserAvatarProps {
* @param additionalClasses Additional CSS classes that will be added to the container. * @param additionalClasses Additional CSS classes that will be added to the container.
* @param showName true when the name should be displayed alongside the image, false otherwise. Defaults to true. * @param showName true when the name should be displayed alongside the image, false otherwise. Defaults to true.
*/ */
export const UserAvatar: React.FC<UserAvatarProps> = ({ user, size, additionalClasses = '', showName = true }) => { export const UserAvatar: React.FC<UserAvatarProps> = ({
photoUrl,
displayName,
size,
additionalClasses = '',
showName = true
}) => {
const { t } = useTranslation() const { t } = useTranslation()
const imageSize = useMemo(() => { const imageSize = useMemo(() => {
@ -42,18 +48,18 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({ user, size, additionalCl
}, [size]) }, [size])
const avatarUrl = useMemo(() => { const avatarUrl = useMemo(() => {
return user.photo !== '' ? user.photo : defaultAvatar.src return photoUrl || defaultAvatar.src
}, [user.photo]) }, [photoUrl])
const imgDescription = useMemo(() => t('common.avatarOf', { name: user.displayName }), [t, user]) const imgDescription = useMemo(() => t('common.avatarOf', { name: displayName }), [t, displayName])
const tooltip = useCallback( const tooltip = useCallback(
(props: OverlayInjectedProps) => ( (overlayInjectedProps: OverlayInjectedProps) => (
<Tooltip id={user.displayName} {...props}> <Tooltip id={displayName} {...overlayInjectedProps}>
{user.displayName} {displayName}
</Tooltip> </Tooltip>
), ),
[user] [displayName]
) )
return ( return (
@ -69,7 +75,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({ user, size, additionalCl
/> />
<ShowIf condition={showName}> <ShowIf condition={showName}>
<OverlayTrigger overlay={tooltip}> <OverlayTrigger overlay={tooltip}>
<span className={`ms-2 me-1 ${styles['user-line-name']}`}>{user.displayName}</span> <span className={`ms-2 me-1 ${styles['user-line-name']}`}>{displayName}</span>
</OverlayTrigger> </OverlayTrigger>
</ShowIf> </ShowIf>
</span> </span>

View file

@ -9,7 +9,7 @@ import { getUser } from '../../../../api/users'
import { useApplicationState } from '../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods' import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods'
import { ShowIf } from '../../../common/show-if/show-if' import { ShowIf } from '../../../common/show-if/show-if'
import { UserAvatar } from '../../../common/user-avatar/user-avatar' import { UserAvatarForUser } from '../../../common/user-avatar/user-avatar-for-user'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary' import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
import { PermissionEntryButtons, PermissionType } from './permission-entry-buttons' import { PermissionEntryButtons, PermissionType } from './permission-entry-buttons'
import { AccessLevel } from './types' import { AccessLevel } from './types'
@ -58,13 +58,13 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps> = ({ entry
}, [entry.username]) }, [entry.username])
if (!value) { if (!value) {
return null return <></>
} }
return ( return (
<ShowIf condition={!loading && !error}> <ShowIf condition={!loading && !error}>
<li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}> <li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}>
<UserAvatar user={value} /> <UserAvatarForUser user={value} />
<PermissionEntryButtons <PermissionEntryButtons
type={PermissionType.USER} type={PermissionType.USER}
currentSetting={entry.canEdit ? AccessLevel.WRITEABLE : AccessLevel.READ_ONLY} currentSetting={entry.canEdit ? AccessLevel.WRITEABLE : AccessLevel.READ_ONLY}

View file

@ -6,7 +6,7 @@
import type { RevisionMetadata } from '../../../../api/revisions/types' import type { RevisionMetadata } from '../../../../api/revisions/types'
import { UiIcon } from '../../../common/icons/ui-icon' import { UiIcon } from '../../../common/icons/ui-icon'
import { ShowIf } from '../../../common/show-if/show-if' import { ShowIf } from '../../../common/show-if/show-if'
import { UserAvatar } from '../../../common/user-avatar/user-avatar' import { UserAvatarForUser } from '../../../common/user-avatar/user-avatar-for-user'
import { WaitSpinner } from '../../../common/wait-spinner/wait-spinner' import { WaitSpinner } from '../../../common/wait-spinner/wait-spinner'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary' import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
import styles from './revision-list-entry.module.scss' import styles from './revision-list-entry.module.scss'
@ -45,7 +45,7 @@ export const RevisionListEntry: React.FC<RevisionListEntryProps> = ({ active, on
try { try {
const authorDetails = await getUserDataForRevision(revision.authorUsernames) const authorDetails = await getUserDataForRevision(revision.authorUsernames)
return authorDetails.map((author) => ( return authorDetails.map((author) => (
<UserAvatar user={author} key={author.username} showName={false} additionalClasses={'mx-1'} /> <UserAvatarForUser user={author} key={author.username} showName={false} additionalClasses={'mx-1'} />
)) ))
} catch (error) { } catch (error) {
showErrorNotification('editor.modal.revision.errorUser')(error as Error) showErrorNotification('editor.modal.revision.errorUser')(error as Error)

View file

@ -3,14 +3,16 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { UserAvatar } from '../../../common/user-avatar/user-avatar'
import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username' import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username'
import { createCursorCssClass } from '../../editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class' import { createCursorCssClass } from '../../editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class'
import { ActiveIndicator } from '../users-online-sidebar-menu/active-indicator' import { ActiveIndicator } from '../users-online-sidebar-menu/active-indicator'
import styles from './user-line.module.scss' import styles from './user-line.module.scss'
import React from 'react' import React, { useMemo } from 'react'
export interface UserLineProps { export interface UserLineProps {
username: string | null username: string | null
displayName: string
active: boolean active: boolean
color: number color: number
} }
@ -22,7 +24,22 @@ export interface UserLineProps {
* @param color The color of the user's edits. * @param color The color of the user's edits.
* @param status The user's current online status. * @param status The user's current online status.
*/ */
export const UserLine: React.FC<UserLineProps> = ({ username, active, color }) => { export const UserLine: React.FC<UserLineProps> = ({ username, displayName, active, color }) => {
const avatar = useMemo(() => {
if (username) {
return (
<UserAvatarForUsername
username={username}
additionalClasses={'flex-fill overflow-hidden px-2 text-nowrap w-100'}
/>
)
} else {
return (
<UserAvatar displayName={displayName} additionalClasses={'flex-fill overflow-hidden px-2 text-nowrap w-100'} />
)
}
}, [displayName, username])
return ( return (
<div className={'d-flex align-items-center h-100 w-100'}> <div className={'d-flex align-items-center h-100 w-100'}>
<div <div
@ -30,10 +47,7 @@ export const UserLine: React.FC<UserLineProps> = ({ username, active, color }) =
color color
)}`} )}`}
/> />
<UserAvatarForUsername {avatar}
username={username}
additionalClasses={'flex-fill overflow-hidden px-2 text-nowrap w-100'}
/>
<div className={styles['active-indicator-container']}> <div className={styles['active-indicator-container']}>
<ActiveIndicator active={active} /> <ActiveIndicator active={active} />
</div> </div>

View file

@ -56,7 +56,8 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
return ( return (
<SidebarButton key={realtimeUser.styleIndex}> <SidebarButton key={realtimeUser.styleIndex}>
<UserLine <UserLine
username={realtimeUser.displayName} displayName={realtimeUser.displayName}
username={realtimeUser.username}
color={realtimeUser.styleIndex} color={realtimeUser.styleIndex}
active={realtimeUser.active} active={realtimeUser.active}
/> />

View file

@ -6,7 +6,7 @@
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
import { UiIcon } from '../../common/icons/ui-icon' import { UiIcon } from '../../common/icons/ui-icon'
import { UserAvatar } from '../../common/user-avatar/user-avatar' import { UserAvatarForUser } from '../../common/user-avatar/user-avatar-for-user'
import { SignOutDropdownButton } from './sign-out-dropdown-button' import { SignOutDropdownButton } from './sign-out-dropdown-button'
import Link from 'next/link' import Link from 'next/link'
import React from 'react' import React from 'react'
@ -29,7 +29,7 @@ export const UserDropdown: React.FC = () => {
return ( return (
<Dropdown align={'end'}> <Dropdown align={'end'}>
<Dropdown.Toggle size='sm' variant='dark' {...cypressId('user-dropdown')} className={'d-flex align-items-center'}> <Dropdown.Toggle size='sm' variant='dark' {...cypressId('user-dropdown')} className={'d-flex align-items-center'}>
<UserAvatar user={user} /> <UserAvatarForUser user={user} />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className='text-start'> <Dropdown.Menu className='text-start'>