diff --git a/app/javascript/mastodon/actions/dropdown_menu.ts b/app/javascript/mastodon/actions/dropdown_menu.ts
index 3694df1ae0..d9d395ba33 100644
--- a/app/javascript/mastodon/actions/dropdown_menu.ts
+++ b/app/javascript/mastodon/actions/dropdown_menu.ts
@@ -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',
);
diff --git a/app/javascript/mastodon/components/account.tsx b/app/javascript/mastodon/components/account.tsx
index 00d3cf27ba..55f1e6fb91 100644
--- a/app/javascript/mastodon/components/account.tsx
+++ b/app/javascript/mastodon/components/account.tsx
@@ -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 = (
<>
-
diff --git a/app/javascript/mastodon/components/dropdown_menu.jsx b/app/javascript/mastodon/components/dropdown_menu.jsx
deleted file mode 100644
index df0be8bc12..0000000000
--- a/app/javascript/mastodon/components/dropdown_menu.jsx
+++ /dev/null
@@ -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
;
- }
-
- const { text, href = '#', target = '_blank', method, dangerous } = option;
-
- return (
-
-
- {text}
-
-
- );
- };
-
- render () {
- const { items, scrollable, renderHeader, loading } = this.props;
-
- let renderItem = this.props.renderItem || this.renderItem;
-
- return (
-
- {loading && (
-
- )}
-
- {!loading && renderHeader && (
-
- {renderHeader(items)}
-
- )}
-
- {!loading && (
-
- {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
-
- )}
-
- );
- }
-
-}
-
-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,
- }) : (
-
- );
-
- return (
- <>
- {button}
-
-
- {({ props, arrowProps, placement }) => (
-
- )}
-
- >
- );
- }
-
-}
-
-export default withRouter(Dropdown);
diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx
new file mode 100644
index 0000000000..a5d2deaae1
--- /dev/null
+++ b/app/javascript/mastodon/components/dropdown_menu.tsx
@@ -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: Item,
+ index: number,
+ handlers: {
+ onClick: (e: React.MouseEvent) => void;
+ onKeyUp: (e: React.KeyboardEvent) => void;
+ },
+) => React.ReactNode;
+
+type RenderHeaderFn
- = (items: Item[]) => React.ReactNode;
+
+interface DropdownMenuProps
- {
+ items?: Item[];
+ loading?: boolean;
+ scrollable?: boolean;
+ onClose: () => void;
+ openedViaKeyboard: boolean;
+ renderItem?: RenderItemFn
- ;
+ renderHeader?: RenderHeaderFn
- ;
+ onItemClick: (e: React.MouseEvent | React.KeyboardEvent) => void;
+}
+
+const DropdownMenu =
- ({
+ items,
+ loading,
+ scrollable,
+ onClose,
+ openedViaKeyboard,
+ renderItem,
+ renderHeader,
+ onItemClick,
+}: DropdownMenuProps
- ) => {
+ const nodeRef = useRef(null);
+ const focusedItemRef = useRef(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 ;
+ }
+
+ const { text, dangerous } = option;
+
+ let element: React.ReactElement;
+
+ if (isActionItem(option)) {
+ element = (
+
+ );
+ } else if (isExternalLinkItem(option)) {
+ element = (
+
+ {text}
+
+ );
+ } else {
+ element = (
+
+ {text}
+
+ );
+ }
+
+ return (
+
+ {element}
+
+ );
+ };
+
+ const renderItemMethod = renderItem ?? nativeRenderItem;
+
+ return (
+
+ {(loading || !items) &&
}
+
+ {!loading && renderHeader && items && (
+
+ {renderHeader(items)}
+
+ )}
+
+ {!loading && items && (
+
+ {items.map((option, i) =>
+ renderItemMethod(option, i, {
+ onClick: handleClick,
+ onKeyUp: handleItemKeyUp,
+ }),
+ )}
+
+ )}
+
+ );
+};
+
+interface DropdownProps- {
+ children?: React.ReactElement;
+ icon?: string;
+ iconComponent?: IconProp;
+ items?: Item[];
+ loading?: boolean;
+ title?: string;
+ disabled?: boolean;
+ scrollable?: boolean;
+ scrollKey?: string;
+ status?: ImmutableMap;
+ renderItem?: RenderItemFn
- ;
+ renderHeader?: RenderHeaderFn
- ;
+ onOpen?: () => void;
+ onItemClick?: (arg0: Item, arg1: number) => void;
+}
+
+const offset = [5, 5] as OffsetValue;
+const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
+
+export const Dropdown =
- ({
+ children,
+ icon,
+ iconComponent,
+ items,
+ loading,
+ title = 'Menu',
+ disabled,
+ scrollable,
+ status,
+ renderItem,
+ renderHeader,
+ onOpen,
+ onItemClick,
+ scrollKey,
+}: DropdownProps
- ) => {
+ 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(null);
+ const targetRef = useRef(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 = (
+
+ );
+ } else {
+ return null;
+ }
+
+ return (
+ <>
+ {button}
+
+
+ {({ props, arrowProps, placement }) => (
+
+ )}
+
+ >
+ );
+};
diff --git a/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js b/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js
deleted file mode 100644
index 726fee9076..0000000000
--- a/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js
+++ /dev/null
@@ -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);
diff --git a/app/javascript/mastodon/components/edited_timestamp/index.jsx b/app/javascript/mastodon/components/edited_timestamp/index.jsx
deleted file mode 100644
index f8fa043259..0000000000
--- a/app/javascript/mastodon/components/edited_timestamp/index.jsx
+++ /dev/null
@@ -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 (
-
- );
- };
-
- renderItem = (item, index, { onClick, onKeyPress }) => {
- const formattedDate = ;
- const formattedName = ;
-
- const label = item.get('original') ? (
-
- ) : (
-
- );
-
- return (
-
-
-
- );
- };
-
- render () {
- const { timestamp, statusId } = this.props;
-
- return (
-
-
-
- );
- }
-
-}
-
-export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp));
diff --git a/app/javascript/mastodon/components/edited_timestamp/index.tsx b/app/javascript/mastodon/components/edited_timestamp/index.tsx
new file mode 100644
index 0000000000..770cf33f8c
--- /dev/null
+++ b/app/javascript/mastodon/components/edited_timestamp/index.tsx
@@ -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;
+
+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
+ | 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 (
+
+ );
+ }, []);
+
+ const renderItem = useCallback(
+ (
+ item: HistoryItem,
+ index: number,
+ {
+ onClick,
+ onKeyUp,
+ }: {
+ onClick: React.MouseEventHandler;
+ onKeyUp: React.KeyboardEventHandler;
+ },
+ ) => {
+ const formattedDate = (
+
+ );
+ const formattedName = (
+
+ );
+
+ const label = (item.get('original') as boolean) ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+
+ );
+ },
+ [],
+ );
+
+ return (
+
+ items={items}
+ loading={loading}
+ renderItem={renderItem}
+ scrollable
+ renderHeader={renderHeader}
+ onOpen={handleOpen}
+ onItemClick={handleItemClick}
+ >
+
+
+ );
+};
diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx
index 179df83627..8ec665bbd8 100644
--- a/app/javascript/mastodon/components/icon_button.tsx
+++ b/app/javascript/mastodon/components/icon_button.tsx
@@ -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;
onKeyDown?: React.KeyboardEventHandler;
onKeyPress?: React.KeyboardEventHandler;
- 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 {
- buttonRef = createRef();
- 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 = (e) => {
- e.preventDefault();
-
- if (!this.props.disabled && this.props.onClick != null) {
- this.props.onClick(e);
- }
- };
-
- handleKeyPress: React.KeyboardEventHandler = (e) => {
- if (this.props.onKeyPress && !this.props.disabled) {
- this.props.onKeyPress(e);
- }
- };
-
- handleMouseDown: React.MouseEventHandler = (e) => {
- if (!this.props.disabled && this.props.onMouseDown) {
- this.props.onMouseDown(e);
- }
- };
-
- handleKeyDown: React.KeyboardEventHandler = (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(
+ (
+ {
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 = useCallback(
+ (e) => {
+ e.preventDefault();
+
+ if (!disabled) {
+ onClick?.(e);
+ }
+ },
+ [disabled, onClick],
+ );
+
+ const handleKeyPress: React.KeyboardEventHandler =
+ useCallback(
+ (e) => {
+ if (!disabled) {
+ onKeyPress?.(e);
+ }
+ },
+ [disabled, onKeyPress],
+ );
+
+ const handleMouseDown: React.MouseEventHandler =
+ useCallback(
+ (e) => {
+ if (!disabled) {
+ onMouseDown?.(e);
+ }
+ },
+ [disabled, onMouseDown],
+ );
+
+ const handleKeyDown: React.KeyboardEventHandler =
+ 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 {
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}
);
- }
-}
+ },
+);
+
+IconButton.displayName = 'IconButton';
diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx
index 9cd2d8d20c..344524f8be 100644
--- a/app/javascript/mastodon/components/status_action_bar.jsx
+++ b/app/javascript/mastodon/components/status_action_bar.jsx
@@ -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 {
-
({
- 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);
diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
index ae1724a728..ca12834528 100644
--- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
@@ -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<{
{!hidden && bellBtn}
{!hidden && shareBtn}
-
{!hidden && actionBtn}
diff --git a/app/javascript/mastodon/features/compose/components/action_bar.jsx b/app/javascript/mastodon/features/compose/components/action_bar.tsx
similarity index 55%
rename from app/javascript/mastodon/features/compose/components/action_bar.jsx
rename to app/javascript/mastodon/features/compose/components/action_bar.tsx
index f7339141ad..af24c565f6 100644
--- a/app/javascript/mastodon/features/compose/components/action_bar.jsx
+++ b/app/javascript/mastodon/features/compose/components/action_bar.tsx
@@ -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 (
-
- );
+ return ;
};
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
index 0d154db1e1..c27cd3727f 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
@@ -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 })
-
{menu.length > 0 && (
-
)}
diff --git a/app/javascript/mastodon/features/lists/index.tsx b/app/javascript/mastodon/features/lists/index.tsx
index 25a537336e..a455597127 100644
--- a/app/javascript/mastodon/features/lists/index.tsx
+++ b/app/javascript/mastodon/features/lists/index.tsx
@@ -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<{
{title}
-
diff --git a/app/javascript/mastodon/features/notifications/components/notification_request.jsx b/app/javascript/mastodon/features/notifications/components/notification_request.jsx
index 626929ae50..9c9365d088 100644
--- a/app/javascript/mastodon/features/notifications/components/notification_request.jsx
+++ b/app/javascript/mastodon/features/notifications/components/notification_request.jsx
@@ -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';
diff --git a/app/javascript/mastodon/features/notifications/requests.jsx b/app/javascript/mastodon/features/notifications/requests.jsx
index ccaed312b4..b2bdd0ec77 100644
--- a/app/javascript/mastodon/features/notifications/requests.jsx
+++ b/app/javascript/mastodon/features/notifications/requests.jsx
@@ -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
0 && !selectAllChecked} onChange={handleSelectAll} />
-
-
+
);
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx
index 0e6ee8c1ea..2fa43ac132 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.tsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx
@@ -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';
diff --git a/app/javascript/mastodon/models/dropdown_menu.ts b/app/javascript/mastodon/models/dropdown_menu.ts
index 35a29ab62a..ceea9ad4dd 100644
--- a/app/javascript/mastodon/models/dropdown_menu.ts
+++ b/app/javascript/mastodon/models/dropdown_menu.ts
@@ -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[];
diff --git a/app/javascript/mastodon/reducers/dropdown_menu.ts b/app/javascript/mastodon/reducers/dropdown_menu.ts
index 59e19bb16d..0e46f0ee80 100644
--- a/app/javascript/mastodon/reducers/dropdown_menu.ts
+++ b/app/javascript/mastodon/reducers/dropdown_menu.ts
@@ -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;
}
});
});