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