Change order of items in navigation panel in web UI (#35029)
This commit is contained in:
parent
013c527406
commit
7c4393e719
29 changed files with 625 additions and 452 deletions
|
|
@ -0,0 +1,85 @@
|
|||
import { useState, useCallback, useId } from 'react';
|
||||
|
||||
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
|
||||
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react';
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
||||
|
||||
export const CollapsiblePanel: React.FC<{
|
||||
children: React.ReactNode[];
|
||||
to: string;
|
||||
title: string;
|
||||
collapseTitle: string;
|
||||
expandTitle: string;
|
||||
icon: string;
|
||||
iconComponent: IconProp;
|
||||
activeIconComponent?: IconProp;
|
||||
loading?: boolean;
|
||||
}> = ({
|
||||
children,
|
||||
to,
|
||||
icon,
|
||||
iconComponent,
|
||||
activeIconComponent,
|
||||
title,
|
||||
collapseTitle,
|
||||
expandTitle,
|
||||
loading,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const accessibilityId = useId();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setExpanded((value) => !value);
|
||||
}, [setExpanded]);
|
||||
|
||||
return (
|
||||
<div className='navigation-panel__list-panel'>
|
||||
<div className='navigation-panel__list-panel__header'>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to={to}
|
||||
icon={icon}
|
||||
iconComponent={iconComponent}
|
||||
activeIconComponent={activeIconComponent}
|
||||
text={title}
|
||||
id={`${accessibilityId}-title`}
|
||||
/>
|
||||
|
||||
{(loading || children.length > 0) && (
|
||||
<>
|
||||
<div className='navigation-panel__list-panel__header__sep' />
|
||||
|
||||
<IconButton
|
||||
icon='down'
|
||||
expanded={expanded}
|
||||
iconComponent={
|
||||
loading
|
||||
? LoadingIndicator
|
||||
: expanded
|
||||
? KeyboardArrowUpIcon
|
||||
: KeyboardArrowDownIcon
|
||||
}
|
||||
title={expanded ? collapseTitle : expandTitle}
|
||||
onClick={handleClick}
|
||||
aria-controls={`${accessibilityId}-content`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children.length > 0 && expanded && (
|
||||
<div
|
||||
className='navigation-panel__list-panel__items'
|
||||
role='region'
|
||||
id={`${accessibilityId}-content`}
|
||||
aria-labelledby={`${accessibilityId}-title`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import {
|
||||
disabledAccountId,
|
||||
movedToAccountId,
|
||||
domain,
|
||||
} from 'mastodon/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
export const DisabledAccountBanner: React.FC = () => {
|
||||
const disabledAccount = useAppSelector((state) =>
|
||||
disabledAccountId ? state.accounts.get(disabledAccountId) : undefined,
|
||||
);
|
||||
const movedToAccount = useAppSelector((state) =>
|
||||
movedToAccountId ? state.accounts.get(movedToAccountId) : undefined,
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleLogOutClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
|
||||
|
||||
return false;
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const disabledAccountLink = (
|
||||
<Link to={`/@${disabledAccount?.acct}`}>
|
||||
{disabledAccount?.acct}@{domain}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='sign-in-banner'>
|
||||
<p>
|
||||
{movedToAccount ? (
|
||||
<FormattedMessage
|
||||
id='moved_to_account_banner.text'
|
||||
defaultMessage='Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.'
|
||||
values={{
|
||||
disabledAccount: disabledAccountLink,
|
||||
movedToAccount: (
|
||||
<Link to={`/@${movedToAccount.acct}`}>
|
||||
{movedToAccount.acct.includes('@')
|
||||
? movedToAccount.acct
|
||||
: `${movedToAccount.acct}@${domain}`}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='disabled_account_banner.text'
|
||||
defaultMessage='Your account {disabledAccount} is currently disabled.'
|
||||
values={{
|
||||
disabledAccount: disabledAccountLink,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<a href='/auth/edit' className='button button--block'>
|
||||
<FormattedMessage
|
||||
id='disabled_account_banner.account_settings'
|
||||
defaultMessage='Account settings'
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
type='button'
|
||||
className='button button--block button-tertiary'
|
||||
onClick={handleLogOutClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='confirmations.logout.confirm'
|
||||
defaultMessage='Log out'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { apiGetFollowedTags } from 'mastodon/api/tags';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
||||
|
||||
import { CollapsiblePanel } from './collapsible_panel';
|
||||
|
||||
const messages = defineMessages({
|
||||
followedTags: {
|
||||
id: 'navigation_bar.followed_tags',
|
||||
defaultMessage: 'Followed hashtags',
|
||||
},
|
||||
expand: {
|
||||
id: 'navigation_panel.expand_followed_tags',
|
||||
defaultMessage: 'Expand followed hashtags menu',
|
||||
},
|
||||
collapse: {
|
||||
id: 'navigation_panel.collapse_followed_tags',
|
||||
defaultMessage: 'Collapse followed hashtags menu',
|
||||
},
|
||||
});
|
||||
|
||||
export const FollowedTagsPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags(undefined, 4)
|
||||
.then(({ tags }) => {
|
||||
setTags(tags);
|
||||
setLoading(false);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setLoading, setTags]);
|
||||
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
to='/followed_tags'
|
||||
icon='hashtag'
|
||||
iconComponent={TagIcon}
|
||||
title={intl.formatMessage(messages.followedTags)}
|
||||
collapseTitle={intl.formatMessage(messages.collapse)}
|
||||
expandTitle={intl.formatMessage(messages.expand)}
|
||||
loading={loading}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<ColumnLink
|
||||
icon='hashtag'
|
||||
key={tag.name}
|
||||
iconComponent={TagIcon}
|
||||
text={`#${tag.name}`}
|
||||
to={`/tags/${tag.name}`}
|
||||
transparent
|
||||
/>
|
||||
))}
|
||||
</CollapsiblePanel>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { fetchLists } from 'mastodon/actions/lists';
|
||||
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
||||
import { getOrderedLists } from 'mastodon/selectors/lists';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { CollapsiblePanel } from './collapsible_panel';
|
||||
|
||||
const messages = defineMessages({
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
expand: {
|
||||
id: 'navigation_panel.expand_lists',
|
||||
defaultMessage: 'Expand list menu',
|
||||
},
|
||||
collapse: {
|
||||
id: 'navigation_panel.collapse_lists',
|
||||
defaultMessage: 'Collapse list menu',
|
||||
},
|
||||
});
|
||||
|
||||
export const ListPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const lists = useAppSelector((state) => getOrderedLists(state));
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
void dispatch(fetchLists()).then(() => {
|
||||
setLoading(false);
|
||||
|
||||
return '';
|
||||
});
|
||||
}, [dispatch, setLoading]);
|
||||
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
to='/lists'
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
activeIconComponent={ListAltActiveIcon}
|
||||
title={intl.formatMessage(messages.lists)}
|
||||
collapseTitle={intl.formatMessage(messages.collapse)}
|
||||
expandTitle={intl.formatMessage(messages.expand)}
|
||||
loading={loading}
|
||||
>
|
||||
{lists.map((list) => (
|
||||
<ColumnLink
|
||||
icon='list-ul'
|
||||
key={list.id}
|
||||
iconComponent={ListAltIcon}
|
||||
activeIconComponent={ListAltActiveIcon}
|
||||
text={list.title}
|
||||
to={`/lists/${list.id}`}
|
||||
transparent
|
||||
/>
|
||||
))}
|
||||
</CollapsiblePanel>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domainBlocks: {
|
||||
id: 'navigation_bar.domain_blocks',
|
||||
defaultMessage: 'Blocked domains',
|
||||
},
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||
administration: {
|
||||
id: 'navigation_bar.administration',
|
||||
defaultMessage: 'Administration',
|
||||
},
|
||||
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
automatedDeletion: {
|
||||
id: 'navigation_bar.automated_deletion',
|
||||
defaultMessage: 'Automated post deletion',
|
||||
},
|
||||
accountSettings: {
|
||||
id: 'navigation_bar.account_settings',
|
||||
defaultMessage: 'Password and security',
|
||||
},
|
||||
importExport: {
|
||||
id: 'navigation_bar.import_export',
|
||||
defaultMessage: 'Import and export',
|
||||
},
|
||||
privacyAndReach: {
|
||||
id: 'navigation_bar.privacy_and_reach',
|
||||
defaultMessage: 'Privacy and reach',
|
||||
},
|
||||
});
|
||||
|
||||
export const MoreLink: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { permissions } = useIdentity();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const arr: MenuItem[] = [
|
||||
{ text: intl.formatMessage(messages.filters), href: '/filters' },
|
||||
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
||||
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
|
||||
{
|
||||
text: intl.formatMessage(messages.domainBlocks),
|
||||
to: '/domain_blocks',
|
||||
},
|
||||
];
|
||||
|
||||
arr.push(
|
||||
null,
|
||||
{
|
||||
href: '/settings/privacy',
|
||||
text: intl.formatMessage(messages.privacyAndReach),
|
||||
},
|
||||
{
|
||||
href: '/statuses_cleanup',
|
||||
text: intl.formatMessage(messages.automatedDeletion),
|
||||
},
|
||||
{
|
||||
href: '/auth/edit',
|
||||
text: intl.formatMessage(messages.accountSettings),
|
||||
},
|
||||
{
|
||||
href: '/settings/export',
|
||||
text: intl.formatMessage(messages.importExport),
|
||||
},
|
||||
);
|
||||
|
||||
if (canManageReports(permissions)) {
|
||||
arr.push(null, {
|
||||
href: '/admin/reports',
|
||||
text: intl.formatMessage(messages.moderation),
|
||||
});
|
||||
}
|
||||
|
||||
if (canViewAdminDashboard(permissions)) {
|
||||
arr.push({
|
||||
href: '/admin/dashboard',
|
||||
text: intl.formatMessage(messages.administration),
|
||||
});
|
||||
}
|
||||
|
||||
const handleLogoutClick = () => {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
|
||||
};
|
||||
|
||||
arr.push(null, {
|
||||
text: intl.formatMessage(messages.logout),
|
||||
action: handleLogoutClick,
|
||||
});
|
||||
|
||||
return arr;
|
||||
}, [intl, dispatch, permissions]);
|
||||
|
||||
return (
|
||||
<Dropdown items={menu}>
|
||||
<button className='column-link column-link--transparent'>
|
||||
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' />
|
||||
|
||||
<FormattedMessage id='navigation_bar.more' defaultMessage='More' />
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { registrationsOpen, sso_redirect } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
export const SignInBanner: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const openClosedRegistrationsModal = useCallback(
|
||||
() =>
|
||||
dispatch(
|
||||
openModal({ modalType: 'CLOSED_REGISTRATIONS', modalProps: {} }),
|
||||
),
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
let signupButton: React.ReactNode;
|
||||
|
||||
const signupUrl = useAppSelector(
|
||||
(state) =>
|
||||
(state.server.getIn(['server', 'registrations', 'url'], null) as
|
||||
| string
|
||||
| null) ?? '/auth/sign_up',
|
||||
);
|
||||
|
||||
if (sso_redirect) {
|
||||
return (
|
||||
<div className='sign-in-banner'>
|
||||
<p>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.mastodon_is'
|
||||
defaultMessage="Mastodon is the best way to keep up with what's happening."
|
||||
/>
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.follow_anyone'
|
||||
defaultMessage='Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.'
|
||||
/>
|
||||
</p>
|
||||
<a
|
||||
href={sso_redirect}
|
||||
data-method='post'
|
||||
className='button button--block button-tertiary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.sso_redirect'
|
||||
defaultMessage='Login or Register'
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (registrationsOpen) {
|
||||
signupButton = (
|
||||
<a href={signupUrl} className='button button--block'>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.create_account'
|
||||
defaultMessage='Create account'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
signupButton = (
|
||||
<button
|
||||
className='button button--block'
|
||||
onClick={openClosedRegistrationsModal}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.create_account'
|
||||
defaultMessage='Create account'
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='sign-in-banner'>
|
||||
<p>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.mastodon_is'
|
||||
defaultMessage="Mastodon is the best way to keep up with what's happening."
|
||||
/>
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.follow_anyone'
|
||||
defaultMessage='Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.'
|
||||
/>
|
||||
</p>
|
||||
{signupButton}
|
||||
<a href='/auth/sign_in' className='button button--block button-tertiary'>
|
||||
<FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
|
||||
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||
import { showTrends } from 'mastodon/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
export const Trends: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const trends = useAppSelector(
|
||||
(state) =>
|
||||
state.trends.getIn(['tags', 'items']) as ImmutableList<
|
||||
ImmutableMap<string, unknown>
|
||||
>,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchTrendingHashtags());
|
||||
|
||||
const refreshInterval = setInterval(() => {
|
||||
dispatch(fetchTrendingHashtags());
|
||||
}, 900 * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(refreshInterval);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
if (!showTrends || trends.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='navigation-panel__portal'>
|
||||
<div className='getting-started__trends'>
|
||||
<h4>
|
||||
<Link to={'/explore/tags'}>
|
||||
<FormattedMessage
|
||||
id='trends.trending_now'
|
||||
defaultMessage='Trending now'
|
||||
/>
|
||||
</Link>
|
||||
</h4>
|
||||
|
||||
{trends.take(4).map((hashtag) => (
|
||||
<Hashtag key={hashtag.get('name') as string} hashtag={hashtag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue