Next: emoji reacts part 1 See merge request soapbox-pub/soapbox-fe!1161next-interactions
commit
41ae50c495
@ -1,342 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
import Motion from '../features/ui/util/optional_motion';
|
||||
|
||||
import { IconButton } from './ui';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
let id = 0;
|
||||
|
||||
@withRouter
|
||||
class DropdownMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
placement: PropTypes.string,
|
||||
arrowOffsetLeft: PropTypes.string,
|
||||
arrowOffsetTop: PropTypes.string,
|
||||
openedViaKeyboard: PropTypes.bool,
|
||||
history: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
placement: 'bottom',
|
||||
};
|
||||
|
||||
state = {
|
||||
mounted: false,
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('keydown', this.handleKeyDown, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
if (this.focusedItem && this.props.openedViaKeyboard) {
|
||||
this.focusedItem.focus({ preventScroll: true });
|
||||
}
|
||||
this.setState({ mounted: true });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('keydown', this.handleKeyDown, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
setFocusRef = c => {
|
||||
this.focusedItem = c;
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
const items = Array.from(this.node.getElementsByTagName('a'));
|
||||
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 i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action(e);
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
handleMiddleClick = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { middleClick } = this.props.items[i];
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
if (e.button === 1 && typeof middleClick === 'function') {
|
||||
e.preventDefault();
|
||||
middleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleAuxClick = e => {
|
||||
if (e.button === 1) {
|
||||
this.handleMiddleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
renderItem(option, i) {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, href, to, newTab, isLogout, icon, destructive } = option;
|
||||
|
||||
return (
|
||||
<li className={classNames('dropdown-menu__item', { destructive })} key={`${text}-${i}`}>
|
||||
<a
|
||||
href={href || to || '#'}
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
ref={i === 0 ? this.setFocusRef : null}
|
||||
onClick={this.handleClick}
|
||||
onAuxClick={this.handleAuxClick}
|
||||
onKeyPress={this.handleItemKeyPress}
|
||||
data-index={i}
|
||||
target={newTab ? '_blank' : null}
|
||||
data-method={isLogout ? 'delete' : null}
|
||||
>
|
||||
{icon && <Icon src={icon} />}
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
|
||||
const { mounted } = this.state;
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
|
||||
<ul>
|
||||
{items.map((option, i) => this.renderItem(option, i))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default @withRouter
|
||||
class Dropdown extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
icon: PropTypes.string,
|
||||
src: PropTypes.string,
|
||||
items: PropTypes.array.isRequired,
|
||||
size: PropTypes.number,
|
||||
active: PropTypes.bool,
|
||||
pressed: PropTypes.bool,
|
||||
title: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
status: ImmutablePropTypes.record,
|
||||
isUserTouching: PropTypes.func,
|
||||
isModalOpen: PropTypes.bool.isRequired,
|
||||
onOpen: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
dropdownPlacement: PropTypes.string,
|
||||
openDropdownId: PropTypes.number,
|
||||
openedViaKeyboard: PropTypes.bool,
|
||||
text: PropTypes.string,
|
||||
onShiftClick: PropTypes.func,
|
||||
history: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
title: 'Menu',
|
||||
};
|
||||
|
||||
state = {
|
||||
id: id++,
|
||||
};
|
||||
|
||||
handleClick = e => {
|
||||
const { onOpen, onShiftClick, openDropdownId } = this.props;
|
||||
e.stopPropagation();
|
||||
|
||||
if (onShiftClick && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onShiftClick(e);
|
||||
} else if (this.state.id === openDropdownId) {
|
||||
this.handleClose();
|
||||
} else {
|
||||
const { top } = e.target.getBoundingClientRect();
|
||||
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
|
||||
|
||||
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
|
||||
}
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
if (this.activeElement) {
|
||||
this.activeElement.focus();
|
||||
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 i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
this.handleClose();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
action(e);
|
||||
} else if (to) {
|
||||
this.props.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
}
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { src, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard, pressed, text } = this.props;
|
||||
const open = this.state.id === openDropdownId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': true,
|
||||
'text-gray-600': open,
|
||||
})}
|
||||
title={title}
|
||||
src={src}
|
||||
pressed={pressed}
|
||||
size={size}
|
||||
text={text}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleButtonKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
ref={this.setTargetRef}
|
||||
/>
|
||||
|
||||
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
|
||||
<DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,401 @@
|
||||
import classNames from 'classnames';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React from 'react';
|
||||
import { spring } from 'react-motion';
|
||||
// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import Motion from 'soapbox/features/ui/util/optional_motion';
|
||||
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
let id = 0;
|
||||
|
||||
export interface MenuItem {
|
||||
action: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
|
||||
middleClick?: React.EventHandler<React.MouseEvent>,
|
||||
text: string,
|
||||
href?: string,
|
||||
to?: string,
|
||||
newTab?: boolean,
|
||||
isLogout?: boolean,
|
||||
icon: string,
|
||||
destructive?: boolean,
|
||||
}
|
||||
|
||||
export type Menu = Array<MenuItem | null>;
|
||||
|
||||
interface IDropdownMenu extends RouteComponentProps {
|
||||
items: Menu,
|
||||
onClose: () => void,
|
||||
style?: React.CSSProperties,
|
||||
placement?: DropdownPlacement,
|
||||
arrowOffsetLeft?: string,
|
||||
arrowOffsetTop?: string,
|
||||
openedViaKeyboard: boolean,
|
||||
}
|
||||
|
||||
interface IDropdownMenuState {
|
||||
mounted: boolean,
|
||||
}
|
||||
|
||||
class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState> {
|
||||
|
||||
static defaultProps: Partial<IDropdownMenu> = {
|
||||
style: {},
|
||||
placement: 'bottom',
|
||||
};
|
||||
|
||||
state = {
|
||||
mounted: false,
|
||||
};
|
||||
|
||||
node: HTMLDivElement | null = null;
|
||||
focusedItem: HTMLAnchorElement | null = null;
|
||||
|
||||
handleDocumentClick = (e: Event) => {
|
||||
if (this.node && !this.node.contains(e.target as Node)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('keydown', this.handleKeyDown, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
if (this.focusedItem && this.props.openedViaKeyboard) {
|
||||
this.focusedItem.focus({ preventScroll: true });
|
||||
}
|
||||
this.setState({ mounted: true });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.handleDocumentClick);
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick);
|
||||
}
|
||||
|
||||
setRef: React.RefCallback<HTMLDivElement> = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
|
||||
this.focusedItem = c;
|
||||
}
|
||||
|
||||
handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!this.node) return;
|
||||
|
||||
const items = Array.from(this.node.getElementsByTagName('a'));
|
||||
const index = items.indexOf(document.activeElement as any);
|
||||
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: React.EventHandler<React.KeyboardEvent> = e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
this.handleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
if (!item) return;
|
||||
const { action, to } = item;
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action(e);
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
if (!item) return;
|
||||
const { middleClick } = item;
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
if (e.button === 1 && typeof middleClick === 'function') {
|
||||
e.preventDefault();
|
||||
middleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleAuxClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
if (e.button === 1) {
|
||||
this.handleMiddleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
renderItem(option: MenuItem | null, i: number): JSX.Element {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, href, to, newTab, isLogout, icon, destructive } = option;
|
||||
|
||||
return (
|
||||
<li className={classNames('dropdown-menu__item', { destructive })} key={`${text}-${i}`}>
|
||||
<a
|
||||
href={href || to || '#'}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
ref={i === 0 ? this.setFocusRef : null}
|
||||
onClick={this.handleClick}
|
||||
onAuxClick={this.handleAuxClick}
|
||||
onKeyPress={this.handleItemKeyPress}
|
||||
data-index={i}
|
||||
target={newTab ? '_blank' : undefined}
|
||||
data-method={isLogout ? 'delete' : undefined}
|
||||
>
|
||||
{icon && <Icon src={icon} />}
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
|
||||
const { mounted } = this.state;
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }} ref={this.setRef}>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
|
||||
<ul>
|
||||
{items.map((option, i) => this.renderItem(option, i))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const RouterDropdownMenu = withRouter(DropdownMenu);
|
||||
|
||||
export interface IDropdown extends RouteComponentProps {
|
||||
icon?: string,
|
||||
src?: string,
|
||||
items: Menu,
|
||||
size?: number,
|
||||
active?: boolean,
|
||||
pressed?: boolean,
|
||||
title?: string,
|
||||
disabled?: boolean,
|
||||
status?: Status,
|
||||
isUserTouching?: () => boolean,
|
||||
isModalOpen?: boolean,
|
||||
onOpen?: (
|
||||
id: number,
|
||||
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
||||
dropdownPlacement: DropdownPlacement,
|
||||
keyboard: boolean,
|
||||
) => void,
|
||||
onClose?: (id: number) => void,
|
||||
dropdownPlacement?: string,
|
||||
openDropdownId?: number,
|
||||
openedViaKeyboard?: boolean,
|
||||
text?: string,
|
||||
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
||||
children?: JSX.Element,
|
||||
}
|
||||
|
||||
interface IDropdownState {
|
||||
id: number,
|
||||
open: boolean,
|
||||
}
|
||||
|
||||
export type DropdownPlacement = 'top' | 'bottom';
|
||||
|
||||
class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
|
||||
|
||||
static defaultProps: Partial<IDropdown> = {
|
||||
title: 'Menu',
|
||||
};
|
||||
|
||||
state = {
|
||||
id: id++,
|
||||
open: false,
|
||||
};
|
||||
|
||||
target: HTMLButtonElement | null = null;
|
||||
activeElement: Element | null = null;
|
||||
|
||||
handleClick: React.EventHandler<React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>> = e => {
|
||||
const { onOpen, onShiftClick, openDropdownId } = this.props;
|
||||
e.stopPropagation();
|
||||
|
||||
if (onShiftClick && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onShiftClick(e);
|
||||
} else if (this.state.id === openDropdownId) {
|
||||
this.handleClose();
|
||||
} else if(onOpen) {
|
||||
const { top } = e.currentTarget.getBoundingClientRect();
|
||||
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
|
||||
|
||||
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
|
||||
}
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
if (this.activeElement && this.activeElement === this.target) {
|
||||
(this.activeElement as HTMLButtonElement).focus();
|
||||
this.activeElement = null;
|
||||
}
|
||||
|
||||
if (this.props.onClose) {
|
||||
this.props.onClose(this.state.id);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
|
||||
if (!this.state.open) {
|
||||
this.activeElement = document.activeElement;
|
||||
}
|
||||
}
|
||||
|
||||
handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (e) => {
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleMouseDown(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleClick(e);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
if (!item) return;
|
||||
|
||||
const { action, to } = item;
|
||||
|
||||
this.handleClose();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
action(e);
|
||||
} else if (to) {
|
||||
this.props.history?.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
setTargetRef: React.RefCallback<HTMLButtonElement> = c => {
|
||||
this.target = c;
|
||||
}
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { src = require('@tabler/icons/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children } = this.props;
|
||||
const open = this.state.id === openDropdownId;
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
React.cloneElement(children, {
|
||||
disabled,
|
||||
onClick: this.handleClick,
|
||||
onMouseDown: this.handleMouseDown,
|
||||
onKeyDown: this.handleButtonKeyDown,
|
||||
onKeyPress: this.handleKeyPress,
|
||||
ref: this.setTargetRef,
|
||||
})
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': true,
|
||||
'text-gray-600': open,
|
||||
})}
|
||||
title={title}
|
||||
src={src}
|
||||
aria-pressed={pressed}
|
||||
text={text}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleButtonKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
ref={this.setTargetRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
|
||||
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(Dropdown);
|
@ -1,124 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
allowedEmoji: getSoapboxConfig(state).get('allowedEmoji'),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class EmojiSelector extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onReact: PropTypes.func.isRequired,
|
||||
onUnfocus: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
focused: PropTypes.bool,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onReact: () => {},
|
||||
onUnfocus: () => {},
|
||||
visible: false,
|
||||
}
|
||||
|
||||
handleBlur = e => {
|
||||
const { focused, onUnfocus } = this.props;
|
||||
|
||||
if (focused && (!e.relatedTarget || !e.relatedTarget.classList.contains('emoji-react-selector__emoji'))) {
|
||||
onUnfocus();
|
||||
}
|
||||
}
|
||||
|
||||
_selectPreviousEmoji = i => {
|
||||
if (i !== 0) {
|
||||
this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`).focus();
|
||||
} else {
|
||||
this.node.querySelector('.emoji-react-selector__emoji:last-child').focus();
|
||||
}
|
||||
};
|
||||
|
||||
_selectNextEmoji = i => {
|
||||
if (i !== this.props.allowedEmoji.size - 1) {
|
||||
this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`).focus();
|
||||
} else {
|
||||
this.node.querySelector('.emoji-react-selector__emoji:first-child').focus();
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = i => e => {
|
||||
const { onUnfocus } = this.props;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) this._selectPreviousEmoji(i);
|
||||
else this._selectNextEmoji(i);
|
||||
break;
|
||||
case 'Left':
|
||||
case 'ArrowLeft':
|
||||
this._selectPreviousEmoji(i);
|
||||
break;
|
||||
case 'Right':
|
||||
case 'ArrowRight':
|
||||
this._selectNextEmoji(i);
|
||||
break;
|
||||
case 'Escape':
|
||||
onUnfocus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleReact = emoji => () => {
|
||||
const { onReact, focused, onUnfocus } = this.props;
|
||||
|
||||
onReact(emoji)();
|
||||
|
||||
if (focused) {
|
||||
onUnfocus();
|
||||
}
|
||||
}
|
||||
|
||||
handlers = {
|
||||
open: () => {},
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { visible, focused, allowedEmoji } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys
|
||||
handlers={this.handlers}
|
||||
className='emoji-react-selector-container'
|
||||
>
|
||||
<div
|
||||
className={classNames('emoji-react-selector', { 'emoji-react-selector--visible': visible, 'emoji-react-selector--focused': focused })}
|
||||
onBlur={this.handleBlur}
|
||||
ref={this.setRef}
|
||||
>
|
||||
{allowedEmoji.map((emoji, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className='emoji-react-selector__emoji'
|
||||
dangerouslySetInnerHTML={{ __html: emojify(emoji) }}
|
||||
onClick={this.handleReact(emoji)}
|
||||
onKeyDown={this.handleKeyDown(i, emoji)}
|
||||
tabIndex={(visible || focused) ? 0 : -1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
// import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
allowedEmoji: getSoapboxConfig(state).allowedEmoji,
|
||||
});
|
||||
|
||||
interface IEmojiSelector {
|
||||
allowedEmoji: ImmutableList<string>,
|
||||
onReact: (emoji: string) => void,
|
||||
onUnfocus: () => void,
|
||||
visible: boolean,
|
||||
focused?: boolean,
|
||||
}
|
||||
|
||||
class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
|
||||
|
||||
static defaultProps: Partial<IEmojiSelector> = {
|
||||
onReact: () => {},
|
||||
onUnfocus: () => {},
|
||||
visible: false,
|
||||
}
|
||||
|
||||
node?: HTMLDivElement = undefined;
|
||||
|
||||
handleBlur: React.FocusEventHandler<HTMLDivElement> = e => {
|
||||
const { focused, onUnfocus } = this.props;
|
||||
|
||||
if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) {
|
||||
onUnfocus();
|
||||
}
|
||||
}
|
||||
|
||||
_selectPreviousEmoji = (i: number): void => {
|
||||
if (!this.node) return;
|
||||
|
||||
if (i !== 0) {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`);
|
||||
button?.focus();
|
||||
} else {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:last-child');
|
||||
button?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
_selectNextEmoji = (i: number) => {
|
||||
if (!this.node) return;
|
||||
|
||||
if (i !== this.props.allowedEmoji.size - 1) {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`);
|
||||
button?.focus();
|
||||
} else {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:first-child');
|
||||
button?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (i: number): React.KeyboardEventHandler => e => {
|
||||
const { onUnfocus } = this.props;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) this._selectPreviousEmoji(i);
|
||||
else this._selectNextEmoji(i);
|
||||
break;
|
||||
case 'Left':
|
||||
case 'ArrowLeft':
|
||||
this._selectPreviousEmoji(i);
|
||||
break;
|
||||
case 'Right':
|
||||
case 'ArrowRight':
|
||||
this._selectNextEmoji(i);
|
||||
break;
|
||||
case 'Escape':
|
||||
onUnfocus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleReact = (emoji: string) => (): void => {
|
||||
const { onReact, focused, onUnfocus } = this.props;
|
||||
|
||||
onReact(emoji);
|
||||
|
||||
if (focused) {
|
||||
onUnfocus();
|
||||
}
|
||||
}
|
||||
|
||||
handlers = {
|
||||
open: () => {},
|
||||
};
|
||||
|
||||
setRef = (c: HTMLDivElement): void => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { visible, focused, allowedEmoji, onReact } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.handlers}>
|
||||
{/*<div
|
||||
className={classNames('flex absolute bg-white dark:bg-slate-500 px-2 py-3 rounded-full shadow-md opacity-0 pointer-events-none duration-100 w-max', { 'opacity-100 pointer-events-auto z-[999]': visible || focused })}
|
||||
onBlur={this.handleBlur}
|
||||
ref={this.setRef}
|
||||
>
|
||||
{allowedEmoji.map((emoji, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className='emoji-react-selector__emoji'
|
||||
onClick={this.handleReact(emoji)}
|
||||
onKeyDown={this.handleKeyDown(i)}
|
||||
tabIndex={(visible || focused) ? 0 : -1}
|
||||
>
|
||||
<Emoji emoji={emoji} />
|
||||
</button>
|
||||
))}
|
||||
</div>*/}
|
||||
<RealEmojiSelector
|
||||
emojis={allowedEmoji.toArray()}
|
||||
onReact={onReact}
|
||||
visible={visible}
|
||||
focused={focused}
|
||||
/>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(EmojiSelector);
|
@ -0,0 +1,63 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
interface IHoverable {
|
||||
component: JSX.Element,
|
||||
}
|
||||
|
||||
/** Wrapper to render a given component when hovered */
|
||||
const Hoverable: React.FC<IHoverable> = ({
|
||||
component,
|
||||
children,
|
||||
}): JSX.Element => {
|
||||
|
||||
const [portalActive, setPortalActive] = useState(false);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const popperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setPortalActive(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setPortalActive(false);
|
||||
};
|
||||
|
||||
const { styles, attributes } = usePopper(ref.current, popperRef.current, {
|
||||
placement: 'top-start',
|
||||
strategy: 'fixed',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [-10, 0],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
|
||||
<div
|
||||
className={classNames('fixed z-50 transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !portalActive,
|
||||
})}
|
||||
ref={popperRef}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{component}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hoverable;
|
@ -0,0 +1,75 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
const COLORS = {
|
||||
accent: 'accent',
|
||||
success: 'success',
|
||||
};
|
||||
|
||||
type Color = keyof typeof COLORS;
|
||||
|
||||
interface IStatusActionCounter {
|
||||
count: number,
|
||||
}
|
||||
|
||||
/** Action button numerical counter, eg "5" likes */
|
||||
const StatusActionCounter: React.FC<IStatusActionCounter> = ({ count = 0 }): JSX.Element => {
|
||||
return (
|
||||
<Text size='xs' weight='semibold' theme='inherit'>
|
||||
{shortNumberFormat(count)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
iconClassName?: string,
|
||||
icon: string,
|
||||
count?: number,
|
||||
active?: boolean,
|
||||
color?: Color,
|
||||
filled?: boolean,
|
||||
}
|
||||
|
||||
const StatusActionButton = React.forwardRef((props: IStatusActionButton, ref: React.ForwardedRef<HTMLButtonElement>): JSX.Element => {
|
||||
const { icon, className, iconClassName, active, color, filled = false, count = 0, ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type='button'
|
||||
className={classNames(
|
||||
'group flex items-center p-1 space-x-0.5 rounded-full',
|
||||
'text-gray-400 hover:text-gray-600 dark:hover:text-white',
|
||||
'bg-white dark:bg-transparent',
|
||||
{
|
||||
'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && color === COLORS.accent,
|
||||
'text-success-600 hover:text-success-600 dark:hover:text-success-600': active && color === COLORS.success,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...filteredProps}
|
||||
>
|
||||
<InlineSVG
|
||||
src={icon}
|
||||
className={classNames(
|
||||
'p-1 rounded-full box-content',
|
||||
'group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 dark:ring-offset-0 group-focus:ring-primary-500',
|
||||
{
|
||||
'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
|
||||
},
|
||||
iconClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
{(count || null) && (
|
||||
<StatusActionCounter count={count} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export default StatusActionButton;
|
@ -0,0 +1,55 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { Emoji, HStack } from 'soapbox/components/ui';
|
||||
|
||||
interface IEmojiButton {
|
||||
emoji: string,
|
||||
onClick: React.EventHandler<React.MouseEvent>,
|
||||
className?: string,
|
||||
tabIndex?: number,
|
||||
}
|
||||
|
||||
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
|
||||
return (
|
||||
<button className={classNames(className)} onClick={onClick} tabIndex={tabIndex}>
|
||||
<Emoji className='w-8 h-8 duration-100 hover:scale-125' emoji={emoji} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface IEmojiSelector {
|
||||
emojis: string[],
|
||||
onReact: (emoji: string) => void,
|
||||
visible?: boolean,
|
||||
focused?: boolean,
|
||||
}
|
||||
|
||||
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => {
|
||||
|
||||
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => {
|
||||
return (e) => {
|
||||
onReact(emoji);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack
|
||||
space={2}
|
||||
className={classNames('bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max')}
|
||||
>
|
||||
{emojis.map((emoji, i) => (
|
||||
<EmojiButton
|
||||
key={i}
|
||||
emoji={emoji}
|
||||
onClick={handleReact(emoji)}
|
||||
tabIndex={(visible || focused) ? 0 : -1}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiSelector;
|
@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
import { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
// Taken from twemoji-parser
|
||||
// https://github.com/twitter/twemoji-parser/blob/a97ef3994e4b88316812926844d51c296e889f76/src/index.js
|
||||
const removeVS16s = (rawEmoji: string): string => {
|
||||
const vs16RegExp = /\uFE0F/g;
|
||||
const zeroWidthJoiner = String.fromCharCode(0x200d);
|
||||
return rawEmoji.indexOf(zeroWidthJoiner) < 0 ? rawEmoji.replace(vs16RegExp, '') : rawEmoji;
|
||||
};
|
||||
|
||||
const toCodePoints = (unicodeSurrogates: string): string[] => {
|
||||
const points = [];
|
||||
let char = 0;
|
||||
let previous = 0;
|
||||
let i = 0;
|
||||
while (i < unicodeSurrogates.length) {
|
||||
char = unicodeSurrogates.charCodeAt(i++);
|
||||
if (previous) {
|
||||
points.push((0x10000 + ((previous - 0xd800) << 10) + (char - 0xdc00)).toString(16));
|
||||
previous = 0;
|
||||
} else if (char > 0xd800 && char <= 0xdbff) {
|
||||
previous = char;
|
||||
} else {
|
||||
points.push(char.toString(16));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
interface IEmoji {
|
||||
className?: string,
|
||||
emoji: string,
|
||||
}
|
||||
|
||||
const Emoji: React.FC<IEmoji> = ({ className, emoji }): JSX.Element | null => {
|
||||
const codepoints = toCodePoints(removeVS16s(emoji));
|
||||
const filename = codepoints.join('-');
|
||||
|
||||
if (!filename) return null;
|
||||
|
||||
return (
|
||||
<img
|
||||
draggable='false'
|
||||
className={className}
|
||||
alt={emoji}
|
||||
src={joinPublicPath(`packs/emoji/${filename}.svg`)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Emoji;
|
@ -1,29 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
|
||||
import { openModal, closeModal } from '../actions/modals';
|
||||
import DropdownMenu from '../components/dropdown_menu';
|
||||
import { isUserTouching } from '../is_mobile';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'ACTIONS'),
|
||||
dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
|
||||
openDropdownId: state.getIn(['dropdown_menu', 'openId']),
|
||||
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { status, items }) => ({
|
||||
onOpen(id, onItemClick, dropdownPlacement, keyboard) {
|
||||
dispatch(isUserTouching() ? openModal('ACTIONS', {
|
||||
status,
|
||||
actions: items,
|
||||
onClick: onItemClick,
|
||||
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
||||
},
|
||||
onClose(id) {
|
||||
dispatch(closeModal('ACTIONS'));
|
||||
dispatch(closeDropdownMenu(id));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
|
@ -0,0 +1,38 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
|
||||
import { openModal, closeModal } from '../actions/modals';
|
||||
import DropdownMenu from '../components/dropdown_menu';
|
||||
import { isUserTouching } from '../is_mobile';
|
||||
|
||||
import type { Dispatch } from 'redux';
|
||||
import type { DropdownPlacement, IDropdown } from 'soapbox/components/dropdown_menu';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
isModalOpen: Boolean(state.modals.size && state.modals.last().modalType === 'ACTIONS'),
|
||||
dropdownPlacement: state.dropdown_menu.get('placement'),
|
||||
openDropdownId: state.dropdown_menu.get('openId'),
|
||||
openedViaKeyboard: state.dropdown_menu.get('keyboard'),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial<IDropdown>) => ({
|
||||
onOpen(
|
||||
id: number,
|
||||
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
||||
dropdownPlacement: DropdownPlacement,
|
||||
keyboard: boolean,
|
||||
) {
|
||||
dispatch(isUserTouching() ? openModal('ACTIONS', {
|
||||
status,
|
||||
actions: items,
|
||||
onClick: onItemClick,
|
||||
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
||||
},
|
||||
onClose(id: number) {
|
||||
dispatch(closeModal('ACTIONS'));
|
||||
dispatch(closeDropdownMenu(id));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
|
@ -1,12 +0,0 @@
|
||||
import { List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
|
||||
|
||||
export default function filters(state = ImmutableList(), action) {
|
||||
switch(action.type) {
|
||||
case FILTERS_FETCH_SUCCESS:
|
||||
return fromJS(action.filters);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import {
|
||||
Map as ImmutableMap,
|
||||
List as ImmutableList,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
|
||||
type Filter = ImmutableMap<string, any>;
|
||||
type State = ImmutableList<Filter>;
|
||||
|
||||
const importFilters = (_state: State, filters: unknown): State => {
|
||||
return ImmutableList(fromJS(filters)).map(filter => ImmutableMap(fromJS(filter)));
|
||||
};
|
||||
|
||||
export default function filters(state: State = ImmutableList<Filter>(), action: AnyAction): State {
|
||||
switch(action.type) {
|
||||
case FILTERS_FETCH_SUCCESS:
|
||||
return importFilters(state, action.filters);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
@ -1,332 +0,0 @@
|
||||
import {
|
||||
Map as ImmutableMap,
|
||||
List as ImmutableList,
|
||||
OrderedSet as ImmutableOrderedSet,
|
||||
} from 'immutable';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getDomain } from 'soapbox/utils/accounts';
|
||||
import { validId } from 'soapbox/utils/auth';
|
||||
import ConfigDB from 'soapbox/utils/config_db';
|
||||
import { shouldFilter } from 'soapbox/utils/timelines';
|
||||
|
||||
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
|
||||
const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
|
||||
const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
|
||||
const getAccountMoved = (state, id) => state.getIn(['accounts', state.getIn(['accounts', id, 'moved'])]);
|
||||
const getAccountMeta = (state, id) => state.getIn(['accounts_meta', id], ImmutableMap());
|
||||
const getAccountAdminData = (state, id) => state.getIn(['admin', 'users', id]);
|
||||
const getAccountPatron = (state, id) => {
|
||||
const url = state.getIn(['accounts', id, 'url']);
|
||||
return state.getIn(['patron', 'accounts', url]);
|
||||
};
|
||||
|
||||
export const makeGetAccount = () => {
|
||||
return createSelector([
|
||||
getAccountBase,
|
||||
getAccountCounters,
|
||||
getAccountRelationship,
|
||||
getAccountMoved,
|
||||
getAccountMeta,
|
||||
getAccountAdminData,
|
||||
getAccountPatron,
|
||||
], (base, counters, relationship, moved, meta, admin, patron) => {
|
||||
if (base === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return base.withMutations(map => {
|
||||
map.merge(counters);
|
||||
map.merge(meta);
|
||||
map.set('pleroma', meta.get('pleroma', ImmutableMap()).merge(base.get('pleroma', ImmutableMap()))); // Lol, thanks Pleroma
|
||||
map.set('relationship', relationship);
|
||||
map.set('moved', moved);
|
||||
map.set('patron', patron);
|
||||
map.setIn(['pleroma', 'admin'], admin);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const findAccountsByUsername = (state, username) => {
|
||||
const accounts = state.get('accounts');
|
||||
|
||||
return accounts.filter(account => {
|
||||
return username.toLowerCase() === account.getIn(['acct'], '').toLowerCase();
|
||||
});
|
||||
};
|
||||
|
||||
export const findAccountByUsername = (state, username) => {
|
||||
const accounts = findAccountsByUsername(state, username);
|
||||
|
||||
if (accounts.size > 1) {
|
||||
const me = state.get('me');
|
||||
const meURL = state.getIn(['accounts', me, 'url']);
|
||||
|
||||
return accounts.find(account => {
|
||||
try {
|
||||
// If more than one account has the same username, try matching its host
|
||||
const { host } = new URL(account.get('url'));
|
||||
const { host: meHost } = new URL(meURL);
|
||||
return host === meHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return accounts.first();
|
||||
}
|
||||
};
|
||||
|
||||
const toServerSideType = columnType => {
|
||||
switch (columnType) {
|
||||
case 'home':
|
||||
case 'notifications':
|
||||
case 'public':
|
||||
case 'thread':
|
||||
return columnType;
|
||||
default:
|
||||
if (columnType.indexOf('list:') > -1) {
|
||||
return 'home';
|
||||
} else {
|
||||
return 'public'; // community, account, hashtag
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getFilters = (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
|
||||
|
||||
const escapeRegExp = string =>
|
||||
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
|
||||
export const regexFromFilters = filters => {
|
||||
if (filters.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RegExp(filters.map(filter => {
|
||||
let expr = escapeRegExp(filter.get('phrase'));
|
||||
|
||||
if (filter.get('whole_word')) {
|
||||
if (/^[\w]/.test(expr)) {
|
||||
expr = `\\b${expr}`;
|
||||
}
|
||||
|
||||
if (/[\w]$/.test(expr)) {
|
||||
expr = `${expr}\\b`;
|
||||
}
|
||||
}
|
||||
|
||||
return expr;
|
||||
}).join('|'), 'i');
|
||||
};
|
||||
|
||||
export const makeGetStatus = () => {
|
||||
return createSelector(
|
||||
[
|
||||
(state, { id }) => state.getIn(['statuses', id]),
|
||||
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
|
||||
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
|
||||
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
|
||||
(state, { username }) => username,
|
||||
getFilters,
|
||||
(state) => state.get('me'),
|
||||
],
|
||||
|
||||
(statusBase, statusReblog, accountBase, accountReblog, username, filters, me) => {
|
||||
if (!statusBase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accountUsername = accountBase.get('acct');
|
||||
//Must be owner of status if username exists
|
||||
if (accountUsername !== username && username !== undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (statusReblog) {
|
||||
statusReblog = statusReblog.set('account', accountReblog);
|
||||
} else {
|
||||
statusReblog = null;
|
||||
}
|
||||
|
||||
const regex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters);
|
||||
const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'));
|
||||
|
||||
return statusBase.withMutations(map => {
|
||||
map.set('reblog', statusReblog);
|
||||
map.set('account', accountBase);
|
||||
map.set('filtered', filtered);
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const getAlertsBase = state => state.get('alerts');
|
||||
|
||||
export const getAlerts = createSelector([getAlertsBase], (base) => {
|
||||
const arr = [];
|
||||
|
||||
base.forEach(item => {
|
||||
arr.push({
|
||||
message: item.get('message'),
|
||||
title: item.get('title'),
|
||||
actionLabel: item.get('actionLabel'),
|
||||
actionLink: item.get('actionLink'),
|
||||
key: item.get('key'),
|
||||
className: `notification-bar-${item.get('severity', 'info')}`,
|
||||
activeClassName: 'snackbar--active',
|
||||
dismissAfter: 6000,
|
||||
style: false,
|
||||
});
|
||||
});
|
||||
|
||||
return arr;
|
||||
});
|
||||
|
||||
export const makeGetNotification = () => {
|
||||
return createSelector([
|
||||
(state, notification) => notification,
|
||||
(state, notification) => state.getIn(['accounts', notification.get('account')]),
|
||||
(state, notification) => state.getIn(['accounts', notification.get('target')]),
|
||||
(state, notification) => state.getIn(['statuses', notification.get('status')]),
|
||||
], (notification, account, target, status) => {
|
||||
return notification.merge({ account, target, status });
|
||||
});
|
||||
};
|
||||
|
||||
export const getAccountGallery = createSelector([
|
||||
(state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
|
||||
state => state.get('statuses'),
|
||||
state => state.get('accounts'),
|
||||
], (statusIds, statuses, accounts) => {
|
||||
|
||||
return statusIds.reduce((medias, statusId) => {
|
||||
const status = statuses.get(statusId);
|
||||
const account = accounts.get(status.get('account'));
|
||||
if (status.get('reblog')) return medias;
|
||||
return medias.concat(status.get('media_attachments')
|
||||
.map(media => media.merge({ status, account })));
|
||||
}, ImmutableList());
|
||||
});
|
||||
|
||||
export const makeGetChat = () => {
|
||||
return createSelector(
|
||||
[
|
||||
(state, { id }) => state.getIn(['chats', 'items', id]),
|
||||
(state, { id }) => state.getIn(['accounts', state.getIn(['chats', 'items', id, 'account'])]),
|
||||
(state, { last_message }) => state.getIn(['chat_messages', last_message]),
|
||||
],
|
||||
|
||||
(chat, account, lastMessage) => {
|
||||
if (!chat) return null;
|
||||
|
||||
return chat.withMutations(map => {
|
||||
map.set('account', account);
|
||||
map.set('last_message', lastMessage);
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const makeGetReport = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
return createSelector(
|
||||
[
|
||||
(state, id) => state.getIn(['admin', 'reports', id]),
|
||||
(state, id) => state.getIn(['admin', 'reports', id, 'statuses']).map(
|
||||
statusId => state.getIn(['statuses', statusId]))
|
||||
.filter(s => s)
|
||||
.map(s => getStatus(state, s.toJS())),
|
||||
],
|
||||
|
||||
(report, statuses) => {
|
||||
if (!report) return null;
|
||||
return report.set('statuses', statuses);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const getAuthUserIds = createSelector([
|
||||
state => state.getIn(['auth', 'users'], ImmutableMap()),
|
||||
], authUsers => {
|
||||
return authUsers.reduce((ids, authUser) => {
|
||||
try {
|
||||
const id = authUser.get('id');
|
||||
return validId(id) ? ids.add(id) : ids;
|
||||
} catch {
|
||||
return ids;
|
||||
}
|
||||
}, ImmutableOrderedSet());
|
||||
});
|
||||
|
||||
export const makeGetOtherAccounts = () => {
|
||||
return createSelector([
|
||||
state => state.get('accounts'),
|
||||
getAuthUserIds,
|
||||
state => state.get('me'),
|
||||
],
|
||||
(accounts, authUserIds, me) => {
|
||||
return authUserIds
|
||||
.reduce((list, id) => {
|
||||
if (id === me) return list;
|
||||
const account = accounts.get(id);
|
||||
return account ? list.push(account) : list;
|
||||
}, ImmutableList());
|
||||
});
|
||||
};
|
||||
|
||||
const getSimplePolicy = createSelector([
|
||||
state => state.getIn(['admin', 'configs'], ImmutableMap()),
|
||||
state => state.getIn(['instance', 'pleroma', 'metadata', 'federation', 'mrf_simple'], ImmutableMap()),
|
||||
], (configs, instancePolicy) => {
|
||||
return instancePolicy.merge(ConfigDB.toSimplePolicy(configs));
|
||||
});
|
||||
|
||||
const getRemoteInstanceFavicon = (state, host) => (
|
||||
state.get('accounts')
|
||||
.find(account => getDomain(account) === host, null, ImmutableMap())
|
||||
.getIn(['pleroma', 'favicon'])
|
||||
);
|
||||
|
||||
const getRemoteInstanceFederation = (state, host) => (
|
||||
getSimplePolicy(state)
|
||||
.map(hosts => hosts.includes(host))
|
||||
);
|
||||
|
||||
export const makeGetHosts = () => {
|
||||
return createSelector([getSimplePolicy], (simplePolicy) => {
|
||||
return simplePolicy
|
||||
.deleteAll(['accept', 'reject_deletes', 'report_removal'])
|
||||
.reduce((acc, hosts) => acc.union(hosts), ImmutableOrderedSet())
|
||||
.sort();
|
||||
});
|
||||
};
|
||||
|
||||
export const makeGetRemoteInstance = () => {
|
||||
return createSelector([
|
||||
(state, host) => host,
|
||||
getRemoteInstanceFavicon,
|
||||
getRemoteInstanceFederation,
|
||||
], (host, favicon, federation) => {
|
||||
return ImmutableMap({
|
||||
host,
|
||||
favicon,
|
||||
federation,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const makeGetStatusIds = () => createSelector([
|
||||
(state, { type, prefix }) => getSettings(state).get(prefix || type, ImmutableMap()),
|
||||
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableOrderedSet()),
|
||||
(state) => state.get('statuses'),
|
||||
(state) => state.get('me'),
|
||||
], (columnSettings, statusIds, statuses, me) => {
|
||||
return statusIds.filter(id => {
|
||||
const status = statuses.get(id);
|
||||
if (!status) return true;
|
||||
return !shouldFilter(status, columnSettings);
|
||||
});
|
||||
});
|
@ -0,0 +1,360 @@
|
||||
import {
|
||||
Map as ImmutableMap,
|
||||
List as ImmutableList,
|
||||
OrderedSet as ImmutableOrderedSet,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getDomain } from 'soapbox/utils/accounts';
|
||||
import { validId } from 'soapbox/utils/auth';
|
||||
import ConfigDB from 'soapbox/utils/config_db';
|
||||
import { shouldFilter } from 'soapbox/utils/timelines';
|
||||
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { Notification } from 'soapbox/types/entities';
|
||||
|
||||
const normalizeId = (id: any): string => typeof id === 'string' ? id : '';
|
||||
|
||||
const getAccountBase = (state: RootState, id: string) => state.accounts.get(id);
|
||||
const getAccountCounters = (state: RootState, id: string) => state.accounts_counters.get(id);
|
||||
const getAccountRelationship = (state: RootState, id: string) => state.relationships.get(id);
|
||||
const getAccountMoved = (state: RootState, id: string) => state.accounts.get(state.accounts.get(id)?.moved || '');
|
||||
const getAccountMeta = (state: RootState, id: string) => state.accounts_meta.get(id, ImmutableMap());
|
||||
const getAccountAdminData = (state: RootState, id: string) => state.admin.users.get(id);
|
||||
const getAccountPatron = (state: RootState, id: string) => {
|
||||
const url = state.accounts.get(id)?.url;
|
||||
return state.patron.getIn(['accounts', url]);
|
||||
};
|
||||
|
||||
export const makeGetAccount = () => {
|
||||
return createSelector([
|
||||
getAccountBase,
|
||||
getAccountCounters,
|
||||
getAccountRelationship,
|
||||
getAccountMoved,
|
||||
getAccountMeta,
|
||||
getAccountAdminData,
|
||||
getAccountPatron,
|
||||
], (base, counters, relationship, moved, meta, admin, patron) => {
|
||||
if (!base) return null;
|
||||
|
||||
return base.withMutations(map => {
|
||||
map.merge(counters);
|
||||
map.merge(meta);
|
||||
map.set('pleroma', meta.get('pleroma', ImmutableMap()).merge(base.get('pleroma', ImmutableMap()))); // Lol, thanks Pleroma
|
||||
map.set('relationship', relationship);
|
||||
map.set('moved', moved || null);
|
||||
map.set('patron', patron);
|
||||
map.setIn(['pleroma', 'admin'], admin);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const findAccountsByUsername = (state: RootState, username: string) => {
|
||||
const accounts = state.accounts;
|
||||
|
||||
return accounts.filter(account => {
|
||||
return username.toLowerCase() === account.acct.toLowerCase();
|
||||
});
|
||||
};
|
||||
|
||||
export const findAccountByUsername = (state: RootState, username: string) => {
|
||||
const accounts = findAccountsByUsername(state, username);
|
||||
|
||||
if (accounts.size > 1) {
|
||||
const me = state.me;
|
||||
const meURL = state.accounts.get(me)?.url || '';
|
||||
|
||||
return accounts.find(account => {
|
||||
try {
|
||||
// If more than one account has the same username, try matching its host
|
||||
const { host } = new URL(account.url);
|
||||
const { host: meHost } = new URL(meURL);
|
||||
return host === meHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return accounts.first();
|
||||
}
|
||||
};
|
||||
|
||||
const toServerSideType = (columnType: string): string => {
|
||||
switch (columnType) {
|
||||
case 'home':
|
||||
case 'notifications':
|
||||
case 'public':
|
||||
case 'thread':
|
||||
return columnType;
|
||||
default:
|
||||
if (columnType.indexOf('list:') > -1) {
|
||||
return 'home';
|
||||
} else {
|
||||
return 'public'; // community, account, hashtag
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type FilterContext = { contextType: string };
|
||||
|
||||
export const getFilters = (state: RootState, { contextType }: FilterContext) => {
|
||||
return state.filters.filter((filter): boolean => {
|
||||
return contextType
|
||||
&& filter.get('context').includes(toServerSideType(contextType))
|
||||
&& (filter.get('expires_at') === null
|
||||
|| Date.parse(filter.get('expires_at')) > new Date().getTime());
|
||||
});
|
||||
};
|
||||
|
||||
const escapeRegExp = (string: string) =>
|
||||
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
|
||||
export const regexFromFilters = (filters: ImmutableList<ImmutableMap<string, any>>) => {
|
||||
if (filters.size === 0) return null;
|
||||
|
||||
return new RegExp(filters.map(filter => {
|
||||
let expr = escapeRegExp(filter.get('phrase'));
|
||||
|
||||
if (filter.get('whole_word')) {
|
||||
if (/^[\w]/.test(expr)) {
|
||||
expr = `\\b${expr}`;
|
||||
}
|
||||
|
||||
if (/[\w]$/.test(expr)) {
|
||||
expr = `${expr}\\b`;
|
||||
}
|
||||
}
|
||||
|
||||
return expr;
|
||||
}).join('|'), 'i');
|
||||
};
|
||||
|
||||
type APIStatus = { id: string, username: string };
|
||||
|
||||
export const makeGetStatus = () => {
|
||||
return createSelector(
|
||||
[
|
||||
(state: RootState, { id }: APIStatus) => state.statuses.get(id),
|
||||
(state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''),
|
||||
(_state: RootState, { username }: APIStatus) => username,
|
||||
getFilters,
|
||||
(state: RootState) => state.me,
|
||||
],
|
||||
|
||||
(statusBase, statusReblog, accountBase, accountReblog, username, filters, me) => {
|
||||
if (!statusBase || !accountBase) return null;
|
||||
|
||||
const accountUsername = accountBase.acct;
|
||||
//Must be owner of status if username exists
|
||||
if (accountUsername !== username && username !== undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (statusReblog && accountReblog) {
|
||||
// @ts-ignore AAHHHHH
|
||||
statusReblog = statusReblog.set('account', accountReblog);
|
||||
} else {
|
||||
statusReblog = undefined;
|
||||
}
|
||||
|
||||
const regex = (accountReblog || accountBase).id !== me && regexFromFilters(filters);
|
||||
const filtered = regex && regex.test(statusReblog?.search_index || statusBase.search_index);
|
||||
|
||||
return statusBase.withMutations(map => {
|
||||
map.set('reblog', statusReblog || null);
|
||||
// @ts-ignore :(
|
||||
map.set('account', accountBase || null);
|
||||
map.set('filtered', Boolean(filtered));
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const getAlertsBase = (state: RootState) => state.alerts;
|
||||
|
||||
const buildAlert = (item: any) => {
|
||||
return {
|
||||
message: item.message,
|
||||
title: item.title,
|
||||
actionLabel: item.actionLabel,
|
||||
actionLink: item.actionLink,
|
||||
key: item.key,
|
||||
className: `notification-bar-${item.severity}`,
|
||||
activeClassName: 'snackbar--active',
|
||||
dismissAfter: 6000,
|
||||
style: false,
|
||||
};
|
||||
};
|
||||
|
||||
type Alert = ReturnType<typeof buildAlert>;
|
||||
|
||||
export const getAlerts = createSelector([getAlertsBase], (base): Alert[] => {
|
||||
const arr: Alert[] = [];
|
||||
base.forEach(item => arr.push(buildAlert(item)));
|
||||
return arr;
|
||||
});
|
||||
|
||||
export const makeGetNotification = () => {
|
||||
return createSelector([
|
||||
(_state: RootState, notification: Notification) => notification,
|
||||
(state: RootState, notification: Notification) => state.accounts.get(normalizeId(notification.account)),
|
||||
(state: RootState, notification: Notification) => state.accounts.get(normalizeId(notification.target)),
|
||||
(state: RootState, notification: Notification) => state.statuses.get(normalizeId(notification.status)),
|
||||
], (notification, account, target, status) => {
|
||||
return notification.merge({
|
||||
// @ts-ignore
|
||||
account: account || null,
|
||||
// @ts-ignore
|
||||
target: target || null,
|
||||
// @ts-ignore
|
||||
status: status || null,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const getAccountGallery = createSelector([
|
||||
(state: RootState, id: string) => state.timelines.getIn([`account:${id}:media`, 'items'], ImmutableList()),
|
||||
(state: RootState) => state.statuses,
|
||||
(state: RootState) => state.accounts,
|
||||
], (statusIds, statuses, accounts) => {
|
||||
|
||||
return statusIds.reduce((medias: ImmutableList<any>, statusId: string) => {
|
||||
const status = statuses.get(statusId);
|
||||
if (!status) return medias;
|
||||
if (status.reblog) return medias;
|
||||
if (typeof status.account !== 'string') return medias;
|
||||
|
||||
const account = accounts.get(status.account);
|
||||
|
||||
return medias.concat(
|
||||
status.media_attachments.map(media => media.merge({ status, account })));
|
||||
}, ImmutableList());
|
||||
});
|
||||
|
||||
type APIChat = { id: string, last_message: string };
|
||||
|
||||
export const makeGetChat = () => {
|
||||
return createSelector(
|
||||
[
|
||||
(state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]),
|
||||
(state: RootState, { id }: APIChat) => state.accounts.get(state.chats.getIn(['items', id, 'account'])),
|
||||
(state: RootState, { last_message }: APIChat) => state.chat_messages.get(last_message),
|
||||
],
|
||||
|
||||
(chat, account, lastMessage: string) => {
|
||||
if (!chat) return null;
|
||||
|
||||
return chat.withMutations((map: ImmutableMap<string, any>) => {
|
||||
map.set('account', account);
|
||||
map.set('last_message', lastMessage);
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const makeGetReport = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
return createSelector(
|
||||
[
|
||||
(state: RootState, id: string) => state.admin.reports.get(id),
|
||||
(state: RootState, id: string) => ImmutableList(fromJS(state.admin.reports.getIn([id, 'statuses']))).map(
|
||||
statusId => state.statuses.get(normalizeId(statusId)))
|
||||
.filter((s: any) => s)
|
||||
.map((s: any) => getStatus(state, s.toJS())),
|
||||
],
|
||||
|
||||
(report, statuses) => {
|
||||
if (!report) return null;
|
||||
return report.set('statuses', statuses);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const getAuthUserIds = createSelector([
|
||||
(state: RootState) => state.auth.get('users', ImmutableMap()),
|
||||
], authUsers => {
|
||||
return authUsers.reduce((ids: ImmutableOrderedSet<string>, authUser: ImmutableMap<string, any>) => {
|
||||
try {
|
||||
const id = authUser.get('id');
|
||||
return validId(id) ? ids.add(id) : ids;
|
||||
} catch {
|
||||
return ids;
|
||||
}
|
||||
}, ImmutableOrderedSet());
|
||||
});
|
||||
|
||||
export const makeGetOtherAccounts = () => {
|
||||
return createSelector([
|
||||
(state: RootState) => state.accounts,
|
||||
getAuthUserIds,
|
||||
(state: RootState) => state.me,
|
||||
],
|
||||
(accounts, authUserIds, me) => {
|
||||
return authUserIds
|
||||
.reduce((list: ImmutableList<any>, id: string) => {
|
||||
if (id === me) return list;
|
||||
const account = accounts.get(id);
|
||||
return account ? list.push(account) : list;
|
||||
}, ImmutableList());
|
||||
});
|
||||
};
|
||||
|
||||
const getSimplePolicy = createSelector([
|
||||
(state: RootState) => state.admin.configs,
|
||||
(state: RootState) => state.instance.pleroma.getIn(['metadata', 'federation', 'mrf_simple'], ImmutableMap()) as ImmutableMap<string, any>,
|
||||
], (configs, instancePolicy: ImmutableMap<string, any>) => {
|
||||
return instancePolicy.merge(ConfigDB.toSimplePolicy(configs));
|
||||
});
|
||||
|
||||
const getRemoteInstanceFavicon = (state: RootState, host: string) => (
|
||||
(state.accounts.find(account => getDomain(account) === host, null) || ImmutableMap())
|
||||
.getIn(['pleroma', 'favicon'])
|
||||
);
|
||||
|
||||
const getRemoteInstanceFederation = (state: RootState, host: string) => (
|
||||
getSimplePolicy(state)
|
||||
.map(hosts => hosts.includes(host))
|
||||
);
|
||||
|
||||
export const makeGetHosts = () => {
|
||||
return createSelector([getSimplePolicy], (simplePolicy) => {
|
||||
return simplePolicy
|
||||
.deleteAll(['accept', 'reject_deletes', 'report_removal'])
|
||||
.reduce((acc, hosts) => acc.union(hosts), ImmutableOrderedSet())
|
||||
.sort();
|
||||
});
|
||||
};
|
||||
|
||||
export const makeGetRemoteInstance = () => {
|
||||
return createSelector([
|
||||
(_state: RootState, host: string) => host,
|
||||
getRemoteInstanceFavicon,
|
||||
getRemoteInstanceFederation,
|
||||
], (host, favicon, federation) => {
|
||||
return ImmutableMap({
|
||||
host,
|
||||
favicon,
|
||||
federation,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
type ColumnQuery = { type: string, prefix?: string };
|
||||
|
||||
export const makeGetStatusIds = () => createSelector([
|
||||
(state: RootState, { type, prefix }: ColumnQuery) => getSettings(state).get(prefix || type, ImmutableMap()),
|
||||
(state: RootState, { type }: ColumnQuery) => state.timelines.getIn([type, 'items'], ImmutableOrderedSet()),
|
||||
(state: RootState) => state.statuses,
|
||||
], (columnSettings, statusIds: string[], statuses) => {
|
||||
return statusIds.filter((id: string) => {
|
||||
const status = statuses.get(id);
|
||||
if (!status) return true;
|
||||
return !shouldFilter(status, columnSettings);
|
||||
});
|
||||
});
|
Loading…
Reference in new issue