Merge branch 'next-emoji-reacts' into 'next'
Next: emoji reacts part 1 See merge request soapbox-pub/soapbox-fe!1161
This commit is contained in:
commit
41ae50c495
|
@ -2,81 +2,85 @@
|
||||||
|
|
||||||
exports[`<EmojiSelector /> renders correctly 1`] = `
|
exports[`<EmojiSelector /> renders correctly 1`] = `
|
||||||
<div
|
<div
|
||||||
className="emoji-react-selector-container"
|
|
||||||
onBlur={[Function]}
|
onBlur={[Function]}
|
||||||
onFocus={[Function]}
|
onFocus={[Function]}
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="emoji-react-selector"
|
className="flex space-x-2 bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max"
|
||||||
onBlur={[Function]}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="emoji-react-selector__emoji"
|
className=""
|
||||||
dangerouslySetInnerHTML={
|
|
||||||
Object {
|
|
||||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"👍\\" title=\\":+1:\\" src=\\"/packs/emoji/1f44d.svg\\" />",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
onKeyDown={[Function]}
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="👍"
|
||||||
|
className="w-8 h-8 duration-100 hover:scale-125"
|
||||||
|
draggable="false"
|
||||||
|
src="/packs/emoji/1f44d.svg"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="emoji-react-selector__emoji"
|
className=""
|
||||||
dangerouslySetInnerHTML={
|
|
||||||
Object {
|
|
||||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"❤\\" title=\\":heart:\\" src=\\"/packs/emoji/2764.svg\\" />",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
onKeyDown={[Function]}
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="❤"
|
||||||
|
className="w-8 h-8 duration-100 hover:scale-125"
|
||||||
|
draggable="false"
|
||||||
|
src="/packs/emoji/2764.svg"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="emoji-react-selector__emoji"
|
className=""
|
||||||
dangerouslySetInnerHTML={
|
|
||||||
Object {
|
|
||||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"😆\\" title=\\":laughing:\\" src=\\"/packs/emoji/1f606.svg\\" />",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
onKeyDown={[Function]}
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="😆"
|
||||||
|
className="w-8 h-8 duration-100 hover:scale-125"
|
||||||
|
draggable="false"
|
||||||
|
src="/packs/emoji/1f606.svg"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="emoji-react-selector__emoji"
|
className=""
|
||||||
dangerouslySetInnerHTML={
|
|
||||||
Object {
|
|
||||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"😮\\" title=\\":open_mouth:\\" src=\\"/packs/emoji/1f62e.svg\\" />",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
onKeyDown={[Function]}
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="😮"
|
||||||
|
className="w-8 h-8 duration-100 hover:scale-125"
|
||||||
|
draggable="false"
|
||||||
|
src="/packs/emoji/1f62e.svg"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="emoji-react-selector__emoji"
|
className=""
|
||||||
dangerouslySetInnerHTML={
|
|
||||||
Object {
|
|
||||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"😢\\" title=\\":cry:\\" src=\\"/packs/emoji/1f622.svg\\" />",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
onKeyDown={[Function]}
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="😢"
|
||||||
|
className="w-8 h-8 duration-100 hover:scale-125"
|
||||||
|
draggable="false"
|
||||||
|
src="/packs/emoji/1f622.svg"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="emoji-react-selector__emoji"
|
className=""
|
||||||
dangerouslySetInnerHTML={
|
|
||||||
Object {
|
|
||||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"😩\\" title=\\":weary:\\" src=\\"/packs/emoji/1f629.svg\\" />",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
onKeyDown={[Function]}
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="😩"
|
||||||
|
className="w-8 h-8 duration-100 hover:scale-125"
|
||||||
|
draggable="false"
|
||||||
|
src="/packs/emoji/1f629.svg"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -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;
|
|
@ -17,7 +17,6 @@ import ActionButton from 'soapbox/features/ui/components/action_button';
|
||||||
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||||
import { UserPanel } from 'soapbox/features/ui/util/async-components';
|
import { UserPanel } from 'soapbox/features/ui/util/async-components';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
import { isAdmin, isModerator } from 'soapbox/utils/accounts';
|
|
||||||
|
|
||||||
import { showProfileHoverCard } from './hover_ref_wrapper';
|
import { showProfileHoverCard } from './hover_ref_wrapper';
|
||||||
import { Card, CardBody, Stack, Text } from './ui';
|
import { Card, CardBody, Stack, Text } from './ui';
|
||||||
|
@ -27,9 +26,9 @@ const getAccount = makeGetAccount();
|
||||||
const getBadges = (account) => {
|
const getBadges = (account) => {
|
||||||
const badges = [];
|
const badges = [];
|
||||||
|
|
||||||
if (isAdmin(account)) {
|
if (account.admin) {
|
||||||
badges.push(<Badge key='admin' slug='admin' title='Admin' />);
|
badges.push(<Badge key='admin' slug='admin' title='Admin' />);
|
||||||
} else if (isModerator(account)) {
|
} else if (account.moderator) {
|
||||||
badges.push(<Badge key='moderator' slug='moderator' title='Moderator' />);
|
badges.push(<Badge key='moderator' slug='moderator' title='Moderator' />);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -107,7 +107,7 @@ class ScrollableList extends PureComponent {
|
||||||
|
|
||||||
this.attachScrollListener();
|
this.attachScrollListener();
|
||||||
this.attachIntersectionObserver();
|
this.attachIntersectionObserver();
|
||||||
// Handle initial scroll posiiton
|
// Handle initial scroll position
|
||||||
this.handleScroll();
|
this.handleScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,7 +115,7 @@ class ScrollableList extends PureComponent {
|
||||||
if (this.documentElement && (this.documentElement.scrollTop > 0 || this.mouseMovedRecently)) {
|
if (this.documentElement && (this.documentElement.scrollTop > 0 || this.mouseMovedRecently)) {
|
||||||
return { height: this.documentElement.scrollHeight, top: this.documentElement.scrollTop };
|
return { height: this.documentElement.scrollHeight, top: this.documentElement.scrollTop };
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,14 +37,16 @@ const SidebarNavigationLink = ({ icon, text, to, count }: ISidebarNavigationLink
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className='h-5 w-5'>
|
||||||
<Icon
|
<Icon
|
||||||
src={icon}
|
src={icon}
|
||||||
className={classNames({
|
className={classNames({
|
||||||
'h-5 w-5': true,
|
'h-full w-full': true,
|
||||||
'text-primary-700 dark:text-white': !isActive,
|
'text-primary-700 dark:text-white': !isActive,
|
||||||
'text-white': isActive,
|
'text-white': isActive,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Text weight='semibold' theme='inherit'>{text}</Text>
|
<Text weight='semibold' theme='inherit'>{text}</Text>
|
||||||
|
|
|
@ -70,7 +70,7 @@ const SidebarNavigation = () => {
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* {(account && isStaff(account)) && (
|
{/* {(account && account.staff) && (
|
||||||
<SidebarNavigationLink
|
<SidebarNavigationLink
|
||||||
to='/admin'
|
to='/admin'
|
||||||
icon={location.pathname.startsWith('/admin') ? require('icons/dashboard-filled.svg') : require('@tabler/icons/icons/dashboard.svg')}
|
icon={location.pathname.startsWith('/admin') ? require('icons/dashboard-filled.svg') : require('@tabler/icons/icons/dashboard.svg')}
|
||||||
|
|
|
@ -15,7 +15,6 @@ import { getFeatures } from 'soapbox/utils/features';
|
||||||
|
|
||||||
import { closeSidebar } from '../actions/sidebar';
|
import { closeSidebar } from '../actions/sidebar';
|
||||||
import { makeGetAccount, makeGetOtherAccounts } from '../selectors';
|
import { makeGetAccount, makeGetOtherAccounts } from '../selectors';
|
||||||
import { isAdmin, isStaff } from '../utils/accounts';
|
|
||||||
|
|
||||||
import { HStack, Icon, IconButton, Text } from './ui';
|
import { HStack, Icon, IconButton, Text } from './ui';
|
||||||
|
|
||||||
|
@ -155,7 +154,7 @@ const SidebarMenu = () => {
|
||||||
<Account account={account} showProfileHoverCard={false} />
|
<Account account={account} showProfileHoverCard={false} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isStaff(account) && (
|
{account.staff && (
|
||||||
<Stack>
|
<Stack>
|
||||||
<button type='button' onClick={handleSwitcherClick} className='py-1'>
|
<button type='button' onClick={handleSwitcherClick} className='py-1'>
|
||||||
<HStack alignItems='center' justifyContent='between'>
|
<HStack alignItems='center' justifyContent='between'>
|
||||||
|
@ -232,7 +231,7 @@ const SidebarMenu = () => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin(account) && (
|
{account.admin && (
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to='/soapbox/config'
|
to='/soapbox/config'
|
||||||
icon={require('@tabler/icons/icons/settings.svg')}
|
icon={require('@tabler/icons/icons/settings.svg')}
|
||||||
|
|
|
@ -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;
|
|
@ -1,11 +1,9 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
import { injectIntl, FormattedMessage, IntlShape } from 'react-intl';
|
||||||
import { NavLink, withRouter } from 'react-router-dom';
|
import { NavLink, withRouter, RouteComponentProps } from 'react-router-dom';
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card';
|
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card';
|
||||||
|
@ -22,13 +20,27 @@ import StatusContent from './status_content';
|
||||||
import StatusReplyMentions from './status_reply_mentions';
|
import StatusReplyMentions from './status_reply_mentions';
|
||||||
import { HStack, Text } from './ui';
|
import { HStack, Text } from './ui';
|
||||||
|
|
||||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
import type { History } from 'history';
|
||||||
const displayName = status.getIn(['account', 'display_name']);
|
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
|
import type {
|
||||||
|
Account as AccountEntity,
|
||||||
|
Attachment as AttachmentEntity,
|
||||||
|
Status as StatusEntity,
|
||||||
|
} from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
// Defined in components/scrollable_list
|
||||||
|
type ScrollPosition = { height: number, top: number };
|
||||||
|
|
||||||
|
export const textForScreenReader = (intl: IntlShape, status: StatusEntity, rebloggedByText?: string): string => {
|
||||||
|
const { account } = status;
|
||||||
|
if (!account || typeof account !== 'object') return '';
|
||||||
|
|
||||||
|
const displayName = account.display_name;
|
||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
|
displayName.length === 0 ? account.acct.split('@')[0] : displayName,
|
||||||
status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length),
|
status.spoiler_text && status.hidden ? status.spoiler_text : status.search_index.slice(status.spoiler_text.length),
|
||||||
intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
|
intl.formatDate(status.created_at, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
|
||||||
status.getIn(['account', 'acct']),
|
status.getIn(['account', 'acct']),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -39,96 +51,106 @@ export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||||
return values.join(', ');
|
return values.join(', ');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultMediaVisibility = (status, displayMedia) => {
|
export const defaultMediaVisibility = (status: StatusEntity, displayMedia: string): boolean => {
|
||||||
if (!status) {
|
if (!status) return false;
|
||||||
return undefined;
|
|
||||||
|
if (status.reblog && typeof status.reblog === 'object') {
|
||||||
|
status = status.reblog;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all');
|
||||||
status = status.get('reblog');
|
};
|
||||||
|
|
||||||
|
interface IStatus extends RouteComponentProps {
|
||||||
|
intl: IntlShape,
|
||||||
|
status: StatusEntity,
|
||||||
|
account: AccountEntity,
|
||||||
|
otherAccounts: ImmutableList<AccountEntity>,
|
||||||
|
onClick: () => void,
|
||||||
|
onReply: (status: StatusEntity, history: History) => void,
|
||||||
|
onFavourite: (status: StatusEntity) => void,
|
||||||
|
onReblog: (status: StatusEntity, e?: KeyboardEvent) => void,
|
||||||
|
onQuote: (status: StatusEntity) => void,
|
||||||
|
onDelete: (status: StatusEntity) => void,
|
||||||
|
onDirect: (status: StatusEntity) => void,
|
||||||
|
onChat: (status: StatusEntity) => void,
|
||||||
|
onMention: (account: StatusEntity['account'], history: History) => void,
|
||||||
|
onPin: (status: StatusEntity) => void,
|
||||||
|
onOpenMedia: (media: ImmutableList<AttachmentEntity>, index: number) => void,
|
||||||
|
onOpenVideo: (media: ImmutableMap<string, any> | AttachmentEntity, startTime: number) => void,
|
||||||
|
onOpenAudio: (media: ImmutableMap<string, any>, startTime: number) => void,
|
||||||
|
onBlock: (status: StatusEntity) => void,
|
||||||
|
onEmbed: (status: StatusEntity) => void,
|
||||||
|
onHeightChange: (status: StatusEntity) => void,
|
||||||
|
onToggleHidden: (status: StatusEntity) => void,
|
||||||
|
onShowHoverProfileCard: (status: StatusEntity) => void,
|
||||||
|
muted: boolean,
|
||||||
|
hidden: boolean,
|
||||||
|
unread: boolean,
|
||||||
|
onMoveUp: (statusId: string, featured: string) => void,
|
||||||
|
onMoveDown: (statusId: string, featured: string) => void,
|
||||||
|
getScrollPosition?: () => ScrollPosition | undefined,
|
||||||
|
updateScrollBottom?: (bottom: number) => void,
|
||||||
|
cacheMediaWidth: () => void,
|
||||||
|
cachedMediaWidth: number,
|
||||||
|
group: ImmutableMap<string, any>,
|
||||||
|
displayMedia: string,
|
||||||
|
allowedEmoji: ImmutableList<string>,
|
||||||
|
focusable: boolean,
|
||||||
|
history: History,
|
||||||
|
featured?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
|
interface IStatusState {
|
||||||
};
|
showMedia: boolean,
|
||||||
|
statusId?: string,
|
||||||
|
emojiSelectorFocused: boolean,
|
||||||
|
mediaWrapperWidth?: number,
|
||||||
|
}
|
||||||
|
|
||||||
export default @injectIntl @withRouter
|
class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
||||||
class Status extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
status: ImmutablePropTypes.record,
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
otherAccounts: ImmutablePropTypes.list,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
onReply: PropTypes.func,
|
|
||||||
onFavourite: PropTypes.func,
|
|
||||||
onReblog: PropTypes.func,
|
|
||||||
onQuote: PropTypes.func,
|
|
||||||
onDelete: PropTypes.func,
|
|
||||||
onDirect: PropTypes.func,
|
|
||||||
onChat: PropTypes.func,
|
|
||||||
onMention: PropTypes.func,
|
|
||||||
onPin: PropTypes.func,
|
|
||||||
onOpenMedia: PropTypes.func,
|
|
||||||
onOpenVideo: PropTypes.func,
|
|
||||||
onOpenAudio: PropTypes.func,
|
|
||||||
onBlock: PropTypes.func,
|
|
||||||
onEmbed: PropTypes.func,
|
|
||||||
onHeightChange: PropTypes.func,
|
|
||||||
onToggleHidden: PropTypes.func,
|
|
||||||
onShowHoverProfileCard: PropTypes.func,
|
|
||||||
muted: PropTypes.bool,
|
|
||||||
hidden: PropTypes.bool,
|
|
||||||
unread: PropTypes.bool,
|
|
||||||
onMoveUp: PropTypes.func,
|
|
||||||
onMoveDown: PropTypes.func,
|
|
||||||
getScrollPosition: PropTypes.func,
|
|
||||||
updateScrollBottom: PropTypes.func,
|
|
||||||
cacheMediaWidth: PropTypes.func,
|
|
||||||
cachedMediaWidth: PropTypes.number,
|
|
||||||
group: ImmutablePropTypes.map,
|
|
||||||
displayMedia: PropTypes.string,
|
|
||||||
allowedEmoji: ImmutablePropTypes.list,
|
|
||||||
focusable: PropTypes.bool,
|
|
||||||
history: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
focusable: true,
|
focusable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
didShowCard = false;
|
||||||
|
node?: HTMLDivElement = undefined;
|
||||||
|
height?: number = undefined;
|
||||||
|
|
||||||
// Avoid checking props that are functions (and whose equality will always
|
// Avoid checking props that are functions (and whose equality will always
|
||||||
// evaluate to false. See react-immutable-pure-component for usage.
|
// evaluate to false. See react-immutable-pure-component for usage.
|
||||||
updateOnProps = [
|
updateOnProps: any[] = [
|
||||||
'status',
|
'status',
|
||||||
'account',
|
'account',
|
||||||
'muted',
|
'muted',
|
||||||
'hidden',
|
'hidden',
|
||||||
];
|
];
|
||||||
|
|
||||||
state = {
|
state: IStatusState = {
|
||||||
showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia),
|
showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia),
|
||||||
statusId: undefined,
|
statusId: undefined,
|
||||||
emojiSelectorFocused: false,
|
emojiSelectorFocused: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track height changes we know about to compensate scrolling
|
// Track height changes we know about to compensate scrolling
|
||||||
componentDidMount() {
|
componentDidMount(): void {
|
||||||
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
|
this.didShowCard = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshotBeforeUpdate() {
|
getSnapshotBeforeUpdate(): ScrollPosition | undefined {
|
||||||
if (this.props.getScrollPosition) {
|
if (this.props.getScrollPosition) {
|
||||||
return this.props.getScrollPosition();
|
return this.props.getScrollPosition();
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromProps(nextProps, prevState) {
|
static getDerivedStateFromProps(nextProps: IStatus, prevState: IStatusState) {
|
||||||
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
|
if (nextProps.status && nextProps.status.id !== prevState.statusId) {
|
||||||
return {
|
return {
|
||||||
showMedia: defaultMediaVisibility(nextProps.status, nextProps.displayMedia),
|
showMedia: defaultMediaVisibility(nextProps.status, nextProps.displayMedia),
|
||||||
statusId: nextProps.status.get('id'),
|
statusId: nextProps.status.id,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
|
@ -136,13 +158,13 @@ class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compensate height changes
|
// Compensate height changes
|
||||||
componentDidUpdate(prevProps, prevState, snapshot) {
|
componentDidUpdate(_prevProps: IStatus, _prevState: IStatusState, snapshot?: ScrollPosition): void {
|
||||||
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
|
const doShowCard: boolean = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card);
|
||||||
|
|
||||||
if (doShowCard && !this.didShowCard) {
|
if (doShowCard && !this.didShowCard) {
|
||||||
this.didShowCard = true;
|
this.didShowCard = true;
|
||||||
|
|
||||||
if (snapshot !== null && this.props.updateScrollBottom) {
|
if (snapshot && this.props.updateScrollBottom) {
|
||||||
if (this.node && this.node.offsetTop < snapshot.top) {
|
if (this.node && this.node.offsetTop < snapshot.top) {
|
||||||
this.props.updateScrollBottom(snapshot.height - snapshot.top);
|
this.props.updateScrollBottom(snapshot.height - snapshot.top);
|
||||||
}
|
}
|
||||||
|
@ -150,24 +172,26 @@ class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount(): void {
|
||||||
// FIXME: Run this code only when a status is being deleted.
|
// FIXME: Run this code only when a status is being deleted.
|
||||||
//
|
//
|
||||||
// if (this.node && this.props.getScrollPosition) {
|
// const { getScrollPosition, updateScrollBottom } = this.props;
|
||||||
// const position = this.props.getScrollPosition();
|
//
|
||||||
// if (position !== null && this.node.offsetTop < position.top) {
|
// if (this.node && getScrollPosition && updateScrollBottom) {
|
||||||
|
// const position = getScrollPosition();
|
||||||
|
// if (position && this.node.offsetTop < position.top) {
|
||||||
// requestAnimationFrame(() => {
|
// requestAnimationFrame(() => {
|
||||||
// this.props.updateScrollBottom(position.height - position.top);
|
// updateScrollBottom(position.height - position.top);
|
||||||
// });
|
// });
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
handleToggleMediaVisibility = () => {
|
handleToggleMediaVisibility = (): void => {
|
||||||
this.setState({ showMedia: !this.state.showMedia });
|
this.setState({ showMedia: !this.state.showMedia });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick = () => {
|
handleClick = (): void => {
|
||||||
if (this.props.onClick) {
|
if (this.props.onClick) {
|
||||||
this.props.onClick();
|
this.props.onClick();
|
||||||
return;
|
return;
|
||||||
|
@ -177,136 +201,139 @@ class Status extends ImmutablePureComponent {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`);
|
this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleExpandClick = (e) => {
|
handleExpandClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
if (e.button === 0) {
|
if (e.button === 0) {
|
||||||
if (!this.props.history) {
|
if (!this.props.history) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`);
|
this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleExpandedToggle = () => {
|
handleExpandedToggle = (): void => {
|
||||||
this.props.onToggleHidden(this._properStatus());
|
this.props.onToggleHidden(this._properStatus());
|
||||||
};
|
};
|
||||||
|
|
||||||
renderLoadingMediaGallery() {
|
renderLoadingMediaGallery(): JSX.Element {
|
||||||
return <div className='media_gallery' style={{ height: '285px' }} />;
|
return <div className='media_gallery' style={{ height: '285px' }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLoadingVideoPlayer() {
|
renderLoadingVideoPlayer(): JSX.Element {
|
||||||
return <div className='media-spoiler-video' style={{ height: '285px' }} />;
|
return <div className='media-spoiler-video' style={{ height: '285px' }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLoadingAudioPlayer() {
|
renderLoadingAudioPlayer(): JSX.Element {
|
||||||
return <div className='media-spoiler-audio' style={{ height: '285px' }} />;
|
return <div className='media-spoiler-audio' style={{ height: '285px' }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpenVideo = (media, startTime) => {
|
handleOpenVideo = (media: ImmutableMap<string, any>, startTime: number): void => {
|
||||||
this.props.onOpenVideo(media, startTime);
|
this.props.onOpenVideo(media, startTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpenAudio = (media, startTime) => {
|
handleOpenAudio = (media: ImmutableMap<string, any>, startTime: number): void => {
|
||||||
this.props.OnOpenAudio(media, startTime);
|
this.props.onOpenAudio(media, startTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyOpenMedia = e => {
|
handleHotkeyOpenMedia = (e?: KeyboardEvent): void => {
|
||||||
const { onOpenMedia, onOpenVideo } = this.props;
|
const { onOpenMedia, onOpenVideo } = this.props;
|
||||||
const status = this._properStatus();
|
const status = this._properStatus();
|
||||||
|
const firstAttachment = status.media_attachments.first();
|
||||||
|
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
if (status.get('media_attachments').size > 0) {
|
if (firstAttachment) {
|
||||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
if (firstAttachment.type === 'video') {
|
||||||
onOpenVideo(status.getIn(['media_attachments', 0]), 0);
|
onOpenVideo(firstAttachment, 0);
|
||||||
} else {
|
} else {
|
||||||
onOpenMedia(status.get('media_attachments'), 0);
|
onOpenMedia(status.media_attachments, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyReply = e => {
|
handleHotkeyReply = (e?: KeyboardEvent): void => {
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
this.props.onReply(this._properStatus(), this.props.history);
|
this.props.onReply(this._properStatus(), this.props.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyFavourite = () => {
|
handleHotkeyFavourite = (): void => {
|
||||||
this.props.onFavourite(this._properStatus());
|
this.props.onFavourite(this._properStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyBoost = e => {
|
handleHotkeyBoost = (e?: KeyboardEvent): void => {
|
||||||
this.props.onReblog(this._properStatus(), e);
|
this.props.onReblog(this._properStatus(), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyMention = e => {
|
handleHotkeyMention = (e?: KeyboardEvent): void => {
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
this.props.onMention(this._properStatus().get('account'), this.props.history);
|
this.props.onMention(this._properStatus().account, this.props.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyOpen = () => {
|
handleHotkeyOpen = (): void => {
|
||||||
this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`);
|
this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyOpenProfile = () => {
|
handleHotkeyOpenProfile = (): void => {
|
||||||
this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}`);
|
this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyMoveUp = e => {
|
handleHotkeyMoveUp = (e?: KeyboardEvent): void => {
|
||||||
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
// FIXME: what's going on here?
|
||||||
|
// this.props.onMoveUp(this.props.status.id, e?.target?.getAttribute('data-featured'));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyMoveDown = e => {
|
handleHotkeyMoveDown = (e?: KeyboardEvent): void => {
|
||||||
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
// FIXME: what's going on here?
|
||||||
|
// this.props.onMoveDown(this.props.status.id, e?.target?.getAttribute('data-featured'));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyToggleHidden = () => {
|
handleHotkeyToggleHidden = (): void => {
|
||||||
this.props.onToggleHidden(this._properStatus());
|
this.props.onToggleHidden(this._properStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyToggleSensitive = () => {
|
handleHotkeyToggleSensitive = (): void => {
|
||||||
this.handleToggleMediaVisibility();
|
this.handleToggleMediaVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyReact = () => {
|
handleHotkeyReact = (): void => {
|
||||||
this._expandEmojiSelector();
|
this._expandEmojiSelector();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEmojiSelectorExpand = e => {
|
handleEmojiSelectorExpand: React.EventHandler<React.KeyboardEvent> = e => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
this._expandEmojiSelector();
|
this._expandEmojiSelector();
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEmojiSelectorUnfocus = () => {
|
handleEmojiSelectorUnfocus = (): void => {
|
||||||
this.setState({ emojiSelectorFocused: false });
|
this.setState({ emojiSelectorFocused: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
_expandEmojiSelector = () => {
|
_expandEmojiSelector = (): void => {
|
||||||
this.setState({ emojiSelectorFocused: true });
|
this.setState({ emojiSelectorFocused: true });
|
||||||
const firstEmoji = this.node.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
|
const firstEmoji: HTMLDivElement | null | undefined = this.node?.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
|
||||||
firstEmoji.focus();
|
firstEmoji?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
_properStatus() {
|
_properStatus(): StatusEntity {
|
||||||
const { status } = this.props;
|
const { status } = this.props;
|
||||||
|
|
||||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
if (status.reblog && typeof status.reblog === 'object') {
|
||||||
return status.get('reblog');
|
return status.reblog;
|
||||||
} else {
|
} else {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRef = c => {
|
handleRef = (c: HTMLDivElement): void => {
|
||||||
this.node = c;
|
this.node = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRef = c => {
|
setRef = (c: HTMLDivElement): void => {
|
||||||
if (c) {
|
if (c) {
|
||||||
this.setState({ mediaWrapperWidth: c.offsetWidth });
|
this.setState({ mediaWrapperWidth: c.offsetWidth });
|
||||||
}
|
}
|
||||||
|
@ -322,28 +349,26 @@ class Status extends ImmutablePureComponent {
|
||||||
// FIXME: why does this need to reassign status and account??
|
// FIXME: why does this need to reassign status and account??
|
||||||
let { status, account, ...other } = this.props; // eslint-disable-line prefer-const
|
let { status, account, ...other } = this.props; // eslint-disable-line prefer-const
|
||||||
|
|
||||||
if (status === null) {
|
if (!status) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
return (
|
return (
|
||||||
<div ref={this.handleRef}>
|
<div ref={this.handleRef}>
|
||||||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
|
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
|
||||||
{status.get('content')}
|
{status.content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
|
if (status.filtered || status.getIn(['reblog', 'filtered'])) {
|
||||||
const minHandlers = this.props.muted ? {} : {
|
const minHandlers = this.props.muted ? undefined : {
|
||||||
moveUp: this.handleHotkeyMoveUp,
|
moveUp: this.handleHotkeyMoveUp,
|
||||||
moveDown: this.handleHotkeyMoveDown,
|
moveDown: this.handleHotkeyMoveDown,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={minHandlers}>
|
<HotKeys handlers={minHandlers}>
|
||||||
<div className={classNames('status__wrapper', 'status__wrapper--filtered', { focusable: this.props.focusable })} tabIndex={this.props.focusable ? 0 : null} ref={this.handleRef}>
|
<div className={classNames('status__wrapper', 'status__wrapper--filtered', { focusable: this.props.focusable })} tabIndex={this.props.focusable ? 0 : undefined} ref={this.handleRef}>
|
||||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
|
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</HotKeys>
|
||||||
|
@ -364,8 +389,8 @@ class Status extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
if (status.reblog && typeof status.reblog === 'object') {
|
||||||
const displayNameHtml = { __html: status.getIn(['account', 'display_name_html']) };
|
const displayNameHtml = { __html: String(status.getIn(['account', 'display_name_html'])) };
|
||||||
|
|
||||||
reblogElement = (
|
reblogElement = (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
@ -417,37 +442,47 @@ class Status extends ImmutablePureComponent {
|
||||||
id: 'status.reblogged_by',
|
id: 'status.reblogged_by',
|
||||||
defaultMessage: '{name} reposted',
|
defaultMessage: '{name} reposted',
|
||||||
}, {
|
}, {
|
||||||
name: status.getIn(['account', 'acct']),
|
name: String(status.getIn(['account', 'acct'])),
|
||||||
});
|
});
|
||||||
|
|
||||||
account = status.get('account');
|
// @ts-ignore what the FUCK
|
||||||
reblogContent = status.get('contentHtml');
|
account = status.account;
|
||||||
status = status.get('reblog');
|
reblogContent = status.contentHtml;
|
||||||
|
status = status.reblog;
|
||||||
}
|
}
|
||||||
|
|
||||||
const size = status.get('media_attachments').size;
|
const size = status.media_attachments.size;
|
||||||
|
const firstAttachment = status.media_attachments.first();
|
||||||
|
|
||||||
if (size > 0) {
|
if (size > 0 && firstAttachment) {
|
||||||
if (this.props.muted) {
|
if (this.props.muted) {
|
||||||
media = (
|
media = (
|
||||||
<AttachmentThumbs
|
<AttachmentThumbs
|
||||||
media={status.get('media_attachments')}
|
media={status.media_attachments}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.sensitive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
} else if (size === 1 && firstAttachment.type === 'video') {
|
||||||
const video = status.getIn(['media_attachments', 0]);
|
const video = firstAttachment;
|
||||||
|
|
||||||
if (video.external_video_id && status.card?.html) {
|
if (video.external_video_id && status.card) {
|
||||||
const { mediaWrapperWidth } = this.state;
|
const { mediaWrapperWidth } = this.state;
|
||||||
const height = mediaWrapperWidth / (video.getIn(['meta', 'original', 'width']) / video.getIn(['meta', 'original', 'height']));
|
|
||||||
|
const getHeight = (): number => {
|
||||||
|
const width = Number(video.meta.getIn(['original', 'width']));
|
||||||
|
const height = Number(video.meta.getIn(['original', 'height']));
|
||||||
|
return Number(mediaWrapperWidth) / (width / height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const height = getHeight();
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
<div className='status-card horizontal compact interactive status-card--video'>
|
<div className='status-card horizontal compact interactive status-card--video'>
|
||||||
<div
|
<div
|
||||||
ref={this.setRef}
|
ref={this.setRef}
|
||||||
className='status-card__image status-card-video'
|
className='status-card__image status-card-video'
|
||||||
style={height ? { height } : {}}
|
style={height ? { height } : undefined}
|
||||||
dangerouslySetInnerHTML={{ __html: status.card.html }}
|
dangerouslySetInnerHTML={{ __html: status.card.html }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -455,17 +490,17 @@ class Status extends ImmutablePureComponent {
|
||||||
} else {
|
} else {
|
||||||
media = (
|
media = (
|
||||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||||
{Component => (
|
{(Component: any) => (
|
||||||
<Component
|
<Component
|
||||||
preview={video.get('preview_url')}
|
preview={video.preview_url}
|
||||||
blurhash={video.get('blurhash')}
|
blurhash={video.blurhash}
|
||||||
src={video.get('url')}
|
src={video.url}
|
||||||
alt={video.get('description')}
|
alt={video.description}
|
||||||
aspectRatio={video.getIn(['meta', 'original', 'aspect'])}
|
aspectRatio={video.meta.getIn(['original', 'aspect'])}
|
||||||
width={this.props.cachedMediaWidth}
|
width={this.props.cachedMediaWidth}
|
||||||
height={285}
|
height={285}
|
||||||
inline
|
inline
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.sensitive}
|
||||||
onOpenVideo={this.handleOpenVideo}
|
onOpenVideo={this.handleOpenVideo}
|
||||||
cacheWidth={this.props.cacheMediaWidth}
|
cacheWidth={this.props.cacheMediaWidth}
|
||||||
visible={this.state.showMedia}
|
visible={this.state.showMedia}
|
||||||
|
@ -475,20 +510,20 @@ class Status extends ImmutablePureComponent {
|
||||||
</Bundle>
|
</Bundle>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'audio' && status.get('media_attachments').size === 1) {
|
} else if (size === 1 && firstAttachment.type === 'audio') {
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
const attachment = firstAttachment;
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
||||||
{Component => (
|
{(Component: any) => (
|
||||||
<Component
|
<Component
|
||||||
src={attachment.get('url')}
|
src={attachment.url}
|
||||||
alt={attachment.get('description')}
|
alt={attachment.description}
|
||||||
poster={attachment.get('preview_url') !== attachment.get('url') ? attachment.get('preview_url') : status.getIn(['account', 'avatar_static'])}
|
poster={attachment.preview_url !== attachment.url ? attachment.preview_url : status.getIn(['account', 'avatar_static'])}
|
||||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
backgroundColor={attachment.meta.getIn(['colors', 'background'])}
|
||||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
foregroundColor={attachment.meta.getIn(['colors', 'foreground'])}
|
||||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
accentColor={attachment.meta.getIn(['colors', 'accent'])}
|
||||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
duration={attachment.meta.getIn(['original', 'duration'], 0)}
|
||||||
width={this.props.cachedMediaWidth}
|
width={this.props.cachedMediaWidth}
|
||||||
height={263}
|
height={263}
|
||||||
cacheWidth={this.props.cacheMediaWidth}
|
cacheWidth={this.props.cacheMediaWidth}
|
||||||
|
@ -499,10 +534,10 @@ class Status extends ImmutablePureComponent {
|
||||||
} else {
|
} else {
|
||||||
media = (
|
media = (
|
||||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||||
{Component => (
|
{(Component: any) => (
|
||||||
<Component
|
<Component
|
||||||
media={status.get('media_attachments')}
|
media={status.media_attachments}
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.sensitive}
|
||||||
height={285}
|
height={285}
|
||||||
onOpenMedia={this.props.onOpenMedia}
|
onOpenMedia={this.props.onOpenMedia}
|
||||||
cacheWidth={this.props.cacheMediaWidth}
|
cacheWidth={this.props.cacheMediaWidth}
|
||||||
|
@ -514,17 +549,17 @@ class Status extends ImmutablePureComponent {
|
||||||
</Bundle>
|
</Bundle>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (status.get('spoiler_text').length === 0 && !status.get('quote') && status.get('card')) {
|
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
|
||||||
media = (
|
media = (
|
||||||
<Card
|
<Card
|
||||||
onOpenMedia={this.props.onOpenMedia}
|
onOpenMedia={this.props.onOpenMedia}
|
||||||
card={status.get('card')}
|
card={status.card}
|
||||||
compact
|
compact
|
||||||
cacheWidth={this.props.cacheMediaWidth}
|
cacheWidth={this.props.cacheMediaWidth}
|
||||||
defaultWidth={this.props.cachedMediaWidth}
|
defaultWidth={this.props.cachedMediaWidth}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (status.get('expectsCard', false)) {
|
} else if (status.expectsCard) {
|
||||||
media = (
|
media = (
|
||||||
<PlaceholderCard />
|
<PlaceholderCard />
|
||||||
);
|
);
|
||||||
|
@ -532,19 +567,19 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
let quote;
|
let quote;
|
||||||
|
|
||||||
if (status.get('quote')) {
|
if (status.quote) {
|
||||||
if (status.getIn(['pleroma', 'quote_visible'], true) === false) {
|
if (status.pleroma.get('quote_visible', true) === false) {
|
||||||
quote = (
|
quote = (
|
||||||
<div className='quoted-status-tombstone'>
|
<div className='quoted-status-tombstone'>
|
||||||
<p><FormattedMessage id='statuses.quote_tombstone' defaultMessage='Post is unavailable.' /></p>
|
<p><FormattedMessage id='statuses.quote_tombstone' defaultMessage='Post is unavailable.' /></p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
quote = <QuotedStatus statusId={status.get('quote')} />;
|
quote = <QuotedStatus statusId={status.quote} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers = this.props.muted ? {} : {
|
const handlers = this.props.muted ? undefined : {
|
||||||
reply: this.handleHotkeyReply,
|
reply: this.handleHotkeyReply,
|
||||||
favourite: this.handleHotkeyFavourite,
|
favourite: this.handleHotkeyFavourite,
|
||||||
boost: this.handleHotkeyBoost,
|
boost: this.handleHotkeyBoost,
|
||||||
|
@ -559,15 +594,15 @@ class Status extends ImmutablePureComponent {
|
||||||
react: this.handleHotkeyReact,
|
react: this.handleHotkeyReact,
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`;
|
const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.id}`;
|
||||||
// const favicon = status.getIn(['account', 'pleroma', 'favicon']);
|
// const favicon = status.getIn(['account', 'pleroma', 'favicon']);
|
||||||
// const domain = getDomain(status.get('account'));
|
// const domain = getDomain(status.account);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<HotKeys handlers={handlers}>
|
||||||
<div
|
<div
|
||||||
className='status cursor-pointer'
|
className='status cursor-pointer'
|
||||||
tabIndex={this.props.focusable && !this.props.muted ? 0 : null}
|
tabIndex={this.props.focusable && !this.props.muted ? 0 : undefined}
|
||||||
data-featured={featured ? 'true' : null}
|
data-featured={featured ? 'true' : null}
|
||||||
aria-label={textForScreenReader(intl, status, rebloggedByText)}
|
aria-label={textForScreenReader(intl, status, rebloggedByText)}
|
||||||
ref={this.handleRef}
|
ref={this.handleRef}
|
||||||
|
@ -580,19 +615,19 @@ class Status extends ImmutablePureComponent {
|
||||||
<div
|
<div
|
||||||
className={classNames({
|
className={classNames({
|
||||||
'status__wrapper': true,
|
'status__wrapper': true,
|
||||||
[`status-${status.get('visibility')}`]: true,
|
[`status-${status.visibility}`]: true,
|
||||||
'status-reply': !!status.get('in_reply_to_id'),
|
'status-reply': !!status.in_reply_to_id,
|
||||||
muted: this.props.muted,
|
muted: this.props.muted,
|
||||||
read: unread === false,
|
read: unread === false,
|
||||||
})}
|
})}
|
||||||
data-id={status.get('id')}
|
data-id={status.id}
|
||||||
>
|
>
|
||||||
<div className='mb-4'>
|
<div className='mb-4'>
|
||||||
<HStack justifyContent='between' alignItems='start'>
|
<HStack justifyContent='between' alignItems='start'>
|
||||||
<AccountContainer
|
<AccountContainer
|
||||||
key={status.getIn(['account', 'id'])}
|
key={String(status.getIn(['account', 'id']))}
|
||||||
id={status.getIn(['account', 'id'])}
|
id={String(status.getIn(['account', 'id']))}
|
||||||
timestamp={status.get('created_at')}
|
timestamp={status.created_at}
|
||||||
timestampUrl={statusUrl}
|
timestampUrl={statusUrl}
|
||||||
action={reblogElement}
|
action={reblogElement}
|
||||||
hideActions={!reblogElement}
|
hideActions={!reblogElement}
|
||||||
|
@ -601,9 +636,9 @@ class Status extends ImmutablePureComponent {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='status__content-wrapper'>
|
<div className='status__content-wrapper'>
|
||||||
{!group && status.get('group') && (
|
{!group && status.group && (
|
||||||
<div className='status__meta'>
|
<div className='status__meta'>
|
||||||
Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink>
|
Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{String(status.getIn(['group', 'title']))}</NavLink>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -613,7 +648,7 @@ class Status extends ImmutablePureComponent {
|
||||||
status={status}
|
status={status}
|
||||||
reblogContent={reblogContent}
|
reblogContent={reblogContent}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
expanded={!status.get('hidden')}
|
expanded={!status.hidden}
|
||||||
onExpandedToggle={this.handleExpandedToggle}
|
onExpandedToggle={this.handleExpandedToggle}
|
||||||
collapsable
|
collapsable
|
||||||
/>
|
/>
|
||||||
|
@ -623,6 +658,7 @@ class Status extends ImmutablePureComponent {
|
||||||
{quote}
|
{quote}
|
||||||
|
|
||||||
<StatusActionBar
|
<StatusActionBar
|
||||||
|
// @ts-ignore what?
|
||||||
status={status}
|
status={status}
|
||||||
account={account}
|
account={account}
|
||||||
emojiSelectorFocused={this.state.emojiSelectorFocused}
|
emojiSelectorFocused={this.state.emojiSelectorFocused}
|
||||||
|
@ -637,3 +673,5 @@ class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withRouter(injectIntl(Status));
|
|
@ -1,25 +1,27 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl, IntlShape } from 'react-intl';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Link, withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
|
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
|
||||||
|
import EmojiSelector from 'soapbox/components/emoji_selector';
|
||||||
|
import Hoverable from 'soapbox/components/hoverable';
|
||||||
|
import StatusActionButton from 'soapbox/components/status-action-button';
|
||||||
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
|
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
|
||||||
import { isUserTouching } from 'soapbox/is_mobile';
|
import { isUserTouching } from 'soapbox/is_mobile';
|
||||||
import { isStaff, isAdmin } from 'soapbox/utils/accounts';
|
|
||||||
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts';
|
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts';
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
|
||||||
|
|
||||||
import { openModal } from '../actions/modals';
|
import { openModal } from '../actions/modals';
|
||||||
|
|
||||||
import { IconButton, Text } from './ui';
|
import type { History } from 'history';
|
||||||
|
import type { AnyAction, Dispatch } from 'redux';
|
||||||
|
import type { Menu } from 'soapbox/components/dropdown_menu';
|
||||||
|
import type { RootState } from 'soapbox/store';
|
||||||
|
import type { Status } from 'soapbox/types/entities';
|
||||||
|
import type { Features } from 'soapbox/utils/features';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
|
@ -66,63 +68,70 @@ const messages = defineMessages({
|
||||||
quotePost: { id: 'status.quote', defaultMessage: 'Quote post' },
|
quotePost: { id: 'status.quote', defaultMessage: 'Quote post' },
|
||||||
});
|
});
|
||||||
|
|
||||||
class StatusActionBar extends ImmutablePureComponent {
|
interface IStatusActionBar {
|
||||||
|
status: Status,
|
||||||
|
onOpenUnauthorizedModal: (modalType?: string) => void,
|
||||||
|
onOpenReblogsModal: (acct: string, statusId: string) => void,
|
||||||
|
onReply: (status: Status, history: History) => void,
|
||||||
|
onFavourite: (status: Status) => void,
|
||||||
|
onBookmark: (status: Status) => void,
|
||||||
|
onReblog: (status: Status, e: React.MouseEvent) => void,
|
||||||
|
onQuote: (status: Status, history: History) => void,
|
||||||
|
onDelete: (status: Status, history: History, redraft?: boolean) => void,
|
||||||
|
onDirect: (account: any, history: History) => void,
|
||||||
|
onChat: (account: any, history: History) => void,
|
||||||
|
onMention: (account: any, history: History) => void,
|
||||||
|
onMute: (account: any) => void,
|
||||||
|
onBlock: (status: Status) => void,
|
||||||
|
onReport: (status: Status) => void,
|
||||||
|
onEmbed: (status: Status) => void,
|
||||||
|
onDeactivateUser: (status: Status) => void,
|
||||||
|
onDeleteUser: (status: Status) => void,
|
||||||
|
onToggleStatusSensitivity: (status: Status) => void,
|
||||||
|
onDeleteStatus: (status: Status) => void,
|
||||||
|
onMuteConversation: (status: Status) => void,
|
||||||
|
onPin: (status: Status) => void,
|
||||||
|
withDismiss: boolean,
|
||||||
|
withGroupAdmin: boolean,
|
||||||
|
intl: IntlShape,
|
||||||
|
me: string | null | false | undefined,
|
||||||
|
isStaff: boolean,
|
||||||
|
isAdmin: boolean,
|
||||||
|
allowedEmoji: ImmutableList<string>,
|
||||||
|
emojiSelectorFocused: boolean,
|
||||||
|
handleEmojiSelectorUnfocus: () => void,
|
||||||
|
features: Features,
|
||||||
|
history: History,
|
||||||
|
dispatch: Dispatch,
|
||||||
|
}
|
||||||
|
|
||||||
static propTypes = {
|
interface IStatusActionBarState {
|
||||||
status: ImmutablePropTypes.record.isRequired,
|
emojiSelectorVisible: boolean,
|
||||||
onOpenUnauthorizedModal: PropTypes.func.isRequired,
|
}
|
||||||
onOpenReblogsModal: PropTypes.func.isRequired,
|
|
||||||
onReply: PropTypes.func,
|
|
||||||
onFavourite: PropTypes.func,
|
|
||||||
onBookmark: PropTypes.func,
|
|
||||||
onReblog: PropTypes.func,
|
|
||||||
onQuote: PropTypes.func,
|
|
||||||
onDelete: PropTypes.func,
|
|
||||||
onDirect: PropTypes.func,
|
|
||||||
onChat: PropTypes.func,
|
|
||||||
onMention: PropTypes.func,
|
|
||||||
onMute: PropTypes.func,
|
|
||||||
onBlock: PropTypes.func,
|
|
||||||
onReport: PropTypes.func,
|
|
||||||
onEmbed: PropTypes.func,
|
|
||||||
onDeactivateUser: PropTypes.func,
|
|
||||||
onDeleteUser: PropTypes.func,
|
|
||||||
onToggleStatusSensitivity: PropTypes.func,
|
|
||||||
onDeleteStatus: PropTypes.func,
|
|
||||||
onMuteConversation: PropTypes.func,
|
|
||||||
onPin: PropTypes.func,
|
|
||||||
withDismiss: PropTypes.bool,
|
|
||||||
withGroupAdmin: PropTypes.bool,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
me: SoapboxPropTypes.me,
|
|
||||||
isStaff: PropTypes.bool.isRequired,
|
|
||||||
isAdmin: PropTypes.bool.isRequired,
|
|
||||||
allowedEmoji: ImmutablePropTypes.list,
|
|
||||||
emojiSelectorFocused: PropTypes.bool,
|
|
||||||
handleEmojiSelectorUnfocus: PropTypes.func.isRequired,
|
|
||||||
features: PropTypes.object.isRequired,
|
|
||||||
history: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusActionBarState> {
|
||||||
|
|
||||||
|
static defaultProps: Partial<IStatusActionBar> = {
|
||||||
isStaff: false,
|
isStaff: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
node?: HTMLDivElement = undefined;
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
emojiSelectorVisible: false,
|
emojiSelectorVisible: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid checking props that are functions (and whose equality will always
|
// Avoid checking props that are functions (and whose equality will always
|
||||||
// evaluate to false. See react-immutable-pure-component for usage.
|
// evaluate to false. See react-immutable-pure-component for usage.
|
||||||
|
// @ts-ignore: the type checker is wrong.
|
||||||
updateOnProps = [
|
updateOnProps = [
|
||||||
'status',
|
'status',
|
||||||
'withDismiss',
|
'withDismiss',
|
||||||
'emojiSelectorFocused',
|
'emojiSelectorFocused',
|
||||||
]
|
]
|
||||||
|
|
||||||
handleReplyClick = (event) => {
|
handleReplyClick = () => {
|
||||||
const { me, onReply, onOpenUnauthorizedModal, status } = this.props;
|
const { me, onReply, onOpenUnauthorizedModal, status } = this.props;
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
if (me) {
|
if (me) {
|
||||||
onReply(status, this.props.history);
|
onReply(status, this.props.history);
|
||||||
|
@ -131,18 +140,16 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleShareClick = (e) => {
|
handleShareClick = () => {
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
navigator.share({
|
navigator.share({
|
||||||
text: this.props.status.get('search_index'),
|
text: this.props.status.search_index,
|
||||||
url: this.props.status.get('url'),
|
url: this.props.status.url,
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
if (e.name !== 'AbortError') console.error(e);
|
if (e.name !== 'AbortError') console.error(e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLikeButtonHover = e => {
|
handleLikeButtonHover: React.EventHandler<React.MouseEvent> = () => {
|
||||||
const { features } = this.props;
|
const { features } = this.props;
|
||||||
|
|
||||||
if (features.emojiReacts && !isUserTouching()) {
|
if (features.emojiReacts && !isUserTouching()) {
|
||||||
|
@ -150,7 +157,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLikeButtonLeave = e => {
|
handleLikeButtonLeave: React.EventHandler<React.MouseEvent> = () => {
|
||||||
const { features } = this.props;
|
const { features } = this.props;
|
||||||
|
|
||||||
if (features.emojiReacts && !isUserTouching()) {
|
if (features.emojiReacts && !isUserTouching()) {
|
||||||
|
@ -158,51 +165,58 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLikeButtonClick = e => {
|
handleLikeButtonClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
const { features } = this.props;
|
const { features } = this.props;
|
||||||
|
|
||||||
e.stopPropagation();
|
const reactForStatus = getReactForStatus(this.props.status, this.props.allowedEmoji);
|
||||||
|
const meEmojiReact = typeof reactForStatus === 'string' ? reactForStatus : '👍';
|
||||||
const meEmojiReact = getReactForStatus(this.props.status, this.props.allowedEmoji) || '👍';
|
|
||||||
|
|
||||||
if (features.emojiReacts && isUserTouching()) {
|
if (features.emojiReacts && isUserTouching()) {
|
||||||
if (this.state.emojiSelectorVisible) {
|
if (this.state.emojiSelectorVisible) {
|
||||||
this.handleReactClick(meEmojiReact)();
|
this.handleReact(meEmojiReact);
|
||||||
} else {
|
} else {
|
||||||
this.setState({ emojiSelectorVisible: true });
|
this.setState({ emojiSelectorVisible: true });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.handleReactClick(meEmojiReact)();
|
this.handleReact(meEmojiReact);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReactClick = emoji => {
|
e.stopPropagation();
|
||||||
return e => {
|
}
|
||||||
|
|
||||||
|
handleReact = (emoji: string): void => {
|
||||||
const { me, dispatch, onOpenUnauthorizedModal, status } = this.props;
|
const { me, dispatch, onOpenUnauthorizedModal, status } = this.props;
|
||||||
if (me) {
|
if (me) {
|
||||||
dispatch(simpleEmojiReact(status, emoji));
|
dispatch(simpleEmojiReact(status, emoji) as any);
|
||||||
} else {
|
} else {
|
||||||
onOpenUnauthorizedModal('FAVOURITE');
|
onOpenUnauthorizedModal('FAVOURITE');
|
||||||
}
|
}
|
||||||
this.setState({ emojiSelectorVisible: false });
|
this.setState({ emojiSelectorVisible: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReactClick = (emoji: string): React.EventHandler<React.MouseEvent> => {
|
||||||
|
return () => {
|
||||||
|
this.handleReact(emoji);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFavouriteClick = () => {
|
handleFavouriteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
const { me, onFavourite, onOpenUnauthorizedModal, status } = this.props;
|
const { me, onFavourite, onOpenUnauthorizedModal, status } = this.props;
|
||||||
if (me) {
|
if (me) {
|
||||||
onFavourite(status);
|
onFavourite(status);
|
||||||
} else {
|
} else {
|
||||||
onOpenUnauthorizedModal('FAVOURITE');
|
onOpenUnauthorizedModal('FAVOURITE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBookmarkClick = (e) => {
|
handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onBookmark(this.props.status);
|
this.props.onBookmark(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReblogClick = e => {
|
handleReblogClick: React.EventHandler<React.MouseEvent> = e => {
|
||||||
const { me, onReblog, onOpenUnauthorizedModal, status } = this.props;
|
const { me, onReblog, onOpenUnauthorizedModal, status } = this.props;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
@ -213,7 +227,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleQuoteClick = (e) => {
|
handleQuoteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const { me, onQuote, onOpenUnauthorizedModal, status } = this.props;
|
const { me, onQuote, onOpenUnauthorizedModal, status } = this.props;
|
||||||
if (me) {
|
if (me) {
|
||||||
|
@ -223,67 +237,67 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDeleteClick = (e) => {
|
handleDeleteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onDelete(this.props.status, this.props.history);
|
this.props.onDelete(this.props.status, this.props.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRedraftClick = (e) => {
|
handleRedraftClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onDelete(this.props.status, this.props.history, true);
|
this.props.onDelete(this.props.status, this.props.history, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePinClick = (e) => {
|
handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onPin(this.props.status);
|
this.props.onPin(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMentionClick = (e) => {
|
handleMentionClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onMention(this.props.status.get('account'), this.props.history);
|
this.props.onMention(this.props.status.account, this.props.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDirectClick = (e) => {
|
handleDirectClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onDirect(this.props.status.get('account'), this.props.history);
|
this.props.onDirect(this.props.status.account, this.props.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChatClick = (e) => {
|
handleChatClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onChat(this.props.status.get('account'), this.props.history);
|
this.props.onChat(this.props.status.account, this.props.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMuteClick = (e) => {
|
handleMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onMute(this.props.status.get('account'));
|
this.props.onMute(this.props.status.account);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBlockClick = (e) => {
|
handleBlockClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onBlock(this.props.status);
|
this.props.onBlock(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpen = (e) => {
|
handleOpen: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.get('id')}`);
|
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEmbed = () => {
|
handleEmbed = () => {
|
||||||
this.props.onEmbed(this.props.status);
|
this.props.onEmbed(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReport = (e) => {
|
handleReport: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onReport(this.props.status);
|
this.props.onReport(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConversationMuteClick = (e) => {
|
handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onMuteConversation(this.props.status);
|
this.props.onMuteConversation(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCopy = (e) => {
|
handleCopy: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
const url = this.props.status.get('url');
|
const { url } = this.props.status;
|
||||||
const textarea = document.createElement('textarea');
|
const textarea = document.createElement('textarea');
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -303,57 +317,56 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGroupRemoveAccount = (e) => {
|
// handleGroupRemoveAccount: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
const { status } = this.props;
|
// const { status } = this.props;
|
||||||
|
//
|
||||||
|
// e.stopPropagation();
|
||||||
|
//
|
||||||
|
// this.props.onGroupRemoveAccount(status.getIn(['group', 'id']), status.getIn(['account', 'id']));
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// handleGroupRemovePost: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
|
// const { status } = this.props;
|
||||||
|
//
|
||||||
|
// e.stopPropagation();
|
||||||
|
//
|
||||||
|
// this.props.onGroupRemoveStatus(status.getIn(['group', 'id']), status.id);
|
||||||
|
// }
|
||||||
|
|
||||||
e.stopPropagation();
|
handleDeactivateUser: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
|
|
||||||
this.props.onGroupRemoveAccount(status.getIn(['group', 'id']), status.getIn(['account', 'id']));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleGroupRemovePost = (e) => {
|
|
||||||
const { status } = this.props;
|
|
||||||
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
this.props.onGroupRemoveStatus(status.getIn(['group', 'id']), status.get('id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDeactivateUser = (e) => {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onDeactivateUser(this.props.status);
|
this.props.onDeactivateUser(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDeleteUser = (e) => {
|
handleDeleteUser: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onDeleteUser(this.props.status);
|
this.props.onDeleteUser(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDeleteStatus = (e) => {
|
handleDeleteStatus: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onDeleteStatus(this.props.status);
|
this.props.onDeleteStatus(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleToggleStatusSensitivity = (e) => {
|
handleToggleStatusSensitivity: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.props.onToggleStatusSensitivity(this.props.status);
|
this.props.onToggleStatusSensitivity(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpenReblogsModal = (event) => {
|
handleOpenReblogsModal = () => {
|
||||||
const { me, status, onOpenUnauthorizedModal, onOpenReblogsModal } = this.props;
|
const { me, status, onOpenUnauthorizedModal, onOpenReblogsModal } = this.props;
|
||||||
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
if (!me) onOpenUnauthorizedModal();
|
if (!me) onOpenUnauthorizedModal();
|
||||||
else onOpenReblogsModal(status.getIn(['account', 'acct']), status.get('id'));
|
else onOpenReblogsModal(String(status.getIn(['account', 'acct'])), status.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
_makeMenu = (publicStatus) => {
|
_makeMenu = (publicStatus: boolean) => {
|
||||||
const { status, intl, withDismiss, withGroupAdmin, me, features, isStaff, isAdmin } = this.props;
|
const { status, intl, withDismiss, me, features, isStaff, isAdmin } = this.props;
|
||||||
const mutingConversation = status.get('muted');
|
const mutingConversation = status.muted;
|
||||||
const ownAccount = status.getIn(['account', 'id']) === me;
|
const ownAccount = status.getIn(['account', 'id']) === me;
|
||||||
|
const username = String(status.getIn(['account', 'username']));
|
||||||
|
|
||||||
const menu = [];
|
const menu: Menu = [];
|
||||||
|
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.open),
|
text: intl.formatMessage(messages.open),
|
||||||
|
@ -380,9 +393,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (features.bookmarks) {
|
if (features.bookmarks) {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(status.get('bookmarked') ? messages.unbookmark : messages.bookmark),
|
text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark),
|
||||||
action: this.handleBookmarkClick,
|
action: this.handleBookmarkClick,
|
||||||
icon: require(status.get('bookmarked') ? '@tabler/icons/icons/bookmark-off.svg' : '@tabler/icons/icons/bookmark.svg'),
|
icon: require(status.bookmarked ? '@tabler/icons/icons/bookmark-off.svg' : '@tabler/icons/icons/bookmark.svg'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -400,14 +413,14 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
if (ownAccount) {
|
if (ownAccount) {
|
||||||
if (publicStatus) {
|
if (publicStatus) {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin),
|
text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin),
|
||||||
action: this.handlePinClick,
|
action: this.handlePinClick,
|
||||||
icon: require(mutingConversation ? '@tabler/icons/icons/pinned-off.svg' : '@tabler/icons/icons/pin.svg'),
|
icon: require(mutingConversation ? '@tabler/icons/icons/pinned-off.svg' : '@tabler/icons/icons/pin.svg'),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (status.get('visibility') === 'private') {
|
if (status.visibility === 'private') {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private),
|
text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog_private),
|
||||||
action: this.handleReblogClick,
|
action: this.handleReblogClick,
|
||||||
icon: require('@tabler/icons/icons/repeat.svg'),
|
icon: require('@tabler/icons/icons/repeat.svg'),
|
||||||
});
|
});
|
||||||
|
@ -428,20 +441,20 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }),
|
text: intl.formatMessage(messages.mention, { name: username }),
|
||||||
action: this.handleMentionClick,
|
action: this.handleMentionClick,
|
||||||
icon: require('@tabler/icons/icons/at.svg'),
|
icon: require('@tabler/icons/icons/at.svg'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// if (status.getIn(['account', 'pleroma', 'accepts_chat_messages'], false) === true) {
|
// if (status.getIn(['account', 'pleroma', 'accepts_chat_messages'], false) === true) {
|
||||||
// menu.push({
|
// menu.push({
|
||||||
// text: intl.formatMessage(messages.chat, { name: status.getIn(['account', 'username']) }),
|
// text: intl.formatMessage(messages.chat, { name: username }),
|
||||||
// action: this.handleChatClick,
|
// action: this.handleChatClick,
|
||||||
// icon: require('@tabler/icons/icons/messages.svg'),
|
// icon: require('@tabler/icons/icons/messages.svg'),
|
||||||
// });
|
// });
|
||||||
// } else {
|
// } else {
|
||||||
// menu.push({
|
// menu.push({
|
||||||
// text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }),
|
// text: intl.formatMessage(messages.direct, { name: username }),
|
||||||
// action: this.handleDirectClick,
|
// action: this.handleDirectClick,
|
||||||
// icon: require('@tabler/icons/icons/mail.svg'),
|
// icon: require('@tabler/icons/icons/mail.svg'),
|
||||||
// });
|
// });
|
||||||
|
@ -449,17 +462,17 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
|
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }),
|
text: intl.formatMessage(messages.mute, { name: username }),
|
||||||
action: this.handleMuteClick,
|
action: this.handleMuteClick,
|
||||||
icon: require('@tabler/icons/icons/circle-x.svg'),
|
icon: require('@tabler/icons/icons/circle-x.svg'),
|
||||||
});
|
});
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }),
|
text: intl.formatMessage(messages.block, { name: username }),
|
||||||
action: this.handleBlockClick,
|
action: this.handleBlockClick,
|
||||||
icon: require('@tabler/icons/icons/ban.svg'),
|
icon: require('@tabler/icons/icons/ban.svg'),
|
||||||
});
|
});
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }),
|
text: intl.formatMessage(messages.report, { name: username }),
|
||||||
action: this.handleReport,
|
action: this.handleReport,
|
||||||
icon: require('@tabler/icons/icons/flag.svg'),
|
icon: require('@tabler/icons/icons/flag.svg'),
|
||||||
});
|
});
|
||||||
|
@ -470,33 +483,33 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }),
|
text: intl.formatMessage(messages.admin_account, { name: username }),
|
||||||
href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`,
|
href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`,
|
||||||
icon: require('@tabler/icons/icons/gavel.svg'),
|
icon: require('@tabler/icons/icons/gavel.svg'),
|
||||||
action: (event) => event.stopPropagation(),
|
action: (event) => event.stopPropagation(),
|
||||||
});
|
});
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.admin_status),
|
text: intl.formatMessage(messages.admin_status),
|
||||||
href: `/pleroma/admin/#/statuses/${status.get('id')}/`,
|
href: `/pleroma/admin/#/statuses/${status.id}/`,
|
||||||
icon: require('@tabler/icons/icons/pencil.svg'),
|
icon: require('@tabler/icons/icons/pencil.svg'),
|
||||||
action: (event) => event.stopPropagation(),
|
action: (event) => event.stopPropagation(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(status.get('sensitive') === false ? messages.markStatusSensitive : messages.markStatusNotSensitive),
|
text: intl.formatMessage(status.sensitive === false ? messages.markStatusSensitive : messages.markStatusNotSensitive),
|
||||||
action: this.handleToggleStatusSensitivity,
|
action: this.handleToggleStatusSensitivity,
|
||||||
icon: require('@tabler/icons/icons/alert-triangle.svg'),
|
icon: require('@tabler/icons/icons/alert-triangle.svg'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ownAccount) {
|
if (!ownAccount) {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.deactivateUser, { name: status.getIn(['account', 'username']) }),
|
text: intl.formatMessage(messages.deactivateUser, { name: username }),
|
||||||
action: this.handleDeactivateUser,
|
action: this.handleDeactivateUser,
|
||||||
icon: require('@tabler/icons/icons/user-off.svg'),
|
icon: require('@tabler/icons/icons/user-off.svg'),
|
||||||
});
|
});
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.deleteUser, { name: status.getIn(['account', 'username']) }),
|
text: intl.formatMessage(messages.deleteUser, { name: username }),
|
||||||
action: this.handleDeleteUser,
|
action: this.handleDeleteUser,
|
||||||
icon: require('@tabler/icons/icons/user-minus.svg'),
|
icon: require('@tabler/icons/icons/user-minus.svg'),
|
||||||
destructive: true,
|
destructive: true,
|
||||||
|
@ -510,223 +523,194 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ownAccount && withGroupAdmin) {
|
// if (!ownAccount && withGroupAdmin) {
|
||||||
menu.push(null);
|
// menu.push(null);
|
||||||
menu.push({
|
// menu.push({
|
||||||
text: intl.formatMessage(messages.group_remove_account),
|
// text: intl.formatMessage(messages.group_remove_account),
|
||||||
action: this.handleGroupRemoveAccount,
|
// action: this.handleGroupRemoveAccount,
|
||||||
icon: require('@tabler/icons/icons/user-x.svg'),
|
// icon: require('@tabler/icons/icons/user-x.svg'),
|
||||||
destructive: true,
|
// destructive: true,
|
||||||
});
|
// });
|
||||||
menu.push({
|
// menu.push({
|
||||||
text: intl.formatMessage(messages.group_remove_post),
|
// text: intl.formatMessage(messages.group_remove_post),
|
||||||
action: this.handleGroupRemovePost,
|
// action: this.handleGroupRemovePost,
|
||||||
icon: require('@tabler/icons/icons/trash.svg'),
|
// icon: require('@tabler/icons/icons/trash.svg'),
|
||||||
destructive: true,
|
// destructive: true,
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRef = c => {
|
setRef = (c: HTMLDivElement) => {
|
||||||
this.node = c;
|
this.node = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', (e) => {
|
||||||
if (this.node && !this.node.contains(e.target))
|
if (this.node && !this.node.contains(e.target as Node))
|
||||||
this.setState({ emojiSelectorVisible: false });
|
this.setState({ emojiSelectorVisible: false });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { status, intl, allowedEmoji, features, me } = this.props;
|
const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus, features, me } = this.props;
|
||||||
|
|
||||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
const publicStatus = ['public', 'unlisted'].includes(status.visibility);
|
||||||
|
|
||||||
|
const replyCount = status.replies_count;
|
||||||
|
const reblogCount = status.reblogs_count;
|
||||||
|
const favouriteCount = status.favourites_count;
|
||||||
|
|
||||||
const replyCount = status.get('replies_count');
|
|
||||||
const reblogCount = status.get('reblogs_count');
|
|
||||||
const favouriteCount = status.get('favourites_count');
|
|
||||||
const emojiReactCount = reduceEmoji(
|
const emojiReactCount = reduceEmoji(
|
||||||
status.getIn(['pleroma', 'emoji_reactions'], ImmutableList()),
|
(status.getIn(['pleroma', 'emoji_reactions']) || ImmutableList()) as ImmutableList<any>,
|
||||||
favouriteCount,
|
favouriteCount,
|
||||||
status.get('favourited'),
|
status.favourited,
|
||||||
allowedEmoji,
|
allowedEmoji,
|
||||||
).reduce((acc, cur) => acc + cur.get('count'), 0);
|
).reduce((acc, cur) => acc + cur.get('count'), 0);
|
||||||
const meEmojiReact = getReactForStatus(status, allowedEmoji);
|
|
||||||
const meEmojiTitle = intl.formatMessage({
|
const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined;
|
||||||
|
|
||||||
|
const reactMessages = {
|
||||||
'👍': messages.reactionLike,
|
'👍': messages.reactionLike,
|
||||||
'❤️': messages.reactionHeart,
|
'❤️': messages.reactionHeart,
|
||||||
'😆': messages.reactionLaughing,
|
'😆': messages.reactionLaughing,
|
||||||
'😮': messages.reactionOpenMouth,
|
'😮': messages.reactionOpenMouth,
|
||||||
'😢': messages.reactionCry,
|
'😢': messages.reactionCry,
|
||||||
'😩': messages.reactionWeary,
|
'😩': messages.reactionWeary,
|
||||||
}[meEmojiReact] || messages.favourite);
|
};
|
||||||
|
|
||||||
|
const meEmojiTitle = intl.formatMessage(meEmojiReact ? reactMessages[meEmojiReact] : messages.favourite);
|
||||||
|
|
||||||
const menu = this._makeMenu(publicStatus);
|
const menu = this._makeMenu(publicStatus);
|
||||||
let reblogIcon = require('@tabler/icons/icons/repeat.svg');
|
let reblogIcon = require('@tabler/icons/icons/repeat.svg');
|
||||||
let replyTitle;
|
let replyTitle;
|
||||||
|
|
||||||
if (status.get('visibility') === 'direct') {
|
if (status.visibility === 'direct') {
|
||||||
reblogIcon = require('@tabler/icons/icons/mail.svg');
|
reblogIcon = require('@tabler/icons/icons/mail.svg');
|
||||||
} else if (status.get('visibility') === 'private') {
|
} else if (status.visibility === 'private') {
|
||||||
reblogIcon = require('@tabler/icons/icons/lock.svg');
|
reblogIcon = require('@tabler/icons/icons/lock.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
let reblogButton;
|
const reblogMenu = [{
|
||||||
|
text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog),
|
||||||
if (me && features.quotePosts) {
|
|
||||||
const reblogMenu = [
|
|
||||||
{
|
|
||||||
text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog),
|
|
||||||
action: this.handleReblogClick,
|
action: this.handleReblogClick,
|
||||||
icon: require('@tabler/icons/icons/repeat.svg'),
|
icon: require('@tabler/icons/icons/repeat.svg'),
|
||||||
},
|
}, {
|
||||||
{
|
|
||||||
text: intl.formatMessage(messages.quotePost),
|
text: intl.formatMessage(messages.quotePost),
|
||||||
action: this.handleQuoteClick,
|
action: this.handleQuoteClick,
|
||||||
icon: require('@tabler/icons/icons/quote.svg'),
|
icon: require('@tabler/icons/icons/quote.svg'),
|
||||||
},
|
}];
|
||||||
];
|
|
||||||
|
|
||||||
reblogButton = (
|
const reblogButton = (
|
||||||
<DropdownMenuContainer
|
<StatusActionButton
|
||||||
items={reblogMenu}
|
icon={reblogIcon}
|
||||||
|
color='success'
|
||||||
disabled={!publicStatus}
|
disabled={!publicStatus}
|
||||||
active={status.get('reblogged')}
|
|
||||||
pressed={status.get('reblogged')}
|
|
||||||
title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)}
|
title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)}
|
||||||
src={reblogIcon}
|
active={status.reblogged}
|
||||||
direction='right'
|
|
||||||
onShiftClick={this.handleReblogClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
reblogButton = (
|
|
||||||
<IconButton
|
|
||||||
disabled={!publicStatus}
|
|
||||||
className={classNames({
|
|
||||||
'text-gray-400 hover:text-gray-600 dark:hover:text-white': !status.get('reblogged'),
|
|
||||||
'text-success-600 hover:text-success-600': status.get('reblogged'),
|
|
||||||
})}
|
|
||||||
title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)}
|
|
||||||
src={reblogIcon}
|
|
||||||
onClick={this.handleReblogClick}
|
onClick={this.handleReblogClick}
|
||||||
|
count={reblogCount}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (status.get('in_reply_to_id', null) === null) {
|
if (!status.in_reply_to_id) {
|
||||||
replyTitle = intl.formatMessage(messages.reply);
|
replyTitle = intl.formatMessage(messages.reply);
|
||||||
} else {
|
} else {
|
||||||
replyTitle = intl.formatMessage(messages.replyAll);
|
replyTitle = intl.formatMessage(messages.replyAll);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canShare = ('share' in navigator) && status.get('visibility') === 'public';
|
const canShare = ('share' in navigator) && status.visibility === 'public';
|
||||||
|
|
||||||
const shareButton = canShare && (
|
|
||||||
<div className='flex items-center space-x-0.5 p-1 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'>
|
|
||||||
<IconButton
|
|
||||||
title={intl.formatMessage(messages.share)}
|
|
||||||
src={require('@tabler/icons/icons/upload.svg')}
|
|
||||||
onClick={this.handleShareClick}
|
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-white'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='pt-4 flex flex-row space-x-2'>
|
<div className='pt-4 flex flex-row space-x-2'>
|
||||||
<div className='flex items-center space-x-0.5 p-1 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'>
|
<StatusActionButton
|
||||||
<IconButton
|
|
||||||
title={replyTitle}
|
title={replyTitle}
|
||||||
src={require('@tabler/icons/icons/message-circle.svg')}
|
icon={require('@tabler/icons/icons/message-circle.svg')}
|
||||||
onClick={this.handleReplyClick}
|
onClick={this.handleReplyClick}
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-white'
|
count={replyCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{replyCount !== 0 ? (
|
{features.quotePosts && me ? (
|
||||||
<Link to={`/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`}>
|
<DropdownMenuContainer items={reblogMenu} onShiftClick={this.handleReblogClick}>
|
||||||
<Text size='xs' theme='muted'>{replyCount}</Text>
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex items-center space-x-0.5 p-1 text-gray-400 hover:text-gray-600 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'>
|
|
||||||
{reblogButton}
|
{reblogButton}
|
||||||
{reblogCount !== 0 && <Text size='xs' theme='muted' role='presentation' onClick={this.handleOpenReblogsModal}>{reblogCount}</Text>}
|
</DropdownMenuContainer>
|
||||||
</div>
|
) : (
|
||||||
|
reblogButton
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
{features.emojiReacts ? (
|
||||||
ref={this.setRef}
|
<Hoverable
|
||||||
className='flex items-center space-x-0.5 p-1 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
|
component={(
|
||||||
// onMouseEnter={this.handleLikeButtonHover}
|
<EmojiSelector
|
||||||
// onMouseLeave={this.handleLikeButtonLeave}
|
onReact={this.handleReact}
|
||||||
>
|
|
||||||
{/* <EmojiSelector
|
|
||||||
onReact={this.handleReactClick}
|
|
||||||
visible={features.emojiReacts && emojiSelectorVisible}
|
|
||||||
focused={emojiSelectorFocused}
|
focused={emojiSelectorFocused}
|
||||||
onUnfocus={handleEmojiSelectorUnfocus}
|
onUnfocus={handleEmojiSelectorUnfocus}
|
||||||
/> */}
|
|
||||||
<IconButton
|
|
||||||
className={classNames({
|
|
||||||
'text-gray-400 hover:text-gray-600 dark:hover:text-white': !meEmojiReact,
|
|
||||||
'text-accent-300 hover:text-accent-300': Boolean(meEmojiReact),
|
|
||||||
})}
|
|
||||||
title={meEmojiTitle}
|
|
||||||
src={require('@tabler/icons/icons/heart.svg')}
|
|
||||||
iconClassName={classNames({
|
|
||||||
'fill-accent-300': Boolean(meEmojiReact),
|
|
||||||
})}
|
|
||||||
// emoji={meEmojiReact}
|
|
||||||
onClick={this.handleLikeButtonClick}
|
|
||||||
/>
|
/>
|
||||||
{emojiReactCount !== 0 && (
|
|
||||||
(features.exposableReactions && !features.emojiReacts) ? (
|
|
||||||
<Link to={`/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}/likes`} className='pointer-events-none'>
|
|
||||||
<Text size='xs' theme='muted'>{emojiReactCount}</Text>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className='detailed-status__link'>{emojiReactCount}</span>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
|
<StatusActionButton
|
||||||
|
title={meEmojiTitle}
|
||||||
|
icon={require('@tabler/icons/icons/thumb-up.svg')}
|
||||||
|
color='accent'
|
||||||
|
onClick={this.handleLikeButtonClick}
|
||||||
|
active={Boolean(meEmojiReact)}
|
||||||
|
count={emojiReactCount}
|
||||||
|
/>
|
||||||
|
</Hoverable>
|
||||||
|
): (
|
||||||
|
<StatusActionButton
|
||||||
|
title={intl.formatMessage(messages.favourite)}
|
||||||
|
icon={require('@tabler/icons/icons/heart.svg')}
|
||||||
|
color='accent'
|
||||||
|
filled
|
||||||
|
onClick={this.handleFavouriteClick}
|
||||||
|
active={Boolean(meEmojiReact)}
|
||||||
|
count={favouriteCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{shareButton}
|
{canShare && (
|
||||||
|
<StatusActionButton
|
||||||
|
title={intl.formatMessage(messages.share)}
|
||||||
|
icon={require('@tabler/icons/icons/upload.svg')}
|
||||||
|
onClick={this.handleShareClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className='flex items-center space-x-0.5 p-1 text-gray-400 hover:text-gray-600 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'>
|
<DropdownMenuContainer items={menu} status={status}>
|
||||||
<DropdownMenuContainer items={menu} title={intl.formatMessage(messages.more)} status={status} src={require('@tabler/icons/icons/dots.svg')} direction='right' />
|
<StatusActionButton
|
||||||
</div>
|
title={intl.formatMessage(messages.more)}
|
||||||
|
icon={require('@tabler/icons/icons/dots.svg')}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
const mapStateToProps = (state: RootState) => {
|
||||||
const me = state.get('me');
|
const { me, instance } = state;
|
||||||
const account = state.getIn(['accounts', me]);
|
const account = state.accounts.get(me);
|
||||||
const instance = state.get('instance');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
me,
|
me,
|
||||||
isStaff: account ? isStaff(account) : false,
|
isStaff: account ? account.staff : false,
|
||||||
isAdmin: account ? isAdmin(account) : false,
|
isAdmin: account ? account.admin : false,
|
||||||
features: getFeatures(instance),
|
features: getFeatures(instance),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { status }) => ({
|
const mapDispatchToProps = (dispatch: Dispatch, { status }: { status: Status}) => ({
|
||||||
dispatch,
|
dispatch,
|
||||||
onOpenUnauthorizedModal(action) {
|
onOpenUnauthorizedModal(action: AnyAction) {
|
||||||
dispatch(openModal('UNAUTHORIZED', {
|
dispatch(openModal('UNAUTHORIZED', {
|
||||||
action,
|
action,
|
||||||
ap_id: status.get('url'),
|
ap_id: status.url,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
onOpenReblogsModal(username, statusId) {
|
onOpenReblogsModal(username: string, statusId: string) {
|
||||||
dispatch(openModal('REBLOGS', {
|
dispatch(openModal('REBLOGS', {
|
||||||
username,
|
username,
|
||||||
statusId,
|
statusId,
|
||||||
|
@ -734,6 +718,9 @@ const mapDispatchToProps = (dispatch, { status }) => ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
export default withRouter(injectIntl(
|
export default withRouter(injectIntl(
|
||||||
|
// @ts-ignore
|
||||||
connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true },
|
connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true },
|
||||||
|
// @ts-ignore
|
||||||
)(StatusActionBar)));
|
)(StatusActionBar)));
|
|
@ -57,7 +57,7 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* (account && isStaff(account)) && (
|
{/* (account && account.staff && (
|
||||||
<ThumbNavigationLink
|
<ThumbNavigationLink
|
||||||
src={require('@tabler/icons/icons/dashboard.svg')}
|
src={require('@tabler/icons/icons/dashboard.svg')}
|
||||||
text={<FormattedMessage id='navigation.dashboard' defaultMessage='Dashboard' />}
|
text={<FormattedMessage id='navigation.dashboard' defaultMessage='Dashboard' />}
|
||||||
|
|
|
@ -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;
|
|
@ -4,15 +4,10 @@ import InlineSVG from 'react-inlinesvg';
|
||||||
|
|
||||||
import Text from '../text/text';
|
import Text from '../text/text';
|
||||||
|
|
||||||
interface IIconButton {
|
interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
alt?: string,
|
|
||||||
className?: string,
|
|
||||||
iconClassName?: string,
|
iconClassName?: string,
|
||||||
disabled?: boolean,
|
|
||||||
src: string,
|
src: string,
|
||||||
onClick?: () => void,
|
|
||||||
text?: string,
|
text?: string,
|
||||||
title?: string,
|
|
||||||
transparent?: boolean
|
transparent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,8 @@ export { default as Avatar } from './avatar/avatar';
|
||||||
export { default as Button } from './button/button';
|
export { default as Button } from './button/button';
|
||||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||||
export { default as Column } from './column/column';
|
export { default as Column } from './column/column';
|
||||||
|
export { default as Emoji } from './emoji/emoji';
|
||||||
|
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
|
||||||
export { default as Form } from './form/form';
|
export { default as Form } from './form/form';
|
||||||
export { default as FormActions } from './form-actions/form-actions';
|
export { default as FormActions } from './form-actions/form-actions';
|
||||||
export { default as FormGroup } from './form-group/form-group';
|
export { default as FormGroup } from './form-group/form-group';
|
||||||
|
|
|
@ -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);
|
|
@ -18,12 +18,8 @@ import StillImage from 'soapbox/components/still_image';
|
||||||
import { HStack, IconButton, Menu, MenuButton, MenuItem, MenuList, MenuLink, MenuDivider } from 'soapbox/components/ui';
|
import { HStack, IconButton, Menu, MenuButton, MenuItem, MenuList, MenuLink, MenuDivider } from 'soapbox/components/ui';
|
||||||
import ActionButton from 'soapbox/features/ui/components/action_button';
|
import ActionButton from 'soapbox/features/ui/components/action_button';
|
||||||
import {
|
import {
|
||||||
isStaff,
|
|
||||||
isAdmin,
|
|
||||||
isModerator,
|
|
||||||
isLocal,
|
isLocal,
|
||||||
isRemote,
|
isRemote,
|
||||||
getDomain,
|
|
||||||
} from 'soapbox/utils/accounts';
|
} from 'soapbox/utils/accounts';
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
|
|
||||||
|
@ -322,7 +318,7 @@ class Header extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRemote(account)) {
|
if (isRemote(account)) {
|
||||||
const domain = getDomain(account);
|
const domain = account.fqn.split('@')[1];
|
||||||
|
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
|
@ -341,10 +337,10 @@ class Header extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isStaff(meAccount)) {
|
if (meAccount.staff) {
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
if (isAdmin(meAccount)) {
|
if (meAccount.admin) {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.admin_account, { name: account.get('username') }),
|
text: intl.formatMessage(messages.admin_account, { name: account.get('username') }),
|
||||||
to: `/pleroma/admin/#/users/${account.id}/`,
|
to: `/pleroma/admin/#/users/${account.id}/`,
|
||||||
|
@ -353,8 +349,8 @@ class Header extends ImmutablePureComponent {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.get('id') !== me && isLocal(account) && isAdmin(meAccount)) {
|
if (account.id !== me && isLocal(account) && meAccount.admin) {
|
||||||
if (isAdmin(account)) {
|
if (account.admin) {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.demoteToModerator, { name: account.get('username') }),
|
text: intl.formatMessage(messages.demoteToModerator, { name: account.get('username') }),
|
||||||
action: this.props.onPromoteToModerator,
|
action: this.props.onPromoteToModerator,
|
||||||
|
@ -365,7 +361,7 @@ class Header extends ImmutablePureComponent {
|
||||||
action: this.props.onDemoteToUser,
|
action: this.props.onDemoteToUser,
|
||||||
icon: require('@tabler/icons/icons/arrow-down-circle.svg'),
|
icon: require('@tabler/icons/icons/arrow-down-circle.svg'),
|
||||||
});
|
});
|
||||||
} else if (isModerator(account)) {
|
} else if (account.moderator) {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.promoteToAdmin, { name: account.get('username') }),
|
text: intl.formatMessage(messages.promoteToAdmin, { name: account.get('username') }),
|
||||||
action: this.props.onPromoteToAdmin,
|
action: this.props.onPromoteToAdmin,
|
||||||
|
@ -404,7 +400,7 @@ class Header extends ImmutablePureComponent {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (features.suggestionsV2 && isAdmin(meAccount)) {
|
if (features.suggestionsV2 && meAccount.admin) {
|
||||||
if (account.getIn(['pleroma', 'is_suggested'])) {
|
if (account.getIn(['pleroma', 'is_suggested'])) {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.unsuggestUser, { name: account.get('username') }),
|
text: intl.formatMessage(messages.unsuggestUser, { name: account.get('username') }),
|
||||||
|
|
|
@ -37,7 +37,6 @@ import { initReport } from 'soapbox/actions/reports';
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
import { isAdmin } from 'soapbox/utils/accounts';
|
|
||||||
|
|
||||||
import Header from '../components/header';
|
import Header from '../components/header';
|
||||||
|
|
||||||
|
@ -216,7 +215,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
onPromoteToModerator(account) {
|
onPromoteToModerator(account) {
|
||||||
const messageType = isAdmin(account) ? messages.demotedToModerator : messages.promotedToModerator;
|
const messageType = account.admin ? messages.demotedToModerator : messages.promotedToModerator;
|
||||||
const message = intl.formatMessage(messageType, { acct: account.get('acct') });
|
const message = intl.formatMessage(messageType, { acct: account.get('acct') });
|
||||||
|
|
||||||
dispatch(promoteToModerator(account.get('id')))
|
dispatch(promoteToModerator(account.get('id')))
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { connect } from 'react-redux';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email_list';
|
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email_list';
|
||||||
import { isAdmin } from 'soapbox/utils/accounts';
|
|
||||||
import sourceCode from 'soapbox/utils/code';
|
import sourceCode from 'soapbox/utils/code';
|
||||||
import { parseVersion } from 'soapbox/utils/features';
|
import { parseVersion } from 'soapbox/utils/features';
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
|
@ -139,7 +138,7 @@ class Dashboard extends ImmutablePureComponent {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin(account) && <RegistrationModePicker />}
|
{account.admin && <RegistrationModePicker />}
|
||||||
<div className='dashwidgets'>
|
<div className='dashwidgets'>
|
||||||
<div className='dashwidget'>
|
<div className='dashwidget'>
|
||||||
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
|
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
|
||||||
|
@ -148,7 +147,7 @@ class Dashboard extends ImmutablePureComponent {
|
||||||
<li>{v.software} <span className='pull-right'>{v.version}</span></li>
|
<li>{v.software} <span className='pull-right'>{v.version}</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{supportsEmailList && isAdmin(account) && <div className='dashwidget'>
|
{supportsEmailList && account.admin && <div className='dashwidget'>
|
||||||
<h4><FormattedMessage id='admin.dashwidgets.email_list_header' defaultMessage='Email list' /></h4>
|
<h4><FormattedMessage id='admin.dashwidgets.email_list_header' defaultMessage='Email list' /></h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href='#' onClick={this.handleSubscribersClick} target='_blank'>subscribers.csv</a></li>
|
<li><a href='#' onClick={this.handleSubscribersClick} target='_blank'>subscribers.csv</a></li>
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { connect } from 'react-redux';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import { isUserTouching } from 'soapbox/is_mobile';
|
import { isUserTouching } from 'soapbox/is_mobile';
|
||||||
import { isStaff, isAdmin } from 'soapbox/utils/accounts';
|
|
||||||
import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
|
import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
||||||
|
@ -65,8 +64,8 @@ const mapStateToProps = state => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
me,
|
me,
|
||||||
isStaff: account ? isStaff(account) : false,
|
isStaff: account ? account.staff : false,
|
||||||
isAdmin: account ? isAdmin(account) : false,
|
isAdmin: account ? account.admin : false,
|
||||||
features: getFeatures(instance),
|
features: getFeatures(instance),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,7 +11,6 @@ import { openModal } from 'soapbox/actions/modals';
|
||||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||||
import InstanceRestrictions from 'soapbox/features/federation_restrictions/components/instance_restrictions';
|
import InstanceRestrictions from 'soapbox/features/federation_restrictions/components/instance_restrictions';
|
||||||
import { makeGetRemoteInstance } from 'soapbox/selectors';
|
import { makeGetRemoteInstance } from 'soapbox/selectors';
|
||||||
import { isAdmin } from 'soapbox/utils/accounts';
|
|
||||||
|
|
||||||
const getRemoteInstance = makeGetRemoteInstance();
|
const getRemoteInstance = makeGetRemoteInstance();
|
||||||
|
|
||||||
|
@ -20,13 +19,13 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, { host }) => {
|
const mapStateToProps = (state, { host }) => {
|
||||||
const me = state.get('me');
|
const { me, instance } = state;
|
||||||
const account = state.getIn(['accounts', me]);
|
const account = state.accounts.get(me);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
instance: state.get('instance'),
|
instance,
|
||||||
remoteInstance: getRemoteInstance(state, host),
|
remoteInstance: getRemoteInstance(state, host),
|
||||||
isAdmin: isAdmin(account),
|
isAdmin: account.admin,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,6 @@ import { fetchOwnAccounts } from 'soapbox/actions/auth';
|
||||||
import { Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
import { Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
import { isStaff } from 'soapbox/utils/accounts';
|
|
||||||
|
|
||||||
import Account from '../../../components/account';
|
import Account from '../../../components/account';
|
||||||
|
|
||||||
|
@ -31,7 +30,7 @@ type IMenuItem = {
|
||||||
action?: (event: React.MouseEvent) => void
|
action?: (event: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAccount: any = makeGetAccount();
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
@ -40,7 +39,7 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||||
const me = useAppSelector((state) => state.me);
|
const me = useAppSelector((state) => state.me);
|
||||||
const currentAccount = useAppSelector((state) => getAccount(state, me));
|
const currentAccount = useAppSelector((state) => getAccount(state, me));
|
||||||
const authUsers = useAppSelector((state) => state.auth.get('users'));
|
const authUsers = useAppSelector((state) => state.auth.get('users'));
|
||||||
const isCurrentAccountStaff = isStaff(currentAccount) || false;
|
const isCurrentAccountStaff = Boolean(currentAccount?.staff);
|
||||||
const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.get('id'))));
|
const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.get('id'))));
|
||||||
|
|
||||||
const handleLogOut = () => {
|
const handleLogOut = () => {
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { initAccountNoteModal } from 'soapbox/actions/account_notes';
|
||||||
import Badge from 'soapbox/components/badge';
|
import Badge from 'soapbox/components/badge';
|
||||||
import { Icon, HStack, Stack, Text } from 'soapbox/components/ui';
|
import { Icon, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
import VerificationBadge from 'soapbox/components/verification_badge';
|
||||||
import { getAcct, isAdmin, isModerator, isLocal } from 'soapbox/utils/accounts';
|
import { isLocal } from 'soapbox/utils/accounts';
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
import { displayFqn } from 'soapbox/utils/state';
|
||||||
|
|
||||||
import ProfileStats from './profile_stats';
|
import ProfileStats from './profile_stats';
|
||||||
|
@ -48,9 +48,9 @@ class ProfileInfoPanel extends ImmutablePureComponent {
|
||||||
getStaffBadge = () => {
|
getStaffBadge = () => {
|
||||||
const { account } = this.props;
|
const { account } = this.props;
|
||||||
|
|
||||||
if (isAdmin(account)) {
|
if (account?.admin) {
|
||||||
return <Badge slug='admin' title='Admin' key='staff' />;
|
return <Badge slug='admin' title='Admin' key='staff' />;
|
||||||
} else if (isModerator(account)) {
|
} else if (account?.moderator) {
|
||||||
return <Badge slug='moderator' title='Moderator' key='staff' />;
|
return <Badge slug='moderator' title='Moderator' key='staff' />;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
|
@ -155,7 +155,7 @@ class ProfileInfoPanel extends ImmutablePureComponent {
|
||||||
|
|
||||||
{verified && <VerificationBadge />}
|
{verified && <VerificationBadge />}
|
||||||
|
|
||||||
{account.get('bot') && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
|
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
|
||||||
|
|
||||||
{badges.length > 0 && (
|
{badges.length > 0 && (
|
||||||
<HStack space={1} alignItems='center'>
|
<HStack space={1} alignItems='center'>
|
||||||
|
@ -166,7 +166,7 @@ class ProfileInfoPanel extends ImmutablePureComponent {
|
||||||
|
|
||||||
<HStack alignItems='center' space={0.5}>
|
<HStack alignItems='center' space={0.5}>
|
||||||
<Text size='sm' theme='muted'>
|
<Text size='sm' theme='muted'>
|
||||||
@{getAcct(account, displayFqn)}
|
@{displayFqn ? account.fqn : account.acct}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{account.get('locked') && (
|
{account.get('locked') && (
|
||||||
|
|
|
@ -26,7 +26,6 @@ import HomePage from 'soapbox/pages/home_page';
|
||||||
import ProfilePage from 'soapbox/pages/profile_page';
|
import ProfilePage from 'soapbox/pages/profile_page';
|
||||||
import RemoteInstancePage from 'soapbox/pages/remote_instance_page';
|
import RemoteInstancePage from 'soapbox/pages/remote_instance_page';
|
||||||
import StatusPage from 'soapbox/pages/status_page';
|
import StatusPage from 'soapbox/pages/status_page';
|
||||||
import { isStaff, isAdmin } from 'soapbox/utils/accounts';
|
|
||||||
import { getAccessToken } from 'soapbox/utils/auth';
|
import { getAccessToken } from 'soapbox/utils/auth';
|
||||||
import { getVapidKey } from 'soapbox/utils/auth';
|
import { getVapidKey } from 'soapbox/utils/auth';
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
|
@ -495,12 +494,12 @@ class UI extends React.PureComponent {
|
||||||
dispatch(fetchChats());
|
dispatch(fetchChats());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isStaff(account)) {
|
if (account.staff) {
|
||||||
dispatch(fetchReports({ state: 'open' }));
|
dispatch(fetchReports({ state: 'open' }));
|
||||||
dispatch(fetchUsers(['local', 'need_approval']));
|
dispatch(fetchUsers(['local', 'need_approval']));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdmin(account)) {
|
if (account.admin) {
|
||||||
dispatch(fetchConfig());
|
dispatch(fetchConfig());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { connect } from 'react-redux';
|
||||||
import { Redirect, Route } from 'react-router-dom';
|
import { Redirect, Route } from 'react-router-dom';
|
||||||
|
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
import { isStaff, isAdmin } from 'soapbox/utils/accounts';
|
|
||||||
|
|
||||||
import BundleColumnError from '../components/bundle_column_error';
|
import BundleColumnError from '../components/bundle_column_error';
|
||||||
import ColumnForbidden from '../components/column_forbidden';
|
import ColumnForbidden from '../components/column_forbidden';
|
||||||
|
@ -111,8 +110,8 @@ class WrappedRoute extends React.Component {
|
||||||
const authorized = [
|
const authorized = [
|
||||||
account || publicRoute,
|
account || publicRoute,
|
||||||
developerOnly ? settings.get('isDeveloper') : true,
|
developerOnly ? settings.get('isDeveloper') : true,
|
||||||
staffOnly ? account && isStaff(account) : true,
|
staffOnly ? account && account.staff : true,
|
||||||
adminOnly ? account && isAdmin(account) : true,
|
adminOnly ? account && account.admin : true,
|
||||||
].every(c => c);
|
].every(c => c);
|
||||||
|
|
||||||
if (!authorized) {
|
if (!authorized) {
|
||||||
|
|
|
@ -168,4 +168,13 @@ describe('normalizeAccount()', () => {
|
||||||
|
|
||||||
expect(result.fqn).toEqual('benis911@mastodon.social');
|
expect(result.fqn).toEqual('benis911@mastodon.social');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes Pleroma staff', () => {
|
||||||
|
const account = require('soapbox/__fixtures__/pleroma-account.json');
|
||||||
|
const result = normalizeAccount(account);
|
||||||
|
|
||||||
|
expect(result.admin).toBe(true);
|
||||||
|
expect(result.staff).toBe(true);
|
||||||
|
expect(result.moderator).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,7 +13,6 @@ import {
|
||||||
|
|
||||||
import emojify from 'soapbox/features/emoji/emoji';
|
import emojify from 'soapbox/features/emoji/emoji';
|
||||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||||
import { acctFull } from 'soapbox/utils/accounts';
|
|
||||||
import { unescapeHTML } from 'soapbox/utils/html';
|
import { unescapeHTML } from 'soapbox/utils/html';
|
||||||
import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers';
|
import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||||
|
|
||||||
|
@ -39,7 +38,7 @@ export const AccountRecord = ImmutableRecord({
|
||||||
last_status_at: new Date(),
|
last_status_at: new Date(),
|
||||||
location: '',
|
location: '',
|
||||||
locked: false,
|
locked: false,
|
||||||
moved: null as EmbeddedEntity<any> | null,
|
moved: null as EmbeddedEntity<any>,
|
||||||
note: '',
|
note: '',
|
||||||
pleroma: ImmutableMap<string, any>(),
|
pleroma: ImmutableMap<string, any>(),
|
||||||
source: ImmutableMap<string, any>(),
|
source: ImmutableMap<string, any>(),
|
||||||
|
@ -51,12 +50,15 @@ export const AccountRecord = ImmutableRecord({
|
||||||
verified: false,
|
verified: false,
|
||||||
|
|
||||||
// Internal fields
|
// Internal fields
|
||||||
|
admin: false,
|
||||||
display_name_html: '',
|
display_name_html: '',
|
||||||
|
moderator: false,
|
||||||
note_emojified: '',
|
note_emojified: '',
|
||||||
note_plain: '',
|
note_plain: '',
|
||||||
patron: ImmutableMap<string, any>(),
|
patron: ImmutableMap<string, any>(),
|
||||||
relationship: ImmutableList<ImmutableMap<string, any>>(),
|
relationship: ImmutableList<ImmutableMap<string, any>>(),
|
||||||
should_refetch: false,
|
should_refetch: false,
|
||||||
|
staff: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// https://docs.joinmastodon.org/entities/field/
|
// https://docs.joinmastodon.org/entities/field/
|
||||||
|
@ -197,8 +199,41 @@ const addInternalFields = (account: ImmutableMap<string, any>) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDomainFromURL = (account: ImmutableMap<string, any>): string => {
|
||||||
|
try {
|
||||||
|
const url = account.get('url');
|
||||||
|
return new URL(url).host;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guessFqn = (account: ImmutableMap<string, any>): string => {
|
||||||
|
const acct = account.get('acct', '');
|
||||||
|
const [user, domain] = acct.split('@');
|
||||||
|
|
||||||
|
if (domain) {
|
||||||
|
return acct;
|
||||||
|
} else {
|
||||||
|
return [user, getDomainFromURL(account)].join('@');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeFqn = (account: ImmutableMap<string, any>) => {
|
const normalizeFqn = (account: ImmutableMap<string, any>) => {
|
||||||
return account.set('fqn', acctFull(account));
|
const fqn = account.get('fqn') || guessFqn(account);
|
||||||
|
return account.set('fqn', fqn);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStaffFields = (account: ImmutableMap<string, any>) => {
|
||||||
|
const admin = account.getIn(['pleroma', 'is_admin']) === true;
|
||||||
|
const moderator = account.getIn(['pleroma', 'is_moderator']) === true;
|
||||||
|
const staff = admin || moderator;
|
||||||
|
|
||||||
|
return account.merge({
|
||||||
|
admin,
|
||||||
|
moderator,
|
||||||
|
staff,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizeAccount = (account: Record<string, any>) => {
|
export const normalizeAccount = (account: Record<string, any>) => {
|
||||||
|
@ -213,6 +248,7 @@ export const normalizeAccount = (account: Record<string, any>) => {
|
||||||
normalizeBirthday(account);
|
normalizeBirthday(account);
|
||||||
normalizeLocation(account);
|
normalizeLocation(account);
|
||||||
normalizeFqn(account);
|
normalizeFqn(account);
|
||||||
|
addStaffFields(account);
|
||||||
fixUsername(account);
|
fixUsername(account);
|
||||||
fixDisplayName(account);
|
fixDisplayName(account);
|
||||||
addInternalFields(account);
|
addInternalFields(account);
|
||||||
|
|
|
@ -26,8 +26,8 @@ export const AttachmentRecord = ImmutableRecord({
|
||||||
|
|
||||||
// Internal fields
|
// Internal fields
|
||||||
// TODO: Remove these? They're set in selectors/index.js
|
// TODO: Remove these? They're set in selectors/index.js
|
||||||
account: null,
|
account: null as any,
|
||||||
status: null,
|
status: null as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure attachments have required fields
|
// Ensure attachments have required fields
|
||||||
|
|
|
@ -9,16 +9,30 @@ import {
|
||||||
fromJS,
|
fromJS,
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
|
|
||||||
|
import type { Account, Status, EmbeddedEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
type NotificationType = ''
|
||||||
|
| 'follow'
|
||||||
|
| 'follow_request'
|
||||||
|
| 'mention'
|
||||||
|
| 'reblog'
|
||||||
|
| 'favourite'
|
||||||
|
| 'poll'
|
||||||
|
| 'status'
|
||||||
|
| 'move'
|
||||||
|
| 'pleroma:chat_mention'
|
||||||
|
| 'pleroma:emoji_reaction';
|
||||||
|
|
||||||
// https://docs.joinmastodon.org/entities/notification/
|
// https://docs.joinmastodon.org/entities/notification/
|
||||||
export const NotificationRecord = ImmutableRecord({
|
export const NotificationRecord = ImmutableRecord({
|
||||||
account: null,
|
account: null as EmbeddedEntity<Account>,
|
||||||
chat_message: null, // pleroma:chat_mention
|
chat_message: null as ImmutableMap<string, any> | string | null, // pleroma:chat_mention
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
emoji: null, // pleroma:emoji_reaction
|
emoji: null as string | null, // pleroma:emoji_reaction
|
||||||
id: '',
|
id: '',
|
||||||
status: null,
|
status: null as EmbeddedEntity<Status>,
|
||||||
target: null, // move
|
target: null as EmbeddedEntity<Account>, // move
|
||||||
type: '',
|
type: '' as NotificationType,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const normalizeNotification = (notification: Record<string, any>) => {
|
export const normalizeNotification = (notification: Record<string, any>) => {
|
||||||
|
|
|
@ -16,21 +16,23 @@ import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||||
import { normalizeMention } from 'soapbox/normalizers/mention';
|
import { normalizeMention } from 'soapbox/normalizers/mention';
|
||||||
import { normalizePoll } from 'soapbox/normalizers/poll';
|
import { normalizePoll } from 'soapbox/normalizers/poll';
|
||||||
|
|
||||||
|
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||||
import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct';
|
type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct';
|
||||||
|
|
||||||
// https://docs.joinmastodon.org/entities/status/
|
// https://docs.joinmastodon.org/entities/status/
|
||||||
export const StatusRecord = ImmutableRecord({
|
export const StatusRecord = ImmutableRecord({
|
||||||
account: null as EmbeddedEntity<Account>,
|
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
||||||
application: null as ImmutableMap<string, any> | null,
|
application: null as ImmutableMap<string, any> | null,
|
||||||
bookmarked: false,
|
bookmarked: false,
|
||||||
card: null as EmbeddedEntity<Card>,
|
card: null as Card | null,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
emojis: ImmutableList<Emoji>(),
|
emojis: ImmutableList<Emoji>(),
|
||||||
favourited: false,
|
favourited: false,
|
||||||
favourites_count: 0,
|
favourites_count: 0,
|
||||||
|
group: null as EmbeddedEntity<any>,
|
||||||
in_reply_to_account_id: null as string | null,
|
in_reply_to_account_id: null as string | null,
|
||||||
in_reply_to_id: null as string | null,
|
in_reply_to_id: null as string | null,
|
||||||
id: '',
|
id: '',
|
||||||
|
@ -55,6 +57,7 @@ export const StatusRecord = ImmutableRecord({
|
||||||
|
|
||||||
// Internal fields
|
// Internal fields
|
||||||
contentHtml: '',
|
contentHtml: '',
|
||||||
|
expectsCard: false,
|
||||||
filtered: false,
|
filtered: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
search_index: '',
|
search_index: '',
|
||||||
|
|
|
@ -11,19 +11,18 @@ import {
|
||||||
InstanceInfoPanel,
|
InstanceInfoPanel,
|
||||||
InstanceModerationPanel,
|
InstanceModerationPanel,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
import { isAdmin } from 'soapbox/utils/accounts';
|
|
||||||
import { federationRestrictionsDisclosed } from 'soapbox/utils/state';
|
import { federationRestrictionsDisclosed } from 'soapbox/utils/state';
|
||||||
|
|
||||||
import { Layout } from '../components/ui';
|
import { Layout } from '../components/ui';
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
const mapStateToProps = state => {
|
||||||
const me = state.get('me');
|
const me = state.me;
|
||||||
const account = state.getIn(['accounts', me]);
|
const account = state.accounts.get(me);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
me,
|
me,
|
||||||
disclosed: federationRestrictionsDisclosed(state),
|
disclosed: federationRestrictionsDisclosed(state),
|
||||||
isAdmin: isAdmin(account),
|
isAdmin: Boolean(account?.admin),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -45,14 +45,18 @@ type AccountMap = ImmutableMap<string, any>;
|
||||||
type APIEntity = Record<string, any>;
|
type APIEntity = Record<string, any>;
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
type State = ImmutableMap<string | number, AccountRecord>;
|
export interface ReducerAccount extends AccountRecord {
|
||||||
|
moved: string | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = ImmutableMap<string | number, ReducerAccount>;
|
||||||
|
|
||||||
const initialState: State = ImmutableMap();
|
const initialState: State = ImmutableMap();
|
||||||
|
|
||||||
const minifyAccount = (account: AccountRecord): AccountRecord => {
|
const minifyAccount = (account: AccountRecord): ReducerAccount => {
|
||||||
return account.mergeWith((o, n) => n || o, {
|
return account.mergeWith((o, n) => n || o, {
|
||||||
moved: normalizeId(account.getIn(['moved', 'id'])),
|
moved: normalizeId(account.getIn(['moved', 'id'])),
|
||||||
});
|
}) as ReducerAccount;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fixAccount = (state: State, account: APIEntity) => {
|
const fixAccount = (state: State, account: APIEntity) => {
|
||||||
|
@ -194,9 +198,9 @@ const importAdminUser = (state: State, adminUser: ImmutableMap<string, any>): St
|
||||||
const account = state.get(id);
|
const account = state.get(id);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return state.set(id, buildAccount(adminUser));
|
return state.set(id, minifyAccount(buildAccount(adminUser)));
|
||||||
} else {
|
} else {
|
||||||
return state.set(id, mergeAdminUser(account, adminUser));
|
return state.set(id, minifyAccount(mergeAdminUser(account, adminUser)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -223,7 +227,7 @@ export default function accounts(state: State = initialState, action: AnyAction)
|
||||||
case ACCOUNTS_IMPORT:
|
case ACCOUNTS_IMPORT:
|
||||||
return normalizeAccounts(state, action.accounts);
|
return normalizeAccounts(state, action.accounts);
|
||||||
case ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP:
|
case ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP:
|
||||||
return state.set(-1, normalizeAccount({ username: action.username }));
|
return fixAccount(state, { id: -1, username: action.username });
|
||||||
case CHATS_FETCH_SUCCESS:
|
case CHATS_FETCH_SUCCESS:
|
||||||
case CHATS_EXPAND_SUCCESS:
|
case CHATS_EXPAND_SUCCESS:
|
||||||
return importAccountsFromChats(state, action.chats);
|
return importAccountsFromChats(state, action.chats);
|
||||||
|
|
|
@ -21,33 +21,54 @@ import {
|
||||||
ADMIN_USERS_APPROVE_SUCCESS,
|
ADMIN_USERS_APPROVE_SUCCESS,
|
||||||
} from '../actions/admin';
|
} from '../actions/admin';
|
||||||
|
|
||||||
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { Config } from 'soapbox/utils/config_db';
|
||||||
|
|
||||||
const ReducerRecord = ImmutableRecord({
|
const ReducerRecord = ImmutableRecord({
|
||||||
reports: ImmutableMap(),
|
reports: ImmutableMap<string, any>(),
|
||||||
openReports: ImmutableOrderedSet(),
|
openReports: ImmutableOrderedSet<string>(),
|
||||||
users: ImmutableMap(),
|
users: ImmutableMap<string, any>(),
|
||||||
latestUsers: ImmutableOrderedSet(),
|
latestUsers: ImmutableOrderedSet<string>(),
|
||||||
awaitingApproval: ImmutableOrderedSet(),
|
awaitingApproval: ImmutableOrderedSet<string>(),
|
||||||
configs: ImmutableList(),
|
configs: ImmutableList<Config>(),
|
||||||
needsReboot: false,
|
needsReboot: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const FILTER_UNAPPROVED = ['local', 'need_approval'];
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
const FILTER_LATEST = ['local', 'active'];
|
|
||||||
|
|
||||||
const filtersMatch = (f1, f2) => is(ImmutableSet(f1), ImmutableSet(f2));
|
// Umm... based?
|
||||||
const toIds = items => items.map(item => item.id);
|
// https://itnext.io/typescript-extract-unpack-a-type-from-a-generic-baca7af14e51
|
||||||
|
type InnerRecord<R> = R extends ImmutableRecord<infer TProps> ? TProps : never;
|
||||||
|
|
||||||
const mergeSet = (state, key, users) => {
|
type InnerState = InnerRecord<State>;
|
||||||
|
|
||||||
|
// Lol https://javascript.plainenglish.io/typescript-essentials-conditionally-filter-types-488705bfbf56
|
||||||
|
type FilterConditionally<Source, Condition> = Pick<Source, {[K in keyof Source]: Source[K] extends Condition ? K : never}[keyof Source]>;
|
||||||
|
|
||||||
|
type SetKeys = keyof FilterConditionally<InnerState, ImmutableOrderedSet<string>>;
|
||||||
|
|
||||||
|
type APIReport = { id: string, state: string, statuses: any[] };
|
||||||
|
type APIUser = { id: string, email: string, nickname: string, registration_reason: string };
|
||||||
|
|
||||||
|
type Filter = 'local' | 'need_approval' | 'active';
|
||||||
|
|
||||||
|
const FILTER_UNAPPROVED: Filter[] = ['local', 'need_approval'];
|
||||||
|
const FILTER_LATEST: Filter[] = ['local', 'active'];
|
||||||
|
|
||||||
|
const filtersMatch = (f1: string[], f2: string[]) => is(ImmutableSet(f1), ImmutableSet(f2));
|
||||||
|
const toIds = (items: any[]) => items.map(item => item.id);
|
||||||
|
|
||||||
|
const mergeSet = (state: State, key: SetKeys, users: APIUser[]): State => {
|
||||||
const newIds = toIds(users);
|
const newIds = toIds(users);
|
||||||
return state.update(key, ImmutableOrderedSet(), ids => ids.union(newIds));
|
return state.update(key, (ids: ImmutableOrderedSet<string>) => ids.union(newIds));
|
||||||
};
|
};
|
||||||
|
|
||||||
const replaceSet = (state, key, users) => {
|
const replaceSet = (state: State, key: SetKeys, users: APIUser[]): State => {
|
||||||
const newIds = toIds(users);
|
const newIds = toIds(users);
|
||||||
return state.set(key, ImmutableOrderedSet(newIds));
|
return state.set(key, ImmutableOrderedSet(newIds));
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeImportUnapproved = (state, users, filters) => {
|
const maybeImportUnapproved = (state: State, users: APIUser[], filters: Filter[]): State => {
|
||||||
if (filtersMatch(FILTER_UNAPPROVED, filters)) {
|
if (filtersMatch(FILTER_UNAPPROVED, filters)) {
|
||||||
return mergeSet(state, 'awaitingApproval', users);
|
return mergeSet(state, 'awaitingApproval', users);
|
||||||
} else {
|
} else {
|
||||||
|
@ -55,7 +76,7 @@ const maybeImportUnapproved = (state, users, filters) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeImportLatest = (state, users, filters, page) => {
|
const maybeImportLatest = (state: State, users: APIUser[], filters: Filter[], page: number): State => {
|
||||||
if (page === 1 && filtersMatch(FILTER_LATEST, filters)) {
|
if (page === 1 && filtersMatch(FILTER_LATEST, filters)) {
|
||||||
return replaceSet(state, 'latestUsers', users);
|
return replaceSet(state, 'latestUsers', users);
|
||||||
} else {
|
} else {
|
||||||
|
@ -63,14 +84,14 @@ const maybeImportLatest = (state, users, filters, page) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const importUser = (state, user) => (
|
const importUser = (state: State, user: APIUser): State => (
|
||||||
state.setIn(['users', user.id], ImmutableMap({
|
state.setIn(['users', user.id], ImmutableMap({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
registration_reason: user.registration_reason,
|
registration_reason: user.registration_reason,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
function importUsers(state, users, filters, page) {
|
function importUsers(state: State, users: APIUser[], filters: Filter[], page: number): State {
|
||||||
return state.withMutations(state => {
|
return state.withMutations(state => {
|
||||||
maybeImportUnapproved(state, users, filters);
|
maybeImportUnapproved(state, users, filters);
|
||||||
maybeImportLatest(state, users, filters, page);
|
maybeImportLatest(state, users, filters, page);
|
||||||
|
@ -81,7 +102,7 @@ function importUsers(state, users, filters, page) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteUsers(state, accountIds) {
|
function deleteUsers(state: State, accountIds: string[]): State {
|
||||||
return state.withMutations(state => {
|
return state.withMutations(state => {
|
||||||
accountIds.forEach(id => {
|
accountIds.forEach(id => {
|
||||||
state.update('awaitingApproval', orderedSet => orderedSet.delete(id));
|
state.update('awaitingApproval', orderedSet => orderedSet.delete(id));
|
||||||
|
@ -90,7 +111,7 @@ function deleteUsers(state, accountIds) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function approveUsers(state, users) {
|
function approveUsers(state: State, users: APIUser[]): State {
|
||||||
return state.withMutations(state => {
|
return state.withMutations(state => {
|
||||||
users.forEach(user => {
|
users.forEach(user => {
|
||||||
state.update('awaitingApproval', orderedSet => orderedSet.delete(user.nickname));
|
state.update('awaitingApproval', orderedSet => orderedSet.delete(user.nickname));
|
||||||
|
@ -99,7 +120,7 @@ function approveUsers(state, users) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function importReports(state, reports) {
|
function importReports(state: State, reports: APIReport[]): State {
|
||||||
return state.withMutations(state => {
|
return state.withMutations(state => {
|
||||||
reports.forEach(report => {
|
reports.forEach(report => {
|
||||||
report.statuses = report.statuses.map(status => status.id);
|
report.statuses = report.statuses.map(status => status.id);
|
||||||
|
@ -111,7 +132,7 @@ function importReports(state, reports) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReportDiffs(state, reports) {
|
function handleReportDiffs(state: State, reports: APIReport[]) {
|
||||||
// Note: the reports here aren't full report objects
|
// Note: the reports here aren't full report objects
|
||||||
// hence the need for a new function.
|
// hence the need for a new function.
|
||||||
return state.withMutations(state => {
|
return state.withMutations(state => {
|
||||||
|
@ -127,11 +148,21 @@ function handleReportDiffs(state, reports) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function admin(state = ReducerRecord(), action) {
|
const normalizeConfig = (config: any): Config => ImmutableMap(fromJS(config));
|
||||||
|
|
||||||
|
const normalizeConfigs = (configs: any): ImmutableList<Config> => {
|
||||||
|
return ImmutableList(fromJS(configs)).map(normalizeConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importConfigs = (state: State, configs: any): State => {
|
||||||
|
return state.set('configs', normalizeConfigs(configs));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function admin(state: State = ReducerRecord(), action: AnyAction): State {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case ADMIN_CONFIG_FETCH_SUCCESS:
|
case ADMIN_CONFIG_FETCH_SUCCESS:
|
||||||
case ADMIN_CONFIG_UPDATE_SUCCESS:
|
case ADMIN_CONFIG_UPDATE_SUCCESS:
|
||||||
return state.set('configs', fromJS(action.configs));
|
return importConfigs(state, action.configs);
|
||||||
case ADMIN_REPORTS_FETCH_SUCCESS:
|
case ADMIN_REPORTS_FETCH_SUCCESS:
|
||||||
return importReports(state, action.reports);
|
return importReports(state, action.reports);
|
||||||
case ADMIN_REPORTS_PATCH_REQUEST:
|
case ADMIN_REPORTS_PATCH_REQUEST:
|
|
@ -15,24 +15,31 @@ const AlertRecord = ImmutableRecord({
|
||||||
actionLink: '',
|
actionLink: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialState = ImmutableList();
|
import type { AnyAction } from 'redux';
|
||||||
|
|
||||||
|
type PlainAlert = Record<string, any>;
|
||||||
|
type Alert = ReturnType<typeof AlertRecord>;
|
||||||
|
type State = ImmutableList<Alert>;
|
||||||
|
|
||||||
// Get next key based on last alert
|
// Get next key based on last alert
|
||||||
const getNextKey = state => state.size > 0 ? state.last().get('key') + 1 : 0;
|
const getNextKey = (state: State): number => {
|
||||||
|
const last = state.last();
|
||||||
|
return last ? last.key + 1 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
// Import the alert
|
// Import the alert
|
||||||
const importAlert = (state, alert) => {
|
const importAlert = (state: State, alert: PlainAlert): State => {
|
||||||
const key = getNextKey(state);
|
const key = getNextKey(state);
|
||||||
const record = AlertRecord({ ...alert, key });
|
const record = AlertRecord({ ...alert, key });
|
||||||
return state.push(record);
|
return state.push(record);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete an alert by its key
|
// Delete an alert by its key
|
||||||
const deleteAlert = (state, alert) => {
|
const deleteAlert = (state: State, alert: PlainAlert): State => {
|
||||||
return state.filterNot(item => item.key === alert.key);
|
return state.filterNot(item => item.key === alert.key);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function alerts(state = initialState, action) {
|
export default function alerts(state: State = ImmutableList<Alert>(), action: AnyAction): State {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case ALERT_SHOW:
|
case ALERT_SHOW:
|
||||||
return importAlert(state, action);
|
return importAlert(state, action);
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -39,15 +39,22 @@ type StatusRecord = ReturnType<typeof normalizeStatus>;
|
||||||
type APIEntity = Record<string, any>;
|
type APIEntity = Record<string, any>;
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
type State = ImmutableMap<string, StatusRecord>;
|
type State = ImmutableMap<string, ReducerStatus>;
|
||||||
|
|
||||||
const minifyStatus = (status: StatusRecord): StatusRecord => {
|
export interface ReducerStatus extends StatusRecord {
|
||||||
|
account: string | null,
|
||||||
|
reblog: string | null,
|
||||||
|
poll: string | null,
|
||||||
|
quote: string | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const minifyStatus = (status: StatusRecord): ReducerStatus => {
|
||||||
return status.mergeWith((o, n) => n || o, {
|
return status.mergeWith((o, n) => n || o, {
|
||||||
account: normalizeId(status.getIn(['account', 'id'])),
|
account: normalizeId(status.getIn(['account', 'id'])),
|
||||||
reblog: normalizeId(status.getIn(['reblog', 'id'])),
|
reblog: normalizeId(status.getIn(['reblog', 'id'])),
|
||||||
poll: normalizeId(status.getIn(['poll', 'id'])),
|
poll: normalizeId(status.getIn(['poll', 'id'])),
|
||||||
quote: normalizeId(status.getIn(['quote', 'id'])),
|
quote: normalizeId(status.getIn(['quote', 'id'])),
|
||||||
});
|
}) as ReducerStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gets titles of poll options from status
|
// Gets titles of poll options from status
|
||||||
|
@ -121,14 +128,14 @@ const fixQuote = (status: StatusRecord, oldStatus?: StatusRecord): StatusRecord
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean): StatusRecord => {
|
const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean): ReducerStatus => {
|
||||||
const oldStatus = state.get(status.id);
|
const oldStatus = state.get(status.id);
|
||||||
|
|
||||||
return normalizeStatus(status).withMutations(status => {
|
return normalizeStatus(status).withMutations(status => {
|
||||||
fixQuote(status, oldStatus);
|
fixQuote(status, oldStatus);
|
||||||
calculateStatus(status, oldStatus, expandSpoilers);
|
calculateStatus(status, oldStatus, expandSpoilers);
|
||||||
minifyStatus(status);
|
minifyStatus(status);
|
||||||
});
|
}) as ReducerStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
const importStatus = (state: State, status: APIEntity, expandSpoilers: boolean): State =>
|
const importStatus = (state: State, status: APIEntity, expandSpoilers: boolean): State =>
|
||||||
|
@ -204,13 +211,13 @@ export default function statuses(state = initialState, action: AnyAction): State
|
||||||
return state
|
return state
|
||||||
.updateIn(
|
.updateIn(
|
||||||
[action.status.get('id'), 'pleroma', 'emoji_reactions'],
|
[action.status.get('id'), 'pleroma', 'emoji_reactions'],
|
||||||
emojiReacts => simulateEmojiReact(emojiReacts, action.emoji),
|
emojiReacts => simulateEmojiReact(emojiReacts as any, action.emoji),
|
||||||
);
|
);
|
||||||
case UNEMOJI_REACT_REQUEST:
|
case UNEMOJI_REACT_REQUEST:
|
||||||
return state
|
return state
|
||||||
.updateIn(
|
.updateIn(
|
||||||
[action.status.get('id'), 'pleroma', 'emoji_reactions'],
|
[action.status.get('id'), 'pleroma', 'emoji_reactions'],
|
||||||
emojiReacts => simulateUnEmojiReact(emojiReacts, action.emoji),
|
emojiReacts => simulateUnEmojiReact(emojiReacts as any, action.emoji),
|
||||||
);
|
);
|
||||||
case FAVOURITE_FAIL:
|
case FAVOURITE_FAIL:
|
||||||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false);
|
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false);
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -14,7 +14,6 @@ import {
|
||||||
|
|
||||||
import type { Record as ImmutableRecord } from 'immutable';
|
import type { Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
type Account = ReturnType<typeof AccountRecord>;
|
|
||||||
type Attachment = ReturnType<typeof AttachmentRecord>;
|
type Attachment = ReturnType<typeof AttachmentRecord>;
|
||||||
type Card = ReturnType<typeof CardRecord>;
|
type Card = ReturnType<typeof CardRecord>;
|
||||||
type Emoji = ReturnType<typeof EmojiRecord>;
|
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||||
|
@ -24,7 +23,18 @@ type Mention = ReturnType<typeof MentionRecord>;
|
||||||
type Notification = ReturnType<typeof NotificationRecord>;
|
type Notification = ReturnType<typeof NotificationRecord>;
|
||||||
type Poll = ReturnType<typeof PollRecord>;
|
type Poll = ReturnType<typeof PollRecord>;
|
||||||
type PollOption = ReturnType<typeof PollOptionRecord>;
|
type PollOption = ReturnType<typeof PollOptionRecord>;
|
||||||
type Status = ReturnType<typeof StatusRecord>;
|
|
||||||
|
interface Account extends ReturnType<typeof AccountRecord> {
|
||||||
|
// HACK: we can't do a circular reference in the Record definition itself,
|
||||||
|
// so do it here.
|
||||||
|
moved: EmbeddedEntity<Account>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Status extends ReturnType<typeof StatusRecord> {
|
||||||
|
// HACK: same as above
|
||||||
|
quote: EmbeddedEntity<Status>;
|
||||||
|
reblog: EmbeddedEntity<Status>;
|
||||||
|
}
|
||||||
|
|
||||||
// Utility types
|
// Utility types
|
||||||
type APIEntity = Record<string, any>;
|
type APIEntity = Record<string, any>;
|
||||||
|
|
|
@ -5,12 +5,15 @@ import {
|
||||||
SoapboxConfigRecord,
|
SoapboxConfigRecord,
|
||||||
} from 'soapbox/normalizers/soapbox/soapbox_config';
|
} from 'soapbox/normalizers/soapbox/soapbox_config';
|
||||||
|
|
||||||
|
type Me = string | null | false | undefined;
|
||||||
|
|
||||||
type PromoPanelItem = ReturnType<typeof PromoPanelItemRecord>;
|
type PromoPanelItem = ReturnType<typeof PromoPanelItemRecord>;
|
||||||
type FooterItem = ReturnType<typeof FooterItemRecord>;
|
type FooterItem = ReturnType<typeof FooterItemRecord>;
|
||||||
type CryptoAddress = ReturnType<typeof CryptoAddressRecord>;
|
type CryptoAddress = ReturnType<typeof CryptoAddressRecord>;
|
||||||
type SoapboxConfig = ReturnType<typeof SoapboxConfigRecord>;
|
type SoapboxConfig = ReturnType<typeof SoapboxConfigRecord>;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
Me,
|
||||||
PromoPanelItem,
|
PromoPanelItem,
|
||||||
FooterItem,
|
FooterItem,
|
||||||
CryptoAddress,
|
CryptoAddress,
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
import { fromJS } from 'immutable';
|
import { AccountRecord } from 'soapbox/normalizers';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getDomain,
|
getDomain,
|
||||||
acctFull,
|
|
||||||
isStaff,
|
|
||||||
isAdmin,
|
|
||||||
isModerator,
|
|
||||||
} from '../accounts';
|
} from '../accounts';
|
||||||
|
|
||||||
describe('getDomain', () => {
|
describe('getDomain', () => {
|
||||||
const account = fromJS({
|
const account = AccountRecord({
|
||||||
acct: 'alice',
|
acct: 'alice',
|
||||||
url: 'https://party.com/users/alice',
|
url: 'https://party.com/users/alice',
|
||||||
});
|
});
|
||||||
|
@ -17,101 +13,3 @@ describe('getDomain', () => {
|
||||||
expect(getDomain(account)).toEqual('party.com');
|
expect(getDomain(account)).toEqual('party.com');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('acctFull', () => {
|
|
||||||
describe('with a local user', () => {
|
|
||||||
const account = fromJS({
|
|
||||||
acct: 'alice',
|
|
||||||
url: 'https://party.com/users/alice',
|
|
||||||
});
|
|
||||||
it('returns the full acct', () => {
|
|
||||||
expect(acctFull(account)).toEqual('alice@party.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with a remote user', () => {
|
|
||||||
const account = fromJS({
|
|
||||||
acct: 'bob@pool.com',
|
|
||||||
url: 'https://pool.com/users/bob',
|
|
||||||
});
|
|
||||||
it('returns the full acct', () => {
|
|
||||||
expect(acctFull(account)).toEqual('bob@pool.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isStaff', () => {
|
|
||||||
describe('with empty user', () => {
|
|
||||||
const account = fromJS({});
|
|
||||||
it('returns false', () => {
|
|
||||||
expect(isStaff(account)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with Pleroma admin', () => {
|
|
||||||
const admin = fromJS({ pleroma: { is_admin: true } });
|
|
||||||
it('returns true', () => {
|
|
||||||
expect(isStaff(admin)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with Pleroma moderator', () => {
|
|
||||||
const mod = fromJS({ pleroma: { is_moderator: true } });
|
|
||||||
it('returns true', () => {
|
|
||||||
expect(isStaff(mod)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with undefined', () => {
|
|
||||||
const account = undefined;
|
|
||||||
it('returns false', () => {
|
|
||||||
expect(isStaff(account)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isAdmin', () => {
|
|
||||||
describe('with empty user', () => {
|
|
||||||
const account = fromJS({});
|
|
||||||
it('returns false', () => {
|
|
||||||
expect(isAdmin(account)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with Pleroma admin', () => {
|
|
||||||
const admin = fromJS({ pleroma: { is_admin: true } });
|
|
||||||
it('returns true', () => {
|
|
||||||
expect(isAdmin(admin)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with Pleroma moderator', () => {
|
|
||||||
const mod = fromJS({ pleroma: { is_moderator: true } });
|
|
||||||
it('returns false', () => {
|
|
||||||
expect(isAdmin(mod)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isModerator', () => {
|
|
||||||
describe('with empty user', () => {
|
|
||||||
const account = fromJS({});
|
|
||||||
it('returns false', () => {
|
|
||||||
expect(isModerator(account)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with Pleroma admin', () => {
|
|
||||||
const admin = fromJS({ pleroma: { is_admin: true } });
|
|
||||||
it('returns false', () => {
|
|
||||||
expect(isModerator(admin)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with Pleroma moderator', () => {
|
|
||||||
const mod = fromJS({ pleroma: { is_moderator: true } });
|
|
||||||
it('returns true', () => {
|
|
||||||
expect(isModerator(mod)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
@ -11,14 +11,14 @@ import {
|
||||||
simulateUnEmojiReact,
|
simulateUnEmojiReact,
|
||||||
} from '../emoji_reacts';
|
} from '../emoji_reacts';
|
||||||
|
|
||||||
const ALLOWED_EMOJI = [
|
const ALLOWED_EMOJI = fromJS([
|
||||||
'👍',
|
'👍',
|
||||||
'❤',
|
'❤',
|
||||||
'😂',
|
'😂',
|
||||||
'😯',
|
'😯',
|
||||||
'😢',
|
'😢',
|
||||||
'😡',
|
'😡',
|
||||||
];
|
]);
|
||||||
|
|
||||||
describe('filterEmoji', () => {
|
describe('filterEmoji', () => {
|
||||||
describe('with a mix of allowed and disallowed emoji', () => {
|
describe('with a mix of allowed and disallowed emoji', () => {
|
||||||
|
@ -168,7 +168,7 @@ describe('getReactForStatus', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined when a status has no reacts (or favourites)', () => {
|
it('returns undefined when a status has no reacts (or favourites)', () => {
|
||||||
const status = fromJS([]);
|
const status = fromJS({});
|
||||||
expect(getReactForStatus(status)).toEqual(undefined);
|
expect(getReactForStatus(status)).toEqual(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,27 +1,21 @@
|
||||||
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||||
|
|
||||||
import { Account } from 'soapbox/types/entities';
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const getDomainFromURL = (account: ImmutableMap<string, any>): string => {
|
const getDomainFromURL = (account: Account): string => {
|
||||||
try {
|
try {
|
||||||
const url = account.get('url');
|
const url = account.url;
|
||||||
return new URL(url).host;
|
return new URL(url).host;
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDomain = (account: ImmutableMap<string, any>): string => {
|
export const getDomain = (account: Account): string => {
|
||||||
const domain = account.get('acct', '').split('@')[1];
|
const domain = account.acct.split('@')[1];
|
||||||
return domain ? domain : getDomainFromURL(account);
|
return domain ? domain : getDomainFromURL(account);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const guessFqn = (account: ImmutableMap<string, any>): string => {
|
|
||||||
const [user, domain] = account.get('acct', '').split('@');
|
|
||||||
if (!domain) return [user, getDomainFromURL(account)].join('@');
|
|
||||||
return account.get('acct', '');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBaseURL = (account: ImmutableMap<string, any>): string => {
|
export const getBaseURL = (account: ImmutableMap<string, any>): string => {
|
||||||
try {
|
try {
|
||||||
const url = account.get('url');
|
const url = account.get('url');
|
||||||
|
@ -31,27 +25,10 @@ export const getBaseURL = (account: ImmutableMap<string, any>): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// user@domain even for local users
|
|
||||||
export const acctFull = (account: ImmutableMap<string, any>): string => (
|
|
||||||
account.get('fqn') || guessFqn(account) || ''
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getAcct = (account: Account, displayFqn: boolean): string => (
|
export const getAcct = (account: Account, displayFqn: boolean): string => (
|
||||||
displayFqn === true ? account.fqn : account.acct
|
displayFqn === true ? account.fqn : account.acct
|
||||||
);
|
);
|
||||||
|
|
||||||
export const isStaff = (account: ImmutableMap<any, any> = ImmutableMap()): boolean => (
|
|
||||||
[isAdmin, isModerator].some(f => f(account) === true)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const isAdmin = (account: ImmutableMap<string, any>): boolean => (
|
|
||||||
account.getIn(['pleroma', 'is_admin']) === true
|
|
||||||
);
|
|
||||||
|
|
||||||
export const isModerator = (account: ImmutableMap<string, any>): boolean => (
|
|
||||||
account.getIn(['pleroma', 'is_moderator']) === true
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getFollowDifference = (state: ImmutableMap<string, any>, accountId: string, type: string): number => {
|
export const getFollowDifference = (state: ImmutableMap<string, any>, accountId: string, type: string): number => {
|
||||||
const items: any = state.getIn(['user_lists', type, accountId, 'items'], ImmutableOrderedSet());
|
const items: any = state.getIn(['user_lists', type, accountId, 'items'], ImmutableOrderedSet());
|
||||||
const counter: number = Number(state.getIn(['accounts_counters', accountId, `${type}_count`], 0));
|
const counter: number = Number(state.getIn(['accounts_counters', accountId, `${type}_count`], 0));
|
||||||
|
|
|
@ -6,8 +6,8 @@ import {
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
import { trimStart } from 'lodash';
|
import { trimStart } from 'lodash';
|
||||||
|
|
||||||
type Config = ImmutableMap<string, any>;
|
export type Config = ImmutableMap<string, any>;
|
||||||
type Policy = ImmutableMap<string, any>;
|
export type Policy = ImmutableMap<string, any>;
|
||||||
|
|
||||||
const find = (
|
const find = (
|
||||||
configs: ImmutableList<Config>,
|
configs: ImmutableList<Config>,
|
||||||
|
|
|
@ -3,31 +3,36 @@ import {
|
||||||
List as ImmutableList,
|
List as ImmutableList,
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
|
|
||||||
|
import type { Me } from 'soapbox/types/soapbox';
|
||||||
|
|
||||||
// https://emojipedia.org/facebook
|
// https://emojipedia.org/facebook
|
||||||
// I've customized them.
|
// I've customized them.
|
||||||
export const ALLOWED_EMOJI = [
|
export const ALLOWED_EMOJI = ImmutableList([
|
||||||
'👍',
|
'👍',
|
||||||
'❤️',
|
'❤️',
|
||||||
'😆',
|
'😆',
|
||||||
'😮',
|
'😮',
|
||||||
'😢',
|
'😢',
|
||||||
'😩',
|
'😩',
|
||||||
];
|
]);
|
||||||
|
|
||||||
export const sortEmoji = emojiReacts => (
|
type Account = ImmutableMap<string, any>;
|
||||||
|
type EmojiReact = ImmutableMap<string, any>;
|
||||||
|
|
||||||
|
export const sortEmoji = (emojiReacts: ImmutableList<EmojiReact>): ImmutableList<EmojiReact> => (
|
||||||
emojiReacts.sortBy(emojiReact => -emojiReact.get('count'))
|
emojiReacts.sortBy(emojiReact => -emojiReact.get('count'))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const mergeEmoji = emojiReacts => (
|
export const mergeEmoji = (emojiReacts: ImmutableList<EmojiReact>): ImmutableList<EmojiReact> => (
|
||||||
emojiReacts // TODO: Merge similar emoji
|
emojiReacts // TODO: Merge similar emoji
|
||||||
);
|
);
|
||||||
|
|
||||||
export const mergeEmojiFavourites = (emojiReacts = ImmutableList(), favouritesCount, favourited) => {
|
export const mergeEmojiFavourites = (emojiReacts = ImmutableList<EmojiReact>(), favouritesCount: number, favourited: boolean) => {
|
||||||
if (!favouritesCount) return emojiReacts;
|
if (!favouritesCount) return emojiReacts;
|
||||||
const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.get('name') === '👍');
|
const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.get('name') === '👍');
|
||||||
if (likeIndex > -1) {
|
if (likeIndex > -1) {
|
||||||
const likeCount = emojiReacts.getIn([likeIndex, 'count']);
|
const likeCount = Number(emojiReacts.getIn([likeIndex, 'count']));
|
||||||
favourited = favourited || emojiReacts.getIn([likeIndex, 'me'], false);
|
favourited = favourited || Boolean(emojiReacts.getIn([likeIndex, 'me'], false));
|
||||||
return emojiReacts
|
return emojiReacts
|
||||||
.setIn([likeIndex, 'count'], likeCount + favouritesCount)
|
.setIn([likeIndex, 'count'], likeCount + favouritesCount)
|
||||||
.setIn([likeIndex, 'me'], favourited);
|
.setIn([likeIndex, 'me'], favourited);
|
||||||
|
@ -36,24 +41,24 @@ export const mergeEmojiFavourites = (emojiReacts = ImmutableList(), favouritesCo
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasMultiReactions = (emojiReacts, account) => (
|
const hasMultiReactions = (emojiReacts: ImmutableList<EmojiReact>, account: Account): boolean => (
|
||||||
emojiReacts.filter(
|
emojiReacts.filter(
|
||||||
e => e.get('accounts').filter(
|
e => e.get('accounts').filter(
|
||||||
a => a.get('id') === account.get('id'),
|
(a: Account) => a.get('id') === account.get('id'),
|
||||||
).count() > 0,
|
).count() > 0,
|
||||||
).count() > 1
|
).count() > 1
|
||||||
);
|
);
|
||||||
|
|
||||||
const inAccounts = (accounts, id) => (
|
const inAccounts = (accounts: ImmutableList<Account>, id: string): boolean => (
|
||||||
accounts.filter(a => a.get('id') === id).count() > 0
|
accounts.filter(a => a.get('id') === id).count() > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
export const oneEmojiPerAccount = (emojiReacts, me) => {
|
export const oneEmojiPerAccount = (emojiReacts: ImmutableList<EmojiReact>, me: Me) => {
|
||||||
emojiReacts = emojiReacts.reverse();
|
emojiReacts = emojiReacts.reverse();
|
||||||
|
|
||||||
return emojiReacts.reduce((acc, cur, idx) => {
|
return emojiReacts.reduce((acc, cur, idx) => {
|
||||||
const accounts = cur.get('accounts', ImmutableList())
|
const accounts = cur.get('accounts', ImmutableList())
|
||||||
.filter(a => !hasMultiReactions(acc, a));
|
.filter((a: Account) => !hasMultiReactions(acc, a));
|
||||||
|
|
||||||
return acc.set(idx, cur.merge({
|
return acc.set(idx, cur.merge({
|
||||||
accounts: accounts,
|
accounts: accounts,
|
||||||
|
@ -65,30 +70,33 @@ export const oneEmojiPerAccount = (emojiReacts, me) => {
|
||||||
.reverse();
|
.reverse();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterEmoji = (emojiReacts, allowedEmoji=ALLOWED_EMOJI) => (
|
export const filterEmoji = (emojiReacts: ImmutableList<EmojiReact>, allowedEmoji=ALLOWED_EMOJI): ImmutableList<EmojiReact> => (
|
||||||
emojiReacts.filter(emojiReact => (
|
emojiReacts.filter(emojiReact => (
|
||||||
allowedEmoji.includes(emojiReact.get('name'))
|
allowedEmoji.includes(emojiReact.get('name'))
|
||||||
)));
|
)));
|
||||||
|
|
||||||
export const reduceEmoji = (emojiReacts, favouritesCount, favourited, allowedEmoji=ALLOWED_EMOJI) => (
|
export const reduceEmoji = (emojiReacts: ImmutableList<EmojiReact>, favouritesCount: number, favourited: boolean, allowedEmoji=ALLOWED_EMOJI): ImmutableList<EmojiReact> => (
|
||||||
filterEmoji(sortEmoji(mergeEmoji(mergeEmojiFavourites(
|
filterEmoji(sortEmoji(mergeEmoji(mergeEmojiFavourites(
|
||||||
emojiReacts, favouritesCount, favourited,
|
emojiReacts, favouritesCount, favourited,
|
||||||
))), allowedEmoji));
|
))), allowedEmoji));
|
||||||
|
|
||||||
export const getReactForStatus = (status, allowedEmoji=ALLOWED_EMOJI) => {
|
export const getReactForStatus = (status: any, allowedEmoji=ALLOWED_EMOJI): string | undefined => {
|
||||||
return reduceEmoji(
|
const result = reduceEmoji(
|
||||||
status.getIn(['pleroma', 'emoji_reactions'], ImmutableList()),
|
status.getIn(['pleroma', 'emoji_reactions'], ImmutableList()),
|
||||||
status.get('favourites_count', 0),
|
status.get('favourites_count', 0),
|
||||||
status.get('favourited'),
|
status.get('favourited'),
|
||||||
allowedEmoji,
|
allowedEmoji,
|
||||||
).filter(e => e.get('me') === true)
|
).filter(e => e.get('me') === true)
|
||||||
.getIn([0, 'name']);
|
.getIn([0, 'name']);
|
||||||
|
|
||||||
|
return typeof result === 'string' ? result : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const simulateEmojiReact = (emojiReacts, emoji) => {
|
export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji: string) => {
|
||||||
const idx = emojiReacts.findIndex(e => e.get('name') === emoji);
|
const idx = emojiReacts.findIndex(e => e.get('name') === emoji);
|
||||||
if (idx > -1) {
|
|
||||||
const emojiReact = emojiReacts.get(idx);
|
const emojiReact = emojiReacts.get(idx);
|
||||||
|
|
||||||
|
if (idx > -1 && emojiReact) {
|
||||||
return emojiReacts.set(idx, emojiReact.merge({
|
return emojiReacts.set(idx, emojiReact.merge({
|
||||||
count: emojiReact.get('count') + 1,
|
count: emojiReact.get('count') + 1,
|
||||||
me: true,
|
me: true,
|
||||||
|
@ -102,12 +110,13 @@ export const simulateEmojiReact = (emojiReacts, emoji) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const simulateUnEmojiReact = (emojiReacts, emoji) => {
|
export const simulateUnEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji: string) => {
|
||||||
const idx = emojiReacts.findIndex(e =>
|
const idx = emojiReacts.findIndex(e =>
|
||||||
e.get('name') === emoji && e.get('me') === true);
|
e.get('name') === emoji && e.get('me') === true);
|
||||||
|
|
||||||
if (idx > -1) {
|
|
||||||
const emojiReact = emojiReacts.get(idx);
|
const emojiReact = emojiReacts.get(idx);
|
||||||
|
|
||||||
|
if (emojiReact) {
|
||||||
const newCount = emojiReact.get('count') - 1;
|
const newCount = emojiReact.get('count') - 1;
|
||||||
if (newCount < 1) return emojiReacts.delete(idx);
|
if (newCount < 1) return emojiReacts.delete(idx);
|
||||||
return emojiReacts.set(idx, emojiReact.merge({
|
return emojiReacts.set(idx, emojiReact.merge({
|
|
@ -121,8 +121,8 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 30px;
|
width: 36px;
|
||||||
height: 30px;
|
height: 36px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
transition: 0.1s;
|
transition: 0.1s;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue