Merge remote-tracking branch 'parent/main' into kbtopic-remove-quote

This commit is contained in:
KMY 2025-06-12 10:17:21 +09:00
commit f3c3ea42c2
301 changed files with 6618 additions and 3070 deletions

View file

@ -1,52 +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, children, ...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;
const childElement = typeof children !== 'undefined' ? <p>{children}</p> : null;
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}
{childElement}
</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,
children: PropTypes.any,
optional: PropTypes.bool,
};
export default ColumnLink;

View file

@ -0,0 +1,109 @@
import { useCallback } from 'react';
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;
onClick?: () => void;
method?: string;
badge?: React.ReactNode;
transparent?: boolean;
optional?: boolean;
children?: React.ReactNode;
className?: string;
id?: string;
}> = ({
icon,
activeIcon,
iconComponent,
activeIconComponent,
text,
to,
href,
onClick,
method,
badge,
transparent,
optional,
children,
...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;
const childElement = typeof children !== 'undefined' ? <p>{children}</p> : null;
const handleClick = useCallback((ev: React.MouseEvent<HTMLAnchorElement>) => {
ev.preventDefault();
onClick?.();
}, [onClick]);
if (href) {
return (
<a href={href} className={className} data-method={method} {...other}>
{active ? activeIconElement : iconElement}
<span>{text}</span>
{badgeElement}
{childElement}
</a>
);
} else if (to) {
return (
<NavLink to={to} className={className} {...other}>
{active ? activeIconElement : iconElement}
<span>{text}</span>
{badgeElement}
{childElement}
</NavLink>
);
} else if (onClick) {
return (
<a href={href} className={className} onClick={handleClick} {...other}>
{active ? activeIconElement : iconElement}
<span>{text}</span>
{badgeElement}
{childElement}
</a>
);
} else {
return null;
}
};

View file

@ -28,9 +28,9 @@ import { useColumnsContext } from '../util/columns_context';
import BundleColumnError from './bundle_column_error';
import { ColumnLoading } from './column_loading';
import ComposePanel from './compose_panel';
import { ComposePanel } from './compose_panel';
import DrawerLoading from './drawer_loading';
import NavigationPanel from './navigation_panel';
import { NavigationPanel } from './navigation_panel';
const componentMap = {
'COMPOSE': Compose,
@ -142,11 +142,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,64 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { connect } from 'react-redux';
import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
import ServerBanner from 'mastodon/components/server_banner';
import { Search } from 'mastodon/features/compose/components/search';
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
class ComposePanel extends PureComponent {
static propTypes = {
identity: identityContextPropShape,
dispatch: PropTypes.func.isRequired,
};
onFocus = () => {
const { dispatch } = this.props;
dispatch(changeComposing(true));
};
onBlur = () => {
const { dispatch } = this.props;
dispatch(changeComposing(false));
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(mountCompose());
}
componentWillUnmount () {
const { dispatch } = this.props;
dispatch(unmountCompose());
}
render() {
const { signedIn } = this.props.identity;
return (
<div className='compose-panel' onFocus={this.onFocus}>
<Search openInRoute />
{!signedIn && (
<>
<ServerBanner />
<div className='flex-spacer' />
</>
)}
{signedIn && (
<ComposeFormContainer singleColumn />
)}
<LinkFooter />
</div>
);
}
}
export default connect()(withIdentity(ComposePanel));

View file

@ -0,0 +1,56 @@
import { useCallback, useEffect } from 'react';
import { useLayout } from '@/mastodon/hooks/useLayout';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import {
changeComposing,
mountCompose,
unmountCompose,
} from 'mastodon/actions/compose';
import ServerBanner from 'mastodon/components/server_banner';
import { Search } from 'mastodon/features/compose/components/search';
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
import { useIdentity } from 'mastodon/identity_context';
export const ComposePanel: React.FC = () => {
const dispatch = useAppDispatch();
const handleFocus = useCallback(() => {
dispatch(changeComposing(true));
}, [dispatch]);
const { signedIn } = useIdentity();
const hideComposer = useAppSelector((state) => {
const mounted = state.compose.get('mounted');
if (typeof mounted === 'number') {
return mounted > 1;
}
return false;
});
useEffect(() => {
dispatch(mountCompose());
return () => {
dispatch(unmountCompose());
};
}, [dispatch]);
const { singleColumn } = useLayout();
return (
<div className='compose-panel' onFocus={handleFocus}>
<Search singleColumn={singleColumn} />
{!signedIn && (
<>
<ServerBanner />
<div className='flex-spacer' />
</>
)}
{signedIn && !hideComposer && <ComposeFormContainer singleColumn />}
{signedIn && hideComposer && <div className='compose-form' />}
<LinkFooter multiColumn={!singleColumn} />
</div>
);
};

View file

@ -1,129 +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 RefreshIcon from '@/material-icons/400-24px/refresh.svg?react';
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' },
reload: { id: 'navigation_bar.refresh', defaultMessage: 'Refresh' },
});
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();
}
handleReload (e) {
e.preventDefault();
window.location.reload();
}
render () {
const { signedIn } = this.props.identity;
const { location, openClosedRegistrationsModal, signupUrl, intl } = this.props;
let content;
if (signedIn) {
content = (
<>
{<button onClick={this.handleReload} className='button button-secondary' aria-label={intl.formatMessage(messages.reload)}><Icon id='refresh' icon={RefreshIcon} /></button>}
{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,57 +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 AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { fetchAntennas } from 'mastodon/actions/antennas';
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 && item.get('favourite')).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(8);
});
const getOrderedAntennas = createSelector([state => state.get('antennas')], antennas => {
if (!antennas) {
return antennas;
}
return antennas.toList().filter(item => !!item && item.get('favourite') && item.get('title') !== undefined).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(8);
});
export const ListPanel = () => {
const dispatch = useDispatch();
const lists = useSelector(state => getOrderedLists(state));
const antennas = useSelector(state => getOrderedAntennas(state));
useEffect(() => {
dispatch(fetchLists());
dispatch(fetchAntennas());
}, [dispatch]);
const size = (lists ? lists.size : 0) + (antennas ? antennas.size : 0);
if (size === 0) {
return null;
}
return (
<div className='list-panel'>
<hr />
{lists && 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 />
))}
{antennas && antennas.map(antenna => (
<ColumnLink icon='wifi' key={antenna.get('id')} iconComponent={AntennaIcon} activeIconComponent={AntennaIcon} text={antenna.get('title')} to={`/antennas/${antenna.get('id')}`} transparent />
))}
</div>
);
};

View file

@ -0,0 +1,152 @@
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 AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { fetchAntennas } from 'mastodon/actions/antennas';
import { fetchLists } from 'mastodon/actions/lists';
import { IconButton } from 'mastodon/components/icon_button';
import { getOrderedAntennas } from 'mastodon/selectors/antennas';
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' },
antennas: { id: 'navigation_bar.antennas', defaultMessage: 'Antennas' },
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 antennas = useAppSelector(state => getOrderedAntennas(state));
const [expanded, setExpanded] = useState(false);
const [expandedAntenna, setExpandedAntenna] = useState(false);
const accessibilityId = useId();
useEffect(() => {
dispatch(fetchLists());
dispatch(fetchAntennas());
}, [dispatch]);
const handleClick = useCallback(() => {
setExpanded((value) => !value);
}, [setExpanded]);
const handleClickAntenna = useCallback(() => {
setExpandedAntenna((value) => !value);
}, [setExpandedAntenna]);
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>
<div className='navigation-panel__list-panel'>
<div className='navigation-panel__list-panel__header'>
<ColumnLink
transparent
to='/antennas'
icon='list-ul'
iconComponent={AntennaIcon}
activeIconComponent={AntennaIcon}
text={intl.formatMessage(messages.antennas)}
id={`${accessibilityId}-title`}
/>
{antennas.length > 0 && (
<IconButton
icon='down'
expanded={expanded}
iconComponent={expanded ? ArrowDropDownIcon : ArrowLeftIcon}
title={intl.formatMessage(
expanded ? messages.collapse : messages.expand,
)}
onClick={handleClickAntenna}
aria-controls={`${accessibilityId}-content`}
/>
)}
</div>
{antennas.length > 0 && expandedAntenna && (
<div
className='navigation-panel__list-panel__items'
role='region'
id={`${accessibilityId}-content`}
aria-labelledby={`${accessibilityId}-title`}
>
{antennas.map((antenna) => (
<ColumnLink
icon='list-ul'
key={antenna.get('id')}
iconComponent={AntennaIcon}
activeIconComponent={AntennaIcon}
text={antenna.get('title')}
to={`/antennas/${antenna.get('id')}`}
transparent
/>
))}
</div>
)}
</div>
</>
);
};

View file

@ -168,8 +168,8 @@ class MediaModal extends ImmutablePureComponent {
const index = this.getIndex();
const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--prev' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--next' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
const content = media.map((image, idx) => {
const width = image.getIn(['meta', 'original', 'width']) || null;

View file

@ -0,0 +1,148 @@
import { useMemo } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { enableEmojiReaction } from '@/mastodon/initial_state';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { openModal } from 'mastodon/actions/modal';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import { useIdentity } from 'mastodon/identity_context';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
import { useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
followedTags: {
id: 'navigation_bar.followed_tags',
defaultMessage: 'Followed hashtags',
},
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domainBlocks: {
id: 'navigation_bar.domain_blocks',
defaultMessage: 'Blocked domains',
},
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
administration: {
id: 'navigation_bar.administration',
defaultMessage: 'Administration',
},
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
automatedDeletion: {
id: 'navigation_bar.automated_deletion',
defaultMessage: 'Automated post deletion',
},
accountSettings: {
id: 'navigation_bar.account_settings',
defaultMessage: 'Password and security',
},
importExport: {
id: 'navigation_bar.import_export',
defaultMessage: 'Import and export',
},
privacyAndReach: {
id: 'navigation_bar.privacy_and_reach',
defaultMessage: 'Privacy and reach',
},
reaction_deck: {
id: 'navigation_bar.reaction_deck',
defaultMessage: 'Reaction deck',
},
emoji_reactions: {
id: 'navigation_bar.emoji_reactions',
defaultMessage: 'Emoji reactions',
},
});
export const MoreLink: React.FC = () => {
const intl = useIntl();
const { permissions } = useIdentity();
const dispatch = useAppDispatch();
const emojiReactionMenu = useMemo(() => {
if (!enableEmojiReaction) return [];
return [{
text: intl.formatMessage(messages.emoji_reactions),
to: '/emoji_reactions',
}];
}, [enableEmojiReaction, intl]);
const menu = useMemo(() => {
const arr: MenuItem[] = [
{
text: intl.formatMessage(messages.followedTags),
to: '/followed_tags',
},
...emojiReactionMenu,
{
text: intl.formatMessage(messages.reaction_deck),
to: '/reaction_deck',
},
null,
{ text: intl.formatMessage(messages.filters), href: '/filters' },
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
{
text: intl.formatMessage(messages.domainBlocks),
to: '/domain_blocks',
},
];
arr.push(
null,
{
href: '/settings/privacy',
text: intl.formatMessage(messages.privacyAndReach),
},
{
href: '/statuses_cleanup',
text: intl.formatMessage(messages.automatedDeletion),
},
{
href: '/auth/edit',
text: intl.formatMessage(messages.accountSettings),
},
{
href: '/settings/export',
text: intl.formatMessage(messages.importExport),
},
);
if (canManageReports(permissions)) {
arr.push(null, {
href: '/admin/reports',
text: intl.formatMessage(messages.moderation),
});
}
if (canViewAdminDashboard(permissions)) {
arr.push({
href: '/admin/dashboard',
text: intl.formatMessage(messages.administration),
});
}
const handleLogoutClick = () => {
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
};
arr.push(null, {
text: intl.formatMessage(messages.logout),
action: handleLogoutClick,
});
return arr;
}, [intl, dispatch, permissions, emojiReactionMenu]);
return (
<Dropdown items={menu}>
<button className='column-link column-link--transparent'>
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' />
<FormattedMessage id='navigation_bar.more' defaultMessage='More' />
</button>
</Dropdown>
);
};

View file

@ -0,0 +1,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,244 +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 CirclesIcon from '@/material-icons/400-24px/account_circle-fill.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 PeopleIcon from '@/material-icons/400-24px/group.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 AntennaIcon from '@/material-icons/400-24px/wifi.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 { enableDtlMenu, timelinePreview, trendsEnabled, dtlTag, enableLocalTimeline, isHideItem } 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' },
local: { id: 'column.local', defaultMessage: 'Local' },
deepLocal: { id: 'column.deep_local', defaultMessage: 'Deep' },
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' },
antennas: { id: 'navigation_bar.antennas', defaultMessage: 'Antennas' },
circles: { id: 'navigation_bar.circles', defaultMessage: 'Circles' },
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')) && !location.pathname.endsWith('/fixed');
};
isAntennasActive = (match, location) => {
return (match || location.pathname.startsWith('/antennas'));
};
render () {
const { intl } = this.props;
const { signedIn, disabledAccountId, permissions } = this.props.identity;
const explorer = (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)} />
));
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 />
</>
)}
{signedIn && enableLocalTimeline && (
<ColumnLink transparent to='/public/local/fixed' icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.local)} />
)}
{signedIn && enableDtlMenu && dtlTag && (
<ColumnLink transparent to={`/tags/${dtlTag}`} icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.deepLocal)} />
)}
{!signedIn && explorer}
{signedIn && (
<ColumnLink transparent to='/public' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
)}
{(!signedIn && timelinePreview) && (
<ColumnLink transparent to={enableLocalTimeline ? '/public/local' : '/public'} isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
)}
{signedIn && (
<>
<ListPanel />
<hr />
</>
)}
{signedIn && (
<>
<ColumnLink transparent to='/lists' icon='list-ul' iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={intl.formatMessage(messages.lists)} />
<ColumnLink transparent to='/antennas' icon='wifi' iconComponent={AntennaIcon} text={intl.formatMessage(messages.antennas)} isActive={this.isAntennasActive} />
<ColumnLink transparent to='/circles' icon='user-circle' iconComponent={CirclesIcon} text={intl.formatMessage(messages.circles)} />
<FollowRequestsLink />
<ColumnLink transparent to='/conversations' icon='at' iconComponent={AlternateEmailIcon} text={intl.formatMessage(messages.direct)} />
</>
)}
{signedIn && explorer}
{signedIn && (
<>
<ColumnLink transparent to='/bookmark_categories' icon='bookmarks' iconComponent={BookmarksIcon} activeIconComponent={BookmarksActiveIcon} text={intl.formatMessage(messages.bookmarks)} />
{ !isHideItem('favourite_menu') && <ColumnLink transparent to='/favourites' icon='star' iconComponent={StarIcon} activeIconComponent={StarActiveIcon} text={intl.formatMessage(messages.favourites)} /> }
<hr />
<ColumnLink transparent href='/settings/preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} />
{canManageReports(permissions) && <ColumnLink transparent href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />}
{canViewAdminDashboard(permissions) && <ColumnLink transparent href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />}
</>
)}
{!signedIn && (
<div className='navigation-panel__sign-in-banner'>
<hr />
{ disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> }
</div>
)}
<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,515 @@
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 CirclesIcon from '@/material-icons/400-24px/account_circle-fill.svg?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 PeopleIcon from '@/material-icons/400-24px/group.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 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 RefreshIcon from '@/material-icons/400-24px/refresh.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 { me, enableDtlMenu, timelinePreview, trendsEnabled, dtlTag, enableLocalTimeline } from 'mastodon/initial_state';
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';
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',
},
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' },
local: { id: 'column.local', defaultMessage: 'Local' },
deepLocal: { id: 'column.deep_local', defaultMessage: 'Deep' },
circles: { id: 'navigation_bar.circles', defaultMessage: 'Circles' },
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
});
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 } = 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 => {
if (location.pathname.startsWith('/public/local/fixed')) return false;
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 handleRefresh = useCallback(() => { window.location.reload(); }, []);
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)}
/>
{enableLocalTimeline && (
<ColumnLink
transparent
to='/public/local/fixed'
icon='users'
iconComponent={PeopleIcon}
activeIconComponent={PeopleIcon}
text={intl.formatMessage(messages.local)}
/>
)}
{enableDtlMenu && (
<ColumnLink
transparent
to={`/tags/${dtlTag}`}
icon='users'
iconComponent={PeopleIcon}
activeIconComponent={PeopleIcon}
text={intl.formatMessage(messages.deepLocal)}
/>
)}
<NotificationsLink />
<FollowRequestsLink />
</>
)}
<ListPanel />
<SearchLink />
{(signedIn || timelinePreview) && (
<ColumnLink
transparent
to={((signedIn || !enableLocalTimeline) ? '/public' : '/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='/circles'
icon='user-circle'
iconComponent={CirclesIcon}
text={intl.formatMessage(messages.circles)}
/>
<ColumnLink
transparent
to='/bookmark_categories'
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)}
/>
<hr />
<ColumnLink
transparent
onClick={handleRefresh}
icon='cog'
iconComponent={RefreshIcon}
text={intl.formatMessage(messages.refresh)}
className='column-link column-link__transparent navigation-panel__refresh'
/>
<ColumnLink
transparent
href='/settings/preferences'
icon='cog'
iconComponent={SettingsIcon}
text={intl.formatMessage(messages.preferences)}
/>
<MoreLink />
</>
)}
<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

@ -4,8 +4,6 @@ import { FormattedMessage } from 'react-intl';
import { animated, config, useSpring } from '@react-spring/web';
import { reduceMotion } from 'mastodon/initial_state';
interface UploadAreaProps {
active?: boolean;
onClose: () => void;
@ -39,7 +37,6 @@ export const UploadArea: React.FC<UploadAreaProps> = ({ active, onClose }) => {
opacity: 1,
},
reverse: !active,
immediate: reduceMotion,
});
const backgroundAnimStyles = useSpring({
from: {
@ -50,7 +47,6 @@ export const UploadArea: React.FC<UploadAreaProps> = ({ active, onClose }) => {
},
reverse: !active,
config: config.wobbly,
immediate: reduceMotion,
});
return (