diff --git a/app/javascript/flavours/glitch/actions/dropdown_menu.ts b/app/javascript/flavours/glitch/actions/dropdown_menu.ts
index 3694df1ae0..d9d395ba33 100644
--- a/app/javascript/flavours/glitch/actions/dropdown_menu.ts
+++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/account.tsx b/app/javascript/flavours/glitch/components/account.tsx
index 4ba91967bc..f12d9aa112 100644
--- a/app/javascript/flavours/glitch/components/account.tsx
+++ b/app/javascript/flavours/glitch/components/account.tsx
@@ -16,12 +16,12 @@ import { Avatar } from 'flavours/glitch/components/avatar';
import { Button } from 'flavours/glitch/components/button';
import { FollowersCounter } from 'flavours/glitch/components/counters';
import { DisplayName } from 'flavours/glitch/components/display_name';
+import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { FollowButton } from 'flavours/glitch/components/follow_button';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
-import DropdownMenu from 'flavours/glitch/containers/dropdown_menu_container';
import { me } from 'flavours/glitch/initial_state';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
@@ -125,11 +125,10 @@ export const Account: React.FC<{
buttons = (
<>
-
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.jsx b/app/javascript/flavours/glitch/components/dropdown_menu.jsx
deleted file mode 100644
index 7dab8291fa..0000000000
--- a/app/javascript/flavours/glitch/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 'flavours/glitch/components/circular_progress';
-import { WithRouterPropTypes } from 'flavours/glitch/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/flavours/glitch/components/dropdown_menu.tsx b/app/javascript/flavours/glitch/components/dropdown_menu.tsx
new file mode 100644
index 0000000000..56b075a897
--- /dev/null
+++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/accounts';
+import {
+ openDropdownMenu,
+ closeDropdownMenu,
+} from 'flavours/glitch/actions/dropdown_menu';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
+import { CircularProgress } from 'flavours/glitch/components/circular_progress';
+import { isUserTouching } from 'flavours/glitch/is_mobile';
+import type {
+ MenuItem,
+ ActionMenuItem,
+ ExternalLinkMenuItem,
+} from 'flavours/glitch/models/dropdown_menu';
+import { useAppDispatch, useAppSelector } from 'flavours/glitch/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 = (
+
+ {text}
+
+ );
+ } 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/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js
deleted file mode 100644
index 6df635c964..0000000000
--- a/app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import { connect } from 'react-redux';
-
-import { openDropdownMenu, closeDropdownMenu } from 'flavours/glitch/actions/dropdown_menu';
-import { fetchHistory } from 'flavours/glitch/actions/history';
-import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
-
-/**
- *
- * @param {import('flavours/glitch/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/flavours/glitch/components/edited_timestamp/index.jsx b/app/javascript/flavours/glitch/components/edited_timestamp/index.jsx
deleted file mode 100644
index 6f731c58fb..0000000000
--- a/app/javascript/flavours/glitch/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 'flavours/glitch/actions/modal';
-import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
-import InlineAccount from 'flavours/glitch/components/inline_account';
-import { RelativeTimestamp } from 'flavours/glitch/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 (
-
- {label}
-
- );
- };
-
- render () {
- const { timestamp, statusId } = this.props;
-
- return (
-
-
- }} />
-
-
- );
- }
-
-}
-
-export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp));
diff --git a/app/javascript/flavours/glitch/components/edited_timestamp/index.tsx b/app/javascript/flavours/glitch/components/edited_timestamp/index.tsx
new file mode 100644
index 0000000000..d02666bde2
--- /dev/null
+++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/history';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
+import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
+import InlineAccount from 'flavours/glitch/components/inline_account';
+import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
+import { useAppDispatch, useAppSelector } from 'flavours/glitch/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 (
+
+
+ {label}
+
+
+ );
+ },
+ [],
+ );
+
+ return (
+
+ items={items}
+ loading={loading}
+ renderItem={renderItem}
+ scrollable
+ renderHeader={renderHeader}
+ onOpen={handleOpen}
+ onItemClick={handleItemClick}
+ >
+
+
+ ),
+ }}
+ />
+
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/components/icon_button.tsx b/app/javascript/flavours/glitch/components/icon_button.tsx
index 613f56d7d6..407cab4e04 100644
--- a/app/javascript/flavours/glitch/components/icon_button.tsx
+++ b/app/javascript/flavours/glitch/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,102 +15,112 @@ 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;
- label?: string;
+ animate?: boolean;
+ overlay?: boolean;
+ tabIndex?: number;
counter?: number;
- obfuscateCount?: boolean;
href?: string;
- ariaHidden: boolean;
+ ariaHidden?: boolean;
+ label?: string;
+ obfuscateCount?: 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,
- obfuscateCount,
href,
- ariaHidden,
- } = this.props;
+ style,
+ activeStyle,
+ onClick,
+ onKeyDown,
+ onKeyPress,
+ onMouseDown,
+ active = false,
+ disabled = false,
+ animate = false,
+ overlay = false,
+ tabIndex = 0,
+ ariaHidden = false,
+ label,
+ obfuscateCount,
+ },
+ 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,
@@ -130,7 +140,7 @@ export class IconButton extends PureComponent {
)}
- {this.props.label}
+ {label}
>
);
@@ -150,18 +160,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/flavours/glitch/components/status_action_bar.jsx b/app/javascript/flavours/glitch/components/status_action_bar.jsx
index 5b0da6aeae..dc62d2309d 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.jsx
+++ b/app/javascript/flavours/glitch/components/status_action_bar.jsx
@@ -26,7 +26,7 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
-import DropdownMenuContainer from '../containers/dropdown_menu_container';
+import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { me } from '../initial_state';
import { IconButton } from './icon_button';
@@ -351,7 +351,7 @@ class StatusActionBar extends ImmutablePureComponent {
{filterButton}
-
({
- 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) {
- 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/flavours/glitch/features/account_timeline/components/account_header.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx
index da4f6af829..d66edd9337 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx
@@ -36,11 +36,11 @@ import {
} from 'flavours/glitch/components/badge';
import { Button } from 'flavours/glitch/components/button';
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
+import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { FollowButton } from 'flavours/glitch/components/follow_button';
import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
-import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { DomainPill } from 'flavours/glitch/features/account/components/domain_pill';
import AccountNoteContainer from 'flavours/glitch/features/account/containers/account_note_container';
import FollowRequestNoteContainer from 'flavours/glitch/features/account/containers/follow_request_note_container';
@@ -52,7 +52,7 @@ import {
domain as localDomain,
} from 'flavours/glitch/initial_state';
import type { Account } from 'flavours/glitch/models/account';
-import type { DropdownMenu } from 'flavours/glitch/models/dropdown_menu';
+import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
import {
PERMISSION_MANAGE_USERS,
PERMISSION_MANAGE_FEDERATION,
@@ -410,7 +410,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;
@@ -819,13 +819,11 @@ export const AccountHeader: React.FC<{
{!hidden && bellBtn}
{!hidden && shareBtn}
-
{!hidden && actionBtn}
diff --git a/app/javascript/flavours/glitch/features/alt_text_modal/index.tsx b/app/javascript/flavours/glitch/features/alt_text_modal/index.tsx
index 1b03a8011a..48e8e72da5 100644
--- a/app/javascript/flavours/glitch/features/alt_text_modal/index.tsx
+++ b/app/javascript/flavours/glitch/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 'flavours/glitch/features/audio';
import { CharacterCounter } from 'flavours/glitch/features/compose/components/character_counter';
import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components';
import { Video, getPointerPosition } from 'flavours/glitch/features/video';
-import { me } from 'flavours/glitch/initial_state';
+import { me, reduceMotion } from 'flavours/glitch/initial_state';
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { assetHost } from 'flavours/glitch/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/flavours/glitch/features/compose/components/action_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/action_bar.tsx
similarity index 55%
rename from app/javascript/flavours/glitch/features/compose/components/action_bar.jsx
rename to app/javascript/flavours/glitch/features/compose/components/action_bar.tsx
index e277e300f8..6312da171d 100644
--- a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx
+++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/modal';
-import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
+import { useAppDispatch } from 'flavours/glitch/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/flavours/glitch/features/direct_timeline/components/conversation.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx
index c3e5fc72b8..48aec3f12f 100644
--- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx
@@ -25,7 +25,7 @@ import { IconButton } from 'flavours/glitch/components/icon_button';
import { Permalink } from 'flavours/glitch/components/permalink';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import StatusContent from 'flavours/glitch/components/status_content';
-import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { autoPlayGif } from 'flavours/glitch/initial_state';
import { makeGetStatus } from 'flavours/glitch/selectors';
@@ -214,7 +214,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
-
{menu.length > 0 && (
-
)}
diff --git a/app/javascript/flavours/glitch/features/lists/index.tsx b/app/javascript/flavours/glitch/features/lists/index.tsx
index 1608553b70..34a3b7c8cc 100644
--- a/app/javascript/flavours/glitch/features/lists/index.tsx
+++ b/app/javascript/flavours/glitch/features/lists/index.tsx
@@ -13,9 +13,9 @@ import { fetchLists } from 'flavours/glitch/actions/lists';
import { openModal } from 'flavours/glitch/actions/modal';
import { Column } from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header';
+import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { Icon } from 'flavours/glitch/components/icon';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
-import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { getOrderedLists } from 'flavours/glitch/selectors/lists';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
@@ -60,12 +60,11 @@ const ListItem: React.FC<{
{title}
-
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification_request.jsx b/app/javascript/flavours/glitch/features/notifications/components/notification_request.jsx
index 111873902c..f69e71ec8a 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notification_request.jsx
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification_request.jsx
@@ -17,7 +17,7 @@ import { initReport } from 'flavours/glitch/actions/reports';
import { Avatar } from 'flavours/glitch/components/avatar';
import { CheckBox } from 'flavours/glitch/components/check_box';
import { IconButton } from 'flavours/glitch/components/icon_button';
-import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { makeGetAccount } from 'flavours/glitch/selectors';
import { toCappedNumber } from 'flavours/glitch/utils/numbers';
diff --git a/app/javascript/flavours/glitch/features/notifications/requests.jsx b/app/javascript/flavours/glitch/features/notifications/requests.jsx
index 87b5727555..606417c819 100644
--- a/app/javascript/flavours/glitch/features/notifications/requests.jsx
+++ b/app/javascript/flavours/glitch/features/notifications/requests.jsx
@@ -23,7 +23,7 @@ import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header';
import { Icon } from 'flavours/glitch/components/icon';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
-import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import { Dropdown } from 'flavours/glitch/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} />
-
-
+
{selectionMode ? (
diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx
index e598a3d058..d0657512b7 100644
--- a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx
@@ -24,7 +24,7 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import { IconButton } from '../../../components/icon_button';
-import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
+import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { me } from '../../../initial_state';
const messages = defineMessages({
@@ -256,7 +256,7 @@ class ActionBar extends PureComponent {
-
+
);
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx
index f2817f03b7..95e877b5dd 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx
@@ -14,7 +14,7 @@ import { Link } from 'react-router-dom';
import { AnimatedNumber } from 'flavours/glitch/components/animated_number';
import AttachmentList from 'flavours/glitch/components/attachment_list';
import { ContentWarning } from 'flavours/glitch/components/content_warning';
-import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
+import { EditedTimestamp } from 'flavours/glitch/components/edited_timestamp';
import { FilterWarning } from 'flavours/glitch/components/filter_warning';
import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
import type { StatusLike } from 'flavours/glitch/components/hashtag_bar';
diff --git a/app/javascript/flavours/glitch/models/dropdown_menu.ts b/app/javascript/flavours/glitch/models/dropdown_menu.ts
index 35a29ab62a..ceea9ad4dd 100644
--- a/app/javascript/flavours/glitch/models/dropdown_menu.ts
+++ b/app/javascript/flavours/glitch/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/flavours/glitch/reducers/dropdown_menu.ts b/app/javascript/flavours/glitch/reducers/dropdown_menu.ts
index 59e19bb16d..0e46f0ee80 100644
--- a/app/javascript/flavours/glitch/reducers/dropdown_menu.ts
+++ b/app/javascript/flavours/glitch/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/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 = (
+
+ {text}
+
+ );
+ } 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 (
-
- {label}
-
- );
- };
-
- 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 (
+
+
+ {label}
+
+
+ );
+ },
+ [],
+ );
+
+ 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} />
-
-
+
{selectionMode ? (
diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx
index 51a11a23e1..81f8163fff 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.jsx
+++ b/app/javascript/mastodon/features/status/components/action_bar.jsx
@@ -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 {
-
+
);
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: