Change navigation layout on small screens in web UI (#34910)

This commit is contained in:
Eugen Rochko 2025-06-11 13:55:43 +02:00 committed by GitHub
parent 8cf246e4d3
commit a13b33d851
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1390 additions and 682 deletions

View file

@ -0,0 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
export const openNavigation = createAction('navigation/open');
export const closeNavigation = createAction('navigation/close');
export const toggleNavigation = createAction('navigation/toggle');

View file

@ -9,7 +9,8 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import UnfoldLessIcon from '@/material-icons/400-24px/unfold_less.svg?react';
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
@ -238,7 +239,10 @@ export const ColumnHeader: React.FC<Props> = ({
onClick={handleToggleClick}
>
<i className='icon-with-badge'>
<Icon id='sliders' icon={SettingsIcon} />
<Icon
id='sliders'
icon={collapsed ? UnfoldMoreIcon : UnfoldLessIcon}
/>
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i>
</button>

View file

@ -27,6 +27,7 @@ interface Props {
counter?: number;
href?: string;
ariaHidden?: boolean;
ariaControls?: string;
}
export const IconButton = forwardRef<HTMLButtonElement, Props>(
@ -52,6 +53,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
overlay = false,
tabIndex = 0,
ariaHidden = false,
ariaControls,
},
buttonRef,
) => {
@ -153,6 +155,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
aria-label={title}
aria-expanded={expanded}
aria-hidden={ariaHidden}
aria-controls={ariaControls}
title={title}
className={classes}
onClick={handleClick}

View file

@ -7,7 +7,7 @@ interface Props {
id: string;
icon: IconProp;
count: number;
issueBadge: boolean;
issueBadge?: boolean;
className: string;
}
export const IconWithBadge: React.FC<Props> = ({

View file

@ -2,34 +2,47 @@ import { useCallback } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { useSelector, useDispatch } from 'react-redux';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { cancelReplyCompose } from 'mastodon/actions/compose';
import { Account } from 'mastodon/components/account';
import { IconButton } from 'mastodon/components/icon_button';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { ActionBar } from './action_bar';
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
export const NavigationBar = () => {
const dispatch = useDispatch();
export const NavigationBar: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
const isReplying = useAppSelector(
(state) => !!state.compose.get('in_reply_to'),
);
const handleCancelClick = useCallback(() => {
dispatch(cancelReplyCompose());
}, [dispatch]);
if (!me) {
return null;
}
return (
<div className='navigation-bar'>
<Account id={me} minimal />
{isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
{isReplying ? (
<IconButton
title={intl.formatMessage(messages.cancel)}
icon=''
iconComponent={CloseIcon}
onClick={handleCancelClick}
/>
) : (
<ActionBar />
)}
</div>
);
};

View file

@ -1,138 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import { Icon } from 'mastodon/components/icon';
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
import { mascot } from '../../initial_state';
import { isMobile } from '../../is_mobile';
import { Search } from './components/search';
import ComposeFormContainer from './containers/compose_form_container';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
});
const mapStateToProps = (state) => ({
columns: state.getIn(['settings', 'columns']),
});
class Compose extends PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
columns: ImmutablePropTypes.list.isRequired,
multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(mountCompose());
}
componentWillUnmount () {
const { dispatch } = this.props;
dispatch(unmountCompose());
}
handleLogoutClick = e => {
const { dispatch } = this.props;
e.preventDefault();
e.stopPropagation();
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
return false;
};
onFocus = () => {
this.props.dispatch(changeComposing(true));
};
onBlur = () => {
this.props.dispatch(changeComposing(false));
};
render () {
const { multiColumn, intl } = this.props;
if (multiColumn) {
const { columns } = this.props;
return (
<div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}>
<nav className='drawer__header'>
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' icon={MenuIcon} /></Link>
{!columns.some(column => column.get('id') === 'HOME') && (
<Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' icon={HomeIcon} /></Link>
)}
{!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
<Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><Icon id='bell' icon={NotificationsIcon} /></Link>
)}
{!columns.some(column => column.get('id') === 'COMMUNITY') && (
<Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' icon={PeopleIcon} /></Link>
)}
{!columns.some(column => column.get('id') === 'PUBLIC') && (
<Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' icon={PublicIcon} /></Link>
)}
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' icon={SettingsIcon} /></a>
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
</nav>
{multiColumn && <Search /> }
<div className='drawer__pager'>
<div className='drawer__inner' onFocus={this.onFocus}>
<ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} />
<div className='drawer__inner__mastodon'>
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
</div>
</div>
</div>
</div>
);
}
return (
<Column onFocus={this.onFocus}>
<ComposeFormContainer />
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Compose));

View file

@ -0,0 +1,200 @@
import { useEffect, useCallback } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
import EditIcon from '@/material-icons/400-24px/edit_square.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
import { mountCompose, unmountCompose } from 'mastodon/actions/compose';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { mascot } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { Search } from './components/search';
import ComposeFormContainer from './containers/compose_form_container';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: {
id: 'tabs_bar.notifications',
defaultMessage: 'Notifications',
},
public: {
id: 'navigation_bar.public_timeline',
defaultMessage: 'Federated timeline',
},
community: {
id: 'navigation_bar.community_timeline',
defaultMessage: 'Local timeline',
},
preferences: {
id: 'navigation_bar.preferences',
defaultMessage: 'Preferences',
},
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
});
type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>;
const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const columns = useAppSelector(
(state) =>
(state.settings as ImmutableMap<string, unknown>).get(
'columns',
) as ImmutableList<ColumnMap>,
);
useEffect(() => {
dispatch(mountCompose());
return () => {
dispatch(unmountCompose());
};
}, [dispatch]);
const handleLogoutClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
return false;
},
[dispatch],
);
if (multiColumn) {
return (
<div
className='drawer'
role='region'
aria-label={intl.formatMessage(messages.compose)}
>
<nav className='drawer__header'>
<Link
to='/getting-started'
className='drawer__tab'
title={intl.formatMessage(messages.start)}
aria-label={intl.formatMessage(messages.start)}
>
<Icon id='bars' icon={MenuIcon} />
</Link>
{!columns.some((column) => column.get('id') === 'HOME') && (
<Link
to='/home'
className='drawer__tab'
title={intl.formatMessage(messages.home_timeline)}
aria-label={intl.formatMessage(messages.home_timeline)}
>
<Icon id='home' icon={HomeIcon} />
</Link>
)}
{!columns.some((column) => column.get('id') === 'NOTIFICATIONS') && (
<Link
to='/notifications'
className='drawer__tab'
title={intl.formatMessage(messages.notifications)}
aria-label={intl.formatMessage(messages.notifications)}
>
<Icon id='bell' icon={NotificationsIcon} />
</Link>
)}
{!columns.some((column) => column.get('id') === 'COMMUNITY') && (
<Link
to='/public/local'
className='drawer__tab'
title={intl.formatMessage(messages.community)}
aria-label={intl.formatMessage(messages.community)}
>
<Icon id='users' icon={PeopleIcon} />
</Link>
)}
{!columns.some((column) => column.get('id') === 'PUBLIC') && (
<Link
to='/public'
className='drawer__tab'
title={intl.formatMessage(messages.public)}
aria-label={intl.formatMessage(messages.public)}
>
<Icon id='globe' icon={PublicIcon} />
</Link>
)}
<a
href='/settings/preferences'
className='drawer__tab'
title={intl.formatMessage(messages.preferences)}
aria-label={intl.formatMessage(messages.preferences)}
>
<Icon id='cog' icon={SettingsIcon} />
</a>
<a
href='/auth/sign_out'
className='drawer__tab'
title={intl.formatMessage(messages.logout)}
aria-label={intl.formatMessage(messages.logout)}
onClick={handleLogoutClick}
>
<Icon id='sign-out' icon={LogoutIcon} />
</a>
</nav>
<Search singleColumn={false} />
<div className='drawer__pager'>
<div className='drawer__inner'>
<ComposeFormContainer />
<div className='drawer__inner__mastodon'>
<img alt='' draggable='false' src={mascot ?? elephantUIPlane} />
</div>
</div>
</div>
</div>
);
}
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.compose)}
>
<ColumnHeader
icon='pencil'
iconComponent={EditIcon}
title={intl.formatMessage(messages.compose)}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable'>
<ComposeFormContainer />
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Compose;

View file

@ -9,7 +9,9 @@ import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { SymbolLogo } from 'mastodon/components/logo';
import { Search } from 'mastodon/features/compose/components/search';
import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
import { useIdentity } from 'mastodon/identity_context';
import Links from './links';
@ -25,6 +27,7 @@ const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const { signedIn } = useIdentity();
const intl = useIntl();
const columnRef = useRef<ColumnRef>(null);
const logoRequired = useBreakpoint('full');
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
@ -38,7 +41,7 @@ const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
>
<ColumnHeader
icon={'explore'}
iconComponent={ExploreIcon}
iconComponent={logoRequired ? SymbolLogo : ExploreIcon}
title={intl.formatMessage(messages.title)}
onClick={handleHeaderClick}
multiColumn={multiColumn}

View file

@ -31,7 +31,7 @@ import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
import { me, showTrends } from '../../initial_state';
import { NavigationBar } from '../compose/components/navigation_bar';
import ColumnLink from '../ui/components/column_link';
import { ColumnLink } from '../ui/components/column_link';
import ColumnSubheading from '../ui/components/column_subheading';
import TrendsContainer from './containers/trends_container';

View file

@ -10,12 +10,14 @@ import { connect } from 'react-redux';
import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import { SymbolLogo } from 'mastodon/components/logo';
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { criticalUpdatesPending } from 'mastodon/initial_state';
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines';
@ -52,6 +54,7 @@ class HomeTimeline extends PureComponent {
hasAnnouncements: PropTypes.bool,
unreadAnnouncements: PropTypes.number,
showAnnouncements: PropTypes.bool,
matchesBreakpoint: PropTypes.bool,
};
handlePin = () => {
@ -121,7 +124,7 @@ class HomeTimeline extends PureComponent {
};
render () {
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props;
const pinned = !!columnId;
const { signedIn } = this.props.identity;
const banners = [];
@ -150,7 +153,7 @@ class HomeTimeline extends PureComponent {
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='home'
iconComponent={HomeIcon}
iconComponent={matchesBreakpoint ? SymbolLogo : HomeIcon}
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
@ -187,4 +190,4 @@ class HomeTimeline extends PureComponent {
}
export default connect(mapStateToProps)(withIdentity(injectIntl(HomeTimeline)));
export default connect(mapStateToProps)(withBreakpoint(withIdentity(injectIntl(HomeTimeline))));

View file

@ -1,49 +0,0 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useRouteMatch, NavLink } from 'react-router-dom';
import { Icon } from 'mastodon/components/icon';
const ColumnLink = ({ icon, activeIcon, iconComponent, activeIconComponent, text, to, href, method, badge, transparent, optional, ...other }) => {
const match = useRouteMatch(to);
const className = classNames('column-link', { 'column-link--transparent': transparent, 'column-link--optional': optional });
const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
const iconElement = (typeof icon === 'string' || iconComponent) ? <Icon id={icon} icon={iconComponent} className='column-link__icon' /> : icon;
const activeIconElement = activeIcon ?? (activeIconComponent ? <Icon id={icon} icon={activeIconComponent} className='column-link__icon' /> : iconElement);
const active = match?.isExact;
if (href) {
return (
<a href={href} className={className} data-method={method} {...other}>
{active ? activeIconElement : iconElement}
<span>{text}</span>
{badgeElement}
</a>
);
} else {
return (
<NavLink to={to} className={className} exact {...other}>
{active ? activeIconElement : iconElement}
<span>{text}</span>
{badgeElement}
</NavLink>
);
}
};
ColumnLink.propTypes = {
icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
iconComponent: PropTypes.func,
activeIcon: PropTypes.node,
activeIconComponent: PropTypes.func,
text: PropTypes.string.isRequired,
to: PropTypes.string,
href: PropTypes.string,
method: PropTypes.string,
badge: PropTypes.node,
transparent: PropTypes.bool,
optional: PropTypes.bool,
};
export default ColumnLink;

View file

@ -0,0 +1,86 @@
import classNames from 'classnames';
import { useRouteMatch, NavLink } from 'react-router-dom';
import { Icon } from 'mastodon/components/icon';
import type { IconProp } from 'mastodon/components/icon';
export const ColumnLink: React.FC<{
icon: React.ReactNode;
iconComponent?: IconProp;
activeIcon?: React.ReactNode;
activeIconComponent?: IconProp;
isActive?: (match: unknown, location: { pathname: string }) => boolean;
text: string;
to?: string;
href?: string;
method?: string;
badge?: React.ReactNode;
transparent?: boolean;
optional?: boolean;
className?: string;
id?: string;
}> = ({
icon,
activeIcon,
iconComponent,
activeIconComponent,
text,
to,
href,
method,
badge,
transparent,
optional,
...other
}) => {
const match = useRouteMatch(to ?? '');
const className = classNames('column-link', {
'column-link--transparent': transparent,
'column-link--optional': optional,
});
const badgeElement =
typeof badge !== 'undefined' ? (
<span className='column-link__badge'>{badge}</span>
) : null;
const iconElement = iconComponent ? (
<Icon
id={typeof icon === 'string' ? icon : ''}
icon={iconComponent}
className='column-link__icon'
/>
) : (
icon
);
const activeIconElement =
activeIcon ??
(activeIconComponent ? (
<Icon
id={typeof icon === 'string' ? icon : ''}
icon={activeIconComponent}
className='column-link__icon'
/>
) : (
iconElement
));
const active = !!match;
if (href) {
return (
<a href={href} className={className} data-method={method} {...other}>
{active ? activeIconElement : iconElement}
<span>{text}</span>
{badgeElement}
</a>
);
} else if (to) {
return (
<NavLink to={to} className={className} {...other}>
{active ? activeIconElement : iconElement}
<span>{text}</span>
{badgeElement}
</NavLink>
);
} else {
return null;
}
};

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 './navigation_panel';
const componentMap = {
'COMPOSE': Compose,
@ -132,11 +132,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
<div className='columns-area columns-area--mobile'>{children}</div>
</div>
<div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'>
<div className='columns-area__panels__pane__inner'>
<NavigationPanel />
</div>
</div>
<NavigationPanel />
</div>
);
}

View file

@ -1,121 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Link, withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { openModal } from 'mastodon/actions/modal';
import { fetchServer } from 'mastodon/actions/server';
import { Avatar } from 'mastodon/components/avatar';
import { Icon } from 'mastodon/components/icon';
import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { registrationsOpen, me, sso_redirect } from 'mastodon/initial_state';
const Account = connect(state => ({
account: state.getIn(['accounts', me]),
}))(({ account }) => (
<Link to={`/@${account.get('acct')}`} title={account.get('acct')}>
<Avatar account={account} size={35} />
</Link>
));
const messages = defineMessages({
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
});
const mapStateToProps = (state) => ({
signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up',
});
const mapDispatchToProps = (dispatch) => ({
openClosedRegistrationsModal() {
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
},
dispatchServer() {
dispatch(fetchServer());
}
});
class Header extends PureComponent {
static propTypes = {
identity: identityContextPropShape,
openClosedRegistrationsModal: PropTypes.func,
location: PropTypes.object,
signupUrl: PropTypes.string.isRequired,
dispatchServer: PropTypes.func,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
const { dispatchServer } = this.props;
dispatchServer();
}
render () {
const { signedIn } = this.props.identity;
const { location, openClosedRegistrationsModal, signupUrl, intl } = this.props;
let content;
if (signedIn) {
content = (
<>
{location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' icon={SearchIcon} /></Link>}
{location.pathname !== '/publish' && <Link to='/publish' className='button button-secondary'><FormattedMessage id='compose_form.publish_form' defaultMessage='New post' /></Link>}
<Account />
</>
);
} else {
if (sso_redirect) {
content = (
<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>
);
} else {
let signupButton;
if (registrationsOpen) {
signupButton = (
<a href={signupUrl} className='button'>
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
</a>
);
} else {
signupButton = (
<button className='button' onClick={openClosedRegistrationsModal}>
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
</button>
);
}
content = (
<>
{signupButton}
<a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
</>
);
}
}
return (
<div className='ui__header'>
<Link to='/' className='ui__header__logo'>
<WordmarkLogo />
<SymbolLogo />
</Link>
<div className='ui__header__links'>
{content}
</div>
</div>
);
}
}
export default injectIntl(withRouter(withIdentity(connect(mapStateToProps, mapDispatchToProps)(Header))));

View file

@ -1,41 +0,0 @@
import { useEffect } from 'react';
import { createSelector } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';
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 './column_link';
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
});
export const ListPanel = () => {
const dispatch = useDispatch();
const lists = useSelector(state => getOrderedLists(state));
useEffect(() => {
dispatch(fetchLists());
}, [dispatch]);
if (!lists || lists.isEmpty()) {
return null;
}
return (
<div className='list-panel'>
<hr />
{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>
);
};

View file

@ -0,0 +1,92 @@
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

@ -0,0 +1,204 @@
import { useCallback, useEffect } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { NavLink, useRouteMatch } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { openModal } from 'mastodon/actions/modal';
import { toggleNavigation } from 'mastodon/actions/navigation';
import { fetchServer } from 'mastodon/actions/server';
import { Icon } from 'mastodon/components/icon';
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { useIdentity } from 'mastodon/identity_context';
import { registrationsOpen, sso_redirect } from 'mastodon/initial_state';
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
search: { id: 'tabs_bar.search', defaultMessage: 'Search' },
publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },
notifications: {
id: 'tabs_bar.notifications',
defaultMessage: 'Notifications',
},
menu: { id: 'tabs_bar.menu', defaultMessage: 'Menu' },
});
const IconLabelButton: React.FC<{
to: string;
icon?: React.ReactNode;
activeIcon?: React.ReactNode;
title: string;
}> = ({ to, icon, activeIcon, title }) => {
const match = useRouteMatch(to);
return (
<NavLink
className='ui__navigation-bar__item'
activeClassName='active'
to={to}
aria-label={title}
>
{match && activeIcon ? activeIcon : icon}
</NavLink>
);
};
const NotificationsButton = () => {
const count = useAppSelector(selectUnreadNotificationGroupsCount);
const intl = useIntl();
return (
<IconLabelButton
to='/notifications'
icon={
<IconWithBadge
id='bell'
icon={NotificationsIcon}
count={count}
className=''
/>
}
activeIcon={
<IconWithBadge
id='bell'
icon={NotificationsActiveIcon}
count={count}
className=''
/>
}
title={intl.formatMessage(messages.notifications)}
/>
);
};
const LoginOrSignUp: React.FC = () => {
const dispatch = useAppDispatch();
const signupUrl = useAppSelector(
(state) =>
(state.server.getIn(['server', 'registrations', 'url'], null) as
| string
| null) ?? '/auth/sign_up',
);
const openClosedRegistrationsModal = useCallback(() => {
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS', modalProps: {} }));
}, [dispatch]);
useEffect(() => {
dispatch(fetchServer());
}, [dispatch]);
if (sso_redirect) {
return (
<div className='ui__navigation-bar__sign-up'>
<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>
);
} else {
let signupButton;
if (registrationsOpen) {
signupButton = (
<a href={signupUrl} className='button'>
<FormattedMessage
id='sign_in_banner.create_account'
defaultMessage='Create account'
/>
</a>
);
} else {
signupButton = (
<button className='button' onClick={openClosedRegistrationsModal}>
<FormattedMessage
id='sign_in_banner.create_account'
defaultMessage='Create account'
/>
</button>
);
}
return (
<div className='ui__navigation-bar__sign-up'>
{signupButton}
<a href='/auth/sign_in' className='button button-tertiary'>
<FormattedMessage
id='sign_in_banner.sign_in'
defaultMessage='Login'
/>
</a>
</div>
);
}
};
export const NavigationBar: React.FC = () => {
const { signedIn } = useIdentity();
const dispatch = useAppDispatch();
const open = useAppSelector((state) => state.navigation.open);
const intl = useIntl();
const handleClick = useCallback(() => {
dispatch(toggleNavigation());
}, [dispatch]);
return (
<div className='ui__navigation-bar'>
{!signedIn && <LoginOrSignUp />}
<div
className={classNames('ui__navigation-bar__items', {
active: signedIn,
})}
>
{signedIn && (
<>
<IconLabelButton
title={intl.formatMessage(messages.home)}
to='/home'
icon={<Icon id='' icon={HomeIcon} />}
activeIcon={<Icon id='' icon={HomeActiveIcon} />}
/>
<IconLabelButton
title={intl.formatMessage(messages.search)}
to='/explore'
icon={<Icon id='' icon={SearchIcon} />}
/>
<IconLabelButton
title={intl.formatMessage(messages.publish)}
to='/publish'
icon={<Icon id='' icon={AddIcon} />}
/>
<NotificationsButton />
</>
)}
<button
className={classNames('ui__navigation-bar__item', { active: open })}
onClick={handleClick}
aria-label={intl.formatMessage(messages.menu)}
>
<Icon id='' icon={MenuIcon} />
</button>
</div>
</div>
);
};

View file

@ -1,206 +0,0 @@
import PropTypes from 'prop-types';
import { Component, useEffect } from 'react';
import { defineMessages, injectIntl, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
import HomeIcon from '@/material-icons/400-24px/home.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 AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
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';
import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { WordmarkLogo } from 'mastodon/components/logo';
import { NavigationPortal } from 'mastodon/components/navigation_portal';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { timelinePreview, trendsEnabled } from 'mastodon/initial_state';
import { transientSingleColumn } from 'mastodon/is_mobile';
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
import ColumnLink from './column_link';
import DisabledAccountBanner from './disabled_account_banner';
import { ListPanel } from './list_panel';
import SignInBanner from './sign_in_banner';
const messages = defineMessages({
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
explore: { id: 'explore.title', defaultMessage: 'Explore' },
firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' },
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
});
const NotificationsLink = () => {
const count = useSelector(selectUnreadNotificationGroupsCount);
const intl = useIntl();
return (
<ColumnLink
key='notifications'
transparent
to='/notifications'
icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={count} className='column-link__icon' />}
activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={count} className='column-link__icon' />}
text={intl.formatMessage(messages.notifications)}
/>
);
};
const FollowRequestsLink = () => {
const count = useSelector(state => state.getIn(['user_lists', 'follow_requests', 'items'])?.size ?? 0);
const intl = useIntl();
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchFollowRequests());
}, [dispatch]);
if (count === 0) {
return null;
}
return (
<ColumnLink
transparent
to='/follow_requests'
icon={<IconWithBadge id='user-plus' icon={PersonAddIcon} count={count} className='column-link__icon' />}
activeIcon={<IconWithBadge id='user-plus' icon={PersonAddActiveIcon} count={count} className='column-link__icon' />}
text={intl.formatMessage(messages.followRequests)}
/>
);
};
class NavigationPanel extends Component {
static propTypes = {
identity: identityContextPropShape,
intl: PropTypes.object.isRequired,
};
isFirehoseActive = (match, location) => {
return match || location.pathname.startsWith('/public');
};
render () {
const { intl } = this.props;
const { signedIn, disabledAccountId, permissions } = this.props.identity;
let banner = undefined;
if (transientSingleColumn) {
banner = (
<div className='switch-to-advanced'>
{intl.formatMessage(messages.openedInClassicInterface)}
{" "}
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
{intl.formatMessage(messages.advancedInterface)}
</a>
</div>
);
}
return (
<div className='navigation-panel'>
<div className='navigation-panel__logo'>
<Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
</div>
{banner &&
<div className='navigation-panel__banner'>
{banner}
</div>
}
<div className='navigation-panel__menu'>
{signedIn && (
<>
<ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} />
<NotificationsLink />
<FollowRequestsLink />
</>
)}
{trendsEnabled ? (
<ColumnLink transparent to='/explore' icon='explore' iconComponent={ExploreIcon} activeIconComponent={ExploreActiveIcon} text={intl.formatMessage(messages.explore)} />
) : (
<ColumnLink transparent to='/search' icon='search' iconComponent={SearchIcon} text={intl.formatMessage(messages.search)} />
)}
{(signedIn || timelinePreview) && (
<ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
)}
{!signedIn && (
<div className='navigation-panel__sign-in-banner'>
<hr />
{ disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> }
</div>
)}
{signedIn && (
<>
<ColumnLink transparent to='/conversations' icon='at' iconComponent={AlternateEmailIcon} text={intl.formatMessage(messages.direct)} />
<ColumnLink transparent to='/bookmarks' icon='bookmarks' iconComponent={BookmarksIcon} activeIconComponent={BookmarksActiveIcon} text={intl.formatMessage(messages.bookmarks)} />
<ColumnLink transparent to='/favourites' icon='star' iconComponent={StarIcon} activeIconComponent={StarActiveIcon} text={intl.formatMessage(messages.favourites)} />
<ColumnLink transparent to='/lists' icon='list-ul' iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={intl.formatMessage(messages.lists)} />
<ListPanel />
<hr />
<ColumnLink transparent href='/settings/preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} />
{canManageReports(permissions) && <ColumnLink optional transparent href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />}
{canViewAdminDashboard(permissions) && <ColumnLink optional transparent href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />}
</>
)}
<div className='navigation-panel__legal'>
<hr />
<ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} />
</div>
</div>
<div className='flex-spacer' />
<NavigationPortal />
</div>
);
}
}
export default injectIntl(withIdentity(NavigationPanel));

View file

@ -0,0 +1,495 @@
import { useEffect, useCallback, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { Link, useLocation } from 'react-router-dom';
import type { Map as ImmutableMap } from 'immutable';
import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react';
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
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';
import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { openNavigation, closeNavigation } from 'mastodon/actions/navigation';
import { Account } from 'mastodon/components/account';
import { IconButton } from 'mastodon/components/icon_button';
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { WordmarkLogo } from 'mastodon/components/logo';
import { NavigationPortal } from 'mastodon/components/navigation_portal';
import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
import { useIdentity } from 'mastodon/identity_context';
import { timelinePreview, trendsEnabled, me } from 'mastodon/initial_state';
import { transientSingleColumn } from 'mastodon/is_mobile';
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
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 SignInBanner from './sign_in_banner';
const messages = defineMessages({
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: {
id: 'tabs_bar.notifications',
defaultMessage: 'Notifications',
},
explore: { id: 'explore.title', defaultMessage: 'Explore' },
firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
preferences: {
id: 'navigation_bar.preferences',
defaultMessage: 'Preferences',
},
administration: {
id: 'navigation_bar.administration',
defaultMessage: 'Administration',
},
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
followsAndFollowers: {
id: 'navigation_bar.follows_and_followers',
defaultMessage: 'Follows and followers',
},
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
advancedInterface: {
id: 'navigation_bar.advanced_interface',
defaultMessage: 'Open in advanced web interface',
},
openedInClassicInterface: {
id: 'navigation_bar.opened_in_classic_interface',
defaultMessage:
'Posts, accounts, and other specific pages are opened by default in the classic web interface.',
},
followRequests: {
id: 'navigation_bar.follow_requests',
defaultMessage: 'Follow requests',
},
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
compose: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },
});
const NotificationsLink = () => {
const count = useAppSelector(selectUnreadNotificationGroupsCount);
const intl = useIntl();
return (
<ColumnLink
key='notifications'
transparent
to='/notifications'
icon={
<IconWithBadge
id='bell'
icon={NotificationsIcon}
count={count}
className='column-link__icon'
/>
}
activeIcon={
<IconWithBadge
id='bell'
icon={NotificationsActiveIcon}
count={count}
className='column-link__icon'
/>
}
text={intl.formatMessage(messages.notifications)}
/>
);
};
const FollowRequestsLink: React.FC = () => {
const intl = useIntl();
const count = useAppSelector(
(state) =>
(
state.user_lists.getIn(['follow_requests', 'items']) as
| ImmutableMap<string, unknown>
| undefined
)?.size ?? 0,
);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(fetchFollowRequests());
}, [dispatch]);
if (count === 0) {
return null;
}
return (
<ColumnLink
transparent
to='/follow_requests'
icon={
<IconWithBadge
id='user-plus'
icon={PersonAddIcon}
count={count}
className='column-link__icon'
/>
}
activeIcon={
<IconWithBadge
id='user-plus'
icon={PersonAddActiveIcon}
count={count}
className='column-link__icon'
/>
}
text={intl.formatMessage(messages.followRequests)}
/>
);
};
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={ExploreIcon}
activeIconComponent={ExploreActiveIcon}
text={intl.formatMessage(messages.explore)}
/>
);
};
const ProfileCard: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleLogoutClick = useCallback(() => {
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
}, [dispatch]);
if (!me) {
return null;
}
return (
<div className='navigation-bar'>
<Account id={me} minimal size={36} />
<IconButton
icon='sign-out'
iconComponent={LogoutIcon}
title={intl.formatMessage(messages.logout)}
onClick={handleLogoutClick}
/>
</div>
);
};
const MENU_WIDTH = 284;
export const NavigationPanel: React.FC = () => {
const intl = useIntl();
const { signedIn, disabledAccountId, permissions } = useIdentity();
const open = useAppSelector((state) => state.navigation.open);
const dispatch = useAppDispatch();
const openable = useBreakpoint('openable');
const location = useLocation();
const overlayRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
dispatch(closeNavigation());
}, [dispatch, location]);
useEffect(() => {
const handleDocumentClick = (e: MouseEvent) => {
if (overlayRef.current && e.target === overlayRef.current) {
dispatch(closeNavigation());
}
};
const handleDocumentKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
dispatch(closeNavigation());
}
};
document.addEventListener('click', handleDocumentClick);
document.addEventListener('keyup', handleDocumentKeyUp);
return () => {
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('keyup', handleDocumentKeyUp);
};
}, [dispatch]);
const [{ x }, spring] = useSpring(
() => ({
x: open ? 0 : MENU_WIDTH,
onRest: {
x({ value }: { value: number }) {
if (value === 0) {
dispatch(openNavigation());
} else if (value > 0) {
dispatch(closeNavigation());
}
},
},
}),
[open],
);
const bind = useDrag(
({ last, offset: [ox], velocity: [vx], direction: [dx], cancel }) => {
if (ox < -70) {
cancel();
}
if (last) {
if (ox > MENU_WIDTH / 2 || (vx > 0.5 && dx > 0)) {
void spring.start({ x: MENU_WIDTH });
} else {
void spring.start({ x: 0 });
}
} else {
void spring.start({ x: ox, immediate: true });
}
},
{
from: () => [x.get(), 0],
filterTaps: true,
bounds: { left: 0 },
rubberband: true,
},
);
const isFirehoseActive = useCallback(
(match: unknown, location: { pathname: string }): boolean => {
return !!match || location.pathname.startsWith('/public');
},
[],
);
const previouslyFocusedElementRef = useRef<HTMLElement | null>();
useEffect(() => {
if (open) {
const firstLink = document.querySelector<HTMLAnchorElement>(
'.navigation-panel__menu .column-link',
);
previouslyFocusedElementRef.current =
document.activeElement as HTMLElement;
firstLink?.focus();
} else {
previouslyFocusedElementRef.current?.focus();
}
}, [open]);
let banner = undefined;
if (transientSingleColumn) {
banner = (
<div className='switch-to-advanced'>
{intl.formatMessage(messages.openedInClassicInterface)}{' '}
<a
href={`/deck${location.pathname}`}
className='switch-to-advanced__toggle'
>
{intl.formatMessage(messages.advancedInterface)}
</a>
</div>
);
}
const showOverlay = openable && open;
return (
<div
className={classNames(
'columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational',
{ 'columns-area__panels__pane--overlay': showOverlay },
)}
ref={overlayRef}
>
<animated.div
className='columns-area__panels__pane__inner'
{...bind()}
style={openable ? { x } : undefined}
>
<div className='navigation-panel'>
<div className='navigation-panel__logo'>
<Link to='/' className='column-link column-link--logo'>
<WordmarkLogo />
</Link>
</div>
<ProfileCard />
{banner && <div className='navigation-panel__banner'>{banner}</div>}
<div className='navigation-panel__menu'>
{signedIn && (
<>
<ColumnLink
to='/publish'
icon='plus'
iconComponent={AddIcon}
activeIconComponent={AddIcon}
text={intl.formatMessage(messages.compose)}
className='button navigation-panel__compose-button'
/>
<ColumnLink
transparent
to='/home'
icon='home'
iconComponent={HomeIcon}
activeIconComponent={HomeActiveIcon}
text={intl.formatMessage(messages.home)}
/>
<NotificationsLink />
<FollowRequestsLink />
</>
)}
<SearchLink />
{(signedIn || timelinePreview) && (
<ColumnLink
transparent
to='/public/local'
isActive={isFirehoseActive}
icon='globe'
iconComponent={PublicIcon}
text={intl.formatMessage(messages.firehose)}
/>
)}
{!signedIn && (
<div className='navigation-panel__sign-in-banner'>
<hr />
{disabledAccountId ? (
<DisabledAccountBanner />
) : (
<SignInBanner />
)}
</div>
)}
{signedIn && (
<>
<ColumnLink
transparent
to='/conversations'
icon='at'
iconComponent={AlternateEmailIcon}
text={intl.formatMessage(messages.direct)}
/>
<ColumnLink
transparent
to='/bookmarks'
icon='bookmarks'
iconComponent={BookmarksIcon}
activeIconComponent={BookmarksActiveIcon}
text={intl.formatMessage(messages.bookmarks)}
/>
<ColumnLink
transparent
to='/favourites'
icon='star'
iconComponent={StarIcon}
activeIconComponent={StarActiveIcon}
text={intl.formatMessage(messages.favourites)}
/>
<ListPanel />
<hr />
<ColumnLink
transparent
href='/settings/preferences'
icon='cog'
iconComponent={SettingsIcon}
text={intl.formatMessage(messages.preferences)}
/>
{canManageReports(permissions) && (
<ColumnLink
optional
transparent
href='/admin/reports'
icon='flag'
iconComponent={ModerationIcon}
text={intl.formatMessage(messages.moderation)}
/>
)}
{canViewAdminDashboard(permissions) && (
<ColumnLink
optional
transparent
href='/admin/dashboard'
icon='tachometer'
iconComponent={AdministrationIcon}
text={intl.formatMessage(messages.administration)}
/>
)}
</>
)}
<div className='navigation-panel__legal'>
<hr />
<ColumnLink
transparent
to='/about'
icon='ellipsis-h'
iconComponent={InfoIcon}
text={intl.formatMessage(messages.about)}
/>
</div>
</div>
<div className='flex-spacer' />
<NavigationPortal />
</div>
</animated.div>
</div>
);
};

View file

@ -0,0 +1,53 @@
import { useState, useEffect } from 'react';
const breakpoints = {
openable: 759, // Device width at which the sidebar becomes an openable hamburger menu
full: 1174, // Device width at which all 3 columns can be displayed
};
type Breakpoint = 'openable' | 'full';
export const useBreakpoint = (breakpoint: Breakpoint) => {
const [isMatching, setIsMatching] = useState(false);
useEffect(() => {
const mediaWatcher = window.matchMedia(
`(max-width: ${breakpoints[breakpoint]}px)`,
);
setIsMatching(mediaWatcher.matches);
const handleChange = (e: MediaQueryListEvent) => {
setIsMatching(e.matches);
};
mediaWatcher.addEventListener('change', handleChange);
return () => {
mediaWatcher.removeEventListener('change', handleChange);
};
}, [breakpoint, setIsMatching]);
return isMatching;
};
interface WithBreakpointType {
matchesBreakpoint: boolean;
}
export function withBreakpoint<P>(
Component: React.ComponentType<P & WithBreakpointType>,
breakpoint: Breakpoint = 'full',
) {
const displayName = `withMobileLayout(${Component.displayName ?? Component.name})`;
const ComponentWithBreakpoint = (props: P) => {
const matchesBreakpoint = useBreakpoint(breakpoint);
return <Component matchesBreakpoint={matchesBreakpoint} {...props} />;
};
ComponentWithBreakpoint.displayName = displayName;
return ComponentWithBreakpoint;
}

View file

@ -29,7 +29,7 @@ import { expandHomeTimeline } from '../../actions/timelines';
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state';
import BundleColumnError from './components/bundle_column_error';
import Header from './components/header';
import { NavigationBar } from './components/navigation_bar';
import { UploadArea } from './components/upload_area';
import { HashtagMenuController } from './components/hashtag_menu_controller';
import ColumnsAreaContainer from './containers/columns_area_container';
@ -603,12 +603,11 @@ class UI extends PureComponent {
return (
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
<Header />
<SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}>
{children}
</SwitchingColumnsArea>
<NavigationBar />
{layout !== 'mobile' && <PictureInPicture />}
<AlertsController />
{!disableHoverCards && <HoverCardController />}

View file

@ -207,7 +207,6 @@
"compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
"compose_form.poll.type": "Style",
"compose_form.publish": "Post",
"compose_form.publish_form": "New post",
"compose_form.reply": "Reply",
"compose_form.save_changes": "Update",
"compose_form.spoiler.marked": "Remove content warning",
@ -579,6 +578,8 @@
"navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.search": "Search",
"navigation_bar.security": "Security",
"navigation_panel.collapse_lists": "Collapse list 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}",
"notification.admin.report_account": "{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}",
@ -907,7 +908,10 @@
"subscribed_languages.save": "Save changes",
"subscribed_languages.target": "Change subscribed languages for {target}",
"tabs_bar.home": "Home",
"tabs_bar.menu": "Menu",
"tabs_bar.notifications": "Notifications",
"tabs_bar.publish": "New Post",
"tabs_bar.search": "Search",
"terms_of_service.effective_as_of": "Effective as of {date}",
"terms_of_service.title": "Terms of Service",
"terms_of_service.upcoming_changes_on": "Upcoming changes on {date}",

View file

@ -21,6 +21,7 @@ import { markersReducer } from './markers';
import media_attachments from './media_attachments';
import meta from './meta';
import { modalReducer } from './modal';
import { navigationReducer } from './navigation';
import { notificationGroupsReducer } from './notification_groups';
import { notificationPolicyReducer } from './notification_policy';
import { notificationRequestsReducer } from './notification_requests';
@ -76,6 +77,7 @@ const reducers = {
history,
notificationPolicy: notificationPolicyReducer,
notificationRequests: notificationRequestsReducer,
navigation: navigationReducer,
};
// We want the root state to be an ImmutableRecord, which is an object with a defined list of keys,

View file

@ -0,0 +1,28 @@
import { createReducer } from '@reduxjs/toolkit';
import {
openNavigation,
closeNavigation,
toggleNavigation,
} from 'mastodon/actions/navigation';
interface State {
open: boolean;
}
const initialState: State = {
open: false,
};
export const navigationReducer = createReducer(initialState, (builder) => {
builder
.addCase(openNavigation, (state) => {
state.open = true;
})
.addCase(closeNavigation, (state) => {
state.open = false;
})
.addCase(toggleNavigation, (state) => {
state.open = !state.open;
});
});

View file

@ -1,15 +1,16 @@
import { createSelector } from '@reduxjs/toolkit';
import type { Map as ImmutableMap } from 'immutable';
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import type { List } from 'mastodon/models/list';
import type { RootState } from 'mastodon/store';
import { createAppSelector } from 'mastodon/store';
export const getOrderedLists = createSelector(
[(state: RootState) => state.lists],
(lists: ImmutableMap<string, List | null>) =>
lists
.toList()
.filter((item: List | null) => !!item)
.sort((a: List, b: List) => a.title.localeCompare(b.title))
.toArray(),
const getLists = createAppSelector(
[(state) => state.lists],
(lists: ImmutableMap<string, List | null>): ImmutableList<List> =>
lists.toList().filter((item: List | null): item is List => !!item),
);
export const getOrderedLists = createAppSelector(
[(state) => getLists(state)],
(lists) =>
lists.sort((a: List, b: List) => a.title.localeCompare(b.title)).toArray(),
);

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M560-280 360-480l200-200v400Z"/></svg>

After

Width:  |  Height:  |  Size: 135 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="M560-280 360-480l200-200v400Z"/></svg>

After

Width:  |  Height:  |  Size: 135 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="M360-360v-170l367-367q12-12 27-18t30-6q16 0 30.5 6t26.5 18l56 57q11 12 17 26.5t6 29.5q0 15-5.5 29.5T897-728L530-360H360Zm424-368 57-56-56-56-57 56 56 56ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h357L280-563v283h282l278-278v358q0 33-23.5 56.5T760-120H200Z"/></svg>

After

Width:  |  Height:  |  Size: 379 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="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h357l-80 80H200v560h560v-278l80-80v358q0 33-23.5 56.5T760-120H200Zm280-360ZM360-360v-170l367-367q12-12 27-18t30-6q16 0 30.5 6t26.5 18l56 57q11 12 17 26.5t6 29.5q0 15-5.5 29.5T897-728L530-360H360Zm481-424-56-56 56 56ZM440-440h56l232-232-28-28-29-28-231 231v57Zm260-260-29-28 29 28 28 28-28-28Z"/></svg>

After

Width:  |  Height:  |  Size: 458 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="m356-160-56-56 180-180 180 180-56 56-124-124-124 124Zm124-404L300-744l56-56 124 124 124-124 56 56-180 180Z"/></svg>

After

Width:  |  Height:  |  Size: 212 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="m356-160-56-56 180-180 180 180-56 56-124-124-124 124Zm124-404L300-744l56-56 124 124 124-124 56 56-180 180Z"/></svg>

After

Width:  |  Height:  |  Size: 212 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-120 300-300l58-58 122 122 122-122 58 58-180 180ZM358-598l-58-58 180-180 180 180-58 58-122-122-122 122Z"/></svg>

After

Width:  |  Height:  |  Size: 213 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-120 300-300l58-58 122 122 122-122 58 58-180 180ZM358-598l-58-58 180-180 180 180-58 58-122-122-122 122Z"/></svg>

After

Width:  |  Height:  |  Size: 213 B

View file

@ -2644,15 +2644,13 @@ a.account__display-name {
min-width: 0;
&__display-name {
font-size: 16px;
line-height: 24px;
letter-spacing: 0.15px;
font-size: 14px;
line-height: 20px;
font-weight: 500;
.display-name__account {
font-size: 14px;
line-height: 20px;
letter-spacing: 0.1px;
font-weight: 400;
}
}
}
@ -2889,67 +2887,69 @@ a.account__display-name {
}
}
$ui-header-height: 55px;
$ui-header-logo-wordmark-width: 99px;
.ui__header {
display: none;
box-sizing: border-box;
height: $ui-header-height;
.ui__navigation-bar {
position: sticky;
top: 0;
z-index: 3;
justify-content: space-between;
align-items: center;
bottom: 0;
background: var(--background-color);
backdrop-filter: var(--background-filter);
border-top: 1px solid var(--background-border-color);
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-bottom: env(safe-area-inset-bottom);
&__logo {
display: inline-flex;
padding: 15px;
flex-grow: 1;
flex-shrink: 1;
overflow: hidden;
container: header-logo / inline-size;
.layout-multiple-columns & {
display: none;
}
.logo {
height: $ui-header-height - 30px;
width: auto;
}
&__items {
display: grid;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
padding: 0 16px;
.logo--wordmark {
display: none;
}
@container header-logo (min-width: #{$ui-header-logo-wordmark-width}) {
.logo--wordmark {
display: block;
}
.logo--icon {
display: none;
}
&.active {
flex: 1;
padding: 0;
}
}
&__links {
&__sign-up {
display: flex;
align-items: center;
gap: 10px;
padding: 0 10px;
overflow: hidden;
flex-shrink: 0;
gap: 4px;
padding-inline-start: 16px;
}
.button {
flex: 0 0 auto;
&__item {
display: flex;
flex-direction: column;
align-items: center;
background: transparent;
border: none;
gap: 8px;
font-size: 12px;
font-weight: 500;
line-height: 16px;
padding-top: 11px;
padding-bottom: 15px;
border-top: 4px solid transparent;
text-decoration: none;
color: inherit;
&.active {
color: $highlight-text-color;
}
.button-tertiary {
flex-shrink: 1;
&:focus {
outline: 0;
}
.icon {
width: 22px;
height: 22px;
&:focus-visible {
border-top-color: $ui-button-focus-outline-color;
border-radius: 0;
}
}
}
@ -2958,13 +2958,12 @@ $ui-header-logo-wordmark-width: 99px;
background: var(--background-color);
backdrop-filter: var(--background-filter);
position: sticky;
top: $ui-header-height;
top: 0;
z-index: 2;
padding-top: 0;
@media screen and (min-width: $no-gap-breakpoint) {
padding-top: 10px;
top: 0;
}
}
@ -3133,8 +3132,10 @@ $ui-header-logo-wordmark-width: 99px;
display: none;
}
.navigation-panel__legal {
display: none;
.navigation-panel__legal,
.navigation-panel__compose-button,
.navigation-panel .navigation-bar {
display: none !important;
}
}
@ -3146,7 +3147,7 @@ $ui-header-logo-wordmark-width: 99px;
}
.columns-area__panels {
min-height: calc(100vh - $ui-header-height);
min-height: 100vh;
gap: 0;
}
@ -3164,24 +3165,14 @@ $ui-header-logo-wordmark-width: 99px;
}
.navigation-panel__sign-in-banner,
.navigation-panel__logo,
.navigation-panel__banner,
.getting-started__trends {
.getting-started__trends,
.navigation-panel__logo {
display: none;
}
.column-link__icon {
font-size: 18px;
}
}
.layout-single-column {
.ui__header {
display: flex;
background: var(--background-color);
border-bottom: 1px solid var(--background-border-color);
}
.column > .scrollable,
.tabs-bar__wrapper .column-header,
.tabs-bar__wrapper .column-back-button,
@ -3205,30 +3196,64 @@ $ui-header-logo-wordmark-width: 99px;
}
}
@media screen and (max-width: $no-gap-breakpoint - 285px - 1px) {
$sidebar-width: 55px;
@media screen and (width <= 759px) {
.columns-area__panels__main {
width: calc(100% - $sidebar-width);
width: 100%;
}
.columns-area__panels__pane--navigational {
min-width: $sidebar-width;
position: fixed;
inset-inline-end: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.columns-area__panels__pane__inner {
width: $sidebar-width;
}
.columns-area__panels__pane--navigational .columns-area__panels__pane__inner {
pointer-events: auto;
background: var(--background-color);
position: fixed;
width: 284px + 70px;
inset-inline-end: -70px;
touch-action: pan-y;
.column-link span {
display: none;
}
.navigation-panel {
width: 284px;
overflow-y: auto;
.list-panel {
display: none;
&__menu {
flex-shrink: 0;
min-height: none;
overflow: hidden;
padding-bottom: calc(65px + env(safe-area-inset-bottom));
}
&__logo {
display: none;
}
}
}
}
.columns-area__panels__pane--navigational {
transition: background 500ms;
}
.columns-area__panels__pane--overlay {
pointer-events: auto;
background: rgba($base-overlay-background, 0.5);
.columns-area__panels__pane__inner {
box-shadow: var(--dropdown-shadow);
}
}
@media screen and (width >= 760px) {
.ui__navigation-bar {
display: none;
}
}
.explore__suggestions__card {
padding: 12px 16px;
gap: 8px;
@ -3455,6 +3480,49 @@ $ui-header-logo-wordmark-width: 99px;
overflow-y: auto;
}
&__list-panel {
&__header {
display: flex;
align-items: center;
padding-inline-end: 12px;
.column-link {
flex: 1 1 auto;
}
}
&__items {
padding-inline-start: 24px + 5px;
.icon {
display: none;
}
}
}
&__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;
margin: 12px;
margin-bottom: 4px;
border-radius: 6px;
.icon {
width: 24px;
height: 24px;
}
}
.navigation-bar {
padding: 16px;
border-bottom: 1px solid var(--background-border-color);
}
.logo {
height: 30px;
width: auto;
@ -3487,12 +3555,6 @@ $ui-header-logo-wordmark-width: 99px;
display: none;
}
}
@media screen and (height <= 1040px) {
.list-panel {
display: none;
}
}
}
.navigation-panel,
@ -4336,6 +4398,10 @@ a.status-card {
&:focus-visible {
outline: $ui-button-icon-focus-outline;
}
.logo {
height: 24px;
}
}
.column-header__back-button + &__title {
@ -4419,10 +4485,6 @@ a.status-card {
&:hover {
color: $primary-text-color;
}
.icon-sliders {
transform: rotate(60deg);
}
}
&:disabled {