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