From 9101067154e0e6632f095c5f19ceed43d6ebf28f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 11 Jun 2025 13:55:43 +0200 Subject: [PATCH] [Glitch] Change navigation layout on small screens in web UI Port a13b33d85131e3e65cfc71894672cb2b15c89c51 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/actions/navigation.ts | 7 + .../glitch/components/column_header.tsx | 8 +- .../glitch/components/icon_button.tsx | 3 + .../glitch/components/icon_with_badge.tsx | 2 +- ...{navigation_bar.jsx => navigation_bar.tsx} | 27 +- .../glitch/features/compose/index.jsx | 183 ------ .../glitch/features/compose/index.tsx | 255 +++++++++ .../glitch/features/explore/index.tsx | 5 +- .../glitch/features/getting_started/index.jsx | 2 +- .../features/getting_started_misc/index.jsx | 2 +- .../glitch/features/home_timeline/index.jsx | 10 +- .../features/ui/components/column_link.jsx | 64 --- .../features/ui/components/column_link.tsx | 103 ++++ .../features/ui/components/columns_area.jsx | 11 +- .../features/ui/components/compose_panel.tsx | 6 +- .../glitch/features/ui/components/header.jsx | 122 ---- .../features/ui/components/list_panel.jsx | 41 -- .../features/ui/components/list_panel.tsx | 92 +++ .../features/ui/components/navigation_bar.tsx | 204 +++++++ .../ui/components/navigation_panel.jsx | 205 ------- .../ui/components/navigation_panel.tsx | 534 ++++++++++++++++++ .../features/ui/hooks/useBreakpoint.tsx | 53 ++ .../flavours/glitch/features/ui/index.jsx | 5 +- .../flavours/glitch/reducers/index.ts | 2 + .../flavours/glitch/reducers/navigation.ts | 28 + .../flavours/glitch/selectors/lists.ts | 23 +- .../flavours/glitch/styles/components.scss | 244 +++++--- 27 files changed, 1496 insertions(+), 745 deletions(-) create mode 100644 app/javascript/flavours/glitch/actions/navigation.ts rename app/javascript/flavours/glitch/features/compose/components/{navigation_bar.jsx => navigation_bar.tsx} (60%) delete mode 100644 app/javascript/flavours/glitch/features/compose/index.jsx create mode 100644 app/javascript/flavours/glitch/features/compose/index.tsx delete mode 100644 app/javascript/flavours/glitch/features/ui/components/column_link.jsx create mode 100644 app/javascript/flavours/glitch/features/ui/components/column_link.tsx delete mode 100644 app/javascript/flavours/glitch/features/ui/components/header.jsx delete mode 100644 app/javascript/flavours/glitch/features/ui/components/list_panel.jsx create mode 100644 app/javascript/flavours/glitch/features/ui/components/list_panel.tsx create mode 100644 app/javascript/flavours/glitch/features/ui/components/navigation_bar.tsx delete mode 100644 app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx create mode 100644 app/javascript/flavours/glitch/features/ui/components/navigation_panel.tsx create mode 100644 app/javascript/flavours/glitch/features/ui/hooks/useBreakpoint.tsx create mode 100644 app/javascript/flavours/glitch/reducers/navigation.ts diff --git a/app/javascript/flavours/glitch/actions/navigation.ts b/app/javascript/flavours/glitch/actions/navigation.ts new file mode 100644 index 0000000000..663a1c1bce --- /dev/null +++ b/app/javascript/flavours/glitch/actions/navigation.ts @@ -0,0 +1,7 @@ +import { createAction } from '@reduxjs/toolkit'; + +export const openNavigation = createAction('navigation/open'); + +export const closeNavigation = createAction('navigation/close'); + +export const toggleNavigation = createAction('navigation/toggle'); diff --git a/app/javascript/flavours/glitch/components/column_header.tsx b/app/javascript/flavours/glitch/components/column_header.tsx index 9bd1559904..48e33b20b4 100644 --- a/app/javascript/flavours/glitch/components/column_header.tsx +++ b/app/javascript/flavours/glitch/components/column_header.tsx @@ -9,7 +9,8 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import SettingsIcon from '@/material-icons/400-24px/settings.svg?react'; +import UnfoldLessIcon from '@/material-icons/400-24px/unfold_less.svg?react'; +import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react'; import type { IconProp } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon'; import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context'; @@ -238,7 +239,10 @@ export const ColumnHeader: React.FC = ({ onClick={handleToggleClick} > - + {collapseIssues && } diff --git a/app/javascript/flavours/glitch/components/icon_button.tsx b/app/javascript/flavours/glitch/components/icon_button.tsx index 407cab4e04..978a72bfe9 100644 --- a/app/javascript/flavours/glitch/components/icon_button.tsx +++ b/app/javascript/flavours/glitch/components/icon_button.tsx @@ -27,6 +27,7 @@ interface Props { counter?: number; href?: string; ariaHidden?: boolean; + ariaControls?: string; label?: string; obfuscateCount?: boolean; } @@ -54,6 +55,7 @@ export const IconButton = forwardRef( overlay = false, tabIndex = 0, ariaHidden = false, + ariaControls, label, obfuscateCount, }, @@ -158,6 +160,7 @@ export const IconButton = forwardRef( aria-label={title} aria-expanded={expanded} aria-hidden={ariaHidden} + aria-controls={ariaControls} title={title} className={classes} onClick={handleClick} diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.tsx b/app/javascript/flavours/glitch/components/icon_with_badge.tsx index c6ab34479c..3469fec338 100644 --- a/app/javascript/flavours/glitch/components/icon_with_badge.tsx +++ b/app/javascript/flavours/glitch/components/icon_with_badge.tsx @@ -7,7 +7,7 @@ interface Props { id: string; icon: IconProp; count: number; - issueBadge: boolean; + issueBadge?: boolean; className: string; } export const IconWithBadge: React.FC = ({ diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.tsx similarity index 60% rename from app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx rename to app/javascript/flavours/glitch/features/compose/components/navigation_bar.tsx index c9ea3534ff..48dc8ed80f 100644 --- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.tsx @@ -2,34 +2,47 @@ import { useCallback } from 'react'; import { useIntl, defineMessages } from 'react-intl'; -import { useSelector, useDispatch } from 'react-redux'; - import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import { cancelReplyCompose } from 'flavours/glitch/actions/compose'; import { Account } from 'flavours/glitch/components/account'; import { IconButton } from 'flavours/glitch/components/icon_button'; import { me } from 'flavours/glitch/initial_state'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { ActionBar } from './action_bar'; - const messages = defineMessages({ cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, }); -export const NavigationBar = () => { - const dispatch = useDispatch(); +export const NavigationBar: React.FC = () => { + const dispatch = useAppDispatch(); const intl = useIntl(); - const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to'])); + const isReplying = useAppSelector( + (state) => !!state.compose.get('in_reply_to'), + ); const handleCancelClick = useCallback(() => { dispatch(cancelReplyCompose()); }, [dispatch]); + if (!me) { + return null; + } + return (
- {isReplying ? : } + {isReplying ? ( + + ) : ( + + )}
); }; diff --git a/app/javascript/flavours/glitch/features/compose/index.jsx b/app/javascript/flavours/glitch/features/compose/index.jsx deleted file mode 100644 index 29394d556a..0000000000 --- a/app/javascript/flavours/glitch/features/compose/index.jsx +++ /dev/null @@ -1,183 +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 ManufacturingIcon from '@/material-icons/400-24px/manufacturing-fill.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 { openModal } from 'flavours/glitch/actions/modal'; -import Column from 'flavours/glitch/components/column'; -import { Icon } from 'flavours/glitch/components/icon'; -import glitchedElephant1 from 'flavours/glitch/images/mbstobon-ui-0.png'; -import glitchedElephant2 from 'flavours/glitch/images/mbstobon-ui-1.png'; -import glitchedElephant3 from 'flavours/glitch/images/mbstobon-ui-2.png'; - -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' }, - settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, - logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, - compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, -}); - -const mapStateToProps = (state) => ({ - columns: state.getIn(['settings', 'columns']), - unreadNotifications: state.getIn(['notifications', 'unread']), - showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']), -}); - -// ~4% chance you'll end up with an unexpected friend -// glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z -const glitchProbability = 1 - 0.0420215528; -const totalElefriends = 3; - -class Compose extends PureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - columns: ImmutablePropTypes.list.isRequired, - multiColumn: PropTypes.bool, - unreadNotifications: PropTypes.number, - showNotificationsBadge: PropTypes.bool, - intl: PropTypes.object.isRequired, - }; - - state = { - elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends, - }; - - 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; - }; - - handleSettingsClick = e => { - const { dispatch } = this.props; - - e.preventDefault(); - e.stopPropagation(); - - dispatch(openModal({ modalType: 'SETTINGS', modalProps: {} })); - }; - - onFocus = () => { - this.props.dispatch(changeComposing(true)); - }; - - onBlur = () => { - this.props.dispatch(changeComposing(false)); - }; - - cycleElefriend = () => { - this.setState((state) => ({ elefriend: (state.elefriend + 1) % totalElefriends })); - }; - - render () { - const { multiColumn, showNotificationsBadge, unreadNotifications, intl } = this.props; - - const elefriend = [glitchedElephant1, glitchedElephant2, glitchedElephant3, elephantUIPlane][this.state.elefriend]; - - if (multiColumn) { - const { columns } = this.props; - - return ( -
- - - {multiColumn && } - -
-
- - - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is not a feature but a visual easter egg */} -
- -
-
-
-
- ); - } - - return ( - - - - - - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(Compose)); diff --git a/app/javascript/flavours/glitch/features/compose/index.tsx b/app/javascript/flavours/glitch/features/compose/index.tsx new file mode 100644 index 0000000000..445719b493 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/index.tsx @@ -0,0 +1,255 @@ +import { useEffect, useCallback, useState } 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 ManufacturingIcon from '@/material-icons/400-24px/manufacturing-fill.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 { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { Column } from 'flavours/glitch/components/column'; +import { ColumnHeader } from 'flavours/glitch/components/column_header'; +import { Icon } from 'flavours/glitch/components/icon'; +import glitchedElephant1 from 'flavours/glitch/images/mbstobon-ui-0.png'; +import glitchedElephant2 from 'flavours/glitch/images/mbstobon-ui-1.png'; +import glitchedElephant3 from 'flavours/glitch/images/mbstobon-ui-2.png'; +import { mascot } from 'flavours/glitch/initial_state'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/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', + }, + settings: { + id: 'navigation_bar.app_settings', + defaultMessage: 'App settings', + }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, + compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, +}); + +type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>; + +// ~4% chance you'll end up with an unexpected friend +// glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z +const glitchProbability = 1 - 0.0420215528; +const totalElefriends = 3; + +const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const columns = useAppSelector( + (state) => + (state.settings as ImmutableMap).get( + 'columns', + ) as ImmutableList, + ); + const unreadNotifications = useAppSelector( + (state) => state.notifications.get('unread', 0) as number, + ); + const showNotificationsBadge = useAppSelector( + (state) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + state.local_settings.getIn( + ['notifications', 'tab_badge'], + false, + ) as boolean, + ); + const [elefriend, setElefriend] = useState( + Math.random() < glitchProbability + ? Math.floor(Math.random() * totalElefriends) + : totalElefriends, + ); + + 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], + ); + + const handleSettingsClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + dispatch(openModal({ modalType: 'SETTINGS', modalProps: {} })); + }, + [dispatch], + ); + + const handleCycleElefriend = useCallback(() => { + setElefriend((elefriend + 1) % totalElefriends); + }, [elefriend, setElefriend]); + + const elephant = [ + glitchedElephant1, + glitchedElephant2, + glitchedElephant3, + elephantUIPlane, + ][elefriend]; + + if (multiColumn) { + return ( +
+ + + + +
+
+ + + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- this is not a feature but a visual easter egg */} +
+ +
+
+
+
+ ); + } + + return ( + + + +
+ +
+ + + + +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default Compose; diff --git a/app/javascript/flavours/glitch/features/explore/index.tsx b/app/javascript/flavours/glitch/features/explore/index.tsx index 36c974df16..4e5133d3fb 100644 --- a/app/javascript/flavours/glitch/features/explore/index.tsx +++ b/app/javascript/flavours/glitch/features/explore/index.tsx @@ -9,7 +9,9 @@ import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; import { Column } from 'flavours/glitch/components/column'; import type { ColumnRef } from 'flavours/glitch/components/column'; import { ColumnHeader } from 'flavours/glitch/components/column_header'; +import { SymbolLogo } from 'flavours/glitch/components/logo'; import { Search } from 'flavours/glitch/features/compose/components/search'; +import { useBreakpoint } from 'flavours/glitch/features/ui/hooks/useBreakpoint'; import { useIdentity } from 'flavours/glitch/identity_context'; import Links from './links'; @@ -25,6 +27,7 @@ const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { const { signedIn } = useIdentity(); const intl = useIntl(); const columnRef = useRef(null); + const logoRequired = useBreakpoint('full'); const handleHeaderClick = useCallback(() => { columnRef.current?.scrollTop(); @@ -38,7 +41,7 @@ const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { > { - const match = useRouteMatch(to); - const className = classNames('column-link', { 'column-link--transparent': transparent, 'column-link--optional': optional }); - const badgeElement = typeof badge !== 'undefined' ? {badge} : null; - const iconElement = (typeof icon === 'string' || iconComponent) ? : icon; - const activeIconElement = activeIcon ?? (activeIconComponent ? : iconElement); - const active = match?.isExact; - - if (href) { - return ( - - {active ? activeIconElement : iconElement} - {text} - {badgeElement} - - ); - } else if (to) { - return ( - - {active ? activeIconElement : iconElement} - {text} - {badgeElement} - - ); - } else { - const handleOnClick = (e) => { - e.preventDefault(); - e.stopPropagation(); - return onClick(e); - }; - return ( - // eslint-disable-next-line jsx-a11y/anchor-is-valid -- intentional to have the same look and feel as other menu items - - {iconElement} - {text} - {badgeElement} - - ); - } -}; - -ColumnLink.propTypes = { - icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, - iconComponent: PropTypes.func, - activeIcon: PropTypes.node, - activeIconComponent: PropTypes.func, - text: PropTypes.string.isRequired, - to: PropTypes.string, - onClick: PropTypes.func, - href: PropTypes.string, - method: PropTypes.string, - badge: PropTypes.node, - transparent: PropTypes.bool, - optional: PropTypes.bool, -}; - -export default ColumnLink; diff --git a/app/javascript/flavours/glitch/features/ui/components/column_link.tsx b/app/javascript/flavours/glitch/features/ui/components/column_link.tsx new file mode 100644 index 0000000000..a69cffc115 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_link.tsx @@ -0,0 +1,103 @@ +import type { MouseEventHandler } from 'react'; + +import classNames from 'classnames'; +import { useRouteMatch, NavLink } from 'react-router-dom'; + +import { Icon } from 'flavours/glitch/components/icon'; +import type { IconProp } from 'flavours/glitch/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; + onClick?: MouseEventHandler; + href?: string; + method?: string; + badge?: React.ReactNode; + transparent?: boolean; + optional?: boolean; + className?: string; + id?: string; +}> = ({ + icon, + activeIcon, + iconComponent, + activeIconComponent, + text, + to, + onClick, + href, + method, + badge, + transparent, + optional, + ...other +}) => { + const match = useRouteMatch(to ?? ''); + const className = classNames('column-link', { + 'column-link--transparent': transparent, + 'column-link--optional': optional, + }); + const badgeElement = + typeof badge !== 'undefined' ? ( + {badge} + ) : null; + const iconElement = iconComponent ? ( + + ) : ( + icon + ); + const activeIconElement = + activeIcon ?? + (activeIconComponent ? ( + + ) : ( + iconElement + )); + const active = !!match; + + if (href) { + return ( + + {active ? activeIconElement : iconElement} + {text} + {badgeElement} + + ); + } else if (to) { + return ( + + {active ? activeIconElement : iconElement} + {text} + {badgeElement} + + ); + } else { + return ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid -- intentional to have the same look and feel as other menu items + + {iconElement} + {text} + {badgeElement} + + ); + } +}; diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx index 94497f62dd..5d596b7bcb 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx @@ -25,7 +25,7 @@ import BundleColumnError from './bundle_column_error'; import { ColumnLoading } from './column_loading'; import { ComposePanel } from './compose_panel'; import DrawerLoading from './drawer_loading'; -import NavigationPanel from './navigation_panel'; +import { NavigationPanel } from './navigation_panel'; const componentMap = { 'COMPOSE': Compose, @@ -59,7 +59,6 @@ export default class ColumnsArea extends ImmutablePureComponent { isModalOpen: PropTypes.bool.isRequired, singleColumn: PropTypes.bool, children: PropTypes.node, - openSettings: PropTypes.func, }; // Corresponds to (max-width: $no-gap-breakpoint - 1px) in SCSS @@ -116,7 +115,7 @@ export default class ColumnsArea extends ImmutablePureComponent { }; render () { - const { columns, children, singleColumn, isModalOpen, openSettings } = this.props; + const { columns, children, singleColumn, isModalOpen } = this.props; const { renderComposePanel } = this.state; if (singleColumn) { @@ -133,11 +132,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
{children}
-
-
- -
-
+ ); } diff --git a/app/javascript/flavours/glitch/features/ui/components/compose_panel.tsx b/app/javascript/flavours/glitch/features/ui/components/compose_panel.tsx index 17154973fd..bed7432d68 100644 --- a/app/javascript/flavours/glitch/features/ui/components/compose_panel.tsx +++ b/app/javascript/flavours/glitch/features/ui/components/compose_panel.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect } from 'react'; import { useLayout } from '@/flavours/glitch/hooks/useLayout'; import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; import { + changeComposing, mountCompose, unmountCompose, } from 'flavours/glitch/actions/compose'; @@ -14,6 +15,9 @@ import { useIdentity } from 'flavours/glitch/identity_context'; export const ComposePanel: React.FC = () => { const dispatch = useAppDispatch(); + const handleFocus = useCallback(() => { + dispatch(changeComposing(true)); + }, [dispatch]); const { signedIn } = useIdentity(); const hideComposer = useAppSelector((state) => { const mounted = state.compose.get('mounted'); @@ -33,7 +37,7 @@ export const ComposePanel: React.FC = () => { const { singleColumn } = useLayout(); return ( -
+
{!signedIn && ( diff --git a/app/javascript/flavours/glitch/features/ui/components/header.jsx b/app/javascript/flavours/glitch/features/ui/components/header.jsx deleted file mode 100644 index f102912faa..0000000000 --- a/app/javascript/flavours/glitch/features/ui/components/header.jsx +++ /dev/null @@ -1,122 +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 'flavours/glitch/actions/modal'; -import { fetchServer } from 'flavours/glitch/actions/server'; -import { Avatar } from 'flavours/glitch/components/avatar'; -import { Icon } from 'flavours/glitch/components/icon'; -import { WordmarkLogo, SymbolLogo } from 'flavours/glitch/components/logo'; -import { Permalink } from 'flavours/glitch/components/permalink'; -import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; -import { registrationsOpen, me, sso_redirect } from 'flavours/glitch/initial_state'; - -const Account = connect(state => ({ - account: state.getIn(['accounts', me]), -}))(({ account }) => ( - - - -)); - -const messages = defineMessages({ - search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, -}); - -const mapStateToProps = (state) => ({ - signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up', -}); - -const mapDispatchToProps = (dispatch) => ({ - openClosedRegistrationsModal() { - dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); - }, - dispatchServer() { - dispatch(fetchServer()); - } -}); - -class Header extends PureComponent { - static propTypes = { - identity: identityContextPropShape, - openClosedRegistrationsModal: PropTypes.func, - location: PropTypes.object, - signupUrl: PropTypes.string.isRequired, - dispatchServer: PropTypes.func, - intl: PropTypes.object.isRequired, - }; - - componentDidMount () { - const { dispatchServer } = this.props; - dispatchServer(); - } - - render () { - const { signedIn } = this.props.identity; - const { location, openClosedRegistrationsModal, signupUrl, intl } = this.props; - - let content; - - if (signedIn) { - content = ( - <> - {location.pathname !== '/search' && } - {location.pathname !== '/publish' && } - - - ); - } else { - - if (sso_redirect) { - content = ( - - ); - } else { - let signupButton; - - if (registrationsOpen) { - signupButton = ( - - - - ); - } else { - signupButton = ( - - ); - } - - content = ( - <> - {signupButton} - - - ); - } - } - - return ( -
- - - - - -
- {content} -
-
- ); - } - -} - -export default injectIntl(withRouter(withIdentity(connect(mapStateToProps, mapDispatchToProps)(Header)))); diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/list_panel.jsx deleted file mode 100644 index 2eba2bdbe2..0000000000 --- a/app/javascript/flavours/glitch/features/ui/components/list_panel.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect } from 'react'; - -import { createSelector } from '@reduxjs/toolkit'; -import { useDispatch, useSelector } from 'react-redux'; - -import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react'; -import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; -import { fetchLists } from 'flavours/glitch/actions/lists'; - -import ColumnLink from './column_link'; - -const getOrderedLists = createSelector([state => state.get('lists')], lists => { - if (!lists) { - return lists; - } - - return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4); -}); - -export const ListPanel = () => { - const dispatch = useDispatch(); - const lists = useSelector(state => getOrderedLists(state)); - - useEffect(() => { - dispatch(fetchLists()); - }, [dispatch]); - - if (!lists || lists.isEmpty()) { - return null; - } - - return ( -
-
- - {lists.map(list => ( - - ))} -
- ); -}; diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.tsx b/app/javascript/flavours/glitch/features/ui/components/list_panel.tsx new file mode 100644 index 0000000000..bb3f9e2119 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState, useCallback, useId } from 'react'; + +import { useIntl, defineMessages } from 'react-intl'; + +import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react'; +import ArrowLeftIcon from '@/material-icons/400-24px/arrow_left.svg?react'; +import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react'; +import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import { fetchLists } from 'flavours/glitch/actions/lists'; +import { IconButton } from 'flavours/glitch/components/icon_button'; +import { getOrderedLists } from 'flavours/glitch/selectors/lists'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; + +import { ColumnLink } from './column_link'; + +const messages = defineMessages({ + lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + expand: { + id: 'navigation_panel.expand_lists', + defaultMessage: 'Expand list menu', + }, + collapse: { + id: 'navigation_panel.collapse_lists', + defaultMessage: 'Collapse list menu', + }, +}); + +export const ListPanel: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const lists = useAppSelector((state) => getOrderedLists(state)); + const [expanded, setExpanded] = useState(false); + const accessibilityId = useId(); + + useEffect(() => { + dispatch(fetchLists()); + }, [dispatch]); + + const handleClick = useCallback(() => { + setExpanded((value) => !value); + }, [setExpanded]); + + return ( +
+
+ + + {lists.length > 0 && ( + + )} +
+ + {lists.length > 0 && expanded && ( +
+ {lists.map((list) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_bar.tsx b/app/javascript/flavours/glitch/features/ui/components/navigation_bar.tsx new file mode 100644 index 0000000000..c6206658a5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_bar.tsx @@ -0,0 +1,204 @@ +import { useCallback, useEffect } from 'react'; + +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { NavLink, useRouteMatch } from 'react-router-dom'; + +import AddIcon from '@/material-icons/400-24px/add.svg?react'; +import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react'; +import HomeIcon from '@/material-icons/400-24px/home.svg?react'; +import MenuIcon from '@/material-icons/400-24px/menu.svg?react'; +import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; +import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; +import SearchIcon from '@/material-icons/400-24px/search.svg?react'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { toggleNavigation } from 'flavours/glitch/actions/navigation'; +import { fetchServer } from 'flavours/glitch/actions/server'; +import { Icon } from 'flavours/glitch/components/icon'; +import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge'; +import { useIdentity } from 'flavours/glitch/identity_context'; +import { registrationsOpen, sso_redirect } from 'flavours/glitch/initial_state'; +import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; + +const messages = defineMessages({ + home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + search: { id: 'tabs_bar.search', defaultMessage: 'Search' }, + publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' }, + notifications: { + id: 'tabs_bar.notifications', + defaultMessage: 'Notifications', + }, + menu: { id: 'tabs_bar.menu', defaultMessage: 'Menu' }, +}); + +const IconLabelButton: React.FC<{ + to: string; + icon?: React.ReactNode; + activeIcon?: React.ReactNode; + title: string; +}> = ({ to, icon, activeIcon, title }) => { + const match = useRouteMatch(to); + + return ( + + {match && activeIcon ? activeIcon : icon} + + ); +}; + +const NotificationsButton = () => { + const count = useAppSelector(selectUnreadNotificationGroupsCount); + const intl = useIntl(); + + return ( + + } + activeIcon={ + + } + title={intl.formatMessage(messages.notifications)} + /> + ); +}; + +const LoginOrSignUp: React.FC = () => { + const dispatch = useAppDispatch(); + const signupUrl = useAppSelector( + (state) => + (state.server.getIn(['server', 'registrations', 'url'], null) as + | string + | null) ?? '/auth/sign_up', + ); + + const openClosedRegistrationsModal = useCallback(() => { + dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS', modalProps: {} })); + }, [dispatch]); + + useEffect(() => { + dispatch(fetchServer()); + }, [dispatch]); + + if (sso_redirect) { + return ( +
+ + + +
+ ); + } else { + let signupButton; + + if (registrationsOpen) { + signupButton = ( + + + + ); + } else { + signupButton = ( + + ); + } + + return ( +
+ {signupButton} + + + +
+ ); + } +}; + +export const NavigationBar: React.FC = () => { + const { signedIn } = useIdentity(); + const dispatch = useAppDispatch(); + const open = useAppSelector((state) => state.navigation.open); + const intl = useIntl(); + + const handleClick = useCallback(() => { + dispatch(toggleNavigation()); + }, [dispatch]); + + return ( +
+ {!signedIn && } + +
+ {signedIn && ( + <> + } + activeIcon={} + /> + } + /> + } + /> + + + )} + + +
+
+ ); +}; diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx deleted file mode 100644 index 0d73cd421f..0000000000 --- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx +++ /dev/null @@ -1,205 +0,0 @@ -import PropTypes from 'prop-types'; -import { Component, useEffect } from 'react'; - -import { defineMessages, injectIntl, useIntl } from 'react-intl'; - -import { useSelector, useDispatch } from 'react-redux'; - - -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 MailActiveIcon from '@/material-icons/400-24px/mail-fill.svg?react'; -import MailIcon from '@/material-icons/400-24px/mail.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 'flavours/glitch/actions/accounts'; -import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge'; -import { NavigationPortal } from 'flavours/glitch/components/navigation_portal'; -import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; -import { timelinePreview, trendsEnabled } from 'flavours/glitch/initial_state'; -import { transientSingleColumn } from 'flavours/glitch/is_mobile'; -import { canManageReports, canViewAdminDashboard } from 'flavours/glitch/permissions'; -import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications'; -import { preferencesLink } from 'flavours/glitch/utils/backend_links'; - -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.' }, - app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, - followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, -}); - -const NotificationsLink = () => { - const count = useSelector(selectUnreadNotificationGroupsCount); - const showCount = useSelector(state => state.getIn(['local_settings', 'notifications', 'tab_badge'])); - const intl = useIntl(); - - return ( - } - activeIcon={} - text={intl.formatMessage(messages.notifications)} - /> - ); -}; - -const FollowRequestsLink = () => { - const count = useSelector(state => state.getIn(['user_lists', 'follow_requests', 'items'])?.size ?? 0); - const intl = useIntl(); - const dispatch = useDispatch(); - - useEffect(() => { - dispatch(fetchFollowRequests()); - }, [dispatch]); - - if (count === 0) { - return null; - } - - return ( - } - activeIcon={} - text={intl.formatMessage(messages.followRequests)} - /> - ); -}; - -class NavigationPanel extends Component { - static propTypes = { - identity: identityContextPropShape, - intl: PropTypes.object.isRequired, - onOpenSettings: PropTypes.func, - }; - - isFirehoseActive = (match, location) => { - return match || location.pathname.startsWith('/public'); - }; - - render () { - const { intl, onOpenSettings } = this.props; - const { signedIn, disabledAccountId, permissions } = this.props.identity; - - let banner = undefined; - - if (transientSingleColumn) { - banner = ( -
- {intl.formatMessage(messages.openedInClassicInterface)} - {" "} - - {intl.formatMessage(messages.advancedInterface)} - -
- ); - } - - return ( -
- {banner && -
- {banner} -
- } - -
- {signedIn && ( - <> - - - - - )} - - {trendsEnabled ? ( - - ) : ( - - )} - - {(signedIn || timelinePreview) && ( - - )} - - {!signedIn && ( -
-
- { disabledAccountId ? : } -
- )} - - {signedIn && ( - <> - - - - - - - -
- - {!!preferencesLink && } - - - {canManageReports(permissions) && } - {canViewAdminDashboard(permissions) && } - - )} - -
-
- -
-
- -
- - -
- ); - } - -} - -export default injectIntl(withIdentity(NavigationPanel)); diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.tsx b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.tsx new file mode 100644 index 0000000000..280ac2862c --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.tsx @@ -0,0 +1,534 @@ +import type { MouseEventHandler } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import classNames from 'classnames'; +import { 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 'flavours/glitch/actions/accounts'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { + openNavigation, + closeNavigation, +} from 'flavours/glitch/actions/navigation'; +import { Account } from 'flavours/glitch/components/account'; +import { IconButton } from 'flavours/glitch/components/icon_button'; +import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge'; +import { NavigationPortal } from 'flavours/glitch/components/navigation_portal'; +import { useBreakpoint } from 'flavours/glitch/features/ui/hooks/useBreakpoint'; +import { useIdentity } from 'flavours/glitch/identity_context'; +import { + timelinePreview, + trendsEnabled, + me, +} from 'flavours/glitch/initial_state'; +import { transientSingleColumn } from 'flavours/glitch/is_mobile'; +import { + canManageReports, + canViewAdminDashboard, +} from 'flavours/glitch/permissions'; +import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications'; +import { useAppSelector, useAppDispatch } from 'flavours/glitch/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' }, + app_settings: { + id: 'navigation_bar.app_settings', + defaultMessage: 'App settings', + }, +}); + +const NotificationsLink = () => { + const count = useAppSelector(selectUnreadNotificationGroupsCount); + const showCount = useAppSelector( + (state) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + state.local_settings.getIn([ + 'notifications', + 'tab_badge', + false, + ]) as boolean, + ); + const intl = useIntl(); + + return ( + + } + activeIcon={ + + } + text={intl.formatMessage(messages.notifications)} + /> + ); +}; + +const FollowRequestsLink: React.FC = () => { + const intl = useIntl(); + const count = useAppSelector( + (state) => + ( + state.user_lists.getIn(['follow_requests', 'items']) as + | ImmutableMap + | undefined + )?.size ?? 0, + ); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(fetchFollowRequests()); + }, [dispatch]); + + if (count === 0) { + return null; + } + + return ( + + } + activeIcon={ + + } + text={intl.formatMessage(messages.followRequests)} + /> + ); +}; + +const SearchLink: React.FC = () => { + const intl = useIntl(); + const showAsSearch = useBreakpoint('full'); + + if (!trendsEnabled || showAsSearch) { + return ( + + ); + } + + return ( + + ); +}; + +const ProfileCard: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const handleLogoutClick = useCallback(() => { + dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} })); + }, [dispatch]); + + if (!me) { + return null; + } + + return ( +
+ + +
+ ); +}; + +const MENU_WIDTH = 284; + +export const NavigationPanel: React.FC = () => { + const intl = useIntl(); + const { signedIn, disabledAccountId, permissions } = useIdentity(); + const open = useAppSelector((state) => state.navigation.open); + const dispatch = useAppDispatch(); + const openable = useBreakpoint('openable'); + const location = useLocation(); + const overlayRef = useRef(null); + + useEffect(() => { + dispatch(closeNavigation()); + }, [dispatch, location]); + + useEffect(() => { + const handleDocumentClick = (e: MouseEvent) => { + if (overlayRef.current && e.target === overlayRef.current) { + dispatch(closeNavigation()); + } + }; + + const handleDocumentKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + dispatch(closeNavigation()); + } + }; + + document.addEventListener('click', handleDocumentClick); + document.addEventListener('keyup', handleDocumentKeyUp); + + return () => { + document.removeEventListener('click', handleDocumentClick); + document.removeEventListener('keyup', handleDocumentKeyUp); + }; + }, [dispatch]); + + const [{ x }, spring] = useSpring( + () => ({ + x: open ? 0 : MENU_WIDTH, + onRest: { + x({ value }: { value: number }) { + if (value === 0) { + dispatch(openNavigation()); + } else if (value > 0) { + dispatch(closeNavigation()); + } + }, + }, + }), + [open], + ); + + const bind = useDrag( + ({ last, offset: [ox], velocity: [vx], direction: [dx], cancel }) => { + if (ox < -70) { + cancel(); + } + + if (last) { + if (ox > MENU_WIDTH / 2 || (vx > 0.5 && dx > 0)) { + void spring.start({ x: MENU_WIDTH }); + } else { + void spring.start({ x: 0 }); + } + } else { + void spring.start({ x: ox, immediate: true }); + } + }, + { + from: () => [x.get(), 0], + filterTaps: true, + bounds: { left: 0 }, + rubberband: true, + }, + ); + + const isFirehoseActive = useCallback( + (match: unknown, location: { pathname: string }): boolean => { + return !!match || location.pathname.startsWith('/public'); + }, + [], + ); + + const previouslyFocusedElementRef = useRef(); + + useEffect(() => { + if (open) { + const firstLink = document.querySelector( + '.navigation-panel__menu .column-link', + ); + previouslyFocusedElementRef.current = + document.activeElement as HTMLElement; + firstLink?.focus(); + } else { + previouslyFocusedElementRef.current?.focus(); + } + }, [open]); + + let banner = undefined; + + if (transientSingleColumn) { + banner = ( +
+ {intl.formatMessage(messages.openedInClassicInterface)}{' '} + + {intl.formatMessage(messages.advancedInterface)} + +
+ ); + } + + const handleOpenSettings = useCallback( + (e) => { + e.preventDefault(); + e.stopPropagation(); + + dispatch( + openModal({ + modalType: 'SETTINGS', + modalProps: {}, + }), + ); + }, + [dispatch], + ); + + const showOverlay = openable && open; + + return ( +
+ +
+ + + {banner &&
{banner}
} + +
+ {signedIn && ( + <> + + + + + + )} + + + + {(signedIn || timelinePreview) && ( + + )} + + {!signedIn && ( +
+
+ {disabledAccountId ? ( + + ) : ( + + )} +
+ )} + + {signedIn && ( + <> + + + + + + +
+ + + + + {canManageReports(permissions) && ( + + )} + {canViewAdminDashboard(permissions) && ( + + )} + + )} + +
+
+ +
+
+ +
+ + +
+ +
+ ); +}; diff --git a/app/javascript/flavours/glitch/features/ui/hooks/useBreakpoint.tsx b/app/javascript/flavours/glitch/features/ui/hooks/useBreakpoint.tsx new file mode 100644 index 0000000000..af96ab3766 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/hooks/useBreakpoint.tsx @@ -0,0 +1,53 @@ +import { useState, useEffect } from 'react'; + +const breakpoints = { + openable: 759, // Device width at which the sidebar becomes an openable hamburger menu + full: 1174, // Device width at which all 3 columns can be displayed +}; + +type Breakpoint = 'openable' | 'full'; + +export const useBreakpoint = (breakpoint: Breakpoint) => { + const [isMatching, setIsMatching] = useState(false); + + useEffect(() => { + const mediaWatcher = window.matchMedia( + `(max-width: ${breakpoints[breakpoint]}px)`, + ); + + setIsMatching(mediaWatcher.matches); + + const handleChange = (e: MediaQueryListEvent) => { + setIsMatching(e.matches); + }; + + mediaWatcher.addEventListener('change', handleChange); + + return () => { + mediaWatcher.removeEventListener('change', handleChange); + }; + }, [breakpoint, setIsMatching]); + + return isMatching; +}; + +interface WithBreakpointType { + matchesBreakpoint: boolean; +} + +export function withBreakpoint

( + Component: React.ComponentType

, + breakpoint: Breakpoint = 'full', +) { + const displayName = `withMobileLayout(${Component.displayName ?? Component.name})`; + + const ComponentWithBreakpoint = (props: P) => { + const matchesBreakpoint = useBreakpoint(breakpoint); + + return ; + }; + + ComponentWithBreakpoint.displayName = displayName; + + return ComponentWithBreakpoint; +} diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index 22814f40e3..c0378891bb 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -32,7 +32,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'; @@ -671,12 +671,11 @@ class UI extends PureComponent { />

)} -
- {children} + {layout !== 'mobile' && } {!disableHoverCards && } diff --git a/app/javascript/flavours/glitch/reducers/index.ts b/app/javascript/flavours/glitch/reducers/index.ts index dd79bd57c3..145c3c4d26 100644 --- a/app/javascript/flavours/glitch/reducers/index.ts +++ b/app/javascript/flavours/glitch/reducers/index.ts @@ -22,6 +22,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'; @@ -78,6 +79,7 @@ const reducers = { history, notificationPolicy: notificationPolicyReducer, notificationRequests: notificationRequestsReducer, + navigation: navigationReducer, }; // We want the root state to be an ImmutableRecord, which is an object with a defined list of keys, diff --git a/app/javascript/flavours/glitch/reducers/navigation.ts b/app/javascript/flavours/glitch/reducers/navigation.ts new file mode 100644 index 0000000000..b9aeb3ca86 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/navigation.ts @@ -0,0 +1,28 @@ +import { createReducer } from '@reduxjs/toolkit'; + +import { + openNavigation, + closeNavigation, + toggleNavigation, +} from 'flavours/glitch/actions/navigation'; + +interface State { + open: boolean; +} + +const initialState: State = { + open: false, +}; + +export const navigationReducer = createReducer(initialState, (builder) => { + builder + .addCase(openNavigation, (state) => { + state.open = true; + }) + .addCase(closeNavigation, (state) => { + state.open = false; + }) + .addCase(toggleNavigation, (state) => { + state.open = !state.open; + }); +}); diff --git a/app/javascript/flavours/glitch/selectors/lists.ts b/app/javascript/flavours/glitch/selectors/lists.ts index 68755a45d1..a4831d97c1 100644 --- a/app/javascript/flavours/glitch/selectors/lists.ts +++ b/app/javascript/flavours/glitch/selectors/lists.ts @@ -1,15 +1,16 @@ -import { createSelector } from '@reduxjs/toolkit'; -import type { Map as ImmutableMap } from 'immutable'; +import type { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import type { List } from 'flavours/glitch/models/list'; -import type { RootState } from 'flavours/glitch/store'; +import { createAppSelector } from 'flavours/glitch/store'; -export const getOrderedLists = createSelector( - [(state: RootState) => state.lists], - (lists: ImmutableMap) => - lists - .toList() - .filter((item: List | null) => !!item) - .sort((a: List, b: List) => a.title.localeCompare(b.title)) - .toArray(), +const getLists = createAppSelector( + [(state) => state.lists], + (lists: ImmutableMap): ImmutableList => + lists.toList().filter((item: List | null): item is List => !!item), +); + +export const getOrderedLists = createAppSelector( + [(state) => getLists(state)], + (lists) => + lists.sort((a: List, b: List) => a.title.localeCompare(b.title)).toArray(), ); diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index ee302be7d8..b5e0f881fd 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -2709,15 +2709,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; } } } @@ -2954,67 +2952,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; } } } @@ -3023,13 +3023,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; } } @@ -3198,8 +3197,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; } } @@ -3211,7 +3212,7 @@ $ui-header-logo-wordmark-width: 99px; } .columns-area__panels { - min-height: calc(100vh - $ui-header-height); + min-height: 100vh; gap: 0; } @@ -3229,24 +3230,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, @@ -3270,30 +3261,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; @@ -3520,6 +3545,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; @@ -3552,12 +3620,6 @@ $ui-header-logo-wordmark-width: 99px; display: none; } } - - @media screen and (height <= 1040px) { - .list-panel { - display: none; - } - } } .navigation-panel, @@ -4424,6 +4486,10 @@ a.status-card { &:focus-visible { outline: $ui-button-icon-focus-outline; } + + .logo { + height: 24px; + } } .column-header__back-button + &__title { @@ -4507,10 +4573,6 @@ a.status-card { &:hover { color: $primary-text-color; } - - .icon-sliders { - transform: rotate(60deg); - } } &:disabled {