diff --git a/app/javascript/mastodon/actions/navigation.ts b/app/javascript/mastodon/actions/navigation.ts new file mode 100644 index 0000000000..663a1c1bce --- /dev/null +++ b/app/javascript/mastodon/actions/navigation.ts @@ -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'); diff --git a/app/javascript/mastodon/components/column_header.tsx b/app/javascript/mastodon/components/column_header.tsx index ec946cab3e..3a8d245b2a 100644 --- a/app/javascript/mastodon/components/column_header.tsx +++ b/app/javascript/mastodon/components/column_header.tsx @@ -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 = ({ onClick={handleToggleClick} > - + {collapseIssues && } diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx index 8ec665bbd8..cd0234e39a 100644 --- a/app/javascript/mastodon/components/icon_button.tsx +++ b/app/javascript/mastodon/components/icon_button.tsx @@ -27,6 +27,7 @@ interface Props { counter?: number; href?: string; ariaHidden?: boolean; + ariaControls?: string; } export const IconButton = forwardRef( @@ -52,6 +53,7 @@ export const IconButton = forwardRef( overlay = false, tabIndex = 0, ariaHidden = false, + ariaControls, }, buttonRef, ) => { @@ -153,6 +155,7 @@ export const IconButton = forwardRef( aria-label={title} aria-expanded={expanded} aria-hidden={ariaHidden} + aria-controls={ariaControls} title={title} className={classes} onClick={handleClick} diff --git a/app/javascript/mastodon/components/icon_with_badge.tsx b/app/javascript/mastodon/components/icon_with_badge.tsx index c6ab34479c..3469fec338 100644 --- a/app/javascript/mastodon/components/icon_with_badge.tsx +++ b/app/javascript/mastodon/components/icon_with_badge.tsx @@ -7,7 +7,7 @@ interface Props { id: string; icon: IconProp; count: number; - issueBadge: boolean; + issueBadge?: boolean; className: string; } export const IconWithBadge: React.FC = ({ diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.jsx b/app/javascript/mastodon/features/compose/components/navigation_bar.tsx similarity index 59% rename from app/javascript/mastodon/features/compose/components/navigation_bar.jsx rename to app/javascript/mastodon/features/compose/components/navigation_bar.tsx index 38382b0ca0..df1c0a129d 100644 --- a/app/javascript/mastodon/features/compose/components/navigation_bar.jsx +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.tsx @@ -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 (
- {isReplying ? : } + {isReplying ? ( + + ) : ( + + )}
); }; diff --git a/app/javascript/mastodon/features/compose/index.jsx b/app/javascript/mastodon/features/compose/index.jsx deleted file mode 100644 index 660f08615b..0000000000 --- a/app/javascript/mastodon/features/compose/index.jsx +++ /dev/null @@ -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 ( -
- - - {multiColumn && } - -
-
- - -
- -
-
-
-
- ); - } - - return ( - - - - - - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(Compose)); diff --git a/app/javascript/mastodon/features/compose/index.tsx b/app/javascript/mastodon/features/compose/index.tsx new file mode 100644 index 0000000000..54776c98ff --- /dev/null +++ b/app/javascript/mastodon/features/compose/index.tsx @@ -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).get( + 'columns', + ) as ImmutableList, + ); + + 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 ( +
+ + + + +
+
+ + +
+ +
+
+
+
+ ); + } + + return ( + + + +
+ +
+ + + + +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default Compose; diff --git a/app/javascript/mastodon/features/explore/index.tsx b/app/javascript/mastodon/features/explore/index.tsx index 671d92d6b4..c6f65a09ac 100644 --- a/app/javascript/mastodon/features/explore/index.tsx +++ b/app/javascript/mastodon/features/explore/index.tsx @@ -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(null); + const logoRequired = useBreakpoint('full'); const handleHeaderClick = useCallback(() => { columnRef.current?.scrollTop(); @@ -38,7 +41,7 @@ const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { > { @@ -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 { { - const match = useRouteMatch(to); - const className = classNames('column-link', { 'column-link--transparent': transparent, 'column-link--optional': optional }); - const badgeElement = typeof badge !== 'undefined' ? {badge} : null; - const iconElement = (typeof icon === 'string' || iconComponent) ? : icon; - const activeIconElement = activeIcon ?? (activeIconComponent ? : iconElement); - const active = match?.isExact; - - if (href) { - return ( - - {active ? activeIconElement : iconElement} - {text} - {badgeElement} - - ); - } else { - return ( - - {active ? activeIconElement : iconElement} - {text} - {badgeElement} - - ); - } -}; - -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; diff --git a/app/javascript/mastodon/features/ui/components/column_link.tsx b/app/javascript/mastodon/features/ui/components/column_link.tsx new file mode 100644 index 0000000000..d322c2e4c4 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/column_link.tsx @@ -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' ? ( + {badge} + ) : null; + const iconElement = iconComponent ? ( + + ) : ( + icon + ); + const activeIconElement = + activeIcon ?? + (activeIconComponent ? ( + + ) : ( + iconElement + )); + const active = !!match; + + if (href) { + return ( + + {active ? activeIconElement : iconElement} + {text} + {badgeElement} + + ); + } else if (to) { + return ( + + {active ? activeIconElement : iconElement} + {text} + {badgeElement} + + ); + } else { + return null; + } +}; diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx index 4901ee2182..5d596b7bcb 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.jsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx @@ -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 {
{children}
-
-
- -
-
+ ); } diff --git a/app/javascript/mastodon/features/ui/components/header.jsx b/app/javascript/mastodon/features/ui/components/header.jsx deleted file mode 100644 index 19c76c722b..0000000000 --- a/app/javascript/mastodon/features/ui/components/header.jsx +++ /dev/null @@ -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 }) => ( - - - -)); - -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' && } - {location.pathname !== '/publish' && } - - - ); - } else { - - if (sso_redirect) { - content = ( - - ); - } else { - let signupButton; - - if (registrationsOpen) { - signupButton = ( - - - - ); - } else { - signupButton = ( - - ); - } - - content = ( - <> - {signupButton} - - - ); - } - } - - return ( -
- - - - - -
- {content} -
-
- ); - } - -} - -export default injectIntl(withRouter(withIdentity(connect(mapStateToProps, mapDispatchToProps)(Header)))); diff --git a/app/javascript/mastodon/features/ui/components/list_panel.jsx b/app/javascript/mastodon/features/ui/components/list_panel.jsx deleted file mode 100644 index 03c8fce9e8..0000000000 --- a/app/javascript/mastodon/features/ui/components/list_panel.jsx +++ /dev/null @@ -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 ( -
-
- - {lists.map(list => ( - - ))} -
- ); -}; diff --git a/app/javascript/mastodon/features/ui/components/list_panel.tsx b/app/javascript/mastodon/features/ui/components/list_panel.tsx new file mode 100644 index 0000000000..ef4cdad2cc --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/list_panel.tsx @@ -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 ( +
+
+ + + {lists.length > 0 && ( + + )} +
+ + {lists.length > 0 && expanded && ( +
+ {lists.map((list) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/app/javascript/mastodon/features/ui/components/navigation_bar.tsx b/app/javascript/mastodon/features/ui/components/navigation_bar.tsx new file mode 100644 index 0000000000..dbb70f9ec8 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/navigation_bar.tsx @@ -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 ( + + {match && activeIcon ? activeIcon : icon} + + ); +}; + +const NotificationsButton = () => { + const count = useAppSelector(selectUnreadNotificationGroupsCount); + const intl = useIntl(); + + return ( + + } + activeIcon={ + + } + 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 ( +
+ + + +
+ ); + } else { + let signupButton; + + if (registrationsOpen) { + signupButton = ( + + + + ); + } else { + signupButton = ( + + ); + } + + return ( +
+ {signupButton} + + + +
+ ); + } +}; + +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 ( +
+ {!signedIn && } + +
+ {signedIn && ( + <> + } + activeIcon={} + /> + } + /> + } + /> + + + )} + + +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx deleted file mode 100644 index 8fa20a554d..0000000000 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx +++ /dev/null @@ -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 ( - } - activeIcon={} - 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 ( - } - activeIcon={} - 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 = ( -
- {intl.formatMessage(messages.openedInClassicInterface)} - {" "} - - {intl.formatMessage(messages.advancedInterface)} - -
- ); - } - - return ( -
-
- -
- - {banner && -
- {banner} -
- } - -
- {signedIn && ( - <> - - - - - )} - - {trendsEnabled ? ( - - ) : ( - - )} - - {(signedIn || timelinePreview) && ( - - )} - - {!signedIn && ( -
-
- { disabledAccountId ? : } -
- )} - - {signedIn && ( - <> - - - - - - - -
- - - - {canManageReports(permissions) && } - {canViewAdminDashboard(permissions) && } - - )} - -
-
- -
-
- -
- - -
- ); - } - -} - -export default injectIntl(withIdentity(NavigationPanel)); diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.tsx b/app/javascript/mastodon/features/ui/components/navigation_panel.tsx new file mode 100644 index 0000000000..61e4f2c1b1 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.tsx @@ -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 ( + + } + activeIcon={ + + } + 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 + | undefined + )?.size ?? 0, + ); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(fetchFollowRequests()); + }, [dispatch]); + + if (count === 0) { + return null; + } + + return ( + + } + activeIcon={ + + } + text={intl.formatMessage(messages.followRequests)} + /> + ); +}; + +const SearchLink: React.FC = () => { + const intl = useIntl(); + const showAsSearch = useBreakpoint('full'); + + if (!trendsEnabled || showAsSearch) { + return ( + + ); + } + + return ( + + ); +}; + +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 ( +
+ + +
+ ); +}; + +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(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(); + + useEffect(() => { + if (open) { + const firstLink = document.querySelector( + '.navigation-panel__menu .column-link', + ); + previouslyFocusedElementRef.current = + document.activeElement as HTMLElement; + firstLink?.focus(); + } else { + previouslyFocusedElementRef.current?.focus(); + } + }, [open]); + + let banner = undefined; + + if (transientSingleColumn) { + banner = ( +
+ {intl.formatMessage(messages.openedInClassicInterface)}{' '} + + {intl.formatMessage(messages.advancedInterface)} + +
+ ); + } + + const showOverlay = openable && open; + + return ( +
+ +
+
+ + + +
+ + + + {banner &&
{banner}
} + +
+ {signedIn && ( + <> + + + + + + )} + + + + {(signedIn || timelinePreview) && ( + + )} + + {!signedIn && ( +
+
+ {disabledAccountId ? ( + + ) : ( + + )} +
+ )} + + {signedIn && ( + <> + + + + + + +
+ + + + {canManageReports(permissions) && ( + + )} + {canViewAdminDashboard(permissions) && ( + + )} + + )} + +
+
+ +
+
+ +
+ + +
+ +
+ ); +}; diff --git a/app/javascript/mastodon/features/ui/hooks/useBreakpoint.tsx b/app/javascript/mastodon/features/ui/hooks/useBreakpoint.tsx new file mode 100644 index 0000000000..af96ab3766 --- /dev/null +++ b/app/javascript/mastodon/features/ui/hooks/useBreakpoint.tsx @@ -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

( + Component: React.ComponentType

, + breakpoint: Breakpoint = 'full', +) { + const displayName = `withMobileLayout(${Component.displayName ?? Component.name})`; + + const ComponentWithBreakpoint = (props: P) => { + const matchesBreakpoint = useBreakpoint(breakpoint); + + return ; + }; + + ComponentWithBreakpoint.displayName = displayName; + + return ComponentWithBreakpoint; +} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 7c4f45721d..4297d750c5 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -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 (

-
- {children} + {layout !== 'mobile' && } {!disableHoverCards && } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 589aae2e55..ab7ad7cfdd 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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}", diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index d35d166115..9c3583c7a3 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -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, diff --git a/app/javascript/mastodon/reducers/navigation.ts b/app/javascript/mastodon/reducers/navigation.ts new file mode 100644 index 0000000000..3f245603a1 --- /dev/null +++ b/app/javascript/mastodon/reducers/navigation.ts @@ -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; + }); +}); diff --git a/app/javascript/mastodon/selectors/lists.ts b/app/javascript/mastodon/selectors/lists.ts index f93e90ce68..9b79a880a9 100644 --- a/app/javascript/mastodon/selectors/lists.ts +++ b/app/javascript/mastodon/selectors/lists.ts @@ -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) => - 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): ImmutableList => + 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(), ); diff --git a/app/javascript/material-icons/400-24px/arrow_left-fill.svg b/app/javascript/material-icons/400-24px/arrow_left-fill.svg new file mode 100644 index 0000000000..bf9b2aef3f --- /dev/null +++ b/app/javascript/material-icons/400-24px/arrow_left-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/arrow_left.svg b/app/javascript/material-icons/400-24px/arrow_left.svg new file mode 100644 index 0000000000..bf9b2aef3f --- /dev/null +++ b/app/javascript/material-icons/400-24px/arrow_left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/edit_square-fill.svg b/app/javascript/material-icons/400-24px/edit_square-fill.svg new file mode 100644 index 0000000000..4f931de0f2 --- /dev/null +++ b/app/javascript/material-icons/400-24px/edit_square-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/edit_square.svg b/app/javascript/material-icons/400-24px/edit_square.svg new file mode 100644 index 0000000000..dccfaa9f3c --- /dev/null +++ b/app/javascript/material-icons/400-24px/edit_square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/unfold_less-fill.svg b/app/javascript/material-icons/400-24px/unfold_less-fill.svg new file mode 100644 index 0000000000..8136d615b2 --- /dev/null +++ b/app/javascript/material-icons/400-24px/unfold_less-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/unfold_less.svg b/app/javascript/material-icons/400-24px/unfold_less.svg new file mode 100644 index 0000000000..8136d615b2 --- /dev/null +++ b/app/javascript/material-icons/400-24px/unfold_less.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/unfold_more-fill.svg b/app/javascript/material-icons/400-24px/unfold_more-fill.svg new file mode 100644 index 0000000000..3e245d2090 --- /dev/null +++ b/app/javascript/material-icons/400-24px/unfold_more-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/unfold_more.svg b/app/javascript/material-icons/400-24px/unfold_more.svg new file mode 100644 index 0000000000..3e245d2090 --- /dev/null +++ b/app/javascript/material-icons/400-24px/unfold_more.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index f732f85922..4eeef5c8f7 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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 {