From 22d33244ee63b310f4572952cd2cc8a1bc3c573b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 8 Apr 2025 21:22:19 +0200 Subject: [PATCH] Refactor `` into TypeScript (#34357) Co-authored-by: Echo --- .../mastodon/actions/dropdown_menu.ts | 6 +- .../mastodon/components/account.tsx | 5 +- .../mastodon/components/dropdown_menu.jsx | 343 ----------- .../mastodon/components/dropdown_menu.tsx | 532 ++++++++++++++++++ .../containers/dropdown_menu_container.js | 32 -- .../components/edited_timestamp/index.jsx | 77 --- .../components/edited_timestamp/index.tsx | 140 +++++ .../mastodon/components/icon_button.tsx | 182 +++--- .../mastodon/components/status_action_bar.jsx | 4 +- .../containers/dropdown_menu_container.js | 50 -- .../components/account_header.tsx | 10 +- .../{action_bar.jsx => action_bar.tsx} | 70 ++- .../components/conversation.jsx | 4 +- .../components/hashtag_header.tsx | 6 +- .../mastodon/features/lists/index.tsx | 7 +- .../components/notification_request.jsx | 2 +- .../features/notifications/requests.jsx | 6 +- .../features/status/components/action_bar.jsx | 4 +- .../status/components/detailed_status.tsx | 2 +- .../mastodon/models/dropdown_menu.ts | 10 +- .../mastodon/reducers/dropdown_menu.ts | 8 +- 21 files changed, 846 insertions(+), 654 deletions(-) delete mode 100644 app/javascript/mastodon/components/dropdown_menu.jsx create mode 100644 app/javascript/mastodon/components/dropdown_menu.tsx delete mode 100644 app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js delete mode 100644 app/javascript/mastodon/components/edited_timestamp/index.jsx create mode 100644 app/javascript/mastodon/components/edited_timestamp/index.tsx delete mode 100644 app/javascript/mastodon/containers/dropdown_menu_container.js rename app/javascript/mastodon/features/compose/components/{action_bar.jsx => action_bar.tsx} (55%) 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; } }); });