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/alt_text_modal/index.tsx b/app/javascript/mastodon/features/alt_text_modal/index.tsx index 8c5e552eb8..e2d05a99ca 100644 --- a/app/javascript/mastodon/features/alt_text_modal/index.tsx +++ b/app/javascript/mastodon/features/alt_text_modal/index.tsx @@ -2,7 +2,6 @@ import { useState, useCallback, useRef, - useEffect, useImperativeHandle, forwardRef, } from 'react'; @@ -13,6 +12,7 @@ import classNames from 'classnames'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import { useSpring, animated } from '@react-spring/web'; import Textarea from 'react-textarea-autosize'; import { length } from 'stringz'; // eslint-disable-next-line import/extensions @@ -31,7 +31,7 @@ import Audio from 'mastodon/features/audio'; import { CharacterCounter } from 'mastodon/features/compose/components/character_counter'; import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components'; import { Video, getPointerPosition } from 'mastodon/features/video'; -import { me } from 'mastodon/initial_state'; +import { me, reduceMotion } from 'mastodon/initial_state'; import type { MediaAttachment } from 'mastodon/models/media_attachment'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { assetHost } from 'mastodon/utils/config'; @@ -105,6 +105,17 @@ const Preview: React.FC<{ position: FocalPoint; onPositionChange: (arg0: FocalPoint) => void; }> = ({ mediaId, position, onPositionChange }) => { + const draggingRef = useRef(false); + const nodeRef = useRef(null); + + const [x, y] = position; + const style = useSpring({ + to: { + left: `${x * 100}%`, + top: `${y * 100}%`, + }, + immediate: reduceMotion || draggingRef.current, + }); const media = useAppSelector((state) => ( (state.compose as ImmutableMap).get( @@ -117,9 +128,6 @@ const Preview: React.FC<{ ); const [dragging, setDragging] = useState(false); - const [x, y] = position; - const nodeRef = useRef(null); - const draggingRef = useRef(false); const setRef = useCallback( (e: HTMLImageElement | HTMLVideoElement | null) => { @@ -134,36 +142,30 @@ const Preview: React.FC<{ return; } + const handleMouseMove = (e: MouseEvent) => { + const { x, y } = getPointerPosition(nodeRef.current, e); + draggingRef.current = true; // This will disable the animation for quicker feedback, only do this if the mouse actually moves + onPositionChange([x, y]); + }; + + const handleMouseUp = () => { + setDragging(false); + draggingRef.current = false; + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('mousemove', handleMouseMove); + }; + const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent); + setDragging(true); - draggingRef.current = true; onPositionChange([x, y]); + + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('mousemove', handleMouseMove); }, [setDragging, onPositionChange], ); - useEffect(() => { - const handleMouseUp = () => { - setDragging(false); - draggingRef.current = false; - }; - - const handleMouseMove = (e: MouseEvent) => { - if (draggingRef.current) { - const { x, y } = getPointerPosition(nodeRef.current, e); - onPositionChange([x, y]); - } - }; - - document.addEventListener('mouseup', handleMouseUp); - document.addEventListener('mousemove', handleMouseMove); - - return () => { - document.removeEventListener('mouseup', handleMouseUp); - document.removeEventListener('mousemove', handleMouseMove); - }; - }, [setDragging, onPositionChange]); - if (!media) { return null; } @@ -179,10 +181,7 @@ const Preview: React.FC<{ role='presentation' onMouseDown={handleMouseDown} /> -
    +
    ); } else if (media.get('type') === 'gifv') { @@ -194,10 +193,7 @@ const Preview: React.FC<{ alt='' onMouseDown={handleMouseDown} /> -
    +
    ); } else if (media.get('type') === 'video') { 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/locales/bg.json b/app/javascript/mastodon/locales/bg.json index eb7a22a04b..bf5a5b1016 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -675,7 +675,7 @@ "onboarding.follows.title": "Последвайте хора, за да започнете", "onboarding.profile.discoverable": "Правене на моя профил откриваем", "onboarding.profile.discoverable_hint": "Включвайки откриваемостта в Mastodon, вашите публикации може да се появят при резултатите от търсене и изгряващи неща, и вашия профил може да бъде предложен на хора с подобни интереси като вашите.", - "onboarding.profile.display_name": "Името на показ", + "onboarding.profile.display_name": "Показвано име", "onboarding.profile.display_name_hint": "Вашето пълно име или псевдоним…", "onboarding.profile.note": "Биография", "onboarding.profile.note_hint": "Може да @споменавате други хора или #хаштагове…", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 4be3211045..53df277b70 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -240,7 +240,7 @@ "conversation.mark_as_read": "Merkitse luetuksi", "conversation.open": "Näytä keskustelu", "conversation.with": "{names} kanssa", - "copy_icon_button.copied": "Sisältö kopioitiin leikepöydälle", + "copy_icon_button.copied": "Kopioitu leikepöydälle", "copypaste.copied": "Kopioitu", "copypaste.copy_to_clipboard": "Kopioi leikepöydälle", "directory.federated": "Tunnetusta fediversumista", diff --git a/app/javascript/mastodon/locales/lad.json b/app/javascript/mastodon/locales/lad.json index ab410dc2a8..d9d941f8e8 100644 --- a/app/javascript/mastodon/locales/lad.json +++ b/app/javascript/mastodon/locales/lad.json @@ -65,6 +65,7 @@ "account.statuses_counter": "{count, plural, one {{counter} publikasyon} other {{counter} publikasyones}}", "account.unblock": "Dezbloka a @{name}", "account.unblock_domain": "Dezbloka domeno {domain}", + "account.unblock_domain_short": "Dezbloka", "account.unblock_short": "Dezbloka", "account.unendorse": "No avalia en profil", "account.unfollow": "Desige", @@ -86,6 +87,7 @@ "alert.unexpected.message": "Afito un yerro no asperado.", "alert.unexpected.title": "Atyo!", "alt_text_badge.title": "Teksto alternativo", + "alt_text_modal.add_alt_text": "Adjusta teksto alternativo", "alt_text_modal.cancel": "Anula", "alt_text_modal.change_thumbnail": "Troka minyatura", "alt_text_modal.done": "Fecho", @@ -195,9 +197,12 @@ "confirmations.discard_edit_media.message": "Tienes trokamientos no guadrados en la deskripsion o vista previa. Keres efasarlos entanto?", "confirmations.edit.confirm": "Edita", "confirmations.edit.message": "Si edites agora, kitaras el mesaj kualo estas eskriviendo aktualmente. Estas siguro ke keres fazerlo?", + "confirmations.follow_to_list.title": "Segir utilizador?", "confirmations.logout.confirm": "Sal", "confirmations.logout.message": "Estas siguro ke keres salir de tu kuento?", "confirmations.logout.title": "Salir?", + "confirmations.missing_alt_text.confirm": "Adjusta teksto alternativo", + "confirmations.missing_alt_text.title": "Adjustar teksto alternativo?", "confirmations.mute.confirm": "Silensia", "confirmations.redraft.confirm": "Efasa i reeskrive", "confirmations.redraft.message": "Estas siguro ke keres efasar esta publikasyon i reeskrivirla? Pedreras todos los favoritos i repartajasyones asosiados kon esta publikasyon i repuestas a eya seran guerfanadas.", @@ -372,6 +377,7 @@ "ignore_notifications_modal.not_followers_title": "Inyorar avizos de personas a las kualas no te sigen?", "ignore_notifications_modal.not_following_title": "Inyorar avizos de personas a las kualas no siges?", "ignore_notifications_modal.private_mentions_title": "Ignorar avizos de mensyones privadas no solisitadas?", + "info_button.label": "Ayuda", "interaction_modal.on_another_server": "En otro sirvidor", "interaction_modal.on_this_server": "En este sirvidor", "interaction_modal.title.favourite": "Endika ke te plaze publikasyon de {name}", @@ -426,6 +432,7 @@ "link_preview.shares": "{count, plural, one {{counter} publikasyon} other {{counter} publikasyones}}", "lists.add_member": "Adjusta", "lists.add_to_list": "Adjusta a lista", + "lists.create": "Kriya", "lists.create_list": "Kriya lista", "lists.delete": "Efasa lista", "lists.done": "Fecho", @@ -588,6 +595,7 @@ "poll_button.remove_poll": "Kita anketa", "privacy.change": "Troka privasita de publikasyon", "privacy.direct.long": "Todos enmentados en la publikasyon", + "privacy.direct.short": "Enmentadura privada", "privacy.private.long": "Solo para tus suivantes", "privacy.private.short": "Suivantes", "privacy.public.long": "Todos en i afuera de Mastodon", @@ -682,6 +690,7 @@ "search_results.accounts": "Profiles", "search_results.all": "Todos", "search_results.hashtags": "Etiketas", + "search_results.no_results": "No ay rezultados.", "search_results.see_all": "Ve todo", "search_results.statuses": "Publikasyones", "search_results.title": "Bushka por \"{q}\"", @@ -775,6 +784,8 @@ "video.expand": "Espande video", "video.fullscreen": "Ekran kompleto", "video.hide": "Eskonde video", + "video.mute": "Silensia", "video.pause": "Pauza", - "video.play": "Reproduze" + "video.play": "Reproduze", + "video.unmute": "Desilensia" } 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; } }); }); diff --git a/config/locales/fi.yml b/config/locales/fi.yml index e999748ad9..fcb180518f 100644 --- a/config/locales/fi.yml +++ b/config/locales/fi.yml @@ -457,7 +457,7 @@ fi: domain: Verkkotunnus new: create: Lisää verkkotunnus - resolve: Selvitä verkkotunnus + resolve: Resolvoi verkkotunnus title: Estä uusi sähköpostiverkkotunnus no_email_domain_block_selected: Sähköpostiverkkotunnusten estoja ei muutettu, koska yhtäkään ei ollut valittuna not_permitted: Ei sallittu diff --git a/config/locales/lad.yml b/config/locales/lad.yml index 86dbc668c0..c1364fbb01 100644 --- a/config/locales/lad.yml +++ b/config/locales/lad.yml @@ -462,6 +462,18 @@ lad: new: title: Importa blokos de domeno no_file: Dinguna dosya tiene sido eskojida + fasp: + debug: + callbacks: + delete: Efasa + providers: + name: Nombre + registrations: + confirm: Konfirma + reject: Refuza + save: Guadra + sign_in: Konektate + status: Estado follow_recommendations: description_html: "Las rekomendasyones de kuentos ayudan a los muevos utilizadores a topar presto kontenido enteresante. Kuando un utilizador no tiene enteraktuado kon otros lo sufisiente komo para djenerar rekomendasyones personalizadas de kuentos a las ke segir, en sus lugar se le rekomiendan estes kuentos. Se rekalkulan diariamente a partir de una mikstura de kuentos kon el mayor numero de enteraksyones rezientes i kon el mayor numero de suivantes lokales kon una lingua determinada." language: Para la lingua diff --git a/config/locales/lv.yml b/config/locales/lv.yml index 9ed84286d8..151cc6cf6c 100644 --- a/config/locales/lv.yml +++ b/config/locales/lv.yml @@ -1507,6 +1507,14 @@ lv: one: Tu gatavojies sekot līdz %{count} kontam no %{filename}. other: Tu gatavojies sekot līdz %{count} kontiem no %{filename}. zero: Tu gatavojies sekot līdz %{count} kontiem no %{filename}. + lists_html: + one: Tu gatavojies pievienot līdz pat %{count} kontam no %{filename} saviem sarakstiem. Tiks izveidoti jauni saraksti, ja nav saraksta, kurā pievienot. + other: Tu gatavojies pievienot līdz pat %{count} kontiem no %{filename} saviem sarakstiem. Tiks izveidoti jauni saraksti, ja nav saraksta, kurā pievienot. + zero: Tu gatavojies pievienot līdz pat %{count} kontiem no %{filename} saviem sarakstiem. Tiks izveidoti jauni saraksti, ja nav saraksta, kurā pievienot. + muting_html: + one: Tu gatavojies apklusināt līdz pat %{count} kontam no %{filename}. + other: Tu gatavojies apklusināt līdz pat %{count} kontiem no %{filename}. + zero: Tu gatavojies apklusināt līdz pat %{count} kontiem no %{filename}. preface: Tu vari ievietot datus, kurus esi izguvis no cita servera, kā, piemēram, cilvēku sarakstu, kuriem Tu seko vai kurus bloķē. recent_imports: Nesen importēts states: @@ -1578,6 +1586,7 @@ lv: unsubscribe: action: Jā, atcelt abonēšanu complete: Anulēts + confirmation_html: Vai tiešām atteikties no %{type} saņemšanas savā e-pasta adresē %{email} par %{domain} esošo Mastodon? Vienmēr var abonēt no jauna savos e-pasta paziņojumu iestatījumos. emails: notification_emails: favourite: izlases paziņojumu e-pasta ziņojumi @@ -1585,10 +1594,13 @@ lv: follow_request: sekošanas pieprasījumu e-pasta ziņojumi mention: pieminēšanas paziņojumu e-pasta ziņojumi reblog: pastiprinājumu paziņojumu e-pasta ziņojumi + resubscribe_html: Ja abonements tika atcelts kļūdas dēļ, abonēt no jauna var savos e-pasta paziņojumu iestatījumos. + success_html: Tu vairs savā e-pasta adresē %{email} nesaņemsi %{type} par %{domain} esošo Mastodon. title: Atcelt abonēšanu media_attachments: validations: images_and_video: Nevar pievienot videoklipu tādai ziņai, kura jau satur attēlus + not_found: Informācijas nesējs %{ids} nav atrasts vai jau pievienots citam ierakstam not_ready: Nevar pievienot failus, kuru apstrāde nav pabeigta. Pēc brīža mēģini vēlreiz! too_many: Nevar pievienot vairāk kā 4 failus migrations: @@ -1666,6 +1678,7 @@ lv: subject: "%{name} laboja ierakstu" notifications: administration_emails: Pārvaldītāju e-pasta paziņojumi + email_events: E-pasta paziņojumu notikumi email_events_hint: 'Atlasi notikumus, par kuriem vēlies saņemt paziņojumus:' number: human: @@ -1722,6 +1735,9 @@ lv: errors: limit_reached: Sasniegts dažādu reakciju limits unrecognized_emoji: nav atpazīta emocijzīme + redirects: + prompt: Ja uzticies šai saitei, jāklikšķina uz tās, lai turpinātu. + title: Tu atstāj %{instance}. relationships: activity: Konta aktivitāte confirm_follow_selected_followers: Vai tiešām vēlies sekot atlasītajiem sekotājiem? @@ -1833,10 +1849,13 @@ lv: severed_relationships: download: Lejupielādēt (%{count}) event_type: + account_suspension: Konta apturēšana (%{target_name}) + domain_block: Servera apturēšana (%{target_name}) user_domain_block: Jūs bloķējāt %{target_name} lost_followers: Zaudētie sekotāji lost_follows: Zaudētie sekojumi preamble: Tu vari zaudēt sekojamos un sekotājus, kad liedz domēnu vai kad satura pārraudzītāji izlemj apturēt attālu serveri. Kad t as notiek, būs iespējams lejupielādēt sarakstus ar pārtrauktajām saiknēm, kurus tad var izpētīt un, iespējams, ievietot citā serverī. + purged: Informāciju par šo serveri notīrīja Tava servera pārvaldītāji. type: Notikums statuses: attached: @@ -1926,6 +1945,7 @@ lv: contrast: Mastodon (Augsts kontrasts) default: Mastodon (Tumšs) mastodon-light: Mastodon (Gaišs) + system: Automātisks (ievēro sistēmas izskatu) time: formats: default: "%b %d, %Y, %H:%M" @@ -1952,6 +1972,10 @@ lv: recovery_instructions_html: Ja kādreiz zaudēsi piekļuvi savam tālrunim, vari izmantot kādu no zemāk norādītajiem atkopes kodiem, lai atgūtu piekļuvi savam kontam. Atkpes kodi jātur drošībā. Piemēram, tos var izdrukāt un glabāt kopā ar citiem svarīgiem dokumentiem. webauthn: Drošības atslēgas user_mailer: + announcement_published: + description: "%{domain} pārvaldītāji veic paziņojumu:" + subject: Pakalpojuma paziņojums + title: "%{domain} pakalpojuma paziņojums" appeal_approved: action: Konta iestatījumi explanation: Apelācija par brīdinājumu jūsu kontam %{strike_date}, ko iesniedzāt %{appeal_date}, ir apstiprināta. Jūsu konts atkal ir labā stāvoklī. @@ -1962,8 +1986,13 @@ lv: subject: Jūsu %{date} apelācija ir noraidīta title: Apelācija noraidīta backup_ready: + explanation: Tu pieprasīji pilnu sava Mastodon konta rezerves kopiju. + extra: Tā tagad ir gatava lejupielādei. subject: Tavs arhīvs ir gatavs lejupielādei title: Arhīva līdzņemšana + failed_2fa: + details: 'Šeit ir informācija par pieteikšanās mēģinājumu:' + explanation: Kāds mēģināja pieteikties Tavā kontā, bet norādīja nederīgu otro autentificēšanās soli. suspicious_sign_in: change_password: mainīt paroli details: 'Šeit ir pieteikšanās izvērsums:' diff --git a/config/locales/simple_form.lad.yml b/config/locales/simple_form.lad.yml index 6fef9f7422..a3f70bd361 100644 --- a/config/locales/simple_form.lad.yml +++ b/config/locales/simple_form.lad.yml @@ -311,6 +311,9 @@ lad: terms_of_service_generator: domain: Domeno user: + date_of_birth_1i: Diya + date_of_birth_2i: Mez + date_of_birth_3i: Anyo role: Rolo time_zone: Zona de tiempo user_role: