mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-13 22:54:42 -04:00
refactor: split avatar component to handle displaynames
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
3a06f84af1
commit
e97a426680
9 changed files with 88 additions and 44 deletions
|
@ -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} />
|
||||||
|
}
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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'>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue