diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e2d8b0e..d6cd9d414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Posts: don't have to click the play button twice for embedded videos. - index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header. - Modals: fix media modal automatically switching to video. +- Navigation: profile dropdown erratic behavior. ### Removed - Admin: single user mode. Now the homepage can be redirected to any URL. diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 2901433ec..069cde252 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -1,11 +1,11 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; import VerificationBadge from 'soapbox/components/verification-badge'; import ActionButton from 'soapbox/features/ui/components/action-button'; -import { useAppSelector, useOnScreen } from 'soapbox/hooks'; +import { useAppSelector } from 'soapbox/hooks'; import { getAcct } from 'soapbox/utils/accounts'; import { displayFqn } from 'soapbox/utils/state'; @@ -117,19 +117,14 @@ const Account = ({ emoji, note, }: IAccount) => { - const overflowRef = React.useRef(null); - const actionRef = React.useRef(null); - // @ts-ignore - const isOnScreen = useOnScreen(overflowRef); - - const [style, setStyle] = React.useState({ visibility: 'hidden' }); + const overflowRef = useRef(null); + const actionRef = useRef(null); const me = useAppSelector((state) => state.me); const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null); const handleAction = () => { - // @ts-ignore - onActionClick(account); + onActionClick!(account); }; const renderAction = () => { @@ -162,19 +157,6 @@ const Account = ({ const intl = useIntl(); - React.useEffect(() => { - const style: React.CSSProperties = {}; - const actionWidth = actionRef.current?.clientWidth || 0; - - if (overflowRef.current) { - style.maxWidth = overflowRef.current.clientWidth - 30 - avatarSize - actionWidth; - } else { - style.visibility = 'hidden'; - } - - setStyle(style); - }, [isOnScreen, overflowRef, actionRef]); - if (!account) { return null; } @@ -195,7 +177,7 @@ const Account = ({ return (
- + {children}} @@ -215,7 +197,7 @@ const Account = ({ -
+
{children}} @@ -225,7 +207,7 @@ const Account = ({ title={account.acct} onClick={(event: React.MouseEvent) => event.stopPropagation()} > - + - + @{username} {account.favicon && ( diff --git a/app/soapbox/components/ui/menu/menu.css b/app/soapbox/components/ui/menu/menu.css index 216ffbd7c..96f510304 100644 --- a/app/soapbox/components/ui/menu/menu.css +++ b/app/soapbox/components/ui/menu/menu.css @@ -31,7 +31,3 @@ div:focus[data-reach-menu-list] { [data-reach-menu-link][data-disabled] { @apply opacity-25 cursor-default; } - -[data-reach-menu-popover] hr { - @apply my-1 mx-2 border-t-2 border-gray-100 dark:border-gray-800; -} diff --git a/app/soapbox/components/ui/menu/menu.tsx b/app/soapbox/components/ui/menu/menu.tsx index 4056674ef..c61cc56d2 100644 --- a/app/soapbox/components/ui/menu/menu.tsx +++ b/app/soapbox/components/ui/menu/menu.tsx @@ -37,6 +37,6 @@ const MenuList: React.FC = (props) => { }; /** Divides menu items. */ -const MenuDivider = () =>
; +const MenuDivider = () =>
; export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink }; diff --git a/app/soapbox/features/ui/components/profile-dropdown.tsx b/app/soapbox/features/ui/components/profile-dropdown.tsx index fa2b5a508..4b91368cf 100644 --- a/app/soapbox/features/ui/components/profile-dropdown.tsx +++ b/app/soapbox/features/ui/components/profile-dropdown.tsx @@ -1,12 +1,14 @@ +import { useFloating } from '@floating-ui/react'; +import clsx from 'clsx'; import throttle from 'lodash/throttle'; -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth'; import Account from 'soapbox/components/account'; -import { Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; +import { MenuDivider } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector, useClickOutside, useFeatures } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; import ThemeToggle from './theme-toggle'; @@ -39,6 +41,8 @@ const ProfileDropdown: React.FC = ({ account, children }) => { const features = useFeatures(); const intl = useIntl(); + const [visible, setVisible] = useState(false); + const { x, y, strategy, refs } = useFloating({ placement: 'bottom-end' }); const authUsers = useAppSelector((state) => state.auth.users); const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!)); @@ -62,7 +66,7 @@ const ProfileDropdown: React.FC = ({ account, children }) => { ); }; - const menu: IMenuItem[] = React.useMemo(() => { + const menu: IMenuItem[] = useMemo(() => { const menu: IMenuItem[] = []; menu.push({ text: renderAccount(account), to: `/@${account.acct}` }); @@ -96,42 +100,82 @@ const ProfileDropdown: React.FC = ({ account, children }) => { return menu; }, [account, authUsers, features]); - React.useEffect(() => { + const toggleVisible = () => setVisible(!visible); + + useEffect(() => { fetchOwnAccountThrottled(); }, [account, authUsers]); + useClickOutside(refs, () => { + setVisible(false); + }); + return ( - - + <> + - - {menu.map((menuItem, idx) => { - if (menuItem.toggle) { - return ( -
- {menuItem.text} - - {menuItem.toggle} -
- ); - } else if (!menuItem.text) { - return ; - } else { - const Comp: any = menuItem.action ? MenuItem : MenuLink; - const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link }; - - return ( - - {menuItem.text} - - ); - } - })} -
-
+ {visible && ( +
+ {menu.map((menuItem, i) => ( + + ))} +
+ )} + ); }; +interface MenuItemProps { + className?: string + menuItem: IMenuItem +} + +const MenuItem: React.FC = ({ className, menuItem }) => { + const baseClassName = clsx(className, 'block w-full cursor-pointer truncate px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 rtl:text-right dark:text-gray-500 dark:hover:bg-gray-800'); + + if (menuItem.toggle) { + return ( +
+ {menuItem.text} + + {menuItem.toggle} +
+ ); + } else if (!menuItem.text) { + return ; + } else if (menuItem.action) { + return ( + + ); + } else if (menuItem.to) { + return ( + + {menuItem.text} + + ); + } else { + throw menuItem; + } +}; + export default ProfileDropdown; diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index 7c662bca2..6460938b6 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -2,6 +2,7 @@ export { useAccount } from './useAccount'; export { useApi } from './useApi'; export { useAppDispatch } from './useAppDispatch'; export { useAppSelector } from './useAppSelector'; +export { useClickOutside } from './useClickOutside'; export { useCompose } from './useCompose'; export { useDebounce } from './useDebounce'; export { useDimensions } from './useDimensions'; diff --git a/app/soapbox/hooks/useClickOutside.ts b/app/soapbox/hooks/useClickOutside.ts new file mode 100644 index 000000000..0bb7f387d --- /dev/null +++ b/app/soapbox/hooks/useClickOutside.ts @@ -0,0 +1,29 @@ +import { ExtendedRefs } from '@floating-ui/react'; +import { useCallback, useEffect } from 'react'; + +/** Trigger `callback` when a Floating UI element is clicked outside from. */ +export const useClickOutside = ( + refs: ExtendedRefs, + callback: (e: MouseEvent) => void, +) => { + const handleWindowClick = useCallback((e: MouseEvent) => { + if (e.target) { + const target = e.target as Node; + + const floating = refs.floating.current; + const reference = refs.reference.current as T | undefined; + + if (!(floating?.contains(target) || reference?.contains(target))) { + callback(e); + } + } + }, [refs.floating.current, refs.reference.current]); + + useEffect(() => { + window.addEventListener('click', handleWindowClick); + + return () => { + window.removeEventListener('click', handleWindowClick); + }; + }, []); +}; \ No newline at end of file diff --git a/app/soapbox/hooks/useOnScreen.ts b/app/soapbox/hooks/useOnScreen.ts index 883fde698..de9aa23b2 100644 --- a/app/soapbox/hooks/useOnScreen.ts +++ b/app/soapbox/hooks/useOnScreen.ts @@ -1,13 +1,17 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; -export const useOnScreen = (ref: React.MutableRefObject) => { - const [isIntersecting, setIntersecting] = React.useState(false); +/** Detect whether a given element is on the screen. */ +// https://stackoverflow.com/a/64892655 +export const useOnScreen = (ref: React.RefObject) => { + const [isIntersecting, setIntersecting] = useState(false); - const observer = new IntersectionObserver( - ([entry]) => setIntersecting(entry.isIntersecting), - ); + const observer = useMemo(() => { + return new IntersectionObserver( + ([entry]) => setIntersecting(entry.isIntersecting), + ); + }, []); - React.useEffect(() => { + useEffect(() => { if (ref.current) { observer.observe(ref.current); }