import { useEffect, useCallback, useRef } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import classNames from 'classnames'; import { Link, useLocation } from 'react-router-dom'; import type { Map as ImmutableMap } from 'immutable'; import { animated, useSpring } from '@react-spring/web'; import { useDrag } from '@use-gesture/react'; import CirclesIcon from '@/material-icons/400-24px/account_circle-fill.svg?react'; import AddIcon from '@/material-icons/400-24px/add.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react'; import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react'; import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react'; import HomeIcon from '@/material-icons/400-24px/home.svg?react'; import InfoIcon from '@/material-icons/400-24px/info.svg?react'; import LogoutIcon from '@/material-icons/400-24px/logout.svg?react'; import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; import PersonAddActiveIcon from '@/material-icons/400-24px/person_add-fill.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react'; import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import SettingsIcon from '@/material-icons/400-24px/settings.svg?react'; import StarActiveIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarIcon from '@/material-icons/400-24px/star.svg?react'; import { fetchFollowRequests } from 'mastodon/actions/accounts'; import { openModal } from 'mastodon/actions/modal'; import { openNavigation, closeNavigation } from 'mastodon/actions/navigation'; import { Account } from 'mastodon/components/account'; import { IconButton } from 'mastodon/components/icon_button'; import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { WordmarkLogo } from 'mastodon/components/logo'; import { NavigationPortal } from 'mastodon/components/navigation_portal'; import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; import { useIdentity } from 'mastodon/identity_context'; import { me, enableDtlMenu, timelinePreview, trendsEnabled, dtlTag, enableLocalTimeline } from 'mastodon/initial_state'; import { transientSingleColumn } from 'mastodon/is_mobile'; import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { ColumnLink } from './column_link'; import DisabledAccountBanner from './disabled_account_banner'; import { ListPanel } from './list_panel'; import { MoreLink } from './more_link'; import SignInBanner from './sign_in_banner'; const messages = defineMessages({ home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications', }, explore: { id: 'explore.title', defaultMessage: 'Explore' }, firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences', }, followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers', }, about: { id: 'navigation_bar.about', defaultMessage: 'About' }, search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface', }, openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.', }, followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests', }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, compose: { id: 'tabs_bar.publish', defaultMessage: 'New Post' }, local: { id: 'column.local', defaultMessage: 'Local' }, deepLocal: { id: 'column.deep_local', defaultMessage: 'Deep' }, circles: { id: 'navigation_bar.circles', defaultMessage: 'Circles' }, refresh: { id: 'refresh', defaultMessage: 'Refresh' }, }); const NotificationsLink = () => { const count = useAppSelector(selectUnreadNotificationGroupsCount); const intl = useIntl(); return ( } 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 } = 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 => { if (location.pathname.startsWith('/public/local/fixed')) return false; 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 handleRefresh = useCallback(() => { window.location.reload(); }, []); const showOverlay = openable && open; return (
{banner &&
{banner}
}
{signedIn && ( <> {enableLocalTimeline && ( )} {enableDtlMenu && ( )} )} {(signedIn || timelinePreview) && ( )} {!signedIn && (

{disabledAccountId ? ( ) : ( )}
)} {signedIn && ( <>
)}

); };