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
parent 013c527406
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

@ -4,14 +4,12 @@ export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL';
export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL';
export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
export * from './lists_typed';
export const fetchList = id => (dispatch, getState) => {
if (getState().getIn(['lists', id])) {
return;
@ -40,28 +38,6 @@ export const fetchListFail = (id, error) => ({
error,
});
export const fetchLists = () => (dispatch) => {
dispatch(fetchListsRequest());
api().get('/api/v1/lists')
.then(({ data }) => dispatch(fetchListsSuccess(data)))
.catch(err => dispatch(fetchListsFail(err)));
};
export const fetchListsRequest = () => ({
type: LISTS_FETCH_REQUEST,
});
export const fetchListsSuccess = lists => ({
type: LISTS_FETCH_SUCCESS,
lists,
});
export const fetchListsFail = error => ({
type: LISTS_FETCH_FAIL,
error,
});
export const deleteList = id => (dispatch) => {
dispatch(deleteListRequest(id));

View File

@ -1,4 +1,4 @@
import { apiCreate, apiUpdate } from 'mastodon/api/lists';
import { apiCreate, apiUpdate, apiGetLists } from 'mastodon/api/lists';
import type { List } from 'mastodon/models/list';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
@ -11,3 +11,7 @@ export const updateList = createDataLoadingThunk(
'list/update',
(list: Partial<List>) => apiUpdate(list),
);
export const fetchLists = createDataLoadingThunk('lists/fetch', () =>
apiGetLists(),
);

View File

@ -13,6 +13,8 @@ export const apiCreate = (list: Partial<ApiListJSON>) =>
export const apiUpdate = (list: Partial<ApiListJSON>) =>
apiRequestPut<ApiListJSON>(`v1/lists/${list.id}`, list);
export const apiGetLists = () => apiRequestGet<ApiListJSON[]>('v1/lists');
export const apiGetAccounts = (listId: string) =>
apiRequestGet<ApiAccountJSON[]>(`v1/lists/${listId}/accounts`, {
limit: 0,

View File

@ -16,10 +16,11 @@ export const apiFeatureTag = (tagId: string) =>
export const apiUnfeatureTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfeature`);
export const apiGetFollowedTags = async (url?: string) => {
export const apiGetFollowedTags = async (url?: string, limit?: number) => {
const response = await api().request<ApiHashtagJSON[]>({
method: 'GET',
url: url ?? '/api/v1/followed_tags',
params: { limit },
});
return {

View File

@ -1,6 +0,0 @@
import Trends from 'mastodon/features/getting_started/containers/trends_container';
import { showTrends } from 'mastodon/initial_state';
export const NavigationPortal: React.FC = () => (
<div className='navigation-panel__portal'>{showTrends && <Trends />}</div>
);

View File

@ -1,54 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
export default class Trends extends ImmutablePureComponent {
static defaultProps = {
loading: false,
};
static propTypes = {
trends: ImmutablePropTypes.list,
fetchTrends: PropTypes.func.isRequired,
};
componentDidMount () {
this.props.fetchTrends();
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
}
componentWillUnmount () {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
render () {
const { trends } = this.props;
if (!trends || trends.isEmpty()) {
return null;
}
return (
<div className='getting-started__trends'>
<h4>
<Link to={'/explore/tags'}>
<FormattedMessage id='trends.trending_now' defaultMessage='Trending now' />
</Link>
</h4>
{trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</div>
);
}
}

View File

@ -1,15 +0,0 @@
import { connect } from 'react-redux';
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
import Trends from '../components/trends';
const mapStateToProps = state => ({
trends: state.getIn(['trends', 'tags', 'items']),
});
const mapDispatchToProps = dispatch => ({
fetchTrends: () => dispatch(fetchTrendingHashtags()),
});
export default connect(mapStateToProps, mapDispatchToProps)(Trends);

View File

@ -34,7 +34,7 @@ import { NavigationBar } from '../compose/components/navigation_bar';
import { ColumnLink } from '../ui/components/column_link';
import ColumnSubheading from '../ui/components/column_subheading';
import TrendsContainer from './containers/trends_container';
import { Trends } from 'mastodon/features/navigation_panel/components/trends';
const messages = defineMessages({
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
@ -164,7 +164,7 @@ class GettingStarted extends ImmutablePureComponent {
<LinkFooter multiColumn />
</div>
{(multiColumn && showTrends) && <TrendsContainer />}
{(multiColumn && showTrends) && <Trends />}
<Helmet>
<title>{intl.formatMessage(messages.menu)}</title>

View File

@ -122,7 +122,7 @@ const ListAdder: React.FC<{
const [listIds, setListIds] = useState<string[]>([]);
useEffect(() => {
dispatch(fetchLists());
void dispatch(fetchLists());
apiGetAccountLists(accountId)
.then((data) => {

View File

@ -79,7 +79,7 @@ const Lists: React.FC<{
const lists = useAppSelector((state) => getOrderedLists(state));
useEffect(() => {
dispatch(fetchLists());
void dispatch(fetchLists());
}, [dispatch]);
const emptyMessage = (

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

@ -12,10 +12,6 @@ import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
import { useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
followedTags: {
id: 'navigation_bar.followed_tags',
defaultMessage: 'Followed hashtags',
},
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domainBlocks: {
id: 'navigation_bar.domain_blocks',
@ -54,11 +50,6 @@ export const MoreLink: React.FC = () => {
const menu = useMemo(() => {
const arr: MenuItem[] = [
{
text: intl.formatMessage(messages.followedTags),
to: '/followed_tags',
},
null,
{ text: intl.formatMessage(messages.filters), href: '/filters' },
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },

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

View File

@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef } from 'react';
import { useEffect, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -22,7 +22,6 @@ import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react
import PersonAddActiveIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import StarActiveIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarIcon from '@/material-icons/400-24px/star.svg?react';
@ -32,7 +31,8 @@ import { openNavigation, closeNavigation } from 'mastodon/actions/navigation';
import { Account } from 'mastodon/components/account';
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { WordmarkLogo } from 'mastodon/components/logo';
import { NavigationPortal } from 'mastodon/components/navigation_portal';
import { Search } from 'mastodon/features/compose/components/search';
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
import { useIdentity } from 'mastodon/identity_context';
import { timelinePreview, trendsEnabled, me } from 'mastodon/initial_state';
@ -40,11 +40,12 @@ import { transientSingleColumn } from 'mastodon/is_mobile';
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { ColumnLink } from './column_link';
import DisabledAccountBanner from './disabled_account_banner';
import { ListPanel } from './list_panel';
import { MoreLink } from './more_link';
import SignInBanner from './sign_in_banner';
import { DisabledAccountBanner } from './components/disabled_account_banner';
import { FollowedTagsPanel } from './components/followed_tags_panel';
import { ListPanel } from './components/list_panel';
import { MoreLink } from './components/more_link';
import { SignInBanner } from './components/sign_in_banner';
import { Trends } from './components/trends';
const messages = defineMessages({
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
@ -67,6 +68,10 @@ const messages = defineMessages({
},
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
searchTrends: {
id: 'navigation_bar.search_trends',
defaultMessage: 'Search / Trending',
},
advancedInterface: {
id: 'navigation_bar.advanced_interface',
defaultMessage: 'Open in advanced web interface',
@ -159,33 +164,6 @@ const FollowRequestsLink: React.FC = () => {
);
};
const SearchLink: React.FC = () => {
const intl = useIntl();
const showAsSearch = useBreakpoint('full');
if (!trendsEnabled || showAsSearch) {
return (
<ColumnLink
transparent
to={trendsEnabled ? '/explore' : '/search'}
icon='search'
iconComponent={SearchIcon}
text={intl.formatMessage(messages.search)}
/>
);
}
return (
<ColumnLink
transparent
to='/explore'
icon='explore'
iconComponent={TrendingUpIcon}
text={intl.formatMessage(messages.explore)}
/>
);
};
const ProfileCard: React.FC = () => {
if (!me) {
return null;
@ -198,6 +176,13 @@ const ProfileCard: React.FC = () => {
);
};
const isFirehoseActive = (
match: unknown,
{ pathname }: { pathname: string },
) => {
return !!match || pathname.startsWith('/public');
};
const MENU_WIDTH = 284;
export const NavigationPanel: React.FC = () => {
@ -206,6 +191,7 @@ export const NavigationPanel: React.FC = () => {
const open = useAppSelector((state) => state.navigation.open);
const dispatch = useAppDispatch();
const openable = useBreakpoint('openable');
const showSearch = useBreakpoint('full');
const location = useLocation();
const overlayRef = useRef<HTMLDivElement | null>(null);
@ -275,13 +261,6 @@ export const NavigationPanel: React.FC = () => {
},
);
const isFirehoseActive = useCallback(
(match: unknown, location: { pathname: string }): boolean => {
return !!match || location.pathname.startsWith('/public');
},
[],
);
const previouslyFocusedElementRef = useRef<HTMLElement | null>();
useEffect(() => {
@ -297,7 +276,7 @@ export const NavigationPanel: React.FC = () => {
}
}, [open]);
let banner = undefined;
let banner: React.ReactNode;
if (transientSingleColumn) {
banner = (
@ -335,6 +314,8 @@ export const NavigationPanel: React.FC = () => {
</Link>
</div>
{showSearch && <Search singleColumn />}
<ProfileCard />
{banner && <div className='navigation-panel__banner'>{banner}</div>}
@ -358,43 +339,49 @@ export const NavigationPanel: React.FC = () => {
activeIconComponent={HomeActiveIcon}
text={intl.formatMessage(messages.home)}
/>
<NotificationsLink />
<FollowRequestsLink />
</>
)}
<SearchLink />
{trendsEnabled && (
<ColumnLink
transparent
to='/explore'
icon='explore'
iconComponent={TrendingUpIcon}
text={intl.formatMessage(messages.explore)}
/>
)}
{(signedIn || timelinePreview) && (
<ColumnLink
transparent
to='/public/local'
isActive={isFirehoseActive}
icon='globe'
iconComponent={PublicIcon}
isActive={isFirehoseActive}
text={intl.formatMessage(messages.firehose)}
/>
)}
{!signedIn && (
<div className='navigation-panel__sign-in-banner'>
<hr />
{disabledAccountId ? (
<DisabledAccountBanner />
) : (
<SignInBanner />
)}
</div>
)}
{signedIn && (
<>
<NotificationsLink />
<FollowRequestsLink />
<hr />
<ListPanel />
<FollowedTagsPanel />
<ColumnLink
transparent
to='/conversations'
icon='at'
iconComponent={AlternateEmailIcon}
text={intl.formatMessage(messages.direct)}
to='/favourites'
icon='star'
iconComponent={StarIcon}
activeIconComponent={StarActiveIcon}
text={intl.formatMessage(messages.favourites)}
/>
<ColumnLink
transparent
@ -406,15 +393,12 @@ export const NavigationPanel: React.FC = () => {
/>
<ColumnLink
transparent
to='/favourites'
icon='star'
iconComponent={StarIcon}
activeIconComponent={StarActiveIcon}
text={intl.formatMessage(messages.favourites)}
to='/conversations'
icon='at'
iconComponent={AlternateEmailIcon}
text={intl.formatMessage(messages.direct)}
/>
<ListPanel />
<hr />
<ColumnLink
@ -430,8 +414,6 @@ export const NavigationPanel: React.FC = () => {
)}
<div className='navigation-panel__legal'>
<hr />
<ColumnLink
transparent
to='/about'
@ -440,11 +422,23 @@ export const NavigationPanel: React.FC = () => {
text={intl.formatMessage(messages.about)}
/>
</div>
{!signedIn && (
<div className='navigation-panel__sign-in-banner'>
<hr />
{disabledAccountId ? (
<DisabledAccountBanner />
) : (
<SignInBanner />
)}
</div>
)}
</div>
<div className='flex-spacer' />
<NavigationPortal />
<Trends />
</div>
</animated.div>
</div>

View File

@ -25,7 +25,7 @@ import BundleColumnError from './bundle_column_error';
import { ColumnLoading } from './column_loading';
import { ComposePanel } from './compose_panel';
import DrawerLoading from './drawer_loading';
import { NavigationPanel } from './navigation_panel';
import { NavigationPanel } from 'mastodon/features/navigation_panel';
const componentMap = {
'COMPOSE': Compose,

View File

@ -1,85 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import { disabledAccountId, movedToAccountId, domain } from 'mastodon/initial_state';
const mapStateToProps = (state) => ({
disabledAcct: state.getIn(['accounts', disabledAccountId, 'acct']),
movedToAcct: movedToAccountId ? state.getIn(['accounts', movedToAccountId, 'acct']) : undefined,
});
const mapDispatchToProps = (dispatch) => ({
onLogout () {
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
},
});
class DisabledAccountBanner extends PureComponent {
static propTypes = {
disabledAcct: PropTypes.string.isRequired,
movedToAcct: PropTypes.string,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleLogOutClick = e => {
e.preventDefault();
e.stopPropagation();
this.props.onLogout();
return false;
};
render () {
const { disabledAcct, movedToAcct } = this.props;
const disabledAccountLink = (
<Link to={`/@${disabledAcct}`}>
{disabledAcct}@{domain}
</Link>
);
return (
<div className='sign-in-banner'>
<p>
{movedToAcct ? (
<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={`/@${movedToAcct}`}>{movedToAcct.includes('@') ? movedToAcct : `${movedToAcct}@${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={this.handleLogOutClick}>
<FormattedMessage id='confirmations.logout.confirm' defaultMessage='Log out' />
</button>
</div>
);
}
}
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(DisabledAccountBanner));

View File

@ -1,92 +0,0 @@
import { useEffect, useState, useCallback, useId } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
import ArrowLeftIcon from '@/material-icons/400-24px/arrow_left.svg?react';
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 { IconButton } from 'mastodon/components/icon_button';
import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { ColumnLink } from './column_link';
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 [expanded, setExpanded] = useState(false);
const accessibilityId = useId();
useEffect(() => {
dispatch(fetchLists());
}, [dispatch]);
const handleClick = useCallback(() => {
setExpanded((value) => !value);
}, [setExpanded]);
return (
<div className='navigation-panel__list-panel'>
<div className='navigation-panel__list-panel__header'>
<ColumnLink
transparent
to='/lists'
icon='list-ul'
iconComponent={ListAltIcon}
activeIconComponent={ListAltActiveIcon}
text={intl.formatMessage(messages.lists)}
id={`${accessibilityId}-title`}
/>
{lists.length > 0 && (
<IconButton
icon='down'
expanded={expanded}
iconComponent={expanded ? ArrowDropDownIcon : ArrowLeftIcon}
title={intl.formatMessage(
expanded ? messages.collapse : messages.expand,
)}
onClick={handleClick}
aria-controls={`${accessibilityId}-content`}
/>
)}
</div>
{lists.length > 0 && expanded && (
<div
className='navigation-panel__list-panel__items'
role='region'
id={`${accessibilityId}-content`}
aria-labelledby={`${accessibilityId}-title`}
>
{lists.map((list) => (
<ColumnLink
icon='list-ul'
key={list.get('id')}
iconComponent={ListAltIcon}
activeIconComponent={ListAltActiveIcon}
text={list.get('title')}
to={`/lists/${list.get('id')}`}
transparent
/>
))}
</div>
)}
</div>
);
};

View File

@ -1,56 +0,0 @@
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';
const SignInBanner = () => {
const dispatch = useAppDispatch();
const openClosedRegistrationsModal = useCallback(
() => dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })),
[dispatch],
);
let signupButton;
const signupUrl = useAppSelector((state) => state.getIn(['server', 'server', 'registrations', 'url'], 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>
);
};
export default SignInBanner;

View File

@ -579,8 +579,11 @@
"navigation_bar.privacy_and_reach": "Privacy and reach",
"navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.search": "Search",
"navigation_bar.search_trends": "Search / Trending",
"navigation_bar.security": "Security",
"navigation_panel.collapse_followed_tags": "Collapse followed hashtags menu",
"navigation_panel.collapse_lists": "Collapse list menu",
"navigation_panel.expand_followed_tags": "Expand followed hashtags menu",
"navigation_panel.expand_lists": "Expand list menu",
"not_signed_in_indicator.not_signed_in": "You need to login to access this resource.",
"notification.admin.report": "{name} reported {target}",

View File

@ -1,7 +1,11 @@
import type { Reducer } from '@reduxjs/toolkit';
import { Map as ImmutableMap } from 'immutable';
import { createList, updateList } from 'mastodon/actions/lists_typed';
import {
createList,
updateList,
fetchLists,
} from 'mastodon/actions/lists_typed';
import type { ApiListJSON } from 'mastodon/api_types/lists';
import { createList as createListFromJSON } from 'mastodon/models/list';
import type { List } from 'mastodon/models/list';
@ -9,7 +13,6 @@ import type { List } from 'mastodon/models/list';
import {
LIST_FETCH_SUCCESS,
LIST_FETCH_FAIL,
LISTS_FETCH_SUCCESS,
LIST_DELETE_SUCCESS,
} from '../actions/lists';
@ -33,12 +36,12 @@ export const listsReducer: Reducer<State> = (state = initialState, action) => {
updateList.fulfilled.match(action)
) {
return normalizeList(state, action.payload);
} else if (fetchLists.fulfilled.match(action)) {
return normalizeLists(state, action.payload);
} else {
switch (action.type) {
case LIST_FETCH_SUCCESS:
return normalizeList(state, action.list as ApiListJSON);
case LISTS_FETCH_SUCCESS:
return normalizeLists(state, action.lists as ApiListJSON[]);
case LIST_DELETE_SUCCESS:
case LIST_FETCH_FAIL:
return state.set(action.id as string, null);

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z"/></svg>

After

Width:  |  Height:  |  Size: 159 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z"/></svg>

After

Width:  |  Height:  |  Size: 159 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z"/></svg>

After

Width:  |  Height:  |  Size: 160 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z"/></svg>

After

Width:  |  Height:  |  Size: 160 B

View File

@ -3144,10 +3144,9 @@ a.account__display-name {
height: 100vh;
}
.navigation-panel__sign-in-banner,
.navigation-panel__banner,
.getting-started__trends,
.navigation-panel__logo {
.navigation-panel__logo,
.getting-started__trends {
display: none;
}
}
@ -3481,15 +3480,24 @@ a.account__display-name {
&__header {
display: flex;
align-items: center;
padding-inline-end: 12px;
&__sep {
width: 0;
height: 24px;
border-left: 1px solid var(--background-border-color);
}
.column-link {
flex: 1 1 auto;
}
.icon-button {
padding: 8px;
}
}
&__items {
padding-inline-start: 24px + 5px;
padding-inline-start: 24px + 8px;
.icon {
display: none;
@ -3500,14 +3508,16 @@ a.account__display-name {
&__compose-button {
display: flex;
justify-content: flex-start;
padding-top: 10px;
padding-bottom: 10px;
padding-inline-start: 13px - 7px;
padding-inline-end: 13px;
gap: 5px;
padding-top: 8px;
padding-bottom: 8px;
padding-inline-start: 12px - 7px;
padding-inline-end: 12px;
gap: 8px;
margin: 12px;
margin-bottom: 4px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
line-height: 18px;
.icon {
width: 24px;
@ -3517,7 +3527,11 @@ a.account__display-name {
.navigation-bar {
padding: 16px;
border-bottom: 1px solid var(--background-border-color);
}
.search {
margin: 16px;
margin-bottom: 12px;
}
.logo {
@ -3529,26 +3543,36 @@ a.account__display-name {
margin-bottom: 12px;
}
@media screen and (height <= 710px) {
&__portal {
.getting-started__trends h4 {
padding: 10px 12px;
padding-inline-start: 16px;
}
.getting-started__trends .trends__item {
padding: 10px 12px;
padding-inline-start: 16px;
}
@media screen and (height <= 930px) {
&__portal .trends__item:nth-child(n + 5) {
display: none;
}
}
@media screen and (height <= 765px) {
&__portal .trends__item:nth-child(n + 3) {
display: none;
}
}
@media screen and (height <= 820px) {
@media screen and (height <= 930px - 56px) {
&__portal .trends__item:nth-child(n + 4) {
display: none;
}
}
@media screen and (height <= 920px) {
.column-link.column-link--optional {
@media screen and (height <= 930px - (56px * 2)) {
&__portal .trends__item:nth-child(n + 3) {
display: none;
}
}
@media screen and (height <= 930px - (56px * 3)) {
&__portal {
display: none;
}
}
@ -3821,9 +3845,10 @@ a.account__display-name {
.column-link {
display: flex;
align-items: center;
gap: 5px;
gap: 8px;
font-size: 16px;
padding: 13px;
font-weight: 400;
padding: 12px;
text-decoration: none;
overflow: hidden;
white-space: nowrap;
@ -4605,7 +4630,8 @@ a.status-card {
}
.load-more .loading-indicator,
.button .loading-indicator {
.button .loading-indicator,
.icon-button .loading-indicator {
position: static;
transform: none;
@ -4616,6 +4642,13 @@ a.status-card {
}
}
.icon-button .loading-indicator .circular-progress {
color: lighten($ui-base-color, 26%);
width: 12px;
height: 12px;
margin: 6px;
}
.load-more .loading-indicator .circular-progress {
color: lighten($ui-base-color, 26%);
}