Merge branch 'fork' into pl-api
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
@ -24,9 +24,14 @@ const closeModal = (type?: ModalType) => ({
|
||||
modalType: type,
|
||||
});
|
||||
|
||||
type ModalsAction =
|
||||
ReturnType<typeof openModalSuccess>
|
||||
| ReturnType<typeof closeModal>;
|
||||
|
||||
export {
|
||||
MODAL_OPEN,
|
||||
MODAL_CLOSE,
|
||||
openModal,
|
||||
closeModal,
|
||||
type ModalsAction,
|
||||
};
|
||||
|
||||
@ -2,10 +2,10 @@ import { offset, Placement, useFloating, flip, arrow, shift } from '@floating-ui
|
||||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { closeDropdownMenu as closeDropdownMenuRedux, openDropdownMenu } from 'soapbox/actions/dropdown-menu';
|
||||
import { closeModal, openModal } from 'soapbox/actions/modals';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { userTouching } from 'soapbox/is-mobile';
|
||||
|
||||
@ -20,7 +20,8 @@ type Menu = Array<MenuItem | null>;
|
||||
interface IDropdownMenu {
|
||||
children?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
items: Menu;
|
||||
items?: Menu;
|
||||
component?: React.FC<{ handleClose: () => any }>;
|
||||
onClose?: () => void;
|
||||
onOpen?: () => void;
|
||||
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>;
|
||||
@ -37,13 +38,13 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
||||
children,
|
||||
disabled,
|
||||
items,
|
||||
component: Component,
|
||||
onClose,
|
||||
onOpen,
|
||||
onShiftClick,
|
||||
placement: initialPlacement = 'top',
|
||||
src = require('@tabler/icons/outline/dots.svg'),
|
||||
title = 'Menu',
|
||||
...filteredProps
|
||||
} = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
@ -52,7 +53,11 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [isDisplayed, setIsDisplayed] = useState<boolean>(false);
|
||||
|
||||
const touching = userTouching.matches;
|
||||
|
||||
const arrowRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownHistoryKey = useRef<number>();
|
||||
const unlistenHistory = useRef<ReturnType<typeof history.listen>>();
|
||||
|
||||
const { x, y, strategy, refs, middlewareData, placement } = useFloating<HTMLButtonElement>({
|
||||
placement: initialPlacement,
|
||||
@ -87,37 +92,30 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* On mobile screens, let's replace the Popper dropdown with a Modal.
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
if (userTouching.matches) {
|
||||
dispatch(
|
||||
openModal('ACTIONS', {
|
||||
status: filteredProps.status,
|
||||
actions: items,
|
||||
onClick: handleItemClick,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(openDropdownMenu());
|
||||
setIsOpen(true);
|
||||
}
|
||||
dispatch(openDropdownMenu());
|
||||
setIsOpen(true);
|
||||
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
const handleClose = (goBack: any = true) => {
|
||||
(refs.reference.current as HTMLButtonElement)?.focus();
|
||||
|
||||
if (userTouching.matches) {
|
||||
dispatch(closeModal('ACTIONS'));
|
||||
} else {
|
||||
closeDropdownMenu();
|
||||
setIsOpen(false);
|
||||
if (unlistenHistory.current) {
|
||||
unlistenHistory.current();
|
||||
unlistenHistory.current = undefined;
|
||||
}
|
||||
const { state } = history.location;
|
||||
if (goBack && state && (state as any).soapboxDropdownKey === dropdownHistoryKey.current) {
|
||||
history.goBack();
|
||||
(history.location.state as any).soapboxDropdownKey = true;
|
||||
}
|
||||
|
||||
closeDropdownMenu();
|
||||
setIsOpen(false);
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
@ -145,36 +143,17 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick: React.EventHandler<React.MouseEvent> = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const i = Number(event.currentTarget.getAttribute('data-index'));
|
||||
const item = items[i];
|
||||
if (!item) return;
|
||||
|
||||
const { action, to } = item;
|
||||
|
||||
handleClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
action(event);
|
||||
} else if (to) {
|
||||
dispatch(closeModal('MEDIA'));
|
||||
history.push(to);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentClick = (event: Event) => {
|
||||
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
|
||||
handleClose();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!refs.floating.current) return;
|
||||
|
||||
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
|
||||
const items = Array.from(refs.floating.current.querySelectorAll('a, button'));
|
||||
const index = items.indexOf(document.activeElement as any);
|
||||
|
||||
let element = null;
|
||||
@ -205,7 +184,7 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
(element as HTMLAnchorElement).focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
@ -257,13 +236,51 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setIsDisplayed(isOpen), isOpen ? 0 : 150);
|
||||
|
||||
if (isOpen && touching) {
|
||||
const { pathname, state } = history.location;
|
||||
|
||||
dropdownHistoryKey.current = Date.now();
|
||||
|
||||
history.push(pathname, { ...(state as any), soapboxDropdownKey: dropdownHistoryKey.current });
|
||||
|
||||
unlistenHistory.current = history.listen(({ state }, action) => {
|
||||
if (!(state as any)?.soapboxDropdownKey) {
|
||||
handleClose(false);
|
||||
} else if (action === 'POP') {
|
||||
handleClose(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (items.length === 0) {
|
||||
if (items?.length === 0 && !Component) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const autoFocus = !items.some((item) => item?.active);
|
||||
const autoFocus = items && !items.some((item) => item?.active);
|
||||
|
||||
const getClassName = () => {
|
||||
const className = clsx('z-[1001] bg-white py-1 shadow-lg ease-in-out focus:outline-none black:bg-black no-reduce-motion:transition-all dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', touching ? clsx({
|
||||
'overflow-auto fixed left-0 right-0 mx-auto w-[calc(100vw-2rem)] max-w-lg max-h-[calc(100dvh-1rem)] rounded-t-xl duration-200': true,
|
||||
'bottom-0 opacity-100': isDisplayed && isOpen,
|
||||
'-bottom-32 opacity-0': !(isDisplayed && isOpen),
|
||||
}) : clsx({
|
||||
'rounded-md min-w-56 max-w-sm duration-100': true,
|
||||
'scale-0': !(isDisplayed && isOpen),
|
||||
'scale-100': isDisplayed && isOpen,
|
||||
'origin-bottom': placement === 'top',
|
||||
'origin-left': placement === 'right',
|
||||
'origin-top': placement === 'bottom',
|
||||
'origin-right': placement === 'left',
|
||||
'origin-bottom-left': placement === 'top-start',
|
||||
'origin-bottom-right': placement === 'top-end',
|
||||
'origin-top-left': placement === 'bottom-start',
|
||||
'origin-top-right': placement === 'bottom-end',
|
||||
}));
|
||||
|
||||
return className;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -291,31 +308,28 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
||||
|
||||
{isOpen || isDisplayed ? (
|
||||
<Portal>
|
||||
{touching && (
|
||||
<div
|
||||
className={clsx('fixed inset-0 z-[1000] bg-gray-500 black:bg-gray-900 no-reduce-motion:transition-opacity dark:bg-gray-700', {
|
||||
'opacity-0': !(isOpen && isDisplayed),
|
||||
'opacity-60': (isOpen && isDisplayed),
|
||||
})}
|
||||
role='button'
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
data-testid='dropdown-menu'
|
||||
className={getClassName()}
|
||||
ref={refs.setFloating}
|
||||
className={
|
||||
clsx('z-[1001] w-56 rounded-md bg-white py-1 shadow-lg duration-100 ease-in-out focus:outline-none black:bg-black no-reduce-motion:transition-transform dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
|
||||
'scale-0': !(isDisplayed && isOpen),
|
||||
'scale-100': (isDisplayed && isOpen),
|
||||
'origin-bottom': placement === 'top',
|
||||
'origin-left': placement === 'right',
|
||||
'origin-top': placement === 'bottom',
|
||||
'origin-right': placement === 'left',
|
||||
'origin-bottom-left': placement === 'top-start',
|
||||
'origin-bottom-right': placement === 'top-end',
|
||||
'origin-top-left': placement === 'bottom-start',
|
||||
'origin-top-right': placement === 'bottom-end',
|
||||
})
|
||||
}
|
||||
style={{
|
||||
style={touching ? undefined : {
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
}}
|
||||
>
|
||||
{Component && <Component handleClose={handleClose} />}
|
||||
<ul>
|
||||
{items.map((item, idx) => (
|
||||
{items?.map((item, idx) => (
|
||||
<DropdownMenuItem
|
||||
key={idx}
|
||||
item={item}
|
||||
@ -324,14 +338,26 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
))}
|
||||
{touching && (
|
||||
<li className='p-2 px-3'>
|
||||
<button
|
||||
className='flex w-full appearance-none place-content-center items-center justify-center rounded-full border border-gray-700 bg-transparent p-2 text-sm font-medium text-gray-700 transition-all hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:border-gray-500 dark:text-gray-500'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Arrow */}
|
||||
<div
|
||||
ref={arrowRef}
|
||||
style={arrowProps}
|
||||
className='pointer-events-none absolute z-[-1] h-3 w-3 bg-white black:bg-black dark:bg-gray-900'
|
||||
/>
|
||||
{!touching && (
|
||||
<div
|
||||
ref={arrowRef}
|
||||
style={arrowProps}
|
||||
className='pointer-events-none absolute z-[-1] h-3 w-3 bg-white black:bg-black dark:bg-gray-900'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Portal>
|
||||
) : null}
|
||||
|
||||
@ -8,7 +8,8 @@ import { cancelReplyCompose } from 'soapbox/actions/compose';
|
||||
import { saveDraftStatus } from 'soapbox/actions/draft-statuses';
|
||||
import { cancelEventCompose } from 'soapbox/actions/events';
|
||||
import { openModal, closeModal } from 'soapbox/actions/modals';
|
||||
import { useAppDispatch, usePrevious } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector, usePrevious } from 'soapbox/hooks';
|
||||
import { userTouching } from 'soapbox/is-mobile';
|
||||
|
||||
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
|
||||
import type { ReducerCompose } from 'soapbox/reducers/compose';
|
||||
@ -49,6 +50,8 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [revealed, setRevealed] = useState(!!children);
|
||||
const isDropdownOpen = useAppSelector(state => state.dropdown_menu.isOpen);
|
||||
const wasDropdownOpen = usePrevious(isDropdownOpen);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const activeElement = useRef<HTMLDivElement | null>(revealed ? document.activeElement as HTMLDivElement | null : null);
|
||||
@ -56,7 +59,6 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
||||
const unlistenHistory = useRef<ReturnType<typeof history.listen>>();
|
||||
|
||||
const prevChildren = usePrevious(children);
|
||||
const prevType = usePrevious(type);
|
||||
|
||||
const visible = !!children;
|
||||
|
||||
@ -158,7 +160,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
||||
});
|
||||
};
|
||||
|
||||
const handleModalClose = (type: string) => {
|
||||
const handleModalClose = () => {
|
||||
if (unlistenHistory.current) {
|
||||
unlistenHistory.current();
|
||||
}
|
||||
@ -206,7 +208,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
||||
activeElement.current = null;
|
||||
getSiblings().forEach(sibling => (sibling as HTMLDivElement).removeAttribute('inert'));
|
||||
|
||||
handleModalClose(prevType!);
|
||||
handleModalClose();
|
||||
}
|
||||
|
||||
if (children) {
|
||||
@ -218,6 +220,17 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
||||
}
|
||||
}, [children]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userTouching.matches) return;
|
||||
|
||||
if (isDropdownOpen && unlistenHistory.current) {
|
||||
unlistenHistory.current();
|
||||
} else if (!isDropdownOpen && wasDropdownOpen) {
|
||||
// TODO find a better solution
|
||||
setTimeout(() => handleModalOpen(), 100);
|
||||
}
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
if (!visible) {
|
||||
return (
|
||||
<div className='z-50 transition-all' ref={ref} style={{ opacity: 0 }} />
|
||||
@ -227,8 +240,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx({
|
||||
'fixed top-0 left-0 z-[100] w-full h-full overflow-x-hidden overflow-y-auto': true,
|
||||
className={clsx('fixed left-0 top-0 z-[100] h-full w-full overflow-y-auto overflow-x-hidden transition-opacity ease-in-out', {
|
||||
'pointer-events-none': !visible,
|
||||
})}
|
||||
style={{ opacity: revealed ? 1 : 0 }}
|
||||
|
||||
@ -105,16 +105,21 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
||||
|
||||
const handleSwitchAccount = (account: AccountEntity): React.MouseEventHandler => (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(switchAccount(account.id));
|
||||
};
|
||||
|
||||
const onClickLogOut: React.MouseEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(logOut());
|
||||
};
|
||||
|
||||
const handleSwitcherClick: React.MouseEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setSwitcher((prevState) => (!prevState));
|
||||
};
|
||||
@ -183,7 +188,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
||||
>
|
||||
<div
|
||||
className={
|
||||
clsx('rtl:r-2 fixed bottom-[60px] left-2 flex max-h-[calc(100dvh-68px)] w-full max-w-xs flex-1 origin-bottom-left flex-col rounded-xl bg-white shadow-lg ease-in-out black:bg-black no-reduce-motion:transition-transform rtl:right-2 rtl:origin-bottom-right dark:border dark:border-gray-800 dark:bg-primary-900 dark:shadow-none', {
|
||||
clsx('fixed bottom-[60px] left-2 flex max-h-[calc(100dvh-68px)] w-full max-w-xs flex-1 origin-bottom-left flex-col overflow-hidden rounded-xl bg-white shadow-lg ease-in-out black:bg-black no-reduce-motion:transition-transform rtl:right-2 rtl:origin-bottom-right dark:border dark:border-gray-800 dark:bg-primary-900 dark:shadow-none', {
|
||||
'scale-100': sidebarVisible && sidebarOpen,
|
||||
'scale-0': !(sidebarVisible && sidebarOpen),
|
||||
})
|
||||
|
||||
@ -84,6 +84,13 @@ const Modal = React.forwardRef<HTMLDivElement, IModal>(({
|
||||
}, ref) => {
|
||||
const intl = useIntl();
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const firstRender = React.useRef(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (firstRender.current) {
|
||||
firstRender.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (buttonRef?.current && !skipFocus) {
|
||||
@ -95,7 +102,10 @@ const Modal = React.forwardRef<HTMLDivElement, IModal>(({
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid='modal'
|
||||
className={clsx(className, 'pointer-events-auto mx-auto block w-full rounded-2xl bg-white p-6 text-start align-middle text-gray-900 shadow-xl transition-all black:bg-black dark:bg-primary-900 dark:text-gray-100', widths[width])}
|
||||
className={clsx(className, 'pointer-events-auto relative mx-auto block w-full rounded-2xl bg-white p-6 text-start align-middle text-gray-900 shadow-xl transition-all ease-in-out black:bg-black dark:bg-primary-900 dark:text-gray-100', widths[width], {
|
||||
'bottom-0': !firstRender.current,
|
||||
'-bottom-32': firstRender.current,
|
||||
})}
|
||||
>
|
||||
<div className='w-full justify-between sm:flex sm:items-start'>
|
||||
<div className='w-full'>
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { offset, useFloating, flip, arrow, shift } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import fuzzysort from 'fuzzysort';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
@ -8,7 +6,8 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { addComposeLanguage, changeComposeLanguage, changeComposeModifiedLanguage, deleteComposeLanguage } from 'soapbox/actions/compose';
|
||||
import { Button, Icon, Input, Portal } from 'soapbox/components/ui';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import { Button, Icon, Input } from 'soapbox/components/ui';
|
||||
import { type Language, languages as languagesObject } from 'soapbox/features/preferences';
|
||||
import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
@ -21,8 +20,6 @@ const getFrequentlyUsedLanguages = createSelector([
|
||||
.toArray()
|
||||
));
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
const languages = Object.entries(languagesObject) as Array<[Language, string]>;
|
||||
|
||||
const messages = defineMessages({
|
||||
@ -35,95 +32,26 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
interface ILanguageDropdown {
|
||||
composeId: string;
|
||||
handleClose: () => any;
|
||||
}
|
||||
|
||||
const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
|
||||
const getLanguageDropdown = (composeId: string): React.FC<ILanguageDropdown> => ({ handleClose: handleMenuClose }) => {
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
const dispatch = useAppDispatch();
|
||||
const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages);
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const focusedItem = useRef<HTMLDivElement>(null);
|
||||
const arrowRef = useRef<HTMLDivElement>(null);
|
||||
const focusedItem = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const { x, y, strategy, refs, middlewareData, placement } = useFloating<HTMLButtonElement>({
|
||||
placement: 'top',
|
||||
middleware: [
|
||||
offset(12),
|
||||
flip(),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
arrow({
|
||||
element: arrowRef,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const {
|
||||
language,
|
||||
modified_language: modifiedLanguage,
|
||||
suggested_language: suggestedLanguage,
|
||||
textMap,
|
||||
} = useCompose(composeId);
|
||||
|
||||
const handleClick: React.EventHandler<
|
||||
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
|
||||
> = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (event) => {
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleClick(event);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptionKeyDown: React.KeyboardEventHandler = e => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
const index = results.findIndex(([key]) => key === value);
|
||||
let element: ChildNode | null | undefined = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
handleClose();
|
||||
break;
|
||||
case 'Enter':
|
||||
handleOptionClick(e);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
element = node.current?.childNodes[index + 1] || node.current?.firstChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = node.current?.childNodes[index - 1] || node.current?.lastChild;
|
||||
break;
|
||||
case 'Home':
|
||||
element = node.current?.firstChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = node.current?.lastChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
(element as HTMLElement).focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptionClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
|
||||
const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index') as Language;
|
||||
|
||||
@ -146,7 +74,6 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
handleClose();
|
||||
dispatch(addComposeLanguage(composeId, value));
|
||||
};
|
||||
|
||||
@ -156,7 +83,6 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
handleClose();
|
||||
dispatch(deleteComposeLanguage(composeId, value));
|
||||
};
|
||||
|
||||
@ -202,104 +128,105 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
|
||||
}).map(result => result.obj);
|
||||
};
|
||||
|
||||
const handleDocumentClick = (event: Event) => {
|
||||
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!refs.floating.current) return;
|
||||
|
||||
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
|
||||
const index = items.indexOf(document.activeElement as any);
|
||||
|
||||
let element = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index + 1] || items[0];
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
} else {
|
||||
element = items[index + 1] || items[0];
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length - 1];
|
||||
break;
|
||||
case 'Escape':
|
||||
handleClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSearchValue('');
|
||||
setIsOpen(false);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const arrowProps: React.CSSProperties = useMemo(() => {
|
||||
if (middlewareData.arrow) {
|
||||
const { x, y } = middlewareData.arrow;
|
||||
|
||||
const staticPlacement = {
|
||||
top: 'bottom',
|
||||
right: 'left',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
}[placement.split('-')[0]];
|
||||
|
||||
return {
|
||||
left: x !== null ? `${x}px` : '',
|
||||
top: y !== null ? `${y}px` : '',
|
||||
// Ensure the static side gets unset when
|
||||
// flipping to other placements' axes.
|
||||
right: '',
|
||||
bottom: '',
|
||||
[staticPlacement as string]: `${(-(arrowRef.current?.offsetWidth || 0)) / 2}px`,
|
||||
transform: 'rotate(45deg)',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}, [middlewareData.arrow, placement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (refs.floating.current) {
|
||||
(refs.floating.current?.querySelector('div[aria-selected=true]') as HTMLDivElement)?.focus();
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleDocumentClick, false);
|
||||
document.addEventListener('keydown', handleKeyDown, false);
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('touchend', handleDocumentClick);
|
||||
};
|
||||
if (node.current) {
|
||||
(node.current?.querySelector('div[aria-selected=true]') as HTMLDivElement)?.focus();
|
||||
}
|
||||
}, [isOpen, refs.floating.current]);
|
||||
}, [node.current]);
|
||||
|
||||
const isSearching = searchValue !== '';
|
||||
const results = search();
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className='relative block grow p-2 pt-1'>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
|
||||
|
||||
<Input
|
||||
className='w-64'
|
||||
type='text'
|
||||
value={searchValue}
|
||||
onChange={({ target }) => setSearchValue(target.value)}
|
||||
outerClassName='mt-0'
|
||||
placeholder={intl.formatMessage(messages.search)}
|
||||
/>
|
||||
<div role='button' tabIndex={0} className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-5 rtl:left-0 rtl:right-auto' onClick={handleClear}>
|
||||
<Icon
|
||||
className='h-5 w-5 text-gray-600'
|
||||
src={isSearching ? require('@tabler/icons/outline/backspace.svg') : require('@tabler/icons/outline/search.svg')}
|
||||
aria-label={intl.formatMessage(messages.search)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div className='-mb-1 h-96 w-full overflow-auto' ref={node} tabIndex={-1}>
|
||||
{results.map(([code, name]) => {
|
||||
const active = code === language;
|
||||
const modified = code === modifiedLanguage;
|
||||
|
||||
return (
|
||||
<button
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={code}
|
||||
data-index={code}
|
||||
onClick={handleOptionClick}
|
||||
className={clsx(
|
||||
'flex w-full gap-2 p-2.5 text-left text-sm text-gray-700 dark:text-gray-400',
|
||||
{
|
||||
'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700': modified,
|
||||
'cursor-pointer hover:bg-gray-100 black:hover:bg-gray-900 dark:hover:bg-gray-800': !textMap.size || textMap.has(code),
|
||||
'cursor-pointer': active,
|
||||
'cursor-default': !active && !(!textMap.size || textMap.has(code)),
|
||||
},
|
||||
)}
|
||||
aria-selected={active}
|
||||
ref={active ? focusedItem : null}
|
||||
>
|
||||
<div
|
||||
className={clsx('flex-auto grow text-primary-600 dark:text-primary-400', {
|
||||
'text-black dark:text-white': modified,
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{features.multiLanguage && !!language && !active && (
|
||||
textMap.has(code) ? (
|
||||
<button title={intl.formatMessage(messages.deleteLanguage)} onClick={handleDeleteLanguageClick}>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/outline/minus.svg')} />
|
||||
</button>
|
||||
) : (
|
||||
<button title={intl.formatMessage(messages.addLanguage)} onClick={handleAddLanguageClick}>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/outline/plus.svg')} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ILanguageDropdownButton {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
const LanguageDropdownButton: React.FC<ILanguageDropdownButton> = ({ composeId }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
language,
|
||||
modified_language: modifiedLanguage,
|
||||
suggested_language: suggestedLanguage,
|
||||
textMap,
|
||||
} = useCompose(composeId);
|
||||
|
||||
let buttonLabel = intl.formatMessage(messages.languagePrompt);
|
||||
if (language) {
|
||||
const list: string[] = [languagesObject[modifiedLanguage || language]];
|
||||
@ -311,8 +238,12 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
|
||||
language: languagesObject[suggestedLanguage as Language] || suggestedLanguage,
|
||||
});
|
||||
|
||||
const LanguageDropdown = useMemo(() => getLanguageDropdown(composeId), [composeId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu
|
||||
component={LanguageDropdown}
|
||||
>
|
||||
<Button
|
||||
theme='muted'
|
||||
size='xs'
|
||||
@ -320,102 +251,10 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
|
||||
icon={require('@tabler/icons/outline/language.svg')}
|
||||
secondaryIcon={require('@tabler/icons/outline/chevron-down.svg')}
|
||||
title={intl.formatMessage(messages.languagePrompt)}
|
||||
onClick={handleClick}
|
||||
onKeyPress={handleKeyPress}
|
||||
ref={refs.setReference}
|
||||
/>
|
||||
{isOpen ? (
|
||||
<Portal>
|
||||
<div
|
||||
id='language-dropdown'
|
||||
ref={refs.setFloating}
|
||||
className={clsx('z-[1001] flex flex-col rounded-md bg-white text-sm shadow-lg transition-opacity duration-100 focus:outline-none black:border black:border-gray-800 black:bg-black dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
|
||||
'opacity-0 pointer-events-none': !isOpen,
|
||||
})}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
}}
|
||||
role='listbox'
|
||||
>
|
||||
<label className='relative grow p-2'>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
|
||||
|
||||
<Input
|
||||
className='w-64'
|
||||
type='text'
|
||||
value={searchValue}
|
||||
onChange={({ target }) => setSearchValue(target.value)}
|
||||
outerClassName='mt-0'
|
||||
placeholder={intl.formatMessage(messages.search)}
|
||||
/>
|
||||
<div role='button' tabIndex={0} className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-5 rtl:left-0 rtl:right-auto' onClick={handleClear}>
|
||||
<Icon
|
||||
className='h-5 w-5 text-gray-600'
|
||||
src={isSearching ? require('@tabler/icons/outline/backspace.svg') : require('@tabler/icons/outline/search.svg')}
|
||||
aria-label={intl.formatMessage(messages.search)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div className='h-96 w-full overflow-scroll' ref={node} tabIndex={-1}>
|
||||
{results.map(([code, name]) => {
|
||||
const active = code === language;
|
||||
const modified = code === modifiedLanguage;
|
||||
|
||||
return (
|
||||
<div
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={code}
|
||||
data-index={code}
|
||||
onKeyDown={handleOptionKeyDown}
|
||||
onClick={handleOptionClick}
|
||||
className={clsx(
|
||||
'flex gap-2 p-2.5 text-sm text-gray-700 dark:text-gray-400',
|
||||
{
|
||||
'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700': modified,
|
||||
'cursor-pointer hover:bg-gray-100 black:hover:bg-gray-900 dark:hover:bg-gray-800': !textMap.size || textMap.has(code),
|
||||
'cursor-pointer': active,
|
||||
'cursor-default': !active && !(!textMap.size || textMap.has(code)),
|
||||
},
|
||||
)}
|
||||
aria-selected={active}
|
||||
ref={active ? focusedItem : null}
|
||||
>
|
||||
<div
|
||||
className={clsx('flex-auto grow text-primary-600 dark:text-primary-400', {
|
||||
'text-black dark:text-white': modified,
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{features.multiLanguage && !!language && !active && (
|
||||
textMap.has(code) ? (
|
||||
<button title={intl.formatMessage(messages.deleteLanguage)} onClick={handleDeleteLanguageClick}>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/outline/minus.svg')} />
|
||||
</button>
|
||||
) : (
|
||||
<button title={intl.formatMessage(messages.addLanguage)} onClick={handleAddLanguageClick}>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/outline/plus.svg')} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
ref={arrowRef}
|
||||
style={arrowProps}
|
||||
className='pointer-events-none absolute z-[-1] h-3 w-3 bg-white black:bg-black dark:bg-gray-900'
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
) : null}
|
||||
</>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export { LanguageDropdown as default };
|
||||
export { LanguageDropdownButton as default };
|
||||
|
||||
@ -1,19 +1,12 @@
|
||||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { spring } from 'react-motion';
|
||||
// @ts-ignore
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
|
||||
import { changeComposeFederated, changeComposeVisibility } from 'soapbox/actions/compose';
|
||||
import { closeModal, openModal } from 'soapbox/actions/modals';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Button, Toggle } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useCompose, useFeatures } from 'soapbox/hooks';
|
||||
import { userTouching } from 'soapbox/is-mobile';
|
||||
|
||||
import Motion from '../../ui/util/optional-motion';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
@ -33,8 +26,6 @@ const messages = defineMessages({
|
||||
local: { id: 'privacy.local', defaultMessage: '{privacy} (local-only)' },
|
||||
});
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
interface Option {
|
||||
icon: string;
|
||||
value: string;
|
||||
@ -43,10 +34,8 @@ interface Option {
|
||||
}
|
||||
|
||||
interface IPrivacyDropdownMenu {
|
||||
style?: React.CSSProperties;
|
||||
items: any[];
|
||||
value: string;
|
||||
placement: string;
|
||||
onClose: () => void;
|
||||
onChange: (value: string | null) => void;
|
||||
unavailable?: boolean;
|
||||
@ -56,21 +45,14 @@ interface IPrivacyDropdownMenu {
|
||||
}
|
||||
|
||||
const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({
|
||||
style, items, placement, value, onClose, onChange, showFederated, federated, onChangeFederated,
|
||||
items, value, onClose, onChange, showFederated, federated, onChangeFederated,
|
||||
}) => {
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const focusedItem = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [mounted, setMounted] = useState<boolean>(false);
|
||||
|
||||
const handleDocumentClick = (e: MouseEvent | TouchEvent) => {
|
||||
if (node.current && !node.current.contains(e.target as HTMLElement)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const node = useRef<HTMLUListElement>(null);
|
||||
const focusedItem = useRef<HTMLLIElement>(null);
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler = e => {
|
||||
const index = [...e.currentTarget.parentElement!.children].indexOf(e.currentTarget); let element: ChildNode | null | undefined = null;
|
||||
const index = [...e.currentTarget.parentElement!.children].indexOf(e.currentTarget);
|
||||
let element: ChildNode | null | undefined = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
@ -121,96 +103,67 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, false);
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
|
||||
focusedItem.current?.focus({ preventScroll: true });
|
||||
setMounted(true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', handleDocumentClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div
|
||||
id='privacy-dropdown'
|
||||
className={clsx('absolute z-[1000] ml-10 overflow-hidden rounded-md bg-white text-sm shadow-lg black:border black:border-gray-800 black:bg-black dark:bg-gray-900', {
|
||||
'block shadow-md': open,
|
||||
'origin-[50%_100%]': placement === 'top',
|
||||
'origin-[50%_0]': placement === 'bottom',
|
||||
})}
|
||||
style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }}
|
||||
role='listbox'
|
||||
ref={node}
|
||||
>
|
||||
{items.map(item => {
|
||||
const active = item.value === value;
|
||||
return (
|
||||
<div
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={item.value}
|
||||
data-index={item.value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'flex cursor-pointer items-center p-2.5 text-gray-700 hover:bg-gray-100 black:hover:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800',
|
||||
{ 'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700': active },
|
||||
)}
|
||||
aria-selected={active}
|
||||
ref={active ? focusedItem : null}
|
||||
>
|
||||
<div className='mr-2.5 flex items-center justify-center rtl:ml-2.5 rtl:mr-0'>
|
||||
<Icon src={item.icon} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx('flex-auto text-xs text-primary-600 dark:text-primary-400', {
|
||||
'text-black dark:text-white': active,
|
||||
})}
|
||||
>
|
||||
<strong className='block text-sm font-medium text-black dark:text-white'>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{showFederated && (
|
||||
<div
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
data-index='local_switch'
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={onChangeFederated}
|
||||
className='flex cursor-pointer items-center p-2.5 text-xs text-gray-700 hover:bg-gray-100 focus:bg-gray-100 black:hover:bg-gray-900 black:focus:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:focus:bg-gray-800'
|
||||
>
|
||||
<div className='mr-2.5 flex items-center justify-center rtl:ml-2.5 rtl:mr-0'>
|
||||
<Icon src={require('@tabler/icons/outline/affiliate.svg')} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='flex-auto text-xs text-primary-600 dark:text-primary-400'
|
||||
>
|
||||
<strong className='block text-sm font-medium text-black focus:text-black dark:text-white dark:focus:text-primary-400'>
|
||||
<FormattedMessage id='privacy.local.short' defaultMessage='Local-only' />
|
||||
</strong>
|
||||
<FormattedMessage id='privacy.local.long' defaultMessage='Only visible on your instance' />
|
||||
</div>
|
||||
|
||||
<Toggle checked={!federated} onChange={onChangeFederated} />
|
||||
<ul ref={node}>
|
||||
{items.map(item => {
|
||||
const active = item.value === value;
|
||||
return (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={item.value}
|
||||
data-index={item.value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'flex cursor-pointer items-center p-2.5 text-gray-700 hover:bg-gray-100 black:hover:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800',
|
||||
{ 'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700': active },
|
||||
)}
|
||||
aria-selected={active}
|
||||
ref={active ? focusedItem : null}
|
||||
>
|
||||
<div className='mr-2.5 flex items-center justify-center rtl:ml-2.5 rtl:mr-0'>
|
||||
<Icon src={item.icon} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx('flex-auto text-xs text-primary-600 dark:text-primary-400', {
|
||||
'text-black dark:text-white': active,
|
||||
})}
|
||||
>
|
||||
<strong className='block text-sm font-medium text-black dark:text-white'>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{showFederated && (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
data-index='local_switch'
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={onChangeFederated}
|
||||
className='flex cursor-pointer items-center p-2.5 text-xs text-gray-700 hover:bg-gray-100 focus:bg-gray-100 black:hover:bg-gray-900 black:focus:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:focus:bg-gray-800'
|
||||
>
|
||||
<div className='mr-2.5 flex items-center justify-center rtl:ml-2.5 rtl:mr-0'>
|
||||
<Icon src={require('@tabler/icons/outline/affiliate.svg')} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='flex-auto text-xs text-primary-600 dark:text-primary-400'
|
||||
>
|
||||
<strong className='block text-sm font-medium text-black focus:text-black dark:text-white dark:focus:text-primary-400'>
|
||||
<FormattedMessage id='privacy.local.short' defaultMessage='Local-only' />
|
||||
</strong>
|
||||
<FormattedMessage id='privacy.local.long' defaultMessage='Only visible on your instance' />
|
||||
</div>
|
||||
|
||||
<Toggle checked={!federated} onChange={onChangeFederated} />
|
||||
</li>
|
||||
)}
|
||||
</Motion>
|
||||
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
@ -223,8 +176,6 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const activeElement = useRef<HTMLElement | null>(null);
|
||||
const features = useFeatures();
|
||||
|
||||
const compose = useCompose(composeId);
|
||||
@ -232,9 +183,6 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
|
||||
const value = compose.privacy;
|
||||
const unavailable = compose.id;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [placement, setPlacement] = useState('bottom');
|
||||
|
||||
const options = [
|
||||
{ icon: require('@tabler/icons/outline/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) },
|
||||
{ icon: require('@tabler/icons/outline/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) },
|
||||
@ -248,70 +196,6 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
|
||||
|
||||
const onChangeFederated = () => dispatch(changeComposeFederated(composeId));
|
||||
|
||||
const onModalOpen = (props: Record<string, any>) => dispatch(openModal('ACTIONS', props));
|
||||
|
||||
const onModalClose = () => dispatch(closeModal('ACTIONS'));
|
||||
|
||||
const handleToggle: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (userTouching.matches) {
|
||||
if (open) {
|
||||
onModalClose();
|
||||
} else {
|
||||
onModalOpen({
|
||||
actions: options.map(option => ({ ...option, active: option.value === value })),
|
||||
onClick: handleModalActionClick,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const { top } = e.currentTarget.getBoundingClientRect();
|
||||
if (open) {
|
||||
activeElement.current?.focus();
|
||||
}
|
||||
setPlacement(top * 2 < innerHeight ? 'bottom' : 'top');
|
||||
setOpen(!open);
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleModalActionClick: React.MouseEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { value } = options[e.currentTarget.getAttribute('data-index') as any];
|
||||
|
||||
onModalClose();
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler = e => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
handleClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = () => {
|
||||
if (!open) {
|
||||
activeElement.current = document.activeElement as HTMLElement | null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonKeyDown: React.KeyboardEventHandler = (e) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleMouseDown();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (open) {
|
||||
activeElement.current?.focus();
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (unavailable) {
|
||||
return null;
|
||||
}
|
||||
@ -319,41 +203,30 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
|
||||
const valueOption = options.find(item => item.value === value);
|
||||
|
||||
return (
|
||||
<div onKeyDown={handleKeyDown} ref={node}>
|
||||
<div
|
||||
className={clsx({
|
||||
'rounded-t-md': open && placement === 'top',
|
||||
active: valueOption && options.indexOf(valueOption) === 0,
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
theme='muted'
|
||||
size='xs'
|
||||
text={compose.federated ? valueOption?.text : intl.formatMessage(messages.local, {
|
||||
privacy: valueOption?.text,
|
||||
})}
|
||||
icon={valueOption?.icon}
|
||||
secondaryIcon={require('@tabler/icons/outline/chevron-down.svg')}
|
||||
title={intl.formatMessage(messages.change_privacy)}
|
||||
onClick={handleToggle}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Overlay show={open} placement={placement} target={node.current}>
|
||||
<DropdownMenu
|
||||
component={({ handleClose }) => (
|
||||
<PrivacyDropdownMenu
|
||||
items={options}
|
||||
value={value}
|
||||
onClose={handleClose}
|
||||
onChange={onChange}
|
||||
placement={placement}
|
||||
showFederated={features.localOnlyStatuses}
|
||||
federated={compose.federated}
|
||||
onChangeFederated={onChangeFederated}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
theme='muted'
|
||||
size='xs'
|
||||
text={compose.federated ? valueOption?.text : intl.formatMessage(messages.local, {
|
||||
privacy: valueOption?.text,
|
||||
})}
|
||||
icon={valueOption?.icon}
|
||||
secondaryIcon={require('@tabler/icons/outline/chevron-down.svg')}
|
||||
title={intl.formatMessage(messages.change_privacy)}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -11,10 +11,10 @@ import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions
|
||||
import { initMuteModal } from 'soapbox/actions/mutes';
|
||||
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
|
||||
import { deleteStatus } from 'soapbox/actions/statuses';
|
||||
import DropdownMenu, { type Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { Button, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList, Stack, Text } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import { Button, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
|
||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
||||
import { useAppDispatch, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
|
||||
import { useChats } from 'soapbox/queries/chats';
|
||||
@ -26,7 +26,6 @@ import PlaceholderEventHeader from '../../placeholder/components/placeholder-eve
|
||||
import EventActionButton from '../components/event-action-button';
|
||||
import EventDate from '../components/event-date';
|
||||
|
||||
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
||||
import type { Status } from 'soapbox/normalizers';
|
||||
|
||||
const messages = defineMessages({
|
||||
@ -378,39 +377,17 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
||||
<Stack space={2}>
|
||||
<HStack className='w-full' alignItems='start' space={2}>
|
||||
<Text className='grow' size='lg' weight='bold'>{event.name}</Text>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
|
||||
<DropdownMenu items={makeMenu()} placement='bottom-end'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/outline/dots.svg')}
|
||||
theme='outlined'
|
||||
className='h-[30px] px-2'
|
||||
iconClassName='h-4 w-4'
|
||||
children={null}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
<MenuList>
|
||||
{makeMenu().map((menuItem, idx) => {
|
||||
if (typeof menuItem?.text === 'undefined') {
|
||||
return <MenuDivider key={idx} />;
|
||||
} else {
|
||||
const Comp = (menuItem.href ? MenuLink : MenuItem) as any;
|
||||
const itemProps = menuItem.href ? { href: menuItem.href, target: menuItem.target || '_self' } : { onSelect: menuItem.action };
|
||||
|
||||
return (
|
||||
<Comp key={idx} {...itemProps} className='group'>
|
||||
<div className='flex items-center'>
|
||||
{menuItem.icon && (
|
||||
<SvgIcon src={menuItem.icon} className='mr-3 h-5 w-5 flex-none text-gray-400 group-hover:text-gray-500' />
|
||||
)}
|
||||
|
||||
<div className='truncate'>{menuItem.text}</div>
|
||||
</div>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
{account.id === ownAccount?.id ? (
|
||||
<Button
|
||||
size='sm'
|
||||
|
||||
@ -96,42 +96,24 @@ interface IModalRoot {
|
||||
onClose: (type?: ModalType) => void;
|
||||
}
|
||||
|
||||
class ModalRoot extends React.PureComponent<IModalRoot> {
|
||||
const ModalRoot: React.FC<IModalRoot> = ({ onClose, props, type }) => {
|
||||
const renderLoading = (modalId: string) => !['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].includes(modalId) ? <ModalLoading /> : null;
|
||||
|
||||
getSnapshotBeforeUpdate() {
|
||||
return { visible: !!this.props.type };
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: IModalRoot, prevState: any, { visible }: any) {
|
||||
if (visible) {
|
||||
document.body.classList.add('with-modals');
|
||||
} else {
|
||||
document.body.classList.remove('with-modals');
|
||||
}
|
||||
}
|
||||
|
||||
renderLoading = (modalId: string) => !['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].includes(modalId) ? <ModalLoading /> : null;
|
||||
|
||||
onClickClose = (_?: ModalType) => {
|
||||
const { onClose, type } = this.props;
|
||||
const onClickClose = (_?: ModalType) => {
|
||||
onClose(type);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type, props } = this.props;
|
||||
const Component = type ? MODAL_COMPONENTS[type] : null;
|
||||
const Component = type ? MODAL_COMPONENTS[type] : null;
|
||||
|
||||
return (
|
||||
<Base onClose={this.onClickClose} type={type}>
|
||||
{(Component && !!type) && (
|
||||
<Suspense fallback={this.renderLoading(type)}>
|
||||
<Component {...props} onClose={this.onClickClose} />
|
||||
</Suspense>
|
||||
)}
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
return (
|
||||
<Base onClose={onClickClose} type={type}>
|
||||
{(Component && !!type) && (
|
||||
<Suspense fallback={renderLoading(type)}>
|
||||
<Component {...props} onClose={onClickClose} />
|
||||
</Suspense>
|
||||
)}
|
||||
</Base>
|
||||
);
|
||||
};
|
||||
|
||||
export { type ModalType, ModalRoot as default };
|
||||
|
||||
@ -6,21 +6,18 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import ReplyIndicator from 'soapbox/features/compose/components/reply-indicator';
|
||||
|
||||
import Motion from '../../util/optional-motion';
|
||||
|
||||
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IActionsModal {
|
||||
status: StatusEntity;
|
||||
actions: Menu;
|
||||
onClick: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClose }) => {
|
||||
const ActionsModal: React.FC<IActionsModal> = ({ actions, onClick, onClose }) => {
|
||||
const renderAction = (action: MenuItem | null, i: number) => {
|
||||
if (action === null) {
|
||||
return <li key={`sep-${i}`} className='m-2 block h-[1px] bg-gray-200 black:bg-gray-800 dark:bg-gray-600' />;
|
||||
@ -60,11 +57,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
|
||||
className='pointer-events-auto relative z-[9999] m-auto flex max-h-[calc(100vh-3rem)] w-full max-w-lg flex-col overflow-hidden rounded-2xl bg-white text-gray-400 shadow-xl black:bg-black dark:bg-gray-900'
|
||||
style={{ top: `${top}%` }}
|
||||
>
|
||||
{status && (
|
||||
<ReplyIndicator className='max-h-[300px] overflow-y-auto rounded-b-none' status={status} hideActions />
|
||||
)}
|
||||
|
||||
<ul className={clsx('my-2 max-h-[calc(100vh-147px)] shrink-0 overflow-y-auto', { 'max-h-[calc(80vh-75px)]': status })}>
|
||||
<ul className='my-2 max-h-[calc(100vh-147px)] shrink-0 overflow-y-auto'>
|
||||
{actions && actions.map(renderAction)}
|
||||
|
||||
<li className='m-2 block h-[1px] bg-gray-200 black:bg-gray-800 dark:bg-gray-600' />
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
useLocale,
|
||||
useAppSelector,
|
||||
} from 'soapbox/hooks';
|
||||
import { userTouching } from 'soapbox/is-mobile';
|
||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||
import { startSentry } from 'soapbox/sentry';
|
||||
import { generateThemeCss } from 'soapbox/utils/theme';
|
||||
@ -25,7 +26,7 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const theme = useTheme();
|
||||
|
||||
const withModals = useAppSelector((state) => !state.modals.isEmpty());
|
||||
const withModals = useAppSelector((state) => !state.modals.isEmpty() || (state.dropdown_menu.isOpen && userTouching.matches));
|
||||
|
||||
const themeCss = generateThemeCss(demo ? normalizeSoapboxConfig({ brandColor: '#0482d8' }) : soapboxConfig);
|
||||
const dsn = soapboxConfig.sentryDsn;
|
||||
|
||||
@ -38,7 +38,8 @@ const SoapboxMount = () => {
|
||||
|
||||
// @ts-ignore: I don't actually know what these should be, lol
|
||||
const shouldUpdateScroll = (prevRouterProps, { location }) =>
|
||||
!(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey);
|
||||
!(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey)
|
||||
&& !(location.state?.soapboxDropdownKey && location.state?.soapboxDropdownKey !== prevRouterProps?.location?.state?.soapboxDropdownKey);
|
||||
|
||||
return (
|
||||
<SiteErrorBoundary>
|
||||
|
||||
Reference in New Issue
Block a user