mirror of
https://github.com/mastodon/mastodon.git
synced 2025-05-07 20:26:15 +00:00
Refactor <Dropdown>
into TypeScript (#34357)
Co-authored-by: Echo <ChaosExAnima@users.noreply.github.com>
This commit is contained in:
parent
b7c3235349
commit
22d33244ee
|
@ -1,11 +1,11 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
export const openDropdownMenu = createAction<{
|
||||
id: string;
|
||||
id: number;
|
||||
keyboard: boolean;
|
||||
scrollKey: string;
|
||||
scrollKey?: string;
|
||||
}>('dropdownMenu/open');
|
||||
|
||||
export const closeDropdownMenu = createAction<{ id: string }>(
|
||||
export const closeDropdownMenu = createAction<{ id: number }>(
|
||||
'dropdownMenu/close',
|
||||
);
|
||||
|
|
|
@ -17,12 +17,12 @@ import { Avatar } from 'mastodon/components/avatar';
|
|||
import { Button } from 'mastodon/components/button';
|
||||
import { FollowersCounter } from 'mastodon/components/counters';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
|
@ -124,11 +124,10 @@ export const Account: React.FC<{
|
|||
|
||||
buttons = (
|
||||
<>
|
||||
<DropdownMenu
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
|
||||
|
|
|
@ -1,343 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent, cloneElement, Children } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import { CircularProgress } from 'mastodon/components/circular_progress';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||
let id = 0;
|
||||
|
||||
class DropdownMenu extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
scrollable: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
openedViaKeyboard: PropTypes.bool,
|
||||
renderItem: PropTypes.func,
|
||||
renderHeader: PropTypes.func,
|
||||
onItemClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, { capture: true });
|
||||
document.addEventListener('keydown', this.handleKeyDown, { capture: true });
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
|
||||
if (this.focusedItem && this.props.openedViaKeyboard) {
|
||||
this.focusedItem.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
|
||||
document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
setFocusRef = c => {
|
||||
this.focusedItem = c;
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
const items = Array.from(this.node.querySelectorAll('a, button'));
|
||||
const index = items.indexOf(document.activeElement);
|
||||
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':
|
||||
this.props.onClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
handleItemKeyPress = e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
this.handleClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleClick = e => {
|
||||
const { onItemClick } = this.props;
|
||||
onItemClick(e);
|
||||
};
|
||||
|
||||
renderItem = (option, i) => {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, href = '#', target = '_blank', method, dangerous } = option;
|
||||
|
||||
return (
|
||||
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
|
||||
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { items, scrollable, renderHeader, loading } = this.props;
|
||||
|
||||
let renderItem = this.props.renderItem || this.renderItem;
|
||||
|
||||
return (
|
||||
<div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}>
|
||||
{loading && (
|
||||
<CircularProgress size={30} strokeWidth={3.5} />
|
||||
)}
|
||||
|
||||
{!loading && renderHeader && (
|
||||
<div className='dropdown-menu__container__header'>
|
||||
{renderHeader(items)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
|
||||
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Dropdown extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
icon: PropTypes.string,
|
||||
iconComponent: PropTypes.func,
|
||||
items: PropTypes.array.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
size: PropTypes.number,
|
||||
title: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
scrollable: PropTypes.bool,
|
||||
status: ImmutablePropTypes.map,
|
||||
isUserTouching: PropTypes.func,
|
||||
onOpen: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
openDropdownId: PropTypes.number,
|
||||
openedViaKeyboard: PropTypes.bool,
|
||||
renderItem: PropTypes.func,
|
||||
renderHeader: PropTypes.func,
|
||||
onItemClick: PropTypes.func,
|
||||
...WithRouterPropTypes
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
title: 'Menu',
|
||||
};
|
||||
|
||||
state = {
|
||||
id: id++,
|
||||
};
|
||||
|
||||
handleClick = ({ type }) => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
} else {
|
||||
this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
|
||||
}
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
if (this.activeElement) {
|
||||
this.activeElement.focus({ preventScroll: true });
|
||||
this.activeElement = null;
|
||||
}
|
||||
this.props.onClose(this.state.id);
|
||||
};
|
||||
|
||||
handleMouseDown = () => {
|
||||
if (!this.state.open) {
|
||||
this.activeElement = document.activeElement;
|
||||
}
|
||||
};
|
||||
|
||||
handleButtonKeyDown = (e) => {
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleMouseDown();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyPress = (e) => {
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleClick(e);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleItemClick = e => {
|
||||
const { onItemClick } = this.props;
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
|
||||
this.handleClose();
|
||||
|
||||
if (typeof onItemClick === 'function') {
|
||||
e.preventDefault();
|
||||
onItemClick(item, i);
|
||||
} else if (item && typeof item.action === 'function') {
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
} else if (item && item.to) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(item.to);
|
||||
}
|
||||
};
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
};
|
||||
|
||||
findTarget = () => {
|
||||
return this.target?.buttonRef?.current ?? this.target;
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
close = () => {
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
render () {
|
||||
const {
|
||||
icon,
|
||||
iconComponent,
|
||||
items,
|
||||
size,
|
||||
title,
|
||||
disabled,
|
||||
loading,
|
||||
scrollable,
|
||||
openDropdownId,
|
||||
openedViaKeyboard,
|
||||
children,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
} = this.props;
|
||||
|
||||
const open = this.state.id === openDropdownId;
|
||||
|
||||
const button = children ? cloneElement(Children.only(children), {
|
||||
onClick: this.handleClick,
|
||||
onMouseDown: this.handleMouseDown,
|
||||
onKeyDown: this.handleButtonKeyDown,
|
||||
onKeyPress: this.handleKeyPress,
|
||||
ref: this.setTargetRef,
|
||||
}) : (
|
||||
<IconButton
|
||||
icon={!open ? icon : 'close'}
|
||||
iconComponent={iconComponent}
|
||||
title={title}
|
||||
active={open}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleButtonKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
ref={this.setTargetRef}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
|
||||
<Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props, arrowProps, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} {...arrowProps} />
|
||||
<DropdownMenu
|
||||
items={items}
|
||||
loading={loading}
|
||||
scrollable={scrollable}
|
||||
onClose={this.handleClose}
|
||||
openedViaKeyboard={openedViaKeyboard}
|
||||
renderItem={renderItem}
|
||||
renderHeader={renderHeader}
|
||||
onItemClick={this.handleItemClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(Dropdown);
|
532
app/javascript/mastodon/components/dropdown_menu.tsx
Normal file
532
app/javascript/mastodon/components/dropdown_menu.tsx
Normal file
|
@ -0,0 +1,532 @@
|
|||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
cloneElement,
|
||||
Children,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import type {
|
||||
OffsetValue,
|
||||
UsePopperOptions,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||
import {
|
||||
openDropdownMenu,
|
||||
closeDropdownMenu,
|
||||
} from 'mastodon/actions/dropdown_menu';
|
||||
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||
import { CircularProgress } from 'mastodon/components/circular_progress';
|
||||
import { isUserTouching } from 'mastodon/is_mobile';
|
||||
import type {
|
||||
MenuItem,
|
||||
ActionMenuItem,
|
||||
ExternalLinkMenuItem,
|
||||
} from 'mastodon/models/dropdown_menu';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { IconProp } from './icon';
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
let id = 0;
|
||||
|
||||
const isMenuItem = (item: unknown): item is MenuItem => {
|
||||
if (item === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof item === 'object' && 'text' in item;
|
||||
};
|
||||
|
||||
const isActionItem = (item: unknown): item is ActionMenuItem => {
|
||||
if (!item || !isMenuItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'action' in item;
|
||||
};
|
||||
|
||||
const isExternalLinkItem = (item: unknown): item is ExternalLinkMenuItem => {
|
||||
if (!item || !isMenuItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'href' in item;
|
||||
};
|
||||
|
||||
type RenderItemFn<Item = MenuItem> = (
|
||||
item: Item,
|
||||
index: number,
|
||||
handlers: {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onKeyUp: (e: React.KeyboardEvent) => void;
|
||||
},
|
||||
) => React.ReactNode;
|
||||
|
||||
type RenderHeaderFn<Item = MenuItem> = (items: Item[]) => React.ReactNode;
|
||||
|
||||
interface DropdownMenuProps<Item = MenuItem> {
|
||||
items?: Item[];
|
||||
loading?: boolean;
|
||||
scrollable?: boolean;
|
||||
onClose: () => void;
|
||||
openedViaKeyboard: boolean;
|
||||
renderItem?: RenderItemFn<Item>;
|
||||
renderHeader?: RenderHeaderFn<Item>;
|
||||
onItemClick: (e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
const DropdownMenu = <Item = MenuItem,>({
|
||||
items,
|
||||
loading,
|
||||
scrollable,
|
||||
onClose,
|
||||
openedViaKeyboard,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
onItemClick,
|
||||
}: DropdownMenuProps<Item>) => {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const focusedItemRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (e: MouseEvent) => {
|
||||
if (
|
||||
e.target instanceof Node &&
|
||||
nodeRef.current &&
|
||||
!nodeRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!nodeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.from(nodeRef.current.querySelectorAll('a, button'));
|
||||
const index = document.activeElement
|
||||
? items.indexOf(document.activeElement)
|
||||
: -1;
|
||||
|
||||
let element: Element | undefined;
|
||||
|
||||
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':
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element && element instanceof HTMLElement) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
|
||||
if (focusedItemRef.current && openedViaKeyboard) {
|
||||
focusedItemRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, {
|
||||
capture: true,
|
||||
});
|
||||
document.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [onClose, openedViaKeyboard]);
|
||||
|
||||
const handleFocusedItemRef = useCallback(
|
||||
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
|
||||
focusedItemRef.current = c as HTMLElement;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleItemKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onItemClick(e);
|
||||
}
|
||||
},
|
||||
[onItemClick],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
onItemClick(e);
|
||||
},
|
||||
[onItemClick],
|
||||
);
|
||||
|
||||
const nativeRenderItem = (option: Item, i: number) => {
|
||||
if (!isMenuItem(option)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, dangerous } = option;
|
||||
|
||||
let element: React.ReactElement;
|
||||
|
||||
if (isActionItem(option)) {
|
||||
element = (
|
||||
<button
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
} else if (isExternalLinkItem(option)) {
|
||||
element = (
|
||||
<a
|
||||
href={option.href}
|
||||
target={option.target ?? '_target'}
|
||||
data-method={option.method}
|
||||
rel='noopener'
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
element = (
|
||||
<Link
|
||||
to={option.to}
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classNames('dropdown-menu__item', {
|
||||
'dropdown-menu__item--dangerous': dangerous,
|
||||
})}
|
||||
key={`${text}-${i}`}
|
||||
>
|
||||
{element}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const renderItemMethod = renderItem ?? nativeRenderItem;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('dropdown-menu__container', {
|
||||
'dropdown-menu__container--loading': loading,
|
||||
})}
|
||||
ref={nodeRef}
|
||||
>
|
||||
{(loading || !items) && <CircularProgress size={30} strokeWidth={3.5} />}
|
||||
|
||||
{!loading && renderHeader && items && (
|
||||
<div className='dropdown-menu__container__header'>
|
||||
{renderHeader(items)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && items && (
|
||||
<ul
|
||||
className={classNames('dropdown-menu__container__list', {
|
||||
'dropdown-menu__container__list--scrollable': scrollable,
|
||||
})}
|
||||
>
|
||||
{items.map((option, i) =>
|
||||
renderItemMethod(option, i, {
|
||||
onClick: handleClick,
|
||||
onKeyUp: handleItemKeyUp,
|
||||
}),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownProps<Item = MenuItem> {
|
||||
children?: React.ReactElement;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
items?: Item[];
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
scrollable?: boolean;
|
||||
scrollKey?: string;
|
||||
status?: ImmutableMap<string, unknown>;
|
||||
renderItem?: RenderItemFn<Item>;
|
||||
renderHeader?: RenderHeaderFn<Item>;
|
||||
onOpen?: () => void;
|
||||
onItemClick?: (arg0: Item, arg1: number) => void;
|
||||
}
|
||||
|
||||
const offset = [5, 5] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const Dropdown = <Item = MenuItem,>({
|
||||
children,
|
||||
icon,
|
||||
iconComponent,
|
||||
items,
|
||||
loading,
|
||||
title = 'Menu',
|
||||
disabled,
|
||||
scrollable,
|
||||
status,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
onOpen,
|
||||
onItemClick,
|
||||
scrollKey,
|
||||
}: DropdownProps<Item>) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const openDropdownId = useAppSelector((state) => state.dropdownMenu.openId);
|
||||
const openedViaKeyboard = useAppSelector(
|
||||
(state) => state.dropdownMenu.keyboard,
|
||||
);
|
||||
const [currentId] = useState(id++);
|
||||
const open = currentId === openDropdownId;
|
||||
const activeElement = useRef<HTMLElement | null>(null);
|
||||
const targetRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (activeElement.current) {
|
||||
activeElement.current.focus({ preventScroll: true });
|
||||
activeElement.current = null;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
closeModal({
|
||||
modalType: 'ACTIONS',
|
||||
ignoreFocus: false,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(closeDropdownMenu({ id: currentId }));
|
||||
}, [dispatch, currentId]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const { type } = e;
|
||||
|
||||
if (open) {
|
||||
handleClose();
|
||||
} else {
|
||||
onOpen?.();
|
||||
|
||||
if (status) {
|
||||
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
|
||||
}
|
||||
|
||||
if (isUserTouching()) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACTIONS',
|
||||
modalProps: {
|
||||
status,
|
||||
actions: items,
|
||||
onClick: onItemClick,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
openDropdownMenu({
|
||||
id: currentId,
|
||||
keyboard: type !== 'click',
|
||||
scrollKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
dispatch,
|
||||
currentId,
|
||||
scrollKey,
|
||||
onOpen,
|
||||
onItemClick,
|
||||
open,
|
||||
status,
|
||||
items,
|
||||
handleClose,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!open && document.activeElement instanceof HTMLElement) {
|
||||
activeElement.current = document.activeElement;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleButtonKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleMouseDown();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleMouseDown],
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = items?.[i];
|
||||
|
||||
handleClose();
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof onItemClick === 'function') {
|
||||
e.preventDefault();
|
||||
onItemClick(item, i);
|
||||
} else if (isActionItem(item)) {
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
}
|
||||
},
|
||||
[handleClose, onItemClick, items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentId === openDropdownId) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
}, [currentId, openDropdownId, handleClose]);
|
||||
|
||||
let button: React.ReactElement;
|
||||
|
||||
if (children) {
|
||||
button = cloneElement(Children.only(children), {
|
||||
onClick: handleClick,
|
||||
onMouseDown: handleMouseDown,
|
||||
onKeyDown: handleButtonKeyDown,
|
||||
onKeyPress: handleKeyPress,
|
||||
ref: targetRef,
|
||||
});
|
||||
} else if (icon && iconComponent) {
|
||||
button = (
|
||||
<IconButton
|
||||
icon={!open ? icon : 'close'}
|
||||
iconComponent={iconComponent}
|
||||
title={title}
|
||||
active={open}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
ref={targetRef}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
|
||||
<Overlay
|
||||
show={open}
|
||||
offset={offset}
|
||||
placement='bottom'
|
||||
flip
|
||||
target={targetRef}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props, arrowProps, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||
<div
|
||||
className={`dropdown-menu__arrow ${placement}`}
|
||||
{...arrowProps}
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
items={items}
|
||||
loading={loading}
|
||||
scrollable={scrollable}
|
||||
onClose={handleClose}
|
||||
openedViaKeyboard={openedViaKeyboard}
|
||||
renderItem={renderItem}
|
||||
renderHeader={renderHeader}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,32 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_menu';
|
||||
import { fetchHistory } from 'mastodon/actions/history';
|
||||
import DropdownMenu from 'mastodon/components/dropdown_menu';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('mastodon/store').RootState} state
|
||||
* @param {*} props
|
||||
*/
|
||||
const mapStateToProps = (state, { statusId }) => ({
|
||||
openDropdownId: state.dropdownMenu.openId,
|
||||
openedViaKeyboard: state.dropdownMenu.keyboard,
|
||||
items: state.getIn(['history', statusId, 'items']),
|
||||
loading: state.getIn(['history', statusId, 'loading']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { statusId }) => ({
|
||||
|
||||
onOpen (id, onItemClick, keyboard) {
|
||||
dispatch(fetchHistory(statusId));
|
||||
dispatch(openDropdownMenu({ id, keyboard }));
|
||||
},
|
||||
|
||||
onClose (id) {
|
||||
dispatch(closeDropdownMenu({ id }));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
|
|
@ -1,77 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import InlineAccount from 'mastodon/components/inline_account';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
|
||||
import DropdownMenu from './containers/dropdown_menu_container';
|
||||
|
||||
const mapDispatchToProps = (dispatch, { statusId }) => ({
|
||||
|
||||
onItemClick (index) {
|
||||
dispatch(openModal({
|
||||
modalType: 'COMPARE_HISTORY',
|
||||
modalProps: { index, statusId },
|
||||
}));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
class EditedTimestamp extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
statusId: PropTypes.string.isRequired,
|
||||
timestamp: PropTypes.string.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onItemClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleItemClick = (item, i) => {
|
||||
const { onItemClick } = this.props;
|
||||
onItemClick(i);
|
||||
};
|
||||
|
||||
renderHeader = items => {
|
||||
return (
|
||||
<FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {# time} other {# times}}' values={{ count: items.size - 1 }} />
|
||||
);
|
||||
};
|
||||
|
||||
renderItem = (item, index, { onClick, onKeyPress }) => {
|
||||
const formattedDate = <RelativeTimestamp timestamp={item.get('created_at')} short={false} />;
|
||||
const formattedName = <InlineAccount accountId={item.get('account')} />;
|
||||
|
||||
const label = item.get('original') ? (
|
||||
<FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} />
|
||||
) : (
|
||||
<FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<li className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at')}>
|
||||
<button data-index={index} onClick={onClick} onKeyPress={onKeyPress}>{label}</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { timestamp, statusId } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
|
||||
<button className='dropdown-menu__text-button'>
|
||||
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <FormattedDateWrapper className='animated-number' value={timestamp} month='short' day='2-digit' hour='2-digit' minute='2-digit' /> }} />
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp));
|
140
app/javascript/mastodon/components/edited_timestamp/index.tsx
Normal file
140
app/javascript/mastodon/components/edited_timestamp/index.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
import { fetchHistory } from 'mastodon/actions/history';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import InlineAccount from 'mastodon/components/inline_account';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
type HistoryItem = ImmutableMap<string, unknown>;
|
||||
|
||||
export const EditedTimestamp: React.FC<{
|
||||
statusId: string;
|
||||
timestamp: string;
|
||||
}> = ({ statusId, timestamp }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector(
|
||||
(state) =>
|
||||
(
|
||||
state.history.getIn([statusId, 'items']) as
|
||||
| ImmutableList<unknown>
|
||||
| undefined
|
||||
)?.toArray() as HistoryItem[],
|
||||
);
|
||||
const loading = useAppSelector(
|
||||
(state) => state.history.getIn([statusId, 'loading']) as boolean,
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
dispatch(fetchHistory(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(_item: HistoryItem, i: number) => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'COMPARE_HISTORY',
|
||||
modalProps: { index: i, statusId },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, statusId],
|
||||
);
|
||||
|
||||
const renderHeader = useCallback((items: HistoryItem[]) => {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='status.edited_x_times'
|
||||
defaultMessage='Edited {count, plural, one {# time} other {# times}}'
|
||||
values={{ count: items.length - 1 }}
|
||||
/>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(
|
||||
item: HistoryItem,
|
||||
index: number,
|
||||
{
|
||||
onClick,
|
||||
onKeyUp,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler;
|
||||
onKeyUp: React.KeyboardEventHandler;
|
||||
},
|
||||
) => {
|
||||
const formattedDate = (
|
||||
<RelativeTimestamp
|
||||
timestamp={item.get('created_at') as string}
|
||||
short={false}
|
||||
/>
|
||||
);
|
||||
const formattedName = (
|
||||
<InlineAccount accountId={item.get('account') as string} />
|
||||
);
|
||||
|
||||
const label = (item.get('original') as boolean) ? (
|
||||
<FormattedMessage
|
||||
id='status.history.created'
|
||||
defaultMessage='{name} created {date}'
|
||||
values={{ name: formattedName, date: formattedDate }}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='status.history.edited'
|
||||
defaultMessage='{name} edited {date}'
|
||||
values={{ name: formattedName, date: formattedDate }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
className='dropdown-menu__item edited-timestamp__history__item'
|
||||
key={item.get('created_at') as string}
|
||||
>
|
||||
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}>
|
||||
{label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown<HistoryItem>
|
||||
items={items}
|
||||
loading={loading}
|
||||
renderItem={renderItem}
|
||||
scrollable
|
||||
renderHeader={renderHeader}
|
||||
onOpen={handleOpen}
|
||||
onItemClick={handleItemClick}
|
||||
>
|
||||
<button className='dropdown-menu__text-button'>
|
||||
<FormattedMessage
|
||||
id='status.edited'
|
||||
defaultMessage='Edited {date}'
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDateWrapper
|
||||
className='animated-number'
|
||||
value={timestamp}
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour='2-digit'
|
||||
minute='2-digit'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { PureComponent, createRef } from 'react';
|
||||
import { useState, useEffect, useCallback, forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
|
@ -15,99 +15,108 @@ interface Props {
|
|||
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
active: boolean;
|
||||
active?: boolean;
|
||||
expanded?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
activeStyle?: React.CSSProperties;
|
||||
disabled: boolean;
|
||||
disabled?: boolean;
|
||||
inverted?: boolean;
|
||||
animate: boolean;
|
||||
overlay: boolean;
|
||||
tabIndex: number;
|
||||
animate?: boolean;
|
||||
overlay?: boolean;
|
||||
tabIndex?: number;
|
||||
counter?: number;
|
||||
href?: string;
|
||||
ariaHidden: boolean;
|
||||
ariaHidden?: boolean;
|
||||
}
|
||||
interface States {
|
||||
activate: boolean;
|
||||
deactivate: boolean;
|
||||
}
|
||||
export class IconButton extends PureComponent<Props, States> {
|
||||
buttonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
static defaultProps = {
|
||||
active: false,
|
||||
disabled: false,
|
||||
animate: false,
|
||||
overlay: false,
|
||||
tabIndex: 0,
|
||||
ariaHidden: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
activate: false,
|
||||
deactivate: false,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: Props) {
|
||||
if (!nextProps.animate) return;
|
||||
|
||||
if (this.props.active && !nextProps.active) {
|
||||
this.setState({ activate: false, deactivate: true });
|
||||
} else if (!this.props.active && nextProps.active) {
|
||||
this.setState({ activate: true, deactivate: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.props.disabled && this.props.onClick != null) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (this.props.onKeyPress && !this.props.disabled) {
|
||||
this.props.onKeyPress(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (!this.props.disabled && this.props.onMouseDown) {
|
||||
this.props.onMouseDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (!this.props.disabled && this.props.onKeyDown) {
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
...this.props.style,
|
||||
...(this.props.active ? this.props.activeStyle : {}),
|
||||
};
|
||||
|
||||
const {
|
||||
active,
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
disabled,
|
||||
expanded,
|
||||
icon,
|
||||
iconComponent,
|
||||
inverted,
|
||||
overlay,
|
||||
tabIndex,
|
||||
title,
|
||||
counter,
|
||||
href,
|
||||
ariaHidden,
|
||||
} = this.props;
|
||||
style,
|
||||
activeStyle,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onKeyPress,
|
||||
onMouseDown,
|
||||
active = false,
|
||||
disabled = false,
|
||||
animate = false,
|
||||
overlay = false,
|
||||
tabIndex = 0,
|
||||
ariaHidden = false,
|
||||
},
|
||||
buttonRef,
|
||||
) => {
|
||||
const [activate, setActivate] = useState(false);
|
||||
const [deactivate, setDeactivate] = useState(false);
|
||||
|
||||
const { activate, deactivate } = this.state;
|
||||
useEffect(() => {
|
||||
if (!animate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activate && !active) {
|
||||
setActivate(false);
|
||||
setDeactivate(true);
|
||||
} else if (!activate && active) {
|
||||
setActivate(true);
|
||||
setDeactivate(false);
|
||||
}
|
||||
}, [setActivate, setDeactivate, animate, active, activate]);
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!disabled) {
|
||||
onClick?.(e);
|
||||
}
|
||||
},
|
||||
[disabled, onClick],
|
||||
);
|
||||
|
||||
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
if (!disabled) {
|
||||
onKeyPress?.(e);
|
||||
}
|
||||
},
|
||||
[disabled, onKeyPress],
|
||||
);
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
if (!disabled) {
|
||||
onMouseDown?.(e);
|
||||
}
|
||||
},
|
||||
[disabled, onMouseDown],
|
||||
);
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
if (!disabled) {
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
},
|
||||
[disabled, onKeyDown],
|
||||
);
|
||||
|
||||
const buttonStyle = {
|
||||
...style,
|
||||
...(active ? activeStyle : {}),
|
||||
};
|
||||
|
||||
const classes = classNames(className, 'icon-button', {
|
||||
active,
|
||||
|
@ -146,18 +155,19 @@ export class IconButton extends PureComponent<Props, States> {
|
|||
aria-hidden={ariaHidden}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
onKeyPress={this.handleKeyPress}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
style={buttonStyle}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
ref={this.buttonRef}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{contents}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
IconButton.displayName = 'IconButton';
|
||||
|
|
|
@ -25,7 +25,7 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
|
|||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
@ -390,7 +390,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
items={menu}
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||
|
||||
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
|
||||
import { openModal, closeModal } from '../actions/modal';
|
||||
import DropdownMenu from '../components/dropdown_menu';
|
||||
import { isUserTouching } from '../is_mobile';
|
||||
|
||||
/**
|
||||
* @param {import('mastodon/store').RootState} state
|
||||
*/
|
||||
const mapStateToProps = state => ({
|
||||
openDropdownId: state.dropdownMenu.openId,
|
||||
openedViaKeyboard: state.dropdownMenu.keyboard,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {any} dispatch
|
||||
* @param {Object} root0
|
||||
* @param {any} [root0.status]
|
||||
* @param {any} root0.items
|
||||
* @param {any} [root0.scrollKey]
|
||||
*/
|
||||
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
|
||||
onOpen(id, onItemClick, keyboard) {
|
||||
if (status) {
|
||||
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
|
||||
}
|
||||
|
||||
dispatch(isUserTouching() ? openModal({
|
||||
modalType: 'ACTIONS',
|
||||
modalProps: {
|
||||
status,
|
||||
actions: items,
|
||||
onClick: onItemClick,
|
||||
},
|
||||
}) : openDropdownMenu({ id, keyboard, scrollKey }));
|
||||
},
|
||||
|
||||
onClose(id) {
|
||||
dispatch(closeModal({
|
||||
modalType: 'ACTIONS',
|
||||
ignoreFocus: false,
|
||||
}));
|
||||
dispatch(closeDropdownMenu({ id }));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
|
|
@ -37,12 +37,12 @@ import {
|
|||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from 'mastodon/components/counters';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
|
||||
import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container';
|
||||
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
|
||||
|
@ -50,7 +50,7 @@ import { useLinks } from 'mastodon/hooks/useLinks';
|
|||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
import type { DropdownMenu } from 'mastodon/models/dropdown_menu';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_USERS,
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
|
@ -406,7 +406,7 @@ export const AccountHeader: React.FC<{
|
|||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const arr: DropdownMenu = [];
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
|
@ -806,13 +806,11 @@ export const AccountHeader: React.FC<{
|
|||
<div className='account__header__tabs__buttons'>
|
||||
{!hidden && bellBtn}
|
||||
{!hidden && shareBtn}
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
disabled={menu.length === 0}
|
||||
items={menu}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
size={24}
|
||||
direction='right'
|
||||
/>
|
||||
{!hidden && actionBtn}
|
||||
</div>
|
||||
|
|
|
@ -2,64 +2,82 @@ import { useMemo } from 'react';
|
|||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
follow_requests: {
|
||||
id: 'navigation_bar.follow_requests',
|
||||
defaultMessage: 'Follow requests',
|
||||
},
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
followed_tags: {
|
||||
id: 'navigation_bar.followed_tags',
|
||||
defaultMessage: 'Followed hashtags',
|
||||
},
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||
domain_blocks: {
|
||||
id: 'navigation_bar.domain_blocks',
|
||||
defaultMessage: 'Blocked domains',
|
||||
},
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
export const ActionBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
export const ActionBar: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const handleLogoutClick = () => {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
|
||||
};
|
||||
|
||||
return ([
|
||||
{ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' },
|
||||
{ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' },
|
||||
return [
|
||||
{
|
||||
text: intl.formatMessage(messages.edit_profile),
|
||||
href: '/settings/profile',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.preferences),
|
||||
href: '/settings/preferences',
|
||||
},
|
||||
{ text: intl.formatMessage(messages.pins), to: '/pinned' },
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' },
|
||||
{
|
||||
text: intl.formatMessage(messages.follow_requests),
|
||||
to: '/follow_requests',
|
||||
},
|
||||
{ text: intl.formatMessage(messages.favourites), to: '/favourites' },
|
||||
{ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' },
|
||||
{ text: intl.formatMessage(messages.lists), to: '/lists' },
|
||||
{ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' },
|
||||
{
|
||||
text: intl.formatMessage(messages.followed_tags),
|
||||
to: '/followed_tags',
|
||||
},
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
||||
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
|
||||
{ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' },
|
||||
{
|
||||
text: intl.formatMessage(messages.domain_blocks),
|
||||
to: '/domain_blocks',
|
||||
},
|
||||
{ text: intl.formatMessage(messages.filters), href: '/filters' },
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.logout), action: handleLogoutClick },
|
||||
]);
|
||||
];
|
||||
}, [intl, dispatch]);
|
||||
|
||||
return (
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icon='bars'
|
||||
iconComponent={MoreHorizIcon}
|
||||
size={24}
|
||||
direction='right'
|
||||
/>
|
||||
);
|
||||
return <Dropdown items={menu} icon='bars' iconComponent={MoreHorizIcon} />;
|
||||
};
|
|
@ -24,7 +24,7 @@ import AvatarComposite from 'mastodon/components/avatar_composite';
|
|||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import StatusContent from 'mastodon/components/status_content';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
|
||||
|
@ -205,7 +205,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={lastStatus}
|
||||
items={menu}
|
||||
|
|
|
@ -12,8 +12,8 @@ import {
|
|||
} from 'mastodon/actions/tags_typed';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
@ -153,13 +153,11 @@ export const HashtagHeader: React.FC<{
|
|||
|
||||
<div className='hashtag-header__header__buttons'>
|
||||
{menu.length > 0 && (
|
||||
<DropdownMenu
|
||||
<Dropdown
|
||||
disabled={menu.length === 0}
|
||||
items={menu}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
size={24}
|
||||
direction='right'
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -13,9 +13,9 @@ import { fetchLists } from 'mastodon/actions/lists';
|
|||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { getOrderedLists } from 'mastodon/selectors/lists';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
|
@ -60,12 +60,11 @@ const ListItem: React.FC<{
|
|||
<span>{title}</span>
|
||||
</Link>
|
||||
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey='lists'
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -17,7 +17,7 @@ import { initReport } from 'mastodon/actions/reports';
|
|||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { CheckBox } from 'mastodon/components/check_box';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import { toCappedNumber } from 'mastodon/utils/numbers';
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ import Column from 'mastodon/components/column';
|
|||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
|
||||
import { NotificationRequest } from './components/notification_request';
|
||||
import { PolicyControls } from './components/policy_controls';
|
||||
|
@ -126,7 +126,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
|||
<div className='column-header__select-row__checkbox'>
|
||||
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={handleSelectAll} />
|
||||
</div>
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
|
@ -139,7 +139,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
|||
</span>
|
||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||
</button>
|
||||
</DropdownMenuContainer>
|
||||
</Dropdown>
|
||||
<div className='column-header__select-row__mode-button'>
|
||||
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
|
||||
{selectionMode ? (
|
||||
|
|
|
@ -24,7 +24,7 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
|
|||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
|
||||
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { me } from '../../../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -317,7 +317,7 @@ class ActionBar extends PureComponent {
|
|||
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>
|
||||
|
||||
<div className='detailed-status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
|
||||
<Dropdown icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -14,7 +14,7 @@ import { Link } from 'react-router-dom';
|
|||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import EditedTimestamp from 'mastodon/components/edited_timestamp';
|
||||
import { EditedTimestamp } from 'mastodon/components/edited_timestamp';
|
||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import type { StatusLike } from 'mastodon/components/hashtag_bar';
|
||||
|
|
|
@ -3,16 +3,18 @@ interface BaseMenuItem {
|
|||
dangerous?: boolean;
|
||||
}
|
||||
|
||||
interface ActionMenuItem extends BaseMenuItem {
|
||||
export interface ActionMenuItem extends BaseMenuItem {
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
interface LinkMenuItem extends BaseMenuItem {
|
||||
export interface LinkMenuItem extends BaseMenuItem {
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface ExternalLinkMenuItem extends BaseMenuItem {
|
||||
export interface ExternalLinkMenuItem extends BaseMenuItem {
|
||||
href: string;
|
||||
target?: string;
|
||||
method?: 'post' | 'put' | 'delete';
|
||||
}
|
||||
|
||||
export type MenuItem =
|
||||
|
@ -20,5 +22,3 @@ export type MenuItem =
|
|||
| LinkMenuItem
|
||||
| ExternalLinkMenuItem
|
||||
| null;
|
||||
|
||||
export type DropdownMenu = MenuItem[];
|
||||
|
|
|
@ -3,15 +3,15 @@ import { createReducer } from '@reduxjs/toolkit';
|
|||
import { closeDropdownMenu, openDropdownMenu } from '../actions/dropdown_menu';
|
||||
|
||||
interface DropdownMenuState {
|
||||
openId: string | null;
|
||||
openId: number | null;
|
||||
keyboard: boolean;
|
||||
scrollKey: string | null;
|
||||
scrollKey: string | undefined;
|
||||
}
|
||||
|
||||
const initialState: DropdownMenuState = {
|
||||
openId: null,
|
||||
keyboard: false,
|
||||
scrollKey: null,
|
||||
scrollKey: undefined,
|
||||
};
|
||||
|
||||
export const dropdownMenuReducer = createReducer(initialState, (builder) => {
|
||||
|
@ -27,7 +27,7 @@ export const dropdownMenuReducer = createReducer(initialState, (builder) => {
|
|||
.addCase(closeDropdownMenu, (state, { payload: { id } }) => {
|
||||
if (state.openId === id) {
|
||||
state.openId = null;
|
||||
state.scrollKey = null;
|
||||
state.scrollKey = undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue
Block a user