Merge branch 'fix-dropdown' into 'develop'

Fix Profile Dropdown

See merge request soapbox-pub/soapbox!2261
This commit is contained in:
Alex Gleason 2023-02-14 15:27:51 +00:00
commit 261d900b51
8 changed files with 129 additions and 72 deletions

View File

@ -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. - 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. - 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. - Modals: fix media modal automatically switching to video.
- Navigation: profile dropdown erratic behavior.
### Removed ### Removed
- Admin: single user mode. Now the homepage can be redirected to any URL. - Admin: single user mode. Now the homepage can be redirected to any URL.

View File

@ -1,11 +1,11 @@
import React from 'react'; import React, { useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import VerificationBadge from 'soapbox/components/verification-badge'; import VerificationBadge from 'soapbox/components/verification-badge';
import ActionButton from 'soapbox/features/ui/components/action-button'; 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 { getAcct } from 'soapbox/utils/accounts';
import { displayFqn } from 'soapbox/utils/state'; import { displayFqn } from 'soapbox/utils/state';
@ -117,19 +117,14 @@ const Account = ({
emoji, emoji,
note, note,
}: IAccount) => { }: IAccount) => {
const overflowRef = React.useRef<HTMLDivElement>(null); const overflowRef = useRef<HTMLDivElement>(null);
const actionRef = React.useRef<HTMLDivElement>(null); const actionRef = useRef<HTMLDivElement>(null);
// @ts-ignore
const isOnScreen = useOnScreen(overflowRef);
const [style, setStyle] = React.useState<React.CSSProperties>({ visibility: 'hidden' });
const me = useAppSelector((state) => state.me); const me = useAppSelector((state) => state.me);
const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null); const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null);
const handleAction = () => { const handleAction = () => {
// @ts-ignore onActionClick!(account);
onActionClick(account);
}; };
const renderAction = () => { const renderAction = () => {
@ -162,19 +157,6 @@ const Account = ({
const intl = useIntl(); 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) { if (!account) {
return null; return null;
} }
@ -195,7 +177,7 @@ const Account = ({
return ( return (
<div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}> <div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'> <HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3}> <HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3} className='overflow-hidden'>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -215,7 +197,7 @@ const Account = ({
</LinkEl> </LinkEl>
</ProfilePopper> </ProfilePopper>
<div className='grow'> <div className='grow overflow-hidden'>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -225,7 +207,7 @@ const Account = ({
title={account.acct} title={account.acct}
onClick={(event: React.MouseEvent) => event.stopPropagation()} onClick={(event: React.MouseEvent) => event.stopPropagation()}
> >
<HStack space={1} alignItems='center' grow style={style}> <HStack space={1} alignItems='center' grow>
<Text <Text
size='sm' size='sm'
weight='semibold' weight='semibold'
@ -241,7 +223,7 @@ const Account = ({
</ProfilePopper> </ProfilePopper>
<Stack space={withAccountNote || note ? 1 : 0}> <Stack space={withAccountNote || note ? 1 : 0}>
<HStack alignItems='center' space={1} style={style}> <HStack alignItems='center' space={1}>
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text> <Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
{account.favicon && ( {account.favicon && (

View File

@ -31,7 +31,3 @@ div:focus[data-reach-menu-list] {
[data-reach-menu-link][data-disabled] { [data-reach-menu-link][data-disabled] {
@apply opacity-25 cursor-default; @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;
}

View File

@ -37,6 +37,6 @@ const MenuList: React.FC<IMenuList> = (props) => {
}; };
/** Divides menu items. */ /** Divides menu items. */
const MenuDivider = () => <hr />; const MenuDivider = () => <hr className='my-1 mx-2 border-t-2 border-gray-100 dark:border-gray-800' />;
export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink }; export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink };

View File

@ -1,12 +1,14 @@
import { useFloating } from '@floating-ui/react';
import clsx from 'clsx';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import React from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth'; import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui'; import { MenuDivider } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useClickOutside, useFeatures } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors'; import { makeGetAccount } from 'soapbox/selectors';
import ThemeToggle from './theme-toggle'; import ThemeToggle from './theme-toggle';
@ -39,6 +41,8 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
const features = useFeatures(); const features = useFeatures();
const intl = useIntl(); const intl = useIntl();
const [visible, setVisible] = useState(false);
const { x, y, strategy, refs } = useFloating<HTMLButtonElement>({ placement: 'bottom-end' });
const authUsers = useAppSelector((state) => state.auth.users); const authUsers = useAppSelector((state) => state.auth.users);
const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!)); const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!));
@ -62,7 +66,7 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
); );
}; };
const menu: IMenuItem[] = React.useMemo(() => { const menu: IMenuItem[] = useMemo(() => {
const menu: IMenuItem[] = []; const menu: IMenuItem[] = [];
menu.push({ text: renderAccount(account), to: `/@${account.acct}` }); menu.push({ text: renderAccount(account), to: `/@${account.acct}` });
@ -96,42 +100,82 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
return menu; return menu;
}, [account, authUsers, features]); }, [account, authUsers, features]);
React.useEffect(() => { const toggleVisible = () => setVisible(!visible);
useEffect(() => {
fetchOwnAccountThrottled(); fetchOwnAccountThrottled();
}, [account, authUsers]); }, [account, authUsers]);
return ( useClickOutside(refs, () => {
<Menu> setVisible(false);
<MenuButton> });
{children}
</MenuButton> return (
<>
<button type='button' ref={refs.setReference} onClick={toggleVisible}>
{children}
</button>
{visible && (
<div
ref={refs.setFloating}
className='z-[1003] mt-2 max-w-xs rounded-md bg-white shadow-lg focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700'
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
}}
>
{menu.map((menuItem, i) => (
<MenuItem key={i} menuItem={menuItem} />
))}
</div>
)}
</>
);
};
interface MenuItemProps {
className?: string
menuItem: IMenuItem
}
const MenuItem: React.FC<MenuItemProps> = ({ 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');
<MenuList>
{menu.map((menuItem, idx) => {
if (menuItem.toggle) { if (menuItem.toggle) {
return ( return (
<div key={idx} className='flex flex-row items-center justify-between space-x-4 px-4 py-1 text-sm text-gray-700 dark:text-gray-400'> <div className='flex flex-row items-center justify-between space-x-4 px-4 py-1 text-sm text-gray-700 dark:text-gray-400'>
<span>{menuItem.text}</span> <span>{menuItem.text}</span>
{menuItem.toggle} {menuItem.toggle}
</div> </div>
); );
} else if (!menuItem.text) { } else if (!menuItem.text) {
return <MenuDivider key={idx} />; return <MenuDivider />;
} else { } else if (menuItem.action) {
const Comp: any = menuItem.action ? MenuItem : MenuLink;
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link };
return ( return (
<Comp key={idx} {...itemProps} className='truncate'> <button
type='button'
onClick={menuItem.action}
className={baseClassName}
>
{menuItem.text} {menuItem.text}
</Comp> </button>
); );
} else if (menuItem.to) {
return (
<Link
to={menuItem.to}
className={baseClassName}
>
{menuItem.text}
</Link>
);
} else {
throw menuItem;
} }
})}
</MenuList>
</Menu>
);
}; };
export default ProfileDropdown; export default ProfileDropdown;

View File

@ -2,6 +2,7 @@ export { useAccount } from './useAccount';
export { useApi } from './useApi'; export { useApi } from './useApi';
export { useAppDispatch } from './useAppDispatch'; export { useAppDispatch } from './useAppDispatch';
export { useAppSelector } from './useAppSelector'; export { useAppSelector } from './useAppSelector';
export { useClickOutside } from './useClickOutside';
export { useCompose } from './useCompose'; export { useCompose } from './useCompose';
export { useDebounce } from './useDebounce'; export { useDebounce } from './useDebounce';
export { useDimensions } from './useDimensions'; export { useDimensions } from './useDimensions';

View File

@ -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 = <T extends HTMLElement>(
refs: ExtendedRefs<T>,
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);
};
}, []);
};

View File

@ -1,13 +1,17 @@
import React from 'react'; import React, { useEffect, useMemo, useState } from 'react';
export const useOnScreen = (ref: React.MutableRefObject<HTMLElement>) => { /** Detect whether a given element is on the screen. */
const [isIntersecting, setIntersecting] = React.useState(false); // https://stackoverflow.com/a/64892655
export const useOnScreen = <T>(ref: React.RefObject<T & Element>) => {
const [isIntersecting, setIntersecting] = useState(false);
const observer = new IntersectionObserver( const observer = useMemo(() => {
return new IntersectionObserver(
([entry]) => setIntersecting(entry.isIntersecting), ([entry]) => setIntersecting(entry.isIntersecting),
); );
}, []);
React.useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
observer.observe(ref.current); observer.observe(ref.current);
} }