mirror of
https://github.com/mastodon/mastodon.git
synced 2025-05-12 04:31:11 +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';
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export const openDropdownMenu = createAction<{
|
export const openDropdownMenu = createAction<{
|
||||||
id: string;
|
id: number;
|
||||||
keyboard: boolean;
|
keyboard: boolean;
|
||||||
scrollKey: string;
|
scrollKey?: string;
|
||||||
}>('dropdownMenu/open');
|
}>('dropdownMenu/open');
|
||||||
|
|
||||||
export const closeDropdownMenu = createAction<{ id: string }>(
|
export const closeDropdownMenu = createAction<{ id: number }>(
|
||||||
'dropdownMenu/close',
|
'dropdownMenu/close',
|
||||||
);
|
);
|
||||||
|
|
|
@ -17,12 +17,12 @@ import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { FollowersCounter } from 'mastodon/components/counters';
|
import { FollowersCounter } from 'mastodon/components/counters';
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { FollowButton } from 'mastodon/components/follow_button';
|
import { FollowButton } from 'mastodon/components/follow_button';
|
||||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
import { Skeleton } from 'mastodon/components/skeleton';
|
import { Skeleton } from 'mastodon/components/skeleton';
|
||||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||||
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
|
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
@ -124,11 +124,10 @@ export const Account: React.FC<{
|
||||||
|
|
||||||
buttons = (
|
buttons = (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu
|
<Dropdown
|
||||||
items={menu}
|
items={menu}
|
||||||
icon='ellipsis-h'
|
icon='ellipsis-h'
|
||||||
iconComponent={MoreHorizIcon}
|
iconComponent={MoreHorizIcon}
|
||||||
direction='right'
|
|
||||||
title={intl.formatMessage(messages.more)}
|
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';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
@ -15,99 +15,108 @@ interface Props {
|
||||||
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||||
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
|
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||||
active: boolean;
|
active?: boolean;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
activeStyle?: React.CSSProperties;
|
activeStyle?: React.CSSProperties;
|
||||||
disabled: boolean;
|
disabled?: boolean;
|
||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
animate: boolean;
|
animate?: boolean;
|
||||||
overlay: boolean;
|
overlay?: boolean;
|
||||||
tabIndex: number;
|
tabIndex?: number;
|
||||||
counter?: number;
|
counter?: number;
|
||||||
href?: string;
|
href?: string;
|
||||||
ariaHidden: boolean;
|
ariaHidden?: boolean;
|
||||||
}
|
}
|
||||||
interface States {
|
|
||||||
activate: boolean;
|
|
||||||
deactivate: boolean;
|
|
||||||
}
|
|
||||||
export class IconButton extends PureComponent<Props, States> {
|
|
||||||
buttonRef = createRef<HTMLButtonElement>();
|
|
||||||
|
|
||||||
static defaultProps = {
|
export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
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,
|
|
||||||
className,
|
className,
|
||||||
disabled,
|
|
||||||
expanded,
|
expanded,
|
||||||
icon,
|
icon,
|
||||||
iconComponent,
|
iconComponent,
|
||||||
inverted,
|
inverted,
|
||||||
overlay,
|
|
||||||
tabIndex,
|
|
||||||
title,
|
title,
|
||||||
counter,
|
counter,
|
||||||
href,
|
href,
|
||||||
ariaHidden,
|
style,
|
||||||
} = this.props;
|
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', {
|
const classes = classNames(className, 'icon-button', {
|
||||||
active,
|
active,
|
||||||
|
@ -146,18 +155,19 @@ export class IconButton extends PureComponent<Props, States> {
|
||||||
aria-hidden={ariaHidden}
|
aria-hidden={ariaHidden}
|
||||||
title={title}
|
title={title}
|
||||||
className={classes}
|
className={classes}
|
||||||
onClick={this.handleClick}
|
onClick={handleClick}
|
||||||
onMouseDown={this.handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
|
||||||
onKeyPress={this.handleKeyPress}
|
style={buttonStyle}
|
||||||
style={style}
|
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
ref={this.buttonRef}
|
ref={buttonRef}
|
||||||
>
|
>
|
||||||
{contents}
|
{contents}
|
||||||
</button>
|
</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 { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
|
||||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
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 { me } from '../initial_state';
|
||||||
|
|
||||||
import { IconButton } from './icon_button';
|
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} />
|
<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>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
<DropdownMenuContainer
|
<Dropdown
|
||||||
scrollKey={scrollKey}
|
scrollKey={scrollKey}
|
||||||
status={status}
|
status={status}
|
||||||
items={menu}
|
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,
|
FollowingCounter,
|
||||||
StatusesCounter,
|
StatusesCounter,
|
||||||
} from 'mastodon/components/counters';
|
} from 'mastodon/components/counters';
|
||||||
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { FollowButton } from 'mastodon/components/follow_button';
|
import { FollowButton } from 'mastodon/components/follow_button';
|
||||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
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 { DomainPill } from 'mastodon/features/account/components/domain_pill';
|
||||||
import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container';
|
import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container';
|
||||||
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_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 { useIdentity } from 'mastodon/identity_context';
|
||||||
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
|
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
|
||||||
import type { Account } from 'mastodon/models/account';
|
import type { Account } from 'mastodon/models/account';
|
||||||
import type { DropdownMenu } from 'mastodon/models/dropdown_menu';
|
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||||
import {
|
import {
|
||||||
PERMISSION_MANAGE_USERS,
|
PERMISSION_MANAGE_USERS,
|
||||||
PERMISSION_MANAGE_FEDERATION,
|
PERMISSION_MANAGE_FEDERATION,
|
||||||
|
@ -406,7 +406,7 @@ export const AccountHeader: React.FC<{
|
||||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||||
|
|
||||||
const menu = useMemo(() => {
|
const menu = useMemo(() => {
|
||||||
const arr: DropdownMenu = [];
|
const arr: MenuItem[] = [];
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return arr;
|
return arr;
|
||||||
|
@ -806,13 +806,11 @@ export const AccountHeader: React.FC<{
|
||||||
<div className='account__header__tabs__buttons'>
|
<div className='account__header__tabs__buttons'>
|
||||||
{!hidden && bellBtn}
|
{!hidden && bellBtn}
|
||||||
{!hidden && shareBtn}
|
{!hidden && shareBtn}
|
||||||
<DropdownMenuContainer
|
<Dropdown
|
||||||
disabled={menu.length === 0}
|
disabled={menu.length === 0}
|
||||||
items={menu}
|
items={menu}
|
||||||
icon='ellipsis-v'
|
icon='ellipsis-v'
|
||||||
iconComponent={MoreHorizIcon}
|
iconComponent={MoreHorizIcon}
|
||||||
size={24}
|
|
||||||
direction='right'
|
|
||||||
/>
|
/>
|
||||||
{!hidden && actionBtn}
|
{!hidden && actionBtn}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,64 +2,82 @@ import { useMemo } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
|
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
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({
|
const messages = defineMessages({
|
||||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
||||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
preferences: {
|
||||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
id: 'navigation_bar.preferences',
|
||||||
|
defaultMessage: 'Preferences',
|
||||||
|
},
|
||||||
|
follow_requests: {
|
||||||
|
id: 'navigation_bar.follow_requests',
|
||||||
|
defaultMessage: 'Follow requests',
|
||||||
|
},
|
||||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
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' },
|
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' },
|
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ActionBar = () => {
|
export const ActionBar: React.FC = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const menu = useMemo(() => {
|
const menu = useMemo(() => {
|
||||||
const handleLogoutClick = () => {
|
const handleLogoutClick = () => {
|
||||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
|
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return ([
|
return [
|
||||||
{ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' },
|
{
|
||||||
{ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' },
|
text: intl.formatMessage(messages.edit_profile),
|
||||||
|
href: '/settings/profile',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.preferences),
|
||||||
|
href: '/settings/preferences',
|
||||||
|
},
|
||||||
{ text: intl.formatMessage(messages.pins), to: '/pinned' },
|
{ text: intl.formatMessage(messages.pins), to: '/pinned' },
|
||||||
null,
|
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.favourites), to: '/favourites' },
|
||||||
{ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' },
|
{ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' },
|
||||||
{ text: intl.formatMessage(messages.lists), to: '/lists' },
|
{ 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,
|
null,
|
||||||
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
||||||
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
|
{ 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' },
|
{ text: intl.formatMessage(messages.filters), href: '/filters' },
|
||||||
null,
|
null,
|
||||||
{ text: intl.formatMessage(messages.logout), action: handleLogoutClick },
|
{ text: intl.formatMessage(messages.logout), action: handleLogoutClick },
|
||||||
]);
|
];
|
||||||
}, [intl, dispatch]);
|
}, [intl, dispatch]);
|
||||||
|
|
||||||
return (
|
return <Dropdown items={menu} icon='bars' iconComponent={MoreHorizIcon} />;
|
||||||
<DropdownMenuContainer
|
|
||||||
items={menu}
|
|
||||||
icon='bars'
|
|
||||||
iconComponent={MoreHorizIcon}
|
|
||||||
size={24}
|
|
||||||
direction='right'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
|
@ -24,7 +24,7 @@ import AvatarComposite from 'mastodon/components/avatar_composite';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||||
import StatusContent from 'mastodon/components/status_content';
|
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 { autoPlayGif } from 'mastodon/initial_state';
|
||||||
import { makeGetStatus } from 'mastodon/selectors';
|
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} />
|
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
|
||||||
|
|
||||||
<div className='status__action-bar-dropdown'>
|
<div className='status__action-bar-dropdown'>
|
||||||
<DropdownMenuContainer
|
<Dropdown
|
||||||
scrollKey={scrollKey}
|
scrollKey={scrollKey}
|
||||||
status={lastStatus}
|
status={lastStatus}
|
||||||
items={menu}
|
items={menu}
|
||||||
|
|
|
@ -12,8 +12,8 @@ import {
|
||||||
} from 'mastodon/actions/tags_typed';
|
} from 'mastodon/actions/tags_typed';
|
||||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
|
|
||||||
import { useIdentity } from 'mastodon/identity_context';
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions';
|
import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions';
|
||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
@ -153,13 +153,11 @@ export const HashtagHeader: React.FC<{
|
||||||
|
|
||||||
<div className='hashtag-header__header__buttons'>
|
<div className='hashtag-header__header__buttons'>
|
||||||
{menu.length > 0 && (
|
{menu.length > 0 && (
|
||||||
<DropdownMenu
|
<Dropdown
|
||||||
disabled={menu.length === 0}
|
disabled={menu.length === 0}
|
||||||
items={menu}
|
items={menu}
|
||||||
icon='ellipsis-v'
|
icon='ellipsis-v'
|
||||||
iconComponent={MoreHorizIcon}
|
iconComponent={MoreHorizIcon}
|
||||||
size={24}
|
|
||||||
direction='right'
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -13,9 +13,9 @@ import { fetchLists } from 'mastodon/actions/lists';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
|
||||||
import { getOrderedLists } from 'mastodon/selectors/lists';
|
import { getOrderedLists } from 'mastodon/selectors/lists';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
@ -60,12 +60,11 @@ const ListItem: React.FC<{
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<DropdownMenuContainer
|
<Dropdown
|
||||||
scrollKey='lists'
|
scrollKey='lists'
|
||||||
items={menu}
|
items={menu}
|
||||||
icons='ellipsis-h'
|
icon='ellipsis-h'
|
||||||
iconComponent={MoreHorizIcon}
|
iconComponent={MoreHorizIcon}
|
||||||
direction='right'
|
|
||||||
title={intl.formatMessage(messages.more)}
|
title={intl.formatMessage(messages.more)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { initReport } from 'mastodon/actions/reports';
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { CheckBox } from 'mastodon/components/check_box';
|
import { CheckBox } from 'mastodon/components/check_box';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
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 { makeGetAccount } from 'mastodon/selectors';
|
||||||
import { toCappedNumber } from 'mastodon/utils/numbers';
|
import { toCappedNumber } from 'mastodon/utils/numbers';
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ import Column from 'mastodon/components/column';
|
||||||
import ColumnHeader from 'mastodon/components/column_header';
|
import ColumnHeader from 'mastodon/components/column_header';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
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 { NotificationRequest } from './components/notification_request';
|
||||||
import { PolicyControls } from './components/policy_controls';
|
import { PolicyControls } from './components/policy_controls';
|
||||||
|
@ -126,7 +126,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
||||||
<div className='column-header__select-row__checkbox'>
|
<div className='column-header__select-row__checkbox'>
|
||||||
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={handleSelectAll} />
|
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={handleSelectAll} />
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuContainer
|
<Dropdown
|
||||||
items={menu}
|
items={menu}
|
||||||
icons='ellipsis-h'
|
icons='ellipsis-h'
|
||||||
iconComponent={MoreHorizIcon}
|
iconComponent={MoreHorizIcon}
|
||||||
|
@ -139,7 +139,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
||||||
</span>
|
</span>
|
||||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuContainer>
|
</Dropdown>
|
||||||
<div className='column-header__select-row__mode-button'>
|
<div className='column-header__select-row__mode-button'>
|
||||||
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
|
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
|
||||||
{selectionMode ? (
|
{selectionMode ? (
|
||||||
|
|
|
@ -24,7 +24,7 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
|
||||||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
|
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
|
||||||
|
|
||||||
import { IconButton } from '../../../components/icon_button';
|
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';
|
import { me } from '../../../initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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__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'>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { Link } from 'react-router-dom';
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
||||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
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 { FilterWarning } from 'mastodon/components/filter_warning';
|
||||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||||
import type { StatusLike } from 'mastodon/components/hashtag_bar';
|
import type { StatusLike } from 'mastodon/components/hashtag_bar';
|
||||||
|
|
|
@ -3,16 +3,18 @@ interface BaseMenuItem {
|
||||||
dangerous?: boolean;
|
dangerous?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionMenuItem extends BaseMenuItem {
|
export interface ActionMenuItem extends BaseMenuItem {
|
||||||
action: () => void;
|
action: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinkMenuItem extends BaseMenuItem {
|
export interface LinkMenuItem extends BaseMenuItem {
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExternalLinkMenuItem extends BaseMenuItem {
|
export interface ExternalLinkMenuItem extends BaseMenuItem {
|
||||||
href: string;
|
href: string;
|
||||||
|
target?: string;
|
||||||
|
method?: 'post' | 'put' | 'delete';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MenuItem =
|
export type MenuItem =
|
||||||
|
@ -20,5 +22,3 @@ export type MenuItem =
|
||||||
| LinkMenuItem
|
| LinkMenuItem
|
||||||
| ExternalLinkMenuItem
|
| ExternalLinkMenuItem
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export type DropdownMenu = MenuItem[];
|
|
||||||
|
|
|
@ -3,15 +3,15 @@ import { createReducer } from '@reduxjs/toolkit';
|
||||||
import { closeDropdownMenu, openDropdownMenu } from '../actions/dropdown_menu';
|
import { closeDropdownMenu, openDropdownMenu } from '../actions/dropdown_menu';
|
||||||
|
|
||||||
interface DropdownMenuState {
|
interface DropdownMenuState {
|
||||||
openId: string | null;
|
openId: number | null;
|
||||||
keyboard: boolean;
|
keyboard: boolean;
|
||||||
scrollKey: string | null;
|
scrollKey: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: DropdownMenuState = {
|
const initialState: DropdownMenuState = {
|
||||||
openId: null,
|
openId: null,
|
||||||
keyboard: false,
|
keyboard: false,
|
||||||
scrollKey: null,
|
scrollKey: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dropdownMenuReducer = createReducer(initialState, (builder) => {
|
export const dropdownMenuReducer = createReducer(initialState, (builder) => {
|
||||||
|
@ -27,7 +27,7 @@ export const dropdownMenuReducer = createReducer(initialState, (builder) => {
|
||||||
.addCase(closeDropdownMenu, (state, { payload: { id } }) => {
|
.addCase(closeDropdownMenu, (state, { payload: { id } }) => {
|
||||||
if (state.openId === id) {
|
if (state.openId === id) {
|
||||||
state.openId = null;
|
state.openId = null;
|
||||||
state.scrollKey = null;
|
state.scrollKey = undefined;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue
Block a user