refactor: Remove duplicated AvatarGroup
CSS and familiar followers cleanup (#34681)
This commit is contained in:
parent
d475bcce65
commit
ccffa11f2b
10 changed files with 110 additions and 100 deletions
|
@ -1,11 +1,13 @@
|
||||||
import { fromJS } from 'immutable';
|
|
||||||
|
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
|
|
||||||
|
import { accountDefaultValues, createAccountFromServerJSON } from '@/mastodon/models/account';
|
||||||
|
|
||||||
import { Avatar } from '../avatar';
|
import { Avatar } from '../avatar';
|
||||||
|
|
||||||
describe('<Avatar />', () => {
|
describe('<Avatar />', () => {
|
||||||
const account = fromJS({
|
const account = createAccountFromServerJSON({
|
||||||
|
...accountDefaultValues,
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
acct: 'alice',
|
acct: 'alice',
|
||||||
display_name: 'Alice',
|
display_name: 'Alice',
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { useHovering } from 'mastodon/hooks/useHovering';
|
import { useHovering } from 'mastodon/hooks/useHovering';
|
||||||
import { autoPlayGif } from 'mastodon/initial_state';
|
import { autoPlayGif } from 'mastodon/initial_state';
|
||||||
import type { Account } from 'mastodon/models/account';
|
import type { Account } from 'mastodon/models/account';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
account:
|
||||||
size: number;
|
| Pick<Account, 'id' | 'acct' | 'avatar' | 'avatar_static'>
|
||||||
|
| undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||||
|
size?: number;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
animate?: boolean;
|
animate?: boolean;
|
||||||
|
withLink?: boolean;
|
||||||
counter?: number | string;
|
counter?: number | string;
|
||||||
counterBorderColor?: string;
|
counterBorderColor?: string;
|
||||||
}
|
}
|
||||||
|
@ -21,6 +25,7 @@ export const Avatar: React.FC<Props> = ({
|
||||||
animate = autoPlayGif,
|
animate = autoPlayGif,
|
||||||
size = 20,
|
size = 20,
|
||||||
inline = false,
|
inline = false,
|
||||||
|
withLink = false,
|
||||||
style: styleFromParent,
|
style: styleFromParent,
|
||||||
counter,
|
counter,
|
||||||
counterBorderColor,
|
counterBorderColor,
|
||||||
|
@ -35,10 +40,7 @@ export const Avatar: React.FC<Props> = ({
|
||||||
height: `${size}px`,
|
height: `${size}px`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const src =
|
const src = hovering || animate ? account?.avatar : account?.avatar_static;
|
||||||
hovering || animate
|
|
||||||
? account?.get('avatar')
|
|
||||||
: account?.get('avatar_static');
|
|
||||||
|
|
||||||
const handleLoad = useCallback(() => {
|
const handleLoad = useCallback(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
@ -48,7 +50,7 @@ export const Avatar: React.FC<Props> = ({
|
||||||
setError(true);
|
setError(true);
|
||||||
}, [setError]);
|
}, [setError]);
|
||||||
|
|
||||||
return (
|
const avatar = (
|
||||||
<div
|
<div
|
||||||
className={classNames('account__avatar', {
|
className={classNames('account__avatar', {
|
||||||
'account__avatar--inline': inline,
|
'account__avatar--inline': inline,
|
||||||
|
@ -72,4 +74,18 @@ export const Avatar: React.FC<Props> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (withLink) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/@${account?.acct}`}
|
||||||
|
title={`@${account?.acct}`}
|
||||||
|
data-hover-card-account={account?.id}
|
||||||
|
>
|
||||||
|
{avatar}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return avatar;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,34 +1,17 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
/**
|
||||||
import { useAppSelector } from 'mastodon/store';
|
* Wrapper for displaying a number of Avatar components horizontally,
|
||||||
|
* either spaced out (default) or overlapping (using the `compact` prop).
|
||||||
const AvatarWrapper: React.FC<{ accountId: string }> = ({ accountId }) => {
|
*/
|
||||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
|
||||||
|
|
||||||
if (!account) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={`/@${account.acct}`}
|
|
||||||
title={`@${account.acct}`}
|
|
||||||
data-hover-card-account={account.id}
|
|
||||||
>
|
|
||||||
<Avatar account={account} size={28} />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AvatarGroup: React.FC<{
|
export const AvatarGroup: React.FC<{
|
||||||
accountIds: string[];
|
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}> = ({ accountIds, compact = false }) => (
|
children: React.ReactNode;
|
||||||
|
}> = ({ children, compact = false }) => (
|
||||||
<div
|
<div
|
||||||
className={classNames('avatar-group', { 'avatar-group--compact': compact })}
|
className={classNames('avatar-group', { 'avatar-group--compact': compact })}
|
||||||
>
|
>
|
||||||
{accountIds.map((accountId) => (
|
{children}
|
||||||
<AvatarWrapper key={accountId} accountId={accountId} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,16 +5,21 @@ import { FormattedMessage } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchAccountsFamiliarFollowers } from '@/mastodon/actions/accounts_familiar_followers';
|
import { fetchAccountsFamiliarFollowers } from '@/mastodon/actions/accounts_familiar_followers';
|
||||||
|
import { Avatar } from '@/mastodon/components/avatar';
|
||||||
import { AvatarGroup } from '@/mastodon/components/avatar_group';
|
import { AvatarGroup } from '@/mastodon/components/avatar_group';
|
||||||
import type { Account } from '@/mastodon/models/account';
|
import type { Account } from '@/mastodon/models/account';
|
||||||
import { getAccountFamiliarFollowers } from '@/mastodon/selectors/accounts';
|
import { getAccountFamiliarFollowers } from '@/mastodon/selectors/accounts';
|
||||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
|
|
||||||
const AccountLink: React.FC<{ account?: Account }> = ({ account }) => (
|
const AccountLink: React.FC<{ account?: Account }> = ({ account }) => {
|
||||||
<Link to={`/@${account?.username}`} data-hover-card-account={account?.id}>
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
{account?.display_name}
|
const name = account?.display_name || `@${account?.acct}`;
|
||||||
|
return (
|
||||||
|
<Link to={`/@${account?.acct}`} data-hover-card-account={account?.id}>
|
||||||
|
{name}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const FamiliarFollowersReadout: React.FC<{ familiarFollowers: Account[] }> = ({
|
const FamiliarFollowersReadout: React.FC<{ familiarFollowers: Account[] }> = ({
|
||||||
familiarFollowers,
|
familiarFollowers,
|
||||||
|
@ -74,10 +79,11 @@ export const FamiliarFollowers: React.FC<{ accountId: string }> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account__header__familiar-followers'>
|
<div className='account__header__familiar-followers'>
|
||||||
<AvatarGroup
|
<AvatarGroup compact>
|
||||||
compact
|
{familiarFollowers.map((account) => (
|
||||||
accountIds={familiarFollowers.slice(0, 3).map((account) => account.id)}
|
<Avatar withLink key={account.id} account={account} size={28} />
|
||||||
/>
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
<FamiliarFollowersReadout familiarFollowers={familiarFollowers} />
|
<FamiliarFollowersReadout familiarFollowers={familiarFollowers} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -35,8 +35,11 @@ import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
heading: { id: 'column.list_members', defaultMessage: 'Manage list members' },
|
manageMembers: {
|
||||||
|
id: 'column.list_members',
|
||||||
|
defaultMessage: 'Manage list members',
|
||||||
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
id: 'lists.search',
|
id: 'lists.search',
|
||||||
defaultMessage: 'Search',
|
defaultMessage: 'Search',
|
||||||
|
@ -255,10 +258,10 @@ const ListMembers: React.FC<{
|
||||||
return (
|
return (
|
||||||
<Column
|
<Column
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
label={intl.formatMessage(messages.heading)}
|
label={intl.formatMessage(messages.manageMembers)}
|
||||||
>
|
>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
title={intl.formatMessage(messages.heading)}
|
title={intl.formatMessage(messages.manageMembers)}
|
||||||
icon='list-ul'
|
icon='list-ul'
|
||||||
iconComponent={ListAltIcon}
|
iconComponent={ListAltIcon}
|
||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
|
@ -331,7 +334,7 @@ const ListMembers: React.FC<{
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
|
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{intl.formatMessage(messages.heading)}</title>
|
<title>{intl.formatMessage(messages.manageMembers)}</title>
|
||||||
<meta name='robots' content='noindex' />
|
<meta name='robots' content='noindex' />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
@ -9,16 +9,23 @@ import { isFulfilled } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import Toggle from 'react-toggle';
|
import Toggle from 'react-toggle';
|
||||||
|
|
||||||
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||||
import { fetchList } from 'mastodon/actions/lists';
|
import { fetchList } from 'mastodon/actions/lists';
|
||||||
import { createList, updateList } from 'mastodon/actions/lists_typed';
|
import { createList, updateList } from 'mastodon/actions/lists_typed';
|
||||||
import { apiGetAccounts } from 'mastodon/api/lists';
|
import { apiGetAccounts } from 'mastodon/api/lists';
|
||||||
|
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||||
import type { RepliesPolicyType } from 'mastodon/api_types/lists';
|
import type { RepliesPolicyType } from 'mastodon/api_types/lists';
|
||||||
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
|
import { AvatarGroup } from 'mastodon/components/avatar_group';
|
||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { messages as membersMessages } from './members';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
edit: { id: 'column.edit_list', defaultMessage: 'Edit list' },
|
edit: { id: 'column.edit_list', defaultMessage: 'Edit list' },
|
||||||
create: { id: 'column.create_list', defaultMessage: 'Create list' },
|
create: { id: 'column.create_list', defaultMessage: 'Create list' },
|
||||||
|
@ -27,42 +34,40 @@ const messages = defineMessages({
|
||||||
const MembersLink: React.FC<{
|
const MembersLink: React.FC<{
|
||||||
id: string;
|
id: string;
|
||||||
}> = ({ id }) => {
|
}> = ({ id }) => {
|
||||||
const [count, setCount] = useState(0);
|
const intl = useIntl();
|
||||||
const [avatars, setAvatars] = useState<string[]>([]);
|
const [avatarCount, setAvatarCount] = useState(0);
|
||||||
|
const [avatarAccounts, setAvatarAccounts] = useState<ApiAccountJSON[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void apiGetAccounts(id)
|
void apiGetAccounts(id)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setCount(data.length);
|
setAvatarCount(data.length);
|
||||||
setAvatars(data.slice(0, 3).map((a) => a.avatar));
|
setAvatarAccounts(data.slice(0, 3));
|
||||||
return '';
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Nothing
|
// Nothing
|
||||||
});
|
});
|
||||||
}, [id, setCount, setAvatars]);
|
}, [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/lists/${id}/members`} className='app-form__link'>
|
<Link to={`/lists/${id}/members`} className='app-form__link'>
|
||||||
<div className='app-form__link__text'>
|
<div className='app-form__link__text'>
|
||||||
<strong>
|
<strong>
|
||||||
<FormattedMessage
|
{intl.formatMessage(membersMessages.manageMembers)}
|
||||||
id='lists.list_members'
|
<Icon id='chevron_right' icon={ChevronRightIcon} />
|
||||||
defaultMessage='List members'
|
|
||||||
/>
|
|
||||||
</strong>
|
</strong>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='lists.list_members_count'
|
id='lists.list_members_count'
|
||||||
defaultMessage='{count, plural, one {# member} other {# members}}'
|
defaultMessage='{count, plural, one {# member} other {# members}}'
|
||||||
values={{ count }}
|
values={{ count: avatarCount }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='avatar-pile'>
|
<AvatarGroup compact>
|
||||||
{avatars.map((url) => (
|
{avatarAccounts.map((a) => (
|
||||||
<img key={url} src={url} alt='' />
|
<Avatar key={a.id} account={a} size={30} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</AvatarGroup>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
import { replyComposeById } from 'mastodon/actions/compose';
|
import { replyComposeById } from 'mastodon/actions/compose';
|
||||||
import { navigateToStatus } from 'mastodon/actions/statuses';
|
import { navigateToStatus } from 'mastodon/actions/statuses';
|
||||||
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { AvatarGroup } from 'mastodon/components/avatar_group';
|
import { AvatarGroup } from 'mastodon/components/avatar_group';
|
||||||
import type { IconProp } from 'mastodon/components/icon';
|
import type { IconProp } from 'mastodon/components/icon';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
@ -17,6 +18,14 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
import { DisplayedName } from './displayed_name';
|
import { DisplayedName } from './displayed_name';
|
||||||
import { EmbeddedStatus } from './embedded_status';
|
import { EmbeddedStatus } from './embedded_status';
|
||||||
|
|
||||||
|
export const AvatarById: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
|
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
return <Avatar withLink account={account} size={28} />;
|
||||||
|
};
|
||||||
|
|
||||||
export type LabelRenderer = (
|
export type LabelRenderer = (
|
||||||
displayedName: JSX.Element,
|
displayedName: JSX.Element,
|
||||||
total: number,
|
total: number,
|
||||||
|
@ -99,12 +108,13 @@ export const NotificationGroupWithStatus: React.FC<{
|
||||||
<div className='notification-group__main'>
|
<div className='notification-group__main'>
|
||||||
<div className='notification-group__main__header'>
|
<div className='notification-group__main__header'>
|
||||||
<div className='notification-group__main__header__wrapper'>
|
<div className='notification-group__main__header__wrapper'>
|
||||||
<AvatarGroup
|
<AvatarGroup>
|
||||||
accountIds={accountIds.slice(
|
{accountIds
|
||||||
0,
|
.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS)
|
||||||
NOTIFICATIONS_GROUP_MAX_AVATARS,
|
.map((id) => (
|
||||||
)}
|
<AvatarById key={id} accountId={id} />
|
||||||
/>
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
|
|
||||||
{actions && (
|
{actions && (
|
||||||
<div className='notification-group__actions'>{actions}</div>
|
<div className='notification-group__actions'>{actions}</div>
|
||||||
|
|
|
@ -518,7 +518,6 @@
|
||||||
"lists.exclusive": "Hide members in Home",
|
"lists.exclusive": "Hide members in Home",
|
||||||
"lists.exclusive_hint": "If someone is on this list, hide them in your Home feed to avoid seeing their posts twice.",
|
"lists.exclusive_hint": "If someone is on this list, hide them in your Home feed to avoid seeing their posts twice.",
|
||||||
"lists.find_users_to_add": "Find users to add",
|
"lists.find_users_to_add": "Find users to add",
|
||||||
"lists.list_members": "List members",
|
|
||||||
"lists.list_members_count": "{count, plural, one {# member} other {# members}}",
|
"lists.list_members_count": "{count, plural, one {# member} other {# members}}",
|
||||||
"lists.list_name": "List name",
|
"lists.list_name": "List name",
|
||||||
"lists.new_list_name": "New list name",
|
"lists.new_list_name": "New list name",
|
||||||
|
|
|
@ -2179,12 +2179,20 @@ a .account__avatar {
|
||||||
|
|
||||||
& > :not(:first-child) {
|
& > :not(:first-child) {
|
||||||
margin-inline-start: -8px;
|
margin-inline-start: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :first-child {
|
||||||
|
transform: rotate(-4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :nth-child(2) {
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
.account__avatar {
|
.account__avatar {
|
||||||
box-shadow: 0 0 0 2px var(--background-color);
|
box-shadow: 0 0 0 2px var(--background-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.account__avatar-overlay {
|
.account__avatar-overlay {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -1440,34 +1440,12 @@ code {
|
||||||
display: block;
|
display: block;
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-pile {
|
.icon {
|
||||||
display: flex;
|
vertical-align: -5px;
|
||||||
align-items: center;
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
img {
|
}
|
||||||
display: block;
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 2px solid var(--background-color);
|
|
||||||
background: var(--surface-background-color);
|
|
||||||
margin-inline-end: -16px;
|
|
||||||
transform: rotate(0);
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
transform: rotate(-4deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
|
||||||
transform: rotate(-2deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-inline-end: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue