Change order of items in navigation panel in web UI (#35029)

This commit is contained in:
Eugen Rochko 2025-06-16 17:06:33 +02:00 committed by GitHub
commit 7c4393e719
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 625 additions and 452 deletions

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};