Change navigation layout on small screens in web UI (#34910)
							
								
								
									
										7
									
								
								app/javascript/mastodon/actions/navigation.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,7 @@ | |||
| import { createAction } from '@reduxjs/toolkit'; | ||||
| 
 | ||||
| export const openNavigation = createAction('navigation/open'); | ||||
| 
 | ||||
| export const closeNavigation = createAction('navigation/close'); | ||||
| 
 | ||||
| export const toggleNavigation = createAction('navigation/toggle'); | ||||
|  | @ -9,7 +9,8 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; | |||
| import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; | ||||
| import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; | ||||
| import CloseIcon from '@/material-icons/400-24px/close.svg?react'; | ||||
| import SettingsIcon from '@/material-icons/400-24px/settings.svg?react'; | ||||
| import UnfoldLessIcon from '@/material-icons/400-24px/unfold_less.svg?react'; | ||||
| import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react'; | ||||
| import type { IconProp } from 'mastodon/components/icon'; | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; | ||||
|  | @ -238,7 +239,10 @@ export const ColumnHeader: React.FC<Props> = ({ | |||
|         onClick={handleToggleClick} | ||||
|       > | ||||
|         <i className='icon-with-badge'> | ||||
|           <Icon id='sliders' icon={SettingsIcon} /> | ||||
|           <Icon | ||||
|             id='sliders' | ||||
|             icon={collapsed ? UnfoldMoreIcon : UnfoldLessIcon} | ||||
|           /> | ||||
|           {collapseIssues && <i className='icon-with-badge__issue-badge' />} | ||||
|         </i> | ||||
|       </button> | ||||
|  |  | |||
|  | @ -27,6 +27,7 @@ interface Props { | |||
|   counter?: number; | ||||
|   href?: string; | ||||
|   ariaHidden?: boolean; | ||||
|   ariaControls?: string; | ||||
| } | ||||
| 
 | ||||
| export const IconButton = forwardRef<HTMLButtonElement, Props>( | ||||
|  | @ -52,6 +53,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>( | |||
|       overlay = false, | ||||
|       tabIndex = 0, | ||||
|       ariaHidden = false, | ||||
|       ariaControls, | ||||
|     }, | ||||
|     buttonRef, | ||||
|   ) => { | ||||
|  | @ -153,6 +155,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>( | |||
|         aria-label={title} | ||||
|         aria-expanded={expanded} | ||||
|         aria-hidden={ariaHidden} | ||||
|         aria-controls={ariaControls} | ||||
|         title={title} | ||||
|         className={classes} | ||||
|         onClick={handleClick} | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ interface Props { | |||
|   id: string; | ||||
|   icon: IconProp; | ||||
|   count: number; | ||||
|   issueBadge: boolean; | ||||
|   issueBadge?: boolean; | ||||
|   className: string; | ||||
| } | ||||
| export const IconWithBadge: React.FC<Props> = ({ | ||||
|  |  | |||
|  | @ -2,34 +2,47 @@ import { useCallback } from 'react'; | |||
| 
 | ||||
| import { useIntl, defineMessages } from 'react-intl'; | ||||
| 
 | ||||
| import { useSelector, useDispatch } from 'react-redux'; | ||||
| 
 | ||||
| import CloseIcon from '@/material-icons/400-24px/close.svg?react'; | ||||
| import { cancelReplyCompose } from 'mastodon/actions/compose'; | ||||
| import { Account } from 'mastodon/components/account'; | ||||
| import { IconButton } from 'mastodon/components/icon_button'; | ||||
| import { me } from 'mastodon/initial_state'; | ||||
| import { useAppDispatch, useAppSelector } from 'mastodon/store'; | ||||
| 
 | ||||
| import { ActionBar } from './action_bar'; | ||||
| 
 | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, | ||||
| }); | ||||
| 
 | ||||
| export const NavigationBar = () => { | ||||
|   const dispatch = useDispatch(); | ||||
| export const NavigationBar: React.FC = () => { | ||||
|   const dispatch = useAppDispatch(); | ||||
|   const intl = useIntl(); | ||||
|   const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to'])); | ||||
|   const isReplying = useAppSelector( | ||||
|     (state) => !!state.compose.get('in_reply_to'), | ||||
|   ); | ||||
| 
 | ||||
|   const handleCancelClick = useCallback(() => { | ||||
|     dispatch(cancelReplyCompose()); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   if (!me) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className='navigation-bar'> | ||||
|       <Account id={me} minimal /> | ||||
|       {isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />} | ||||
|       {isReplying ? ( | ||||
|         <IconButton | ||||
|           title={intl.formatMessage(messages.cancel)} | ||||
|           icon='' | ||||
|           iconComponent={CloseIcon} | ||||
|           onClick={handleCancelClick} | ||||
|         /> | ||||
|       ) : ( | ||||
|         <ActionBar /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | @ -1,138 +0,0 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { PureComponent } from 'react'; | ||||
| 
 | ||||
| import { injectIntl, defineMessages } from 'react-intl'; | ||||
| 
 | ||||
| import { Helmet } from 'react-helmet'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| 
 | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; | ||||
| import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; | ||||
| import LogoutIcon from '@/material-icons/400-24px/logout.svg?react'; | ||||
| import MenuIcon from '@/material-icons/400-24px/menu.svg?react'; | ||||
| import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; | ||||
| import PublicIcon from '@/material-icons/400-24px/public.svg?react'; | ||||
| import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react'; | ||||
| import { openModal } from 'mastodon/actions/modal'; | ||||
| import Column from 'mastodon/components/column'; | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| 
 | ||||
| import elephantUIPlane from '../../../images/elephant_ui_plane.svg'; | ||||
| import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose'; | ||||
| import { mascot } from '../../initial_state'; | ||||
| import { isMobile } from '../../is_mobile'; | ||||
| 
 | ||||
| import { Search } from './components/search'; | ||||
| import ComposeFormContainer from './containers/compose_form_container'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, | ||||
|   home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, | ||||
|   notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, | ||||
|   public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, | ||||
|   community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, | ||||
|   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, | ||||
|   logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, | ||||
|   compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, | ||||
| }); | ||||
| 
 | ||||
| const mapStateToProps = (state) => ({ | ||||
|   columns: state.getIn(['settings', 'columns']), | ||||
| }); | ||||
| 
 | ||||
| class Compose extends PureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     dispatch: PropTypes.func.isRequired, | ||||
|     columns: ImmutablePropTypes.list.isRequired, | ||||
|     multiColumn: PropTypes.bool, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   componentDidMount () { | ||||
|     const { dispatch } = this.props; | ||||
|     dispatch(mountCompose()); | ||||
|   } | ||||
| 
 | ||||
|   componentWillUnmount () { | ||||
|     const { dispatch } = this.props; | ||||
|     dispatch(unmountCompose()); | ||||
|   } | ||||
| 
 | ||||
|   handleLogoutClick = e => { | ||||
|     const { dispatch } = this.props; | ||||
| 
 | ||||
|     e.preventDefault(); | ||||
|     e.stopPropagation(); | ||||
| 
 | ||||
|     dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' })); | ||||
| 
 | ||||
|     return false; | ||||
|   }; | ||||
| 
 | ||||
|   onFocus = () => { | ||||
|     this.props.dispatch(changeComposing(true)); | ||||
|   }; | ||||
| 
 | ||||
|   onBlur = () => { | ||||
|     this.props.dispatch(changeComposing(false)); | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { multiColumn, intl } = this.props; | ||||
| 
 | ||||
|     if (multiColumn) { | ||||
|       const { columns } = this.props; | ||||
| 
 | ||||
|       return ( | ||||
|         <div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}> | ||||
|           <nav className='drawer__header'> | ||||
|             <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' icon={MenuIcon} /></Link> | ||||
|             {!columns.some(column => column.get('id') === 'HOME') && ( | ||||
|               <Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' icon={HomeIcon} /></Link> | ||||
|             )} | ||||
|             {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && ( | ||||
|               <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><Icon id='bell' icon={NotificationsIcon} /></Link> | ||||
|             )} | ||||
|             {!columns.some(column => column.get('id') === 'COMMUNITY') && ( | ||||
|               <Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' icon={PeopleIcon} /></Link> | ||||
|             )} | ||||
|             {!columns.some(column => column.get('id') === 'PUBLIC') && ( | ||||
|               <Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' icon={PublicIcon} /></Link> | ||||
|             )} | ||||
|             <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' icon={SettingsIcon} /></a> | ||||
|             <a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a> | ||||
|           </nav> | ||||
| 
 | ||||
|           {multiColumn && <Search /> } | ||||
| 
 | ||||
|           <div className='drawer__pager'> | ||||
|             <div className='drawer__inner' onFocus={this.onFocus}> | ||||
|               <ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} /> | ||||
| 
 | ||||
|               <div className='drawer__inner__mastodon'> | ||||
|                 <img alt='' draggable='false' src={mascot || elephantUIPlane} /> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <Column onFocus={this.onFocus}> | ||||
|         <ComposeFormContainer /> | ||||
| 
 | ||||
|         <Helmet> | ||||
|           <meta name='robots' content='noindex' /> | ||||
|         </Helmet> | ||||
|       </Column> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default connect(mapStateToProps)(injectIntl(Compose)); | ||||
							
								
								
									
										200
									
								
								app/javascript/mastodon/features/compose/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,200 @@ | |||
| import { useEffect, useCallback } from 'react'; | ||||
| 
 | ||||
| import { useIntl, defineMessages } from 'react-intl'; | ||||
| 
 | ||||
| import { Helmet } from 'react-helmet'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| 
 | ||||
| import type { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | ||||
| 
 | ||||
| import elephantUIPlane from '@/images/elephant_ui_plane.svg'; | ||||
| import EditIcon from '@/material-icons/400-24px/edit_square.svg?react'; | ||||
| import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; | ||||
| import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; | ||||
| import LogoutIcon from '@/material-icons/400-24px/logout.svg?react'; | ||||
| import MenuIcon from '@/material-icons/400-24px/menu.svg?react'; | ||||
| import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; | ||||
| import PublicIcon from '@/material-icons/400-24px/public.svg?react'; | ||||
| import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react'; | ||||
| import { mountCompose, unmountCompose } from 'mastodon/actions/compose'; | ||||
| import { openModal } from 'mastodon/actions/modal'; | ||||
| import { Column } from 'mastodon/components/column'; | ||||
| import { ColumnHeader } from 'mastodon/components/column_header'; | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import { mascot } from 'mastodon/initial_state'; | ||||
| import { useAppDispatch, useAppSelector } from 'mastodon/store'; | ||||
| 
 | ||||
| import { Search } from './components/search'; | ||||
| import ComposeFormContainer from './containers/compose_form_container'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, | ||||
|   home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, | ||||
|   notifications: { | ||||
|     id: 'tabs_bar.notifications', | ||||
|     defaultMessage: 'Notifications', | ||||
|   }, | ||||
|   public: { | ||||
|     id: 'navigation_bar.public_timeline', | ||||
|     defaultMessage: 'Federated timeline', | ||||
|   }, | ||||
|   community: { | ||||
|     id: 'navigation_bar.community_timeline', | ||||
|     defaultMessage: 'Local timeline', | ||||
|   }, | ||||
|   preferences: { | ||||
|     id: 'navigation_bar.preferences', | ||||
|     defaultMessage: 'Preferences', | ||||
|   }, | ||||
|   logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, | ||||
|   compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, | ||||
| }); | ||||
| 
 | ||||
| type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>; | ||||
| 
 | ||||
| const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { | ||||
|   const intl = useIntl(); | ||||
|   const dispatch = useAppDispatch(); | ||||
|   const columns = useAppSelector( | ||||
|     (state) => | ||||
|       (state.settings as ImmutableMap<string, unknown>).get( | ||||
|         'columns', | ||||
|       ) as ImmutableList<ColumnMap>, | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     dispatch(mountCompose()); | ||||
| 
 | ||||
|     return () => { | ||||
|       dispatch(unmountCompose()); | ||||
|     }; | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   const handleLogoutClick = useCallback( | ||||
|     (e: React.MouseEvent) => { | ||||
|       e.preventDefault(); | ||||
|       e.stopPropagation(); | ||||
| 
 | ||||
|       dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} })); | ||||
| 
 | ||||
|       return false; | ||||
|     }, | ||||
|     [dispatch], | ||||
|   ); | ||||
| 
 | ||||
|   if (multiColumn) { | ||||
|     return ( | ||||
|       <div | ||||
|         className='drawer' | ||||
|         role='region' | ||||
|         aria-label={intl.formatMessage(messages.compose)} | ||||
|       > | ||||
|         <nav className='drawer__header'> | ||||
|           <Link | ||||
|             to='/getting-started' | ||||
|             className='drawer__tab' | ||||
|             title={intl.formatMessage(messages.start)} | ||||
|             aria-label={intl.formatMessage(messages.start)} | ||||
|           > | ||||
|             <Icon id='bars' icon={MenuIcon} /> | ||||
|           </Link> | ||||
|           {!columns.some((column) => column.get('id') === 'HOME') && ( | ||||
|             <Link | ||||
|               to='/home' | ||||
|               className='drawer__tab' | ||||
|               title={intl.formatMessage(messages.home_timeline)} | ||||
|               aria-label={intl.formatMessage(messages.home_timeline)} | ||||
|             > | ||||
|               <Icon id='home' icon={HomeIcon} /> | ||||
|             </Link> | ||||
|           )} | ||||
|           {!columns.some((column) => column.get('id') === 'NOTIFICATIONS') && ( | ||||
|             <Link | ||||
|               to='/notifications' | ||||
|               className='drawer__tab' | ||||
|               title={intl.formatMessage(messages.notifications)} | ||||
|               aria-label={intl.formatMessage(messages.notifications)} | ||||
|             > | ||||
|               <Icon id='bell' icon={NotificationsIcon} /> | ||||
|             </Link> | ||||
|           )} | ||||
|           {!columns.some((column) => column.get('id') === 'COMMUNITY') && ( | ||||
|             <Link | ||||
|               to='/public/local' | ||||
|               className='drawer__tab' | ||||
|               title={intl.formatMessage(messages.community)} | ||||
|               aria-label={intl.formatMessage(messages.community)} | ||||
|             > | ||||
|               <Icon id='users' icon={PeopleIcon} /> | ||||
|             </Link> | ||||
|           )} | ||||
|           {!columns.some((column) => column.get('id') === 'PUBLIC') && ( | ||||
|             <Link | ||||
|               to='/public' | ||||
|               className='drawer__tab' | ||||
|               title={intl.formatMessage(messages.public)} | ||||
|               aria-label={intl.formatMessage(messages.public)} | ||||
|             > | ||||
|               <Icon id='globe' icon={PublicIcon} /> | ||||
|             </Link> | ||||
|           )} | ||||
|           <a | ||||
|             href='/settings/preferences' | ||||
|             className='drawer__tab' | ||||
|             title={intl.formatMessage(messages.preferences)} | ||||
|             aria-label={intl.formatMessage(messages.preferences)} | ||||
|           > | ||||
|             <Icon id='cog' icon={SettingsIcon} /> | ||||
|           </a> | ||||
|           <a | ||||
|             href='/auth/sign_out' | ||||
|             className='drawer__tab' | ||||
|             title={intl.formatMessage(messages.logout)} | ||||
|             aria-label={intl.formatMessage(messages.logout)} | ||||
|             onClick={handleLogoutClick} | ||||
|           > | ||||
|             <Icon id='sign-out' icon={LogoutIcon} /> | ||||
|           </a> | ||||
|         </nav> | ||||
| 
 | ||||
|         <Search singleColumn={false} /> | ||||
| 
 | ||||
|         <div className='drawer__pager'> | ||||
|           <div className='drawer__inner'> | ||||
|             <ComposeFormContainer /> | ||||
| 
 | ||||
|             <div className='drawer__inner__mastodon'> | ||||
|               <img alt='' draggable='false' src={mascot ?? elephantUIPlane} /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Column | ||||
|       bindToDocument={!multiColumn} | ||||
|       label={intl.formatMessage(messages.compose)} | ||||
|     > | ||||
|       <ColumnHeader | ||||
|         icon='pencil' | ||||
|         iconComponent={EditIcon} | ||||
|         title={intl.formatMessage(messages.compose)} | ||||
|         multiColumn={multiColumn} | ||||
|         showBackButton | ||||
|       /> | ||||
| 
 | ||||
|       <div className='scrollable'> | ||||
|         <ComposeFormContainer /> | ||||
|       </div> | ||||
| 
 | ||||
|       <Helmet> | ||||
|         <meta name='robots' content='noindex' /> | ||||
|       </Helmet> | ||||
|     </Column> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| // eslint-disable-next-line import/no-default-export
 | ||||
| export default Compose; | ||||
|  | @ -9,7 +9,9 @@ import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; | |||
| import { Column } from 'mastodon/components/column'; | ||||
| import type { ColumnRef } from 'mastodon/components/column'; | ||||
| import { ColumnHeader } from 'mastodon/components/column_header'; | ||||
| import { SymbolLogo } from 'mastodon/components/logo'; | ||||
| import { Search } from 'mastodon/features/compose/components/search'; | ||||
| import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; | ||||
| import { useIdentity } from 'mastodon/identity_context'; | ||||
| 
 | ||||
| import Links from './links'; | ||||
|  | @ -25,6 +27,7 @@ const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { | |||
|   const { signedIn } = useIdentity(); | ||||
|   const intl = useIntl(); | ||||
|   const columnRef = useRef<ColumnRef>(null); | ||||
|   const logoRequired = useBreakpoint('full'); | ||||
| 
 | ||||
|   const handleHeaderClick = useCallback(() => { | ||||
|     columnRef.current?.scrollTop(); | ||||
|  | @ -38,7 +41,7 @@ const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { | |||
|     > | ||||
|       <ColumnHeader | ||||
|         icon={'explore'} | ||||
|         iconComponent={ExploreIcon} | ||||
|         iconComponent={logoRequired ? SymbolLogo : ExploreIcon} | ||||
|         title={intl.formatMessage(messages.title)} | ||||
|         onClick={handleHeaderClick} | ||||
|         multiColumn={multiColumn} | ||||
|  |  | |||
|  | @ -31,7 +31,7 @@ import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions'; | |||
| 
 | ||||
| import { me, showTrends } from '../../initial_state'; | ||||
| import { NavigationBar } from '../compose/components/navigation_bar'; | ||||
| import ColumnLink from '../ui/components/column_link'; | ||||
| import { ColumnLink } from '../ui/components/column_link'; | ||||
| import ColumnSubheading from '../ui/components/column_subheading'; | ||||
| 
 | ||||
| import TrendsContainer from './containers/trends_container'; | ||||
|  |  | |||
|  | @ -10,12 +10,14 @@ import { connect } from 'react-redux'; | |||
| 
 | ||||
| import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react'; | ||||
| import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; | ||||
| import { SymbolLogo } from 'mastodon/components/logo'; | ||||
| import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements'; | ||||
| import { IconWithBadge } from 'mastodon/components/icon_with_badge'; | ||||
| import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; | ||||
| import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container'; | ||||
| import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; | ||||
| import { criticalUpdatesPending } from 'mastodon/initial_state'; | ||||
| import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; | ||||
| 
 | ||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||
| import { expandHomeTimeline } from '../../actions/timelines'; | ||||
|  | @ -52,6 +54,7 @@ class HomeTimeline extends PureComponent { | |||
|     hasAnnouncements: PropTypes.bool, | ||||
|     unreadAnnouncements: PropTypes.number, | ||||
|     showAnnouncements: PropTypes.bool, | ||||
|     matchesBreakpoint: PropTypes.bool, | ||||
|   }; | ||||
| 
 | ||||
|   handlePin = () => { | ||||
|  | @ -121,7 +124,7 @@ class HomeTimeline extends PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; | ||||
|     const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props; | ||||
|     const pinned = !!columnId; | ||||
|     const { signedIn } = this.props.identity; | ||||
|     const banners = []; | ||||
|  | @ -150,7 +153,7 @@ class HomeTimeline extends PureComponent { | |||
|       <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> | ||||
|         <ColumnHeader | ||||
|           icon='home' | ||||
|           iconComponent={HomeIcon} | ||||
|           iconComponent={matchesBreakpoint ? SymbolLogo : HomeIcon} | ||||
|           active={hasUnread} | ||||
|           title={intl.formatMessage(messages.title)} | ||||
|           onPin={this.handlePin} | ||||
|  | @ -187,4 +190,4 @@ class HomeTimeline extends PureComponent { | |||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default connect(mapStateToProps)(withIdentity(injectIntl(HomeTimeline))); | ||||
| export default connect(mapStateToProps)(withBreakpoint(withIdentity(injectIntl(HomeTimeline)))); | ||||
|  |  | |||
|  | @ -1,49 +0,0 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| 
 | ||||
| import classNames from 'classnames'; | ||||
| import { useRouteMatch, NavLink } from 'react-router-dom'; | ||||
| 
 | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| 
 | ||||
| const ColumnLink = ({ icon, activeIcon, iconComponent, activeIconComponent, text, to, href, method, badge, transparent, optional, ...other }) => { | ||||
|   const match = useRouteMatch(to); | ||||
|   const className = classNames('column-link', { 'column-link--transparent': transparent, 'column-link--optional': optional }); | ||||
|   const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null; | ||||
|   const iconElement = (typeof icon === 'string' || iconComponent) ? <Icon id={icon} icon={iconComponent} className='column-link__icon' /> : icon; | ||||
|   const activeIconElement = activeIcon ?? (activeIconComponent ? <Icon id={icon} icon={activeIconComponent} className='column-link__icon' /> : iconElement); | ||||
|   const active = match?.isExact; | ||||
| 
 | ||||
|   if (href) { | ||||
|     return ( | ||||
|       <a href={href} className={className} data-method={method} {...other}> | ||||
|         {active ? activeIconElement : iconElement} | ||||
|         <span>{text}</span> | ||||
|         {badgeElement} | ||||
|       </a> | ||||
|     ); | ||||
|   } else { | ||||
|     return ( | ||||
|       <NavLink to={to} className={className} exact {...other}> | ||||
|         {active ? activeIconElement : iconElement} | ||||
|         <span>{text}</span> | ||||
|         {badgeElement} | ||||
|       </NavLink> | ||||
|     ); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| ColumnLink.propTypes = { | ||||
|   icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, | ||||
|   iconComponent: PropTypes.func, | ||||
|   activeIcon: PropTypes.node, | ||||
|   activeIconComponent: PropTypes.func, | ||||
|   text: PropTypes.string.isRequired, | ||||
|   to: PropTypes.string, | ||||
|   href: PropTypes.string, | ||||
|   method: PropTypes.string, | ||||
|   badge: PropTypes.node, | ||||
|   transparent: PropTypes.bool, | ||||
|   optional: PropTypes.bool, | ||||
| }; | ||||
| 
 | ||||
| export default ColumnLink; | ||||
|  | @ -0,0 +1,86 @@ | |||
| import classNames from 'classnames'; | ||||
| import { useRouteMatch, NavLink } from 'react-router-dom'; | ||||
| 
 | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import type { IconProp } from 'mastodon/components/icon'; | ||||
| 
 | ||||
| export const ColumnLink: React.FC<{ | ||||
|   icon: React.ReactNode; | ||||
|   iconComponent?: IconProp; | ||||
|   activeIcon?: React.ReactNode; | ||||
|   activeIconComponent?: IconProp; | ||||
|   isActive?: (match: unknown, location: { pathname: string }) => boolean; | ||||
|   text: string; | ||||
|   to?: string; | ||||
|   href?: string; | ||||
|   method?: string; | ||||
|   badge?: React.ReactNode; | ||||
|   transparent?: boolean; | ||||
|   optional?: boolean; | ||||
|   className?: string; | ||||
|   id?: string; | ||||
| }> = ({ | ||||
|   icon, | ||||
|   activeIcon, | ||||
|   iconComponent, | ||||
|   activeIconComponent, | ||||
|   text, | ||||
|   to, | ||||
|   href, | ||||
|   method, | ||||
|   badge, | ||||
|   transparent, | ||||
|   optional, | ||||
|   ...other | ||||
| }) => { | ||||
|   const match = useRouteMatch(to ?? ''); | ||||
|   const className = classNames('column-link', { | ||||
|     'column-link--transparent': transparent, | ||||
|     'column-link--optional': optional, | ||||
|   }); | ||||
|   const badgeElement = | ||||
|     typeof badge !== 'undefined' ? ( | ||||
|       <span className='column-link__badge'>{badge}</span> | ||||
|     ) : null; | ||||
|   const iconElement = iconComponent ? ( | ||||
|     <Icon | ||||
|       id={typeof icon === 'string' ? icon : ''} | ||||
|       icon={iconComponent} | ||||
|       className='column-link__icon' | ||||
|     /> | ||||
|   ) : ( | ||||
|     icon | ||||
|   ); | ||||
|   const activeIconElement = | ||||
|     activeIcon ?? | ||||
|     (activeIconComponent ? ( | ||||
|       <Icon | ||||
|         id={typeof icon === 'string' ? icon : ''} | ||||
|         icon={activeIconComponent} | ||||
|         className='column-link__icon' | ||||
|       /> | ||||
|     ) : ( | ||||
|       iconElement | ||||
|     )); | ||||
|   const active = !!match; | ||||
| 
 | ||||
|   if (href) { | ||||
|     return ( | ||||
|       <a href={href} className={className} data-method={method} {...other}> | ||||
|         {active ? activeIconElement : iconElement} | ||||
|         <span>{text}</span> | ||||
|         {badgeElement} | ||||
|       </a> | ||||
|     ); | ||||
|   } else if (to) { | ||||
|     return ( | ||||
|       <NavLink to={to} className={className} {...other}> | ||||
|         {active ? activeIconElement : iconElement} | ||||
|         <span>{text}</span> | ||||
|         {badgeElement} | ||||
|       </NavLink> | ||||
|     ); | ||||
|   } else { | ||||
|     return null; | ||||
|   } | ||||
| }; | ||||
|  | @ -25,7 +25,7 @@ import BundleColumnError from './bundle_column_error'; | |||
| import { ColumnLoading } from './column_loading'; | ||||
| import { ComposePanel } from './compose_panel'; | ||||
| import DrawerLoading from './drawer_loading'; | ||||
| import NavigationPanel from './navigation_panel'; | ||||
| import { NavigationPanel } from './navigation_panel'; | ||||
| 
 | ||||
| const componentMap = { | ||||
|   'COMPOSE': Compose, | ||||
|  | @ -132,11 +132,7 @@ export default class ColumnsArea extends ImmutablePureComponent { | |||
|             <div className='columns-area columns-area--mobile'>{children}</div> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'> | ||||
|             <div className='columns-area__panels__pane__inner'> | ||||
|               <NavigationPanel /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <NavigationPanel /> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
|  |  | |||
|  | @ -1,121 +0,0 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { PureComponent } from 'react'; | ||||
| 
 | ||||
| import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; | ||||
| 
 | ||||
| import { Link, withRouter } from 'react-router-dom'; | ||||
| 
 | ||||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import SearchIcon from '@/material-icons/400-24px/search.svg?react'; | ||||
| import { openModal } from 'mastodon/actions/modal'; | ||||
| import { fetchServer } from 'mastodon/actions/server'; | ||||
| import { Avatar } from 'mastodon/components/avatar'; | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo'; | ||||
| import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; | ||||
| import { registrationsOpen, me, sso_redirect } from 'mastodon/initial_state'; | ||||
| 
 | ||||
| const Account = connect(state => ({ | ||||
|   account: state.getIn(['accounts', me]), | ||||
| }))(({ account }) => ( | ||||
|   <Link to={`/@${account.get('acct')}`} title={account.get('acct')}> | ||||
|     <Avatar account={account} size={35} /> | ||||
|   </Link> | ||||
| )); | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, | ||||
| }); | ||||
| 
 | ||||
| const mapStateToProps = (state) => ({ | ||||
|   signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up', | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = (dispatch) => ({ | ||||
|   openClosedRegistrationsModal() { | ||||
|     dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); | ||||
|   }, | ||||
|   dispatchServer() { | ||||
|     dispatch(fetchServer()); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| class Header extends PureComponent { | ||||
|   static propTypes = { | ||||
|     identity: identityContextPropShape, | ||||
|     openClosedRegistrationsModal: PropTypes.func, | ||||
|     location: PropTypes.object, | ||||
|     signupUrl: PropTypes.string.isRequired, | ||||
|     dispatchServer: PropTypes.func, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   componentDidMount () { | ||||
|     const { dispatchServer } = this.props; | ||||
|     dispatchServer(); | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { signedIn } = this.props.identity; | ||||
|     const { location, openClosedRegistrationsModal, signupUrl, intl } = this.props; | ||||
| 
 | ||||
|     let content; | ||||
| 
 | ||||
|     if (signedIn) { | ||||
|       content = ( | ||||
|         <> | ||||
|           {location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' icon={SearchIcon} /></Link>} | ||||
|           {location.pathname !== '/publish' && <Link to='/publish' className='button button-secondary'><FormattedMessage id='compose_form.publish_form' defaultMessage='New post' /></Link>} | ||||
|           <Account /> | ||||
|         </> | ||||
|       ); | ||||
|     } else { | ||||
| 
 | ||||
|       if (sso_redirect) { | ||||
|         content = ( | ||||
|           <a href={sso_redirect} data-method='post' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sso_redirect' defaultMessage='Login or Register' /></a> | ||||
|         ); | ||||
|       } else { | ||||
|         let signupButton; | ||||
| 
 | ||||
|         if (registrationsOpen) { | ||||
|           signupButton = ( | ||||
|             <a href={signupUrl} className='button'> | ||||
|               <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> | ||||
|             </a> | ||||
|           ); | ||||
|         } else { | ||||
|           signupButton = ( | ||||
|             <button className='button' onClick={openClosedRegistrationsModal}> | ||||
|               <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> | ||||
|             </button> | ||||
|           ); | ||||
|         } | ||||
| 
 | ||||
|         content = ( | ||||
|           <> | ||||
|             {signupButton} | ||||
|             <a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a> | ||||
|           </> | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='ui__header'> | ||||
|         <Link to='/' className='ui__header__logo'> | ||||
|           <WordmarkLogo /> | ||||
|           <SymbolLogo /> | ||||
|         </Link> | ||||
| 
 | ||||
|         <div className='ui__header__links'> | ||||
|           {content} | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default injectIntl(withRouter(withIdentity(connect(mapStateToProps, mapDispatchToProps)(Header)))); | ||||
|  | @ -1,41 +0,0 @@ | |||
| import { useEffect } from 'react'; | ||||
| 
 | ||||
| import { createSelector } from '@reduxjs/toolkit'; | ||||
| import { useDispatch, useSelector } from 'react-redux'; | ||||
| 
 | ||||
| import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react'; | ||||
| import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; | ||||
| import { fetchLists } from 'mastodon/actions/lists'; | ||||
| 
 | ||||
| import ColumnLink from './column_link'; | ||||
| 
 | ||||
| const getOrderedLists = createSelector([state => state.get('lists')], lists => { | ||||
|   if (!lists) { | ||||
|     return lists; | ||||
|   } | ||||
| 
 | ||||
|   return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4); | ||||
| }); | ||||
| 
 | ||||
| export const ListPanel = () => { | ||||
|   const dispatch = useDispatch(); | ||||
|   const lists = useSelector(state => getOrderedLists(state)); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     dispatch(fetchLists()); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   if (!lists || lists.isEmpty()) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className='list-panel'> | ||||
|       <hr /> | ||||
| 
 | ||||
|       {lists.map(list => ( | ||||
|         <ColumnLink icon='list-ul' key={list.get('id')} iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={list.get('title')} to={`/lists/${list.get('id')}`} transparent /> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | @ -0,0 +1,92 @@ | |||
| import { useEffect, useState, useCallback, useId } from 'react'; | ||||
| 
 | ||||
| import { useIntl, defineMessages } from 'react-intl'; | ||||
| 
 | ||||
| import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react'; | ||||
| import ArrowLeftIcon from '@/material-icons/400-24px/arrow_left.svg?react'; | ||||
| import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react'; | ||||
| import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; | ||||
| import { fetchLists } from 'mastodon/actions/lists'; | ||||
| import { IconButton } from 'mastodon/components/icon_button'; | ||||
| import { getOrderedLists } from 'mastodon/selectors/lists'; | ||||
| import { useAppDispatch, useAppSelector } from 'mastodon/store'; | ||||
| 
 | ||||
| import { ColumnLink } from './column_link'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, | ||||
|   expand: { | ||||
|     id: 'navigation_panel.expand_lists', | ||||
|     defaultMessage: 'Expand list menu', | ||||
|   }, | ||||
|   collapse: { | ||||
|     id: 'navigation_panel.collapse_lists', | ||||
|     defaultMessage: 'Collapse list menu', | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| export const ListPanel: React.FC = () => { | ||||
|   const intl = useIntl(); | ||||
|   const dispatch = useAppDispatch(); | ||||
|   const lists = useAppSelector((state) => getOrderedLists(state)); | ||||
|   const [expanded, setExpanded] = useState(false); | ||||
|   const accessibilityId = useId(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     dispatch(fetchLists()); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   const handleClick = useCallback(() => { | ||||
|     setExpanded((value) => !value); | ||||
|   }, [setExpanded]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className='navigation-panel__list-panel'> | ||||
|       <div className='navigation-panel__list-panel__header'> | ||||
|         <ColumnLink | ||||
|           transparent | ||||
|           to='/lists' | ||||
|           icon='list-ul' | ||||
|           iconComponent={ListAltIcon} | ||||
|           activeIconComponent={ListAltActiveIcon} | ||||
|           text={intl.formatMessage(messages.lists)} | ||||
|           id={`${accessibilityId}-title`} | ||||
|         /> | ||||
| 
 | ||||
|         {lists.length > 0 && ( | ||||
|           <IconButton | ||||
|             icon='down' | ||||
|             expanded={expanded} | ||||
|             iconComponent={expanded ? ArrowDropDownIcon : ArrowLeftIcon} | ||||
|             title={intl.formatMessage( | ||||
|               expanded ? messages.collapse : messages.expand, | ||||
|             )} | ||||
|             onClick={handleClick} | ||||
|             aria-controls={`${accessibilityId}-content`} | ||||
|           /> | ||||
|         )} | ||||
|       </div> | ||||
| 
 | ||||
|       {lists.length > 0 && expanded && ( | ||||
|         <div | ||||
|           className='navigation-panel__list-panel__items' | ||||
|           role='region' | ||||
|           id={`${accessibilityId}-content`} | ||||
|           aria-labelledby={`${accessibilityId}-title`} | ||||
|         > | ||||
|           {lists.map((list) => ( | ||||
|             <ColumnLink | ||||
|               icon='list-ul' | ||||
|               key={list.get('id')} | ||||
|               iconComponent={ListAltIcon} | ||||
|               activeIconComponent={ListAltActiveIcon} | ||||
|               text={list.get('title')} | ||||
|               to={`/lists/${list.get('id')}`} | ||||
|               transparent | ||||
|             /> | ||||
|           ))} | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | @ -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> | ||||
|   ); | ||||
| }; | ||||
|  | @ -1,206 +0,0 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { Component, useEffect } from 'react'; | ||||
| 
 | ||||
| import { defineMessages, injectIntl, useIntl } from 'react-intl'; | ||||
| 
 | ||||
| import { Link } from 'react-router-dom'; | ||||
| 
 | ||||
| import { useSelector, useDispatch } from 'react-redux'; | ||||
| 
 | ||||
| import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; | ||||
| import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; | ||||
| import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react'; | ||||
| import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react'; | ||||
| import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; | ||||
| import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react'; | ||||
| import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react'; | ||||
| import HomeIcon from '@/material-icons/400-24px/home.svg?react'; | ||||
| import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react'; | ||||
| import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; | ||||
| import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react'; | ||||
| import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; | ||||
| import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; | ||||
| import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; | ||||
| import PersonAddActiveIcon from '@/material-icons/400-24px/person_add-fill.svg?react'; | ||||
| import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; | ||||
| import PublicIcon from '@/material-icons/400-24px/public.svg?react'; | ||||
| import SearchIcon from '@/material-icons/400-24px/search.svg?react'; | ||||
| import SettingsIcon from '@/material-icons/400-24px/settings.svg?react'; | ||||
| import StarActiveIcon from '@/material-icons/400-24px/star-fill.svg?react'; | ||||
| import StarIcon from '@/material-icons/400-24px/star.svg?react'; | ||||
| import { fetchFollowRequests } from 'mastodon/actions/accounts'; | ||||
| import { IconWithBadge } from 'mastodon/components/icon_with_badge'; | ||||
| import { WordmarkLogo } from 'mastodon/components/logo'; | ||||
| import { NavigationPortal } from 'mastodon/components/navigation_portal'; | ||||
| import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; | ||||
| import { timelinePreview, trendsEnabled } from 'mastodon/initial_state'; | ||||
| import { transientSingleColumn } from 'mastodon/is_mobile'; | ||||
| import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions'; | ||||
| import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications'; | ||||
| 
 | ||||
| import ColumnLink from './column_link'; | ||||
| import DisabledAccountBanner from './disabled_account_banner'; | ||||
| import { ListPanel } from './list_panel'; | ||||
| import SignInBanner from './sign_in_banner'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, | ||||
|   notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, | ||||
|   explore: { id: 'explore.title', defaultMessage: 'Explore' }, | ||||
|   firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' }, | ||||
|   direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, | ||||
|   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, | ||||
|   bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, | ||||
|   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, | ||||
|   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, | ||||
|   administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' }, | ||||
|   moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' }, | ||||
|   followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' }, | ||||
|   about: { id: 'navigation_bar.about', defaultMessage: 'About' }, | ||||
|   search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, | ||||
|   advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' }, | ||||
|   openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' }, | ||||
|   followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, | ||||
| }); | ||||
| 
 | ||||
| const NotificationsLink = () => { | ||||
| 
 | ||||
|   const count = useSelector(selectUnreadNotificationGroupsCount); | ||||
|   const intl = useIntl(); | ||||
| 
 | ||||
|   return ( | ||||
|     <ColumnLink | ||||
|       key='notifications' | ||||
|       transparent | ||||
|       to='/notifications' | ||||
|       icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={count} className='column-link__icon' />} | ||||
|       activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={count} className='column-link__icon' />} | ||||
|       text={intl.formatMessage(messages.notifications)} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const FollowRequestsLink = () => { | ||||
|   const count = useSelector(state => state.getIn(['user_lists', 'follow_requests', 'items'])?.size ?? 0); | ||||
|   const intl = useIntl(); | ||||
|   const dispatch = useDispatch(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     dispatch(fetchFollowRequests()); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   if (count === 0) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <ColumnLink | ||||
|       transparent | ||||
|       to='/follow_requests' | ||||
|       icon={<IconWithBadge id='user-plus' icon={PersonAddIcon} count={count} className='column-link__icon' />} | ||||
|       activeIcon={<IconWithBadge id='user-plus' icon={PersonAddActiveIcon} count={count} className='column-link__icon' />} | ||||
|       text={intl.formatMessage(messages.followRequests)} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| class NavigationPanel extends Component { | ||||
|   static propTypes = { | ||||
|     identity: identityContextPropShape, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   isFirehoseActive = (match, location) => { | ||||
|     return match || location.pathname.startsWith('/public'); | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl } = this.props; | ||||
|     const { signedIn, disabledAccountId, permissions } = this.props.identity; | ||||
| 
 | ||||
|     let banner = undefined; | ||||
| 
 | ||||
|     if (transientSingleColumn) { | ||||
|       banner = ( | ||||
|         <div className='switch-to-advanced'> | ||||
|           {intl.formatMessage(messages.openedInClassicInterface)} | ||||
|           {" "} | ||||
|           <a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'> | ||||
|             {intl.formatMessage(messages.advancedInterface)} | ||||
|           </a> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='navigation-panel'> | ||||
|         <div className='navigation-panel__logo'> | ||||
|           <Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link> | ||||
|         </div> | ||||
| 
 | ||||
|         {banner && | ||||
|           <div className='navigation-panel__banner'> | ||||
|             {banner} | ||||
|           </div> | ||||
|         } | ||||
| 
 | ||||
|         <div className='navigation-panel__menu'> | ||||
|           {signedIn && ( | ||||
|             <> | ||||
|               <ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} /> | ||||
|               <NotificationsLink /> | ||||
|               <FollowRequestsLink /> | ||||
|             </> | ||||
|           )} | ||||
| 
 | ||||
|           {trendsEnabled ? ( | ||||
|             <ColumnLink transparent to='/explore' icon='explore' iconComponent={ExploreIcon} activeIconComponent={ExploreActiveIcon} text={intl.formatMessage(messages.explore)} /> | ||||
|           ) : ( | ||||
|             <ColumnLink transparent to='/search' icon='search' iconComponent={SearchIcon} text={intl.formatMessage(messages.search)} /> | ||||
|           )} | ||||
| 
 | ||||
|           {(signedIn || timelinePreview) && ( | ||||
|             <ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} /> | ||||
|           )} | ||||
| 
 | ||||
|           {!signedIn && ( | ||||
|             <div className='navigation-panel__sign-in-banner'> | ||||
|               <hr /> | ||||
|               { disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> } | ||||
|             </div> | ||||
|           )} | ||||
| 
 | ||||
|           {signedIn && ( | ||||
|             <> | ||||
|               <ColumnLink transparent to='/conversations' icon='at' iconComponent={AlternateEmailIcon} text={intl.formatMessage(messages.direct)} /> | ||||
|               <ColumnLink transparent to='/bookmarks' icon='bookmarks' iconComponent={BookmarksIcon} activeIconComponent={BookmarksActiveIcon} text={intl.formatMessage(messages.bookmarks)} /> | ||||
|               <ColumnLink transparent to='/favourites' icon='star' iconComponent={StarIcon} activeIconComponent={StarActiveIcon} text={intl.formatMessage(messages.favourites)} /> | ||||
|               <ColumnLink transparent to='/lists' icon='list-ul' iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={intl.formatMessage(messages.lists)} /> | ||||
| 
 | ||||
|               <ListPanel /> | ||||
| 
 | ||||
|               <hr /> | ||||
| 
 | ||||
|               <ColumnLink transparent href='/settings/preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} /> | ||||
| 
 | ||||
|               {canManageReports(permissions) && <ColumnLink optional transparent href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />} | ||||
|               {canViewAdminDashboard(permissions) && <ColumnLink optional transparent href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />} | ||||
|             </> | ||||
|           )} | ||||
| 
 | ||||
|           <div className='navigation-panel__legal'> | ||||
|             <hr /> | ||||
|             <ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} /> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='flex-spacer' /> | ||||
| 
 | ||||
|         <NavigationPortal /> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default injectIntl(withIdentity(NavigationPanel)); | ||||
|  | @ -0,0 +1,495 @@ | |||
| import { useEffect, useCallback, useRef } from 'react'; | ||||
| 
 | ||||
| import { defineMessages, useIntl } from 'react-intl'; | ||||
| 
 | ||||
| import classNames from 'classnames'; | ||||
| import { Link, useLocation } from 'react-router-dom'; | ||||
| 
 | ||||
| import type { Map as ImmutableMap } from 'immutable'; | ||||
| 
 | ||||
| import { animated, useSpring } from '@react-spring/web'; | ||||
| import { useDrag } from '@use-gesture/react'; | ||||
| 
 | ||||
| import AddIcon from '@/material-icons/400-24px/add.svg?react'; | ||||
| import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; | ||||
| import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; | ||||
| import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react'; | ||||
| import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react'; | ||||
| import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; | ||||
| import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react'; | ||||
| import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react'; | ||||
| import HomeIcon from '@/material-icons/400-24px/home.svg?react'; | ||||
| import InfoIcon from '@/material-icons/400-24px/info.svg?react'; | ||||
| import LogoutIcon from '@/material-icons/400-24px/logout.svg?react'; | ||||
| import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react'; | ||||
| import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; | ||||
| import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; | ||||
| import PersonAddActiveIcon from '@/material-icons/400-24px/person_add-fill.svg?react'; | ||||
| import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; | ||||
| import PublicIcon from '@/material-icons/400-24px/public.svg?react'; | ||||
| import SearchIcon from '@/material-icons/400-24px/search.svg?react'; | ||||
| import SettingsIcon from '@/material-icons/400-24px/settings.svg?react'; | ||||
| import StarActiveIcon from '@/material-icons/400-24px/star-fill.svg?react'; | ||||
| import StarIcon from '@/material-icons/400-24px/star.svg?react'; | ||||
| import { fetchFollowRequests } from 'mastodon/actions/accounts'; | ||||
| import { openModal } from 'mastodon/actions/modal'; | ||||
| import { openNavigation, closeNavigation } from 'mastodon/actions/navigation'; | ||||
| import { Account } from 'mastodon/components/account'; | ||||
| import { IconButton } from 'mastodon/components/icon_button'; | ||||
| import { IconWithBadge } from 'mastodon/components/icon_with_badge'; | ||||
| import { WordmarkLogo } from 'mastodon/components/logo'; | ||||
| import { NavigationPortal } from 'mastodon/components/navigation_portal'; | ||||
| import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; | ||||
| import { useIdentity } from 'mastodon/identity_context'; | ||||
| import { timelinePreview, trendsEnabled, me } from 'mastodon/initial_state'; | ||||
| import { transientSingleColumn } from 'mastodon/is_mobile'; | ||||
| import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions'; | ||||
| import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications'; | ||||
| import { useAppSelector, useAppDispatch } from 'mastodon/store'; | ||||
| 
 | ||||
| import { ColumnLink } from './column_link'; | ||||
| import DisabledAccountBanner from './disabled_account_banner'; | ||||
| import { ListPanel } from './list_panel'; | ||||
| import SignInBanner from './sign_in_banner'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, | ||||
|   notifications: { | ||||
|     id: 'tabs_bar.notifications', | ||||
|     defaultMessage: 'Notifications', | ||||
|   }, | ||||
|   explore: { id: 'explore.title', defaultMessage: 'Explore' }, | ||||
|   firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' }, | ||||
|   direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, | ||||
|   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, | ||||
|   bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, | ||||
|   preferences: { | ||||
|     id: 'navigation_bar.preferences', | ||||
|     defaultMessage: 'Preferences', | ||||
|   }, | ||||
|   administration: { | ||||
|     id: 'navigation_bar.administration', | ||||
|     defaultMessage: 'Administration', | ||||
|   }, | ||||
|   moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' }, | ||||
|   followsAndFollowers: { | ||||
|     id: 'navigation_bar.follows_and_followers', | ||||
|     defaultMessage: 'Follows and followers', | ||||
|   }, | ||||
|   about: { id: 'navigation_bar.about', defaultMessage: 'About' }, | ||||
|   search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, | ||||
|   advancedInterface: { | ||||
|     id: 'navigation_bar.advanced_interface', | ||||
|     defaultMessage: 'Open in advanced web interface', | ||||
|   }, | ||||
|   openedInClassicInterface: { | ||||
|     id: 'navigation_bar.opened_in_classic_interface', | ||||
|     defaultMessage: | ||||
|       'Posts, accounts, and other specific pages are opened by default in the classic web interface.', | ||||
|   }, | ||||
|   followRequests: { | ||||
|     id: 'navigation_bar.follow_requests', | ||||
|     defaultMessage: 'Follow requests', | ||||
|   }, | ||||
|   logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, | ||||
|   compose: { id: 'tabs_bar.publish', defaultMessage: 'New Post' }, | ||||
| }); | ||||
| 
 | ||||
| const NotificationsLink = () => { | ||||
|   const count = useAppSelector(selectUnreadNotificationGroupsCount); | ||||
|   const intl = useIntl(); | ||||
| 
 | ||||
|   return ( | ||||
|     <ColumnLink | ||||
|       key='notifications' | ||||
|       transparent | ||||
|       to='/notifications' | ||||
|       icon={ | ||||
|         <IconWithBadge | ||||
|           id='bell' | ||||
|           icon={NotificationsIcon} | ||||
|           count={count} | ||||
|           className='column-link__icon' | ||||
|         /> | ||||
|       } | ||||
|       activeIcon={ | ||||
|         <IconWithBadge | ||||
|           id='bell' | ||||
|           icon={NotificationsActiveIcon} | ||||
|           count={count} | ||||
|           className='column-link__icon' | ||||
|         /> | ||||
|       } | ||||
|       text={intl.formatMessage(messages.notifications)} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const FollowRequestsLink: React.FC = () => { | ||||
|   const intl = useIntl(); | ||||
|   const count = useAppSelector( | ||||
|     (state) => | ||||
|       ( | ||||
|         state.user_lists.getIn(['follow_requests', 'items']) as | ||||
|           | ImmutableMap<string, unknown> | ||||
|           | undefined | ||||
|       )?.size ?? 0, | ||||
|   ); | ||||
|   const dispatch = useAppDispatch(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     dispatch(fetchFollowRequests()); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   if (count === 0) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <ColumnLink | ||||
|       transparent | ||||
|       to='/follow_requests' | ||||
|       icon={ | ||||
|         <IconWithBadge | ||||
|           id='user-plus' | ||||
|           icon={PersonAddIcon} | ||||
|           count={count} | ||||
|           className='column-link__icon' | ||||
|         /> | ||||
|       } | ||||
|       activeIcon={ | ||||
|         <IconWithBadge | ||||
|           id='user-plus' | ||||
|           icon={PersonAddActiveIcon} | ||||
|           count={count} | ||||
|           className='column-link__icon' | ||||
|         /> | ||||
|       } | ||||
|       text={intl.formatMessage(messages.followRequests)} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const SearchLink: React.FC = () => { | ||||
|   const intl = useIntl(); | ||||
|   const showAsSearch = useBreakpoint('full'); | ||||
| 
 | ||||
|   if (!trendsEnabled || showAsSearch) { | ||||
|     return ( | ||||
|       <ColumnLink | ||||
|         transparent | ||||
|         to={trendsEnabled ? '/explore' : '/search'} | ||||
|         icon='search' | ||||
|         iconComponent={SearchIcon} | ||||
|         text={intl.formatMessage(messages.search)} | ||||
|       /> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <ColumnLink | ||||
|       transparent | ||||
|       to='/explore' | ||||
|       icon='explore' | ||||
|       iconComponent={ExploreIcon} | ||||
|       activeIconComponent={ExploreActiveIcon} | ||||
|       text={intl.formatMessage(messages.explore)} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const ProfileCard: React.FC = () => { | ||||
|   const intl = useIntl(); | ||||
|   const dispatch = useAppDispatch(); | ||||
| 
 | ||||
|   const handleLogoutClick = useCallback(() => { | ||||
|     dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} })); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   if (!me) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className='navigation-bar'> | ||||
|       <Account id={me} minimal size={36} /> | ||||
|       <IconButton | ||||
|         icon='sign-out' | ||||
|         iconComponent={LogoutIcon} | ||||
|         title={intl.formatMessage(messages.logout)} | ||||
|         onClick={handleLogoutClick} | ||||
|       /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const MENU_WIDTH = 284; | ||||
| 
 | ||||
| export const NavigationPanel: React.FC = () => { | ||||
|   const intl = useIntl(); | ||||
|   const { signedIn, disabledAccountId, permissions } = useIdentity(); | ||||
|   const open = useAppSelector((state) => state.navigation.open); | ||||
|   const dispatch = useAppDispatch(); | ||||
|   const openable = useBreakpoint('openable'); | ||||
|   const location = useLocation(); | ||||
|   const overlayRef = useRef<HTMLDivElement | null>(null); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     dispatch(closeNavigation()); | ||||
|   }, [dispatch, location]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const handleDocumentClick = (e: MouseEvent) => { | ||||
|       if (overlayRef.current && e.target === overlayRef.current) { | ||||
|         dispatch(closeNavigation()); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     const handleDocumentKeyUp = (e: KeyboardEvent) => { | ||||
|       if (e.key === 'Escape') { | ||||
|         dispatch(closeNavigation()); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     document.addEventListener('click', handleDocumentClick); | ||||
|     document.addEventListener('keyup', handleDocumentKeyUp); | ||||
| 
 | ||||
|     return () => { | ||||
|       document.removeEventListener('click', handleDocumentClick); | ||||
|       document.removeEventListener('keyup', handleDocumentKeyUp); | ||||
|     }; | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   const [{ x }, spring] = useSpring( | ||||
|     () => ({ | ||||
|       x: open ? 0 : MENU_WIDTH, | ||||
|       onRest: { | ||||
|         x({ value }: { value: number }) { | ||||
|           if (value === 0) { | ||||
|             dispatch(openNavigation()); | ||||
|           } else if (value > 0) { | ||||
|             dispatch(closeNavigation()); | ||||
|           } | ||||
|         }, | ||||
|       }, | ||||
|     }), | ||||
|     [open], | ||||
|   ); | ||||
| 
 | ||||
|   const bind = useDrag( | ||||
|     ({ last, offset: [ox], velocity: [vx], direction: [dx], cancel }) => { | ||||
|       if (ox < -70) { | ||||
|         cancel(); | ||||
|       } | ||||
| 
 | ||||
|       if (last) { | ||||
|         if (ox > MENU_WIDTH / 2 || (vx > 0.5 && dx > 0)) { | ||||
|           void spring.start({ x: MENU_WIDTH }); | ||||
|         } else { | ||||
|           void spring.start({ x: 0 }); | ||||
|         } | ||||
|       } else { | ||||
|         void spring.start({ x: ox, immediate: true }); | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       from: () => [x.get(), 0], | ||||
|       filterTaps: true, | ||||
|       bounds: { left: 0 }, | ||||
|       rubberband: true, | ||||
|     }, | ||||
|   ); | ||||
| 
 | ||||
|   const isFirehoseActive = useCallback( | ||||
|     (match: unknown, location: { pathname: string }): boolean => { | ||||
|       return !!match || location.pathname.startsWith('/public'); | ||||
|     }, | ||||
|     [], | ||||
|   ); | ||||
| 
 | ||||
|   const previouslyFocusedElementRef = useRef<HTMLElement | null>(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (open) { | ||||
|       const firstLink = document.querySelector<HTMLAnchorElement>( | ||||
|         '.navigation-panel__menu .column-link', | ||||
|       ); | ||||
|       previouslyFocusedElementRef.current = | ||||
|         document.activeElement as HTMLElement; | ||||
|       firstLink?.focus(); | ||||
|     } else { | ||||
|       previouslyFocusedElementRef.current?.focus(); | ||||
|     } | ||||
|   }, [open]); | ||||
| 
 | ||||
|   let banner = undefined; | ||||
| 
 | ||||
|   if (transientSingleColumn) { | ||||
|     banner = ( | ||||
|       <div className='switch-to-advanced'> | ||||
|         {intl.formatMessage(messages.openedInClassicInterface)}{' '} | ||||
|         <a | ||||
|           href={`/deck${location.pathname}`} | ||||
|           className='switch-to-advanced__toggle' | ||||
|         > | ||||
|           {intl.formatMessage(messages.advancedInterface)} | ||||
|         </a> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   const showOverlay = openable && open; | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       className={classNames( | ||||
|         'columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational', | ||||
|         { 'columns-area__panels__pane--overlay': showOverlay }, | ||||
|       )} | ||||
|       ref={overlayRef} | ||||
|     > | ||||
|       <animated.div | ||||
|         className='columns-area__panels__pane__inner' | ||||
|         {...bind()} | ||||
|         style={openable ? { x } : undefined} | ||||
|       > | ||||
|         <div className='navigation-panel'> | ||||
|           <div className='navigation-panel__logo'> | ||||
|             <Link to='/' className='column-link column-link--logo'> | ||||
|               <WordmarkLogo /> | ||||
|             </Link> | ||||
|           </div> | ||||
| 
 | ||||
|           <ProfileCard /> | ||||
| 
 | ||||
|           {banner && <div className='navigation-panel__banner'>{banner}</div>} | ||||
| 
 | ||||
|           <div className='navigation-panel__menu'> | ||||
|             {signedIn && ( | ||||
|               <> | ||||
|                 <ColumnLink | ||||
|                   to='/publish' | ||||
|                   icon='plus' | ||||
|                   iconComponent={AddIcon} | ||||
|                   activeIconComponent={AddIcon} | ||||
|                   text={intl.formatMessage(messages.compose)} | ||||
|                   className='button navigation-panel__compose-button' | ||||
|                 /> | ||||
|                 <ColumnLink | ||||
|                   transparent | ||||
|                   to='/home' | ||||
|                   icon='home' | ||||
|                   iconComponent={HomeIcon} | ||||
|                   activeIconComponent={HomeActiveIcon} | ||||
|                   text={intl.formatMessage(messages.home)} | ||||
|                 /> | ||||
|                 <NotificationsLink /> | ||||
|                 <FollowRequestsLink /> | ||||
|               </> | ||||
|             )} | ||||
| 
 | ||||
|             <SearchLink /> | ||||
| 
 | ||||
|             {(signedIn || timelinePreview) && ( | ||||
|               <ColumnLink | ||||
|                 transparent | ||||
|                 to='/public/local' | ||||
|                 isActive={isFirehoseActive} | ||||
|                 icon='globe' | ||||
|                 iconComponent={PublicIcon} | ||||
|                 text={intl.formatMessage(messages.firehose)} | ||||
|               /> | ||||
|             )} | ||||
| 
 | ||||
|             {!signedIn && ( | ||||
|               <div className='navigation-panel__sign-in-banner'> | ||||
|                 <hr /> | ||||
|                 {disabledAccountId ? ( | ||||
|                   <DisabledAccountBanner /> | ||||
|                 ) : ( | ||||
|                   <SignInBanner /> | ||||
|                 )} | ||||
|               </div> | ||||
|             )} | ||||
| 
 | ||||
|             {signedIn && ( | ||||
|               <> | ||||
|                 <ColumnLink | ||||
|                   transparent | ||||
|                   to='/conversations' | ||||
|                   icon='at' | ||||
|                   iconComponent={AlternateEmailIcon} | ||||
|                   text={intl.formatMessage(messages.direct)} | ||||
|                 /> | ||||
|                 <ColumnLink | ||||
|                   transparent | ||||
|                   to='/bookmarks' | ||||
|                   icon='bookmarks' | ||||
|                   iconComponent={BookmarksIcon} | ||||
|                   activeIconComponent={BookmarksActiveIcon} | ||||
|                   text={intl.formatMessage(messages.bookmarks)} | ||||
|                 /> | ||||
|                 <ColumnLink | ||||
|                   transparent | ||||
|                   to='/favourites' | ||||
|                   icon='star' | ||||
|                   iconComponent={StarIcon} | ||||
|                   activeIconComponent={StarActiveIcon} | ||||
|                   text={intl.formatMessage(messages.favourites)} | ||||
|                 /> | ||||
| 
 | ||||
|                 <ListPanel /> | ||||
| 
 | ||||
|                 <hr /> | ||||
| 
 | ||||
|                 <ColumnLink | ||||
|                   transparent | ||||
|                   href='/settings/preferences' | ||||
|                   icon='cog' | ||||
|                   iconComponent={SettingsIcon} | ||||
|                   text={intl.formatMessage(messages.preferences)} | ||||
|                 /> | ||||
| 
 | ||||
|                 {canManageReports(permissions) && ( | ||||
|                   <ColumnLink | ||||
|                     optional | ||||
|                     transparent | ||||
|                     href='/admin/reports' | ||||
|                     icon='flag' | ||||
|                     iconComponent={ModerationIcon} | ||||
|                     text={intl.formatMessage(messages.moderation)} | ||||
|                   /> | ||||
|                 )} | ||||
|                 {canViewAdminDashboard(permissions) && ( | ||||
|                   <ColumnLink | ||||
|                     optional | ||||
|                     transparent | ||||
|                     href='/admin/dashboard' | ||||
|                     icon='tachometer' | ||||
|                     iconComponent={AdministrationIcon} | ||||
|                     text={intl.formatMessage(messages.administration)} | ||||
|                   /> | ||||
|                 )} | ||||
|               </> | ||||
|             )} | ||||
| 
 | ||||
|             <div className='navigation-panel__legal'> | ||||
|               <hr /> | ||||
|               <ColumnLink | ||||
|                 transparent | ||||
|                 to='/about' | ||||
|                 icon='ellipsis-h' | ||||
|                 iconComponent={InfoIcon} | ||||
|                 text={intl.formatMessage(messages.about)} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className='flex-spacer' /> | ||||
| 
 | ||||
|           <NavigationPortal /> | ||||
|         </div> | ||||
|       </animated.div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										53
									
								
								app/javascript/mastodon/features/ui/hooks/useBreakpoint.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,53 @@ | |||
| import { useState, useEffect } from 'react'; | ||||
| 
 | ||||
| const breakpoints = { | ||||
|   openable: 759, // Device width at which the sidebar becomes an openable hamburger menu
 | ||||
|   full: 1174, // Device width at which all 3 columns can be displayed
 | ||||
| }; | ||||
| 
 | ||||
| type Breakpoint = 'openable' | 'full'; | ||||
| 
 | ||||
| export const useBreakpoint = (breakpoint: Breakpoint) => { | ||||
|   const [isMatching, setIsMatching] = useState(false); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const mediaWatcher = window.matchMedia( | ||||
|       `(max-width: ${breakpoints[breakpoint]}px)`, | ||||
|     ); | ||||
| 
 | ||||
|     setIsMatching(mediaWatcher.matches); | ||||
| 
 | ||||
|     const handleChange = (e: MediaQueryListEvent) => { | ||||
|       setIsMatching(e.matches); | ||||
|     }; | ||||
| 
 | ||||
|     mediaWatcher.addEventListener('change', handleChange); | ||||
| 
 | ||||
|     return () => { | ||||
|       mediaWatcher.removeEventListener('change', handleChange); | ||||
|     }; | ||||
|   }, [breakpoint, setIsMatching]); | ||||
| 
 | ||||
|   return isMatching; | ||||
| }; | ||||
| 
 | ||||
| interface WithBreakpointType { | ||||
|   matchesBreakpoint: boolean; | ||||
| } | ||||
| 
 | ||||
| export function withBreakpoint<P>( | ||||
|   Component: React.ComponentType<P & WithBreakpointType>, | ||||
|   breakpoint: Breakpoint = 'full', | ||||
| ) { | ||||
|   const displayName = `withMobileLayout(${Component.displayName ?? Component.name})`; | ||||
| 
 | ||||
|   const ComponentWithBreakpoint = (props: P) => { | ||||
|     const matchesBreakpoint = useBreakpoint(breakpoint); | ||||
| 
 | ||||
|     return <Component matchesBreakpoint={matchesBreakpoint} {...props} />; | ||||
|   }; | ||||
| 
 | ||||
|   ComponentWithBreakpoint.displayName = displayName; | ||||
| 
 | ||||
|   return ComponentWithBreakpoint; | ||||
| } | ||||
|  | @ -29,7 +29,7 @@ import { expandHomeTimeline } from '../../actions/timelines'; | |||
| import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state'; | ||||
| 
 | ||||
| import BundleColumnError from './components/bundle_column_error'; | ||||
| import Header from './components/header'; | ||||
| import { NavigationBar } from './components/navigation_bar'; | ||||
| import { UploadArea } from './components/upload_area'; | ||||
| import { HashtagMenuController } from './components/hashtag_menu_controller'; | ||||
| import ColumnsAreaContainer from './containers/columns_area_container'; | ||||
|  | @ -603,12 +603,11 @@ class UI extends PureComponent { | |||
|     return ( | ||||
|       <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused> | ||||
|         <div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}> | ||||
|           <Header /> | ||||
| 
 | ||||
|           <SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}> | ||||
|             {children} | ||||
|           </SwitchingColumnsArea> | ||||
| 
 | ||||
|           <NavigationBar /> | ||||
|           {layout !== 'mobile' && <PictureInPicture />} | ||||
|           <AlertsController /> | ||||
|           {!disableHoverCards && <HoverCardController />} | ||||
|  |  | |||
|  | @ -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}", | ||||
|  |  | |||
|  | @ -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,
 | ||||
|  |  | |||
							
								
								
									
										28
									
								
								app/javascript/mastodon/reducers/navigation.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,28 @@ | |||
| import { createReducer } from '@reduxjs/toolkit'; | ||||
| 
 | ||||
| import { | ||||
|   openNavigation, | ||||
|   closeNavigation, | ||||
|   toggleNavigation, | ||||
| } from 'mastodon/actions/navigation'; | ||||
| 
 | ||||
| interface State { | ||||
|   open: boolean; | ||||
| } | ||||
| 
 | ||||
| const initialState: State = { | ||||
|   open: false, | ||||
| }; | ||||
| 
 | ||||
| export const navigationReducer = createReducer(initialState, (builder) => { | ||||
|   builder | ||||
|     .addCase(openNavigation, (state) => { | ||||
|       state.open = true; | ||||
|     }) | ||||
|     .addCase(closeNavigation, (state) => { | ||||
|       state.open = false; | ||||
|     }) | ||||
|     .addCase(toggleNavigation, (state) => { | ||||
|       state.open = !state.open; | ||||
|     }); | ||||
| }); | ||||
|  | @ -1,15 +1,16 @@ | |||
| import { createSelector } from '@reduxjs/toolkit'; | ||||
| import type { Map as ImmutableMap } from 'immutable'; | ||||
| import type { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | ||||
| 
 | ||||
| import type { List } from 'mastodon/models/list'; | ||||
| import type { RootState } from 'mastodon/store'; | ||||
| import { createAppSelector } from 'mastodon/store'; | ||||
| 
 | ||||
| export const getOrderedLists = createSelector( | ||||
|   [(state: RootState) => state.lists], | ||||
|   (lists: ImmutableMap<string, List | null>) => | ||||
|     lists | ||||
|       .toList() | ||||
|       .filter((item: List | null) => !!item) | ||||
|       .sort((a: List, b: List) => a.title.localeCompare(b.title)) | ||||
|       .toArray(), | ||||
| const getLists = createAppSelector( | ||||
|   [(state) => state.lists], | ||||
|   (lists: ImmutableMap<string, List | null>): ImmutableList<List> => | ||||
|     lists.toList().filter((item: List | null): item is List => !!item), | ||||
| ); | ||||
| 
 | ||||
| export const getOrderedLists = createAppSelector( | ||||
|   [(state) => getLists(state)], | ||||
|   (lists) => | ||||
|     lists.sort((a: List, b: List) => a.title.localeCompare(b.title)).toArray(), | ||||
| ); | ||||
|  |  | |||
|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M560-280 360-480l200-200v400Z"/></svg> | ||||
| After Width: | Height: | Size: 135 B | 
							
								
								
									
										1
									
								
								app/javascript/material-icons/400-24px/arrow_left.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M560-280 360-480l200-200v400Z"/></svg> | ||||
| After Width: | Height: | Size: 135 B | 
|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M360-360v-170l367-367q12-12 27-18t30-6q16 0 30.5 6t26.5 18l56 57q11 12 17 26.5t6 29.5q0 15-5.5 29.5T897-728L530-360H360Zm424-368 57-56-56-56-57 56 56 56ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h357L280-563v283h282l278-278v358q0 33-23.5 56.5T760-120H200Z"/></svg> | ||||
| After Width: | Height: | Size: 379 B | 
							
								
								
									
										1
									
								
								app/javascript/material-icons/400-24px/edit_square.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h357l-80 80H200v560h560v-278l80-80v358q0 33-23.5 56.5T760-120H200Zm280-360ZM360-360v-170l367-367q12-12 27-18t30-6q16 0 30.5 6t26.5 18l56 57q11 12 17 26.5t6 29.5q0 15-5.5 29.5T897-728L530-360H360Zm481-424-56-56 56 56ZM440-440h56l232-232-28-28-29-28-231 231v57Zm260-260-29-28 29 28 28 28-28-28Z"/></svg> | ||||
| After Width: | Height: | Size: 458 B | 
|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m356-160-56-56 180-180 180 180-56 56-124-124-124 124Zm124-404L300-744l56-56 124 124 124-124 56 56-180 180Z"/></svg> | ||||
| After Width: | Height: | Size: 212 B | 
							
								
								
									
										1
									
								
								app/javascript/material-icons/400-24px/unfold_less.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m356-160-56-56 180-180 180 180-56 56-124-124-124 124Zm124-404L300-744l56-56 124 124 124-124 56 56-180 180Z"/></svg> | ||||
| After Width: | Height: | Size: 212 B | 
|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-120 300-300l58-58 122 122 122-122 58 58-180 180ZM358-598l-58-58 180-180 180 180-58 58-122-122-122 122Z"/></svg> | ||||
| After Width: | Height: | Size: 213 B | 
							
								
								
									
										1
									
								
								app/javascript/material-icons/400-24px/unfold_more.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-120 300-300l58-58 122 122 122-122 58 58-180 180ZM358-598l-58-58 180-180 180 180-58 58-122-122-122 122Z"/></svg> | ||||
| After Width: | Height: | Size: 213 B | 
|  | @ -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 { | ||||
|  |  | |||