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`] = `
|
||||
<div
|
||||
className="emoji-react-selector-container"
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<div
|
||||
className="emoji-react-selector"
|
||||
onBlur={[Function]}
|
||||
className="flex space-x-2 bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max"
|
||||
>
|
||||
<button
|
||||
className="emoji-react-selector__emoji"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"👍\\" title=\\":+1:\\" src=\\"/packs/emoji/1f44d.svg\\" />",
|
||||
}
|
||||
}
|
||||
className=""
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
alt="👍"
|
||||
className="w-8 h-8 duration-100 hover:scale-125"
|
||||
draggable="false"
|
||||
src="/packs/emoji/1f44d.svg"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="emoji-react-selector__emoji"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"❤\\" title=\\":heart:\\" src=\\"/packs/emoji/2764.svg\\" />",
|
||||
}
|
||||
}
|
||||
className=""
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
alt="❤"
|
||||
className="w-8 h-8 duration-100 hover:scale-125"
|
||||
draggable="false"
|
||||
src="/packs/emoji/2764.svg"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="emoji-react-selector__emoji"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"😆\\" title=\\":laughing:\\" src=\\"/packs/emoji/1f606.svg\\" />",
|
||||
}
|
||||
}
|
||||
className=""
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
alt="😆"
|
||||
className="w-8 h-8 duration-100 hover:scale-125"
|
||||
draggable="false"
|
||||
src="/packs/emoji/1f606.svg"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="emoji-react-selector__emoji"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"😮\\" title=\\":open_mouth:\\" src=\\"/packs/emoji/1f62e.svg\\" />",
|
||||
}
|
||||
}
|
||||
className=""
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
alt="😮"
|
||||
className="w-8 h-8 duration-100 hover:scale-125"
|
||||
draggable="false"
|
||||
src="/packs/emoji/1f62e.svg"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="emoji-react-selector__emoji"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"😢\\" title=\\":cry:\\" src=\\"/packs/emoji/1f622.svg\\" />",
|
||||
}
|
||||
}
|
||||
className=""
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
alt="😢"
|
||||
className="w-8 h-8 duration-100 hover:scale-125"
|
||||
draggable="false"
|
||||
src="/packs/emoji/1f622.svg"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="emoji-react-selector__emoji"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<img draggable=\\"false\\" class=\\"emojione\\" alt=\\"😩\\" title=\\":weary:\\" src=\\"/packs/emoji/1f629.svg\\" />",
|
||||
}
|
||||
}
|
||||
className=""
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
alt="😩"
|
||||
className="w-8 h-8 duration-100 hover:scale-125"
|
||||
draggable="false"
|
||||
src="/packs/emoji/1f629.svg"
|
||||
/>
|
||||
</button>
|
||||
</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 { UserPanel } from 'soapbox/features/ui/util/async-components';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import { isAdmin, isModerator } from 'soapbox/utils/accounts';
|
||||
|
||||
import { showProfileHoverCard } from './hover_ref_wrapper';
|
||||
import { Card, CardBody, Stack, Text } from './ui';
|
||||
|
@ -27,9 +26,9 @@ const getAccount = makeGetAccount();
|
|||
const getBadges = (account) => {
|
||||
const badges = [];
|
||||
|
||||
if (isAdmin(account)) {
|
||||
if (account.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' />);
|
||||
}
|
||||
|
||||
|
|
|
@ -107,7 +107,7 @@ class ScrollableList extends PureComponent {
|
|||
|
||||
this.attachScrollListener();
|
||||
this.attachIntersectionObserver();
|
||||
// Handle initial scroll posiiton
|
||||
// Handle initial scroll position
|
||||
this.handleScroll();
|
||||
}
|
||||
|
||||
|
@ -115,7 +115,7 @@ class ScrollableList extends PureComponent {
|
|||
if (this.documentElement && (this.documentElement.scrollTop > 0 || this.mouseMovedRecently)) {
|
||||
return { height: this.documentElement.scrollHeight, top: this.documentElement.scrollTop };
|
||||
} else {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,14 +37,16 @@ const SidebarNavigationLink = ({ icon, text, to, count }: ISidebarNavigationLink
|
|||
</span>
|
||||
) : null}
|
||||
|
||||
<div className='h-5 w-5'>
|
||||
<Icon
|
||||
src={icon}
|
||||
className={classNames({
|
||||
'h-5 w-5': true,
|
||||
'h-full w-full': true,
|
||||
'text-primary-700 dark:text-white': !isActive,
|
||||
'text-white': isActive,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<Text weight='semibold' theme='inherit'>{text}</Text>
|
||||
|
|
|
@ -70,7 +70,7 @@ const SidebarNavigation = () => {
|
|||
)
|
||||
)}
|
||||
|
||||
{/* {(account && isStaff(account)) && (
|
||||
{/* {(account && account.staff) && (
|
||||
<SidebarNavigationLink
|
||||
to='/admin'
|
||||
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 { makeGetAccount, makeGetOtherAccounts } from '../selectors';
|
||||
import { isAdmin, isStaff } from '../utils/accounts';
|
||||
|
||||
import { HStack, Icon, IconButton, Text } from './ui';
|
||||
|
||||
|
@ -155,7 +154,7 @@ const SidebarMenu = () => {
|
|||
<Account account={account} showProfileHoverCard={false} />
|
||||
</Link>
|
||||
|
||||
{isStaff(account) && (
|
||||
{account.staff && (
|
||||
<Stack>
|
||||
<button type='button' onClick={handleSwitcherClick} className='py-1'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
|
@ -232,7 +231,7 @@ const SidebarMenu = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{isAdmin(account) && (
|
||||
{account.admin && (
|
||||
<SidebarLink
|
||||
to='/soapbox/config'
|
||||
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 PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { NavLink, withRouter } from 'react-router-dom';
|
||||
import { injectIntl, FormattedMessage, IntlShape } from 'react-intl';
|
||||
import { NavLink, withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
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 { HStack, Text } from './ui';
|
||||
|
||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||
const displayName = status.getIn(['account', 'display_name']);
|
||||
import type { History } from 'history';
|
||||
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 = [
|
||||
displayName.length === 0 ? status.getIn(['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),
|
||||
intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
|
||||
displayName.length === 0 ? account.acct.split('@')[0] : displayName,
|
||||
status.spoiler_text && status.hidden ? status.spoiler_text : status.search_index.slice(status.spoiler_text.length),
|
||||
intl.formatDate(status.created_at, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
|
||||
status.getIn(['account', 'acct']),
|
||||
];
|
||||
|
||||
|
@ -39,96 +51,106 @@ export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
|||
return values.join(', ');
|
||||
};
|
||||
|
||||
export const defaultMediaVisibility = (status, displayMedia) => {
|
||||
if (!status) {
|
||||
return undefined;
|
||||
export const defaultMediaVisibility = (status: StatusEntity, displayMedia: string): boolean => {
|
||||
if (!status) return false;
|
||||
|
||||
if (status.reblog && typeof status.reblog === 'object') {
|
||||
status = status.reblog;
|
||||
}
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
status = status.get('reblog');
|
||||
return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all');
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
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,
|
||||
};
|
||||
class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
||||
|
||||
static defaultProps = {
|
||||
focusable: true,
|
||||
};
|
||||
|
||||
didShowCard = false;
|
||||
node?: HTMLDivElement = undefined;
|
||||
height?: number = undefined;
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
updateOnProps = [
|
||||
updateOnProps: any[] = [
|
||||
'status',
|
||||
'account',
|
||||
'muted',
|
||||
'hidden',
|
||||
];
|
||||
|
||||
state = {
|
||||
state: IStatusState = {
|
||||
showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia),
|
||||
statusId: undefined,
|
||||
emojiSelectorFocused: false,
|
||||
};
|
||||
|
||||
// Track height changes we know about to compensate scrolling
|
||||
componentDidMount() {
|
||||
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
|
||||
componentDidMount(): void {
|
||||
this.didShowCard = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card);
|
||||
}
|
||||
|
||||
getSnapshotBeforeUpdate() {
|
||||
getSnapshotBeforeUpdate(): ScrollPosition | undefined {
|
||||
if (this.props.getScrollPosition) {
|
||||
return this.props.getScrollPosition();
|
||||
} else {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
|
||||
static getDerivedStateFromProps(nextProps: IStatus, prevState: IStatusState) {
|
||||
if (nextProps.status && nextProps.status.id !== prevState.statusId) {
|
||||
return {
|
||||
showMedia: defaultMediaVisibility(nextProps.status, nextProps.displayMedia),
|
||||
statusId: nextProps.status.get('id'),
|
||||
statusId: nextProps.status.id,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
|
@ -136,13 +158,13 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
// Compensate height changes
|
||||
componentDidUpdate(prevProps, prevState, snapshot) {
|
||||
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
|
||||
componentDidUpdate(_prevProps: IStatus, _prevState: IStatusState, snapshot?: ScrollPosition): void {
|
||||
const doShowCard: boolean = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card);
|
||||
|
||||
if (doShowCard && !this.didShowCard) {
|
||||
this.didShowCard = true;
|
||||
|
||||
if (snapshot !== null && this.props.updateScrollBottom) {
|
||||
if (snapshot && this.props.updateScrollBottom) {
|
||||
if (this.node && this.node.offsetTop < 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.
|
||||
//
|
||||
// if (this.node && this.props.getScrollPosition) {
|
||||
// const position = this.props.getScrollPosition();
|
||||
// if (position !== null && this.node.offsetTop < position.top) {
|
||||
// const { getScrollPosition, updateScrollBottom } = this.props;
|
||||
//
|
||||
// if (this.node && getScrollPosition && updateScrollBottom) {
|
||||
// const position = getScrollPosition();
|
||||
// if (position && this.node.offsetTop < position.top) {
|
||||
// requestAnimationFrame(() => {
|
||||
// this.props.updateScrollBottom(position.height - position.top);
|
||||
// updateScrollBottom(position.height - position.top);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
handleToggleMediaVisibility = () => {
|
||||
handleToggleMediaVisibility = (): void => {
|
||||
this.setState({ showMedia: !this.state.showMedia });
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
handleClick = (): void => {
|
||||
if (this.props.onClick) {
|
||||
this.props.onClick();
|
||||
return;
|
||||
|
@ -177,136 +201,139 @@ class Status extends ImmutablePureComponent {
|
|||
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 (!this.props.history) {
|
||||
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());
|
||||
};
|
||||
|
||||
renderLoadingMediaGallery() {
|
||||
renderLoadingMediaGallery(): JSX.Element {
|
||||
return <div className='media_gallery' style={{ height: '285px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingVideoPlayer() {
|
||||
renderLoadingVideoPlayer(): JSX.Element {
|
||||
return <div className='media-spoiler-video' style={{ height: '285px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingAudioPlayer() {
|
||||
renderLoadingAudioPlayer(): JSX.Element {
|
||||
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);
|
||||
}
|
||||
|
||||
handleOpenAudio = (media, startTime) => {
|
||||
this.props.OnOpenAudio(media, startTime);
|
||||
handleOpenAudio = (media: ImmutableMap<string, any>, startTime: number): void => {
|
||||
this.props.onOpenAudio(media, startTime);
|
||||
}
|
||||
|
||||
handleHotkeyOpenMedia = e => {
|
||||
handleHotkeyOpenMedia = (e?: KeyboardEvent): void => {
|
||||
const { onOpenMedia, onOpenVideo } = this.props;
|
||||
const status = this._properStatus();
|
||||
const firstAttachment = status.media_attachments.first();
|
||||
|
||||
e.preventDefault();
|
||||
e?.preventDefault();
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
onOpenVideo(status.getIn(['media_attachments', 0]), 0);
|
||||
if (firstAttachment) {
|
||||
if (firstAttachment.type === 'video') {
|
||||
onOpenVideo(firstAttachment, 0);
|
||||
} else {
|
||||
onOpenMedia(status.get('media_attachments'), 0);
|
||||
onOpenMedia(status.media_attachments, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleHotkeyReply = e => {
|
||||
e.preventDefault();
|
||||
handleHotkeyReply = (e?: KeyboardEvent): void => {
|
||||
e?.preventDefault();
|
||||
this.props.onReply(this._properStatus(), this.props.history);
|
||||
}
|
||||
|
||||
handleHotkeyFavourite = () => {
|
||||
handleHotkeyFavourite = (): void => {
|
||||
this.props.onFavourite(this._properStatus());
|
||||
}
|
||||
|
||||
handleHotkeyBoost = e => {
|
||||
handleHotkeyBoost = (e?: KeyboardEvent): void => {
|
||||
this.props.onReblog(this._properStatus(), e);
|
||||
}
|
||||
|
||||
handleHotkeyMention = e => {
|
||||
e.preventDefault();
|
||||
this.props.onMention(this._properStatus().get('account'), this.props.history);
|
||||
handleHotkeyMention = (e?: KeyboardEvent): void => {
|
||||
e?.preventDefault();
|
||||
this.props.onMention(this._properStatus().account, this.props.history);
|
||||
}
|
||||
|
||||
handleHotkeyOpen = () => {
|
||||
this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`);
|
||||
handleHotkeyOpen = (): void => {
|
||||
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'])}`);
|
||||
}
|
||||
|
||||
handleHotkeyMoveUp = e => {
|
||||
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
||||
handleHotkeyMoveUp = (e?: KeyboardEvent): void => {
|
||||
// FIXME: what's going on here?
|
||||
// this.props.onMoveUp(this.props.status.id, e?.target?.getAttribute('data-featured'));
|
||||
}
|
||||
|
||||
handleHotkeyMoveDown = e => {
|
||||
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
||||
handleHotkeyMoveDown = (e?: KeyboardEvent): void => {
|
||||
// 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());
|
||||
}
|
||||
|
||||
handleHotkeyToggleSensitive = () => {
|
||||
handleHotkeyToggleSensitive = (): void => {
|
||||
this.handleToggleMediaVisibility();
|
||||
}
|
||||
|
||||
handleHotkeyReact = () => {
|
||||
handleHotkeyReact = (): void => {
|
||||
this._expandEmojiSelector();
|
||||
}
|
||||
|
||||
handleEmojiSelectorExpand = e => {
|
||||
handleEmojiSelectorExpand: React.EventHandler<React.KeyboardEvent> = e => {
|
||||
if (e.key === 'Enter') {
|
||||
this._expandEmojiSelector();
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
handleEmojiSelectorUnfocus = () => {
|
||||
handleEmojiSelectorUnfocus = (): void => {
|
||||
this.setState({ emojiSelectorFocused: false });
|
||||
}
|
||||
|
||||
_expandEmojiSelector = () => {
|
||||
_expandEmojiSelector = (): void => {
|
||||
this.setState({ emojiSelectorFocused: true });
|
||||
const firstEmoji = this.node.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
|
||||
firstEmoji.focus();
|
||||
const firstEmoji: HTMLDivElement | null | undefined = this.node?.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
|
||||
firstEmoji?.focus();
|
||||
};
|
||||
|
||||
_properStatus() {
|
||||
_properStatus(): StatusEntity {
|
||||
const { status } = this.props;
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
return status.get('reblog');
|
||||
if (status.reblog && typeof status.reblog === 'object') {
|
||||
return status.reblog;
|
||||
} else {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
handleRef = c => {
|
||||
handleRef = (c: HTMLDivElement): void => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
setRef = (c: HTMLDivElement): void => {
|
||||
if (c) {
|
||||
this.setState({ mediaWrapperWidth: c.offsetWidth });
|
||||
}
|
||||
|
@ -322,28 +349,26 @@ class Status extends ImmutablePureComponent {
|
|||
// FIXME: why does this need to reassign status and account??
|
||||
let { status, account, ...other } = this.props; // eslint-disable-line prefer-const
|
||||
|
||||
if (status === null) {
|
||||
return null;
|
||||
}
|
||||
if (!status) return null;
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<div ref={this.handleRef}>
|
||||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
|
||||
{status.get('content')}
|
||||
{status.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
|
||||
const minHandlers = this.props.muted ? {} : {
|
||||
if (status.filtered || status.getIn(['reblog', 'filtered'])) {
|
||||
const minHandlers = this.props.muted ? undefined : {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
};
|
||||
|
||||
return (
|
||||
<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' />
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
@ -364,8 +389,8 @@ class Status extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
const displayNameHtml = { __html: status.getIn(['account', 'display_name_html']) };
|
||||
if (status.reblog && typeof status.reblog === 'object') {
|
||||
const displayNameHtml = { __html: String(status.getIn(['account', 'display_name_html'])) };
|
||||
|
||||
reblogElement = (
|
||||
<NavLink
|
||||
|
@ -417,37 +442,47 @@ class Status extends ImmutablePureComponent {
|
|||
id: 'status.reblogged_by',
|
||||
defaultMessage: '{name} reposted',
|
||||
}, {
|
||||
name: status.getIn(['account', 'acct']),
|
||||
name: String(status.getIn(['account', 'acct'])),
|
||||
});
|
||||
|
||||
account = status.get('account');
|
||||
reblogContent = status.get('contentHtml');
|
||||
status = status.get('reblog');
|
||||
// @ts-ignore what the FUCK
|
||||
account = status.account;
|
||||
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) {
|
||||
media = (
|
||||
<AttachmentThumbs
|
||||
media={status.get('media_attachments')}
|
||||
media={status.media_attachments}
|
||||
onClick={this.handleClick}
|
||||
sensitive={status.get('sensitive')}
|
||||
sensitive={status.sensitive}
|
||||
/>
|
||||
);
|
||||
} else if (size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const video = status.getIn(['media_attachments', 0]);
|
||||
} else if (size === 1 && firstAttachment.type === 'video') {
|
||||
const video = firstAttachment;
|
||||
|
||||
if (video.external_video_id && status.card?.html) {
|
||||
if (video.external_video_id && status.card) {
|
||||
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 = (
|
||||
<div className='status-card horizontal compact interactive status-card--video'>
|
||||
<div
|
||||
ref={this.setRef}
|
||||
className='status-card__image status-card-video'
|
||||
style={height ? { height } : {}}
|
||||
style={height ? { height } : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: status.card.html }}
|
||||
/>
|
||||
</div>
|
||||
|
@ -455,17 +490,17 @@ class Status extends ImmutablePureComponent {
|
|||
} else {
|
||||
media = (
|
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||
{Component => (
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
preview={video.get('preview_url')}
|
||||
blurhash={video.get('blurhash')}
|
||||
src={video.get('url')}
|
||||
alt={video.get('description')}
|
||||
aspectRatio={video.getIn(['meta', 'original', 'aspect'])}
|
||||
preview={video.preview_url}
|
||||
blurhash={video.blurhash}
|
||||
src={video.url}
|
||||
alt={video.description}
|
||||
aspectRatio={video.meta.getIn(['original', 'aspect'])}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={285}
|
||||
inline
|
||||
sensitive={status.get('sensitive')}
|
||||
sensitive={status.sensitive}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
|
@ -475,20 +510,20 @@ class Status extends ImmutablePureComponent {
|
|||
</Bundle>
|
||||
);
|
||||
}
|
||||
} else if (size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'audio' && status.get('media_attachments').size === 1) {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
} else if (size === 1 && firstAttachment.type === 'audio') {
|
||||
const attachment = firstAttachment;
|
||||
|
||||
media = (
|
||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
||||
{Component => (
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
poster={attachment.get('preview_url') !== attachment.get('url') ? attachment.get('preview_url') : status.getIn(['account', 'avatar_static'])}
|
||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
src={attachment.url}
|
||||
alt={attachment.description}
|
||||
poster={attachment.preview_url !== attachment.url ? attachment.preview_url : status.getIn(['account', 'avatar_static'])}
|
||||
backgroundColor={attachment.meta.getIn(['colors', 'background'])}
|
||||
foregroundColor={attachment.meta.getIn(['colors', 'foreground'])}
|
||||
accentColor={attachment.meta.getIn(['colors', 'accent'])}
|
||||
duration={attachment.meta.getIn(['original', 'duration'], 0)}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={263}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
|
@ -499,10 +534,10 @@ class Status extends ImmutablePureComponent {
|
|||
} else {
|
||||
media = (
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
{Component => (
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
media={status.get('media_attachments')}
|
||||
sensitive={status.get('sensitive')}
|
||||
media={status.media_attachments}
|
||||
sensitive={status.sensitive}
|
||||
height={285}
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
|
@ -514,17 +549,17 @@ class Status extends ImmutablePureComponent {
|
|||
</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 = (
|
||||
<Card
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
card={status.get('card')}
|
||||
card={status.card}
|
||||
compact
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
/>
|
||||
);
|
||||
} else if (status.get('expectsCard', false)) {
|
||||
} else if (status.expectsCard) {
|
||||
media = (
|
||||
<PlaceholderCard />
|
||||
);
|
||||
|
@ -532,19 +567,19 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
let quote;
|
||||
|
||||
if (status.get('quote')) {
|
||||
if (status.getIn(['pleroma', 'quote_visible'], true) === false) {
|
||||
if (status.quote) {
|
||||
if (status.pleroma.get('quote_visible', true) === false) {
|
||||
quote = (
|
||||
<div className='quoted-status-tombstone'>
|
||||
<p><FormattedMessage id='statuses.quote_tombstone' defaultMessage='Post is unavailable.' /></p>
|
||||
</div>
|
||||
);
|
||||
} 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,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
|
@ -559,15 +594,15 @@ class Status extends ImmutablePureComponent {
|
|||
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 domain = getDomain(status.get('account'));
|
||||
// const domain = getDomain(status.account);
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div
|
||||
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}
|
||||
aria-label={textForScreenReader(intl, status, rebloggedByText)}
|
||||
ref={this.handleRef}
|
||||
|
@ -580,19 +615,19 @@ class Status extends ImmutablePureComponent {
|
|||
<div
|
||||
className={classNames({
|
||||
'status__wrapper': true,
|
||||
[`status-${status.get('visibility')}`]: true,
|
||||
'status-reply': !!status.get('in_reply_to_id'),
|
||||
[`status-${status.visibility}`]: true,
|
||||
'status-reply': !!status.in_reply_to_id,
|
||||
muted: this.props.muted,
|
||||
read: unread === false,
|
||||
})}
|
||||
data-id={status.get('id')}
|
||||
data-id={status.id}
|
||||
>
|
||||
<div className='mb-4'>
|
||||
<HStack justifyContent='between' alignItems='start'>
|
||||
<AccountContainer
|
||||
key={status.getIn(['account', 'id'])}
|
||||
id={status.getIn(['account', 'id'])}
|
||||
timestamp={status.get('created_at')}
|
||||
key={String(status.getIn(['account', 'id']))}
|
||||
id={String(status.getIn(['account', 'id']))}
|
||||
timestamp={status.created_at}
|
||||
timestampUrl={statusUrl}
|
||||
action={reblogElement}
|
||||
hideActions={!reblogElement}
|
||||
|
@ -601,9 +636,9 @@ class Status extends ImmutablePureComponent {
|
|||
</div>
|
||||
|
||||
<div className='status__content-wrapper'>
|
||||
{!group && status.get('group') && (
|
||||
{!group && status.group && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
@ -613,7 +648,7 @@ class Status extends ImmutablePureComponent {
|
|||
status={status}
|
||||
reblogContent={reblogContent}
|
||||
onClick={this.handleClick}
|
||||
expanded={!status.get('hidden')}
|
||||
expanded={!status.hidden}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
collapsable
|
||||
/>
|
||||
|
@ -623,6 +658,7 @@ class Status extends ImmutablePureComponent {
|
|||
{quote}
|
||||
|
||||
<StatusActionBar
|
||||
// @ts-ignore what?
|
||||
status={status}
|
||||
account={account}
|
||||
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 PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
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 { Link, withRouter } from 'react-router-dom';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
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 { isUserTouching } from 'soapbox/is_mobile';
|
||||
import { isStaff, isAdmin } from 'soapbox/utils/accounts';
|
||||
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
||||
|
||||
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({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -66,63 +68,70 @@ const messages = defineMessages({
|
|||
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 = {
|
||||
status: ImmutablePropTypes.record.isRequired,
|
||||
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,
|
||||
};
|
||||
interface IStatusActionBarState {
|
||||
emojiSelectorVisible: boolean,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusActionBarState> {
|
||||
|
||||
static defaultProps: Partial<IStatusActionBar> = {
|
||||
isStaff: false,
|
||||
}
|
||||
|
||||
node?: HTMLDivElement = undefined;
|
||||
|
||||
state = {
|
||||
emojiSelectorVisible: false,
|
||||
}
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
// @ts-ignore: the type checker is wrong.
|
||||
updateOnProps = [
|
||||
'status',
|
||||
'withDismiss',
|
||||
'emojiSelectorFocused',
|
||||
]
|
||||
|
||||
handleReplyClick = (event) => {
|
||||
handleReplyClick = () => {
|
||||
const { me, onReply, onOpenUnauthorizedModal, status } = this.props;
|
||||
event.stopPropagation();
|
||||
|
||||
if (me) {
|
||||
onReply(status, this.props.history);
|
||||
|
@ -131,18 +140,16 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleShareClick = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
handleShareClick = () => {
|
||||
navigator.share({
|
||||
text: this.props.status.get('search_index'),
|
||||
url: this.props.status.get('url'),
|
||||
text: this.props.status.search_index,
|
||||
url: this.props.status.url,
|
||||
}).catch((e) => {
|
||||
if (e.name !== 'AbortError') console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
handleLikeButtonHover = e => {
|
||||
handleLikeButtonHover: React.EventHandler<React.MouseEvent> = () => {
|
||||
const { features } = this.props;
|
||||
|
||||
if (features.emojiReacts && !isUserTouching()) {
|
||||
|
@ -150,7 +157,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleLikeButtonLeave = e => {
|
||||
handleLikeButtonLeave: React.EventHandler<React.MouseEvent> = () => {
|
||||
const { features } = this.props;
|
||||
|
||||
if (features.emojiReacts && !isUserTouching()) {
|
||||
|
@ -158,51 +165,58 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleLikeButtonClick = e => {
|
||||
handleLikeButtonClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
const { features } = this.props;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
const meEmojiReact = getReactForStatus(this.props.status, this.props.allowedEmoji) || '👍';
|
||||
const reactForStatus = getReactForStatus(this.props.status, this.props.allowedEmoji);
|
||||
const meEmojiReact = typeof reactForStatus === 'string' ? reactForStatus : '👍';
|
||||
|
||||
if (features.emojiReacts && isUserTouching()) {
|
||||
if (this.state.emojiSelectorVisible) {
|
||||
this.handleReactClick(meEmojiReact)();
|
||||
this.handleReact(meEmojiReact);
|
||||
} else {
|
||||
this.setState({ emojiSelectorVisible: true });
|
||||
}
|
||||
} else {
|
||||
this.handleReactClick(meEmojiReact)();
|
||||
}
|
||||
this.handleReact(meEmojiReact);
|
||||
}
|
||||
|
||||
handleReactClick = emoji => {
|
||||
return e => {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handleReact = (emoji: string): void => {
|
||||
const { me, dispatch, onOpenUnauthorizedModal, status } = this.props;
|
||||
if (me) {
|
||||
dispatch(simpleEmojiReact(status, emoji));
|
||||
dispatch(simpleEmojiReact(status, emoji) as any);
|
||||
} else {
|
||||
onOpenUnauthorizedModal('FAVOURITE');
|
||||
}
|
||||
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;
|
||||
if (me) {
|
||||
onFavourite(status);
|
||||
} else {
|
||||
onOpenUnauthorizedModal('FAVOURITE');
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handleBookmarkClick = (e) => {
|
||||
handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onBookmark(this.props.status);
|
||||
}
|
||||
|
||||
handleReblogClick = e => {
|
||||
handleReblogClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const { me, onReblog, onOpenUnauthorizedModal, status } = this.props;
|
||||
e.stopPropagation();
|
||||
|
||||
|
@ -213,7 +227,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleQuoteClick = (e) => {
|
||||
handleQuoteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
const { me, onQuote, onOpenUnauthorizedModal, status } = this.props;
|
||||
if (me) {
|
||||
|
@ -223,67 +237,67 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleDeleteClick = (e) => {
|
||||
handleDeleteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDelete(this.props.status, this.props.history);
|
||||
}
|
||||
|
||||
handleRedraftClick = (e) => {
|
||||
handleRedraftClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDelete(this.props.status, this.props.history, true);
|
||||
}
|
||||
|
||||
handlePinClick = (e) => {
|
||||
handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onPin(this.props.status);
|
||||
}
|
||||
|
||||
handleMentionClick = (e) => {
|
||||
handleMentionClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
this.props.onBlock(this.props.status);
|
||||
}
|
||||
|
||||
handleOpen = (e) => {
|
||||
handleOpen: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
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 = () => {
|
||||
this.props.onEmbed(this.props.status);
|
||||
}
|
||||
|
||||
handleReport = (e) => {
|
||||
handleReport: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onReport(this.props.status);
|
||||
}
|
||||
|
||||
handleConversationMuteClick = (e) => {
|
||||
handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onMuteConversation(this.props.status);
|
||||
}
|
||||
|
||||
handleCopy = (e) => {
|
||||
const url = this.props.status.get('url');
|
||||
handleCopy: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
const { url } = this.props.status;
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
e.stopPropagation();
|
||||
|
@ -303,57 +317,56 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleGroupRemoveAccount = (e) => {
|
||||
const { status } = this.props;
|
||||
// handleGroupRemoveAccount: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
// 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();
|
||||
|
||||
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) => {
|
||||
handleDeactivateUser: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDeactivateUser(this.props.status);
|
||||
}
|
||||
|
||||
handleDeleteUser = (e) => {
|
||||
handleDeleteUser: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDeleteUser(this.props.status);
|
||||
}
|
||||
|
||||
handleDeleteStatus = (e) => {
|
||||
handleDeleteStatus: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDeleteStatus(this.props.status);
|
||||
}
|
||||
|
||||
handleToggleStatusSensitivity = (e) => {
|
||||
handleToggleStatusSensitivity: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onToggleStatusSensitivity(this.props.status);
|
||||
}
|
||||
|
||||
handleOpenReblogsModal = (event) => {
|
||||
handleOpenReblogsModal = () => {
|
||||
const { me, status, onOpenUnauthorizedModal, onOpenReblogsModal } = this.props;
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
if (!me) onOpenUnauthorizedModal();
|
||||
else onOpenReblogsModal(status.getIn(['account', 'acct']), status.get('id'));
|
||||
else onOpenReblogsModal(String(status.getIn(['account', 'acct'])), status.id);
|
||||
}
|
||||
|
||||
_makeMenu = (publicStatus) => {
|
||||
const { status, intl, withDismiss, withGroupAdmin, me, features, isStaff, isAdmin } = this.props;
|
||||
const mutingConversation = status.get('muted');
|
||||
_makeMenu = (publicStatus: boolean) => {
|
||||
const { status, intl, withDismiss, me, features, isStaff, isAdmin } = this.props;
|
||||
const mutingConversation = status.muted;
|
||||
const ownAccount = status.getIn(['account', 'id']) === me;
|
||||
const username = String(status.getIn(['account', 'username']));
|
||||
|
||||
const menu = [];
|
||||
const menu: Menu = [];
|
||||
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.open),
|
||||
|
@ -380,9 +393,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
if (features.bookmarks) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(status.get('bookmarked') ? messages.unbookmark : messages.bookmark),
|
||||
text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark),
|
||||
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 (publicStatus) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin),
|
||||
text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin),
|
||||
action: this.handlePinClick,
|
||||
icon: require(mutingConversation ? '@tabler/icons/icons/pinned-off.svg' : '@tabler/icons/icons/pin.svg'),
|
||||
});
|
||||
} else {
|
||||
if (status.get('visibility') === 'private') {
|
||||
if (status.visibility === 'private') {
|
||||
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,
|
||||
icon: require('@tabler/icons/icons/repeat.svg'),
|
||||
});
|
||||
|
@ -428,20 +441,20 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
});
|
||||
} else {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }),
|
||||
text: intl.formatMessage(messages.mention, { name: username }),
|
||||
action: this.handleMentionClick,
|
||||
icon: require('@tabler/icons/icons/at.svg'),
|
||||
});
|
||||
|
||||
// if (status.getIn(['account', 'pleroma', 'accepts_chat_messages'], false) === true) {
|
||||
// menu.push({
|
||||
// text: intl.formatMessage(messages.chat, { name: status.getIn(['account', 'username']) }),
|
||||
// text: intl.formatMessage(messages.chat, { name: username }),
|
||||
// action: this.handleChatClick,
|
||||
// icon: require('@tabler/icons/icons/messages.svg'),
|
||||
// });
|
||||
// } else {
|
||||
// menu.push({
|
||||
// text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }),
|
||||
// text: intl.formatMessage(messages.direct, { name: username }),
|
||||
// action: this.handleDirectClick,
|
||||
// icon: require('@tabler/icons/icons/mail.svg'),
|
||||
// });
|
||||
|
@ -449,17 +462,17 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }),
|
||||
text: intl.formatMessage(messages.mute, { name: username }),
|
||||
action: this.handleMuteClick,
|
||||
icon: require('@tabler/icons/icons/circle-x.svg'),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }),
|
||||
text: intl.formatMessage(messages.block, { name: username }),
|
||||
action: this.handleBlockClick,
|
||||
icon: require('@tabler/icons/icons/ban.svg'),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }),
|
||||
text: intl.formatMessage(messages.report, { name: username }),
|
||||
action: this.handleReport,
|
||||
icon: require('@tabler/icons/icons/flag.svg'),
|
||||
});
|
||||
|
@ -470,33 +483,33 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
if (isAdmin) {
|
||||
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'])}/`,
|
||||
icon: require('@tabler/icons/icons/gavel.svg'),
|
||||
action: (event) => event.stopPropagation(),
|
||||
});
|
||||
menu.push({
|
||||
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'),
|
||||
action: (event) => event.stopPropagation(),
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
icon: require('@tabler/icons/icons/alert-triangle.svg'),
|
||||
});
|
||||
|
||||
if (!ownAccount) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.deactivateUser, { name: status.getIn(['account', 'username']) }),
|
||||
text: intl.formatMessage(messages.deactivateUser, { name: username }),
|
||||
action: this.handleDeactivateUser,
|
||||
icon: require('@tabler/icons/icons/user-off.svg'),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.deleteUser, { name: status.getIn(['account', 'username']) }),
|
||||
text: intl.formatMessage(messages.deleteUser, { name: username }),
|
||||
action: this.handleDeleteUser,
|
||||
icon: require('@tabler/icons/icons/user-minus.svg'),
|
||||
destructive: true,
|
||||
|
@ -510,223 +523,194 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
if (!ownAccount && withGroupAdmin) {
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.group_remove_account),
|
||||
action: this.handleGroupRemoveAccount,
|
||||
icon: require('@tabler/icons/icons/user-x.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.group_remove_post),
|
||||
action: this.handleGroupRemovePost,
|
||||
icon: require('@tabler/icons/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
// if (!ownAccount && withGroupAdmin) {
|
||||
// menu.push(null);
|
||||
// menu.push({
|
||||
// text: intl.formatMessage(messages.group_remove_account),
|
||||
// action: this.handleGroupRemoveAccount,
|
||||
// icon: require('@tabler/icons/icons/user-x.svg'),
|
||||
// destructive: true,
|
||||
// });
|
||||
// menu.push({
|
||||
// text: intl.formatMessage(messages.group_remove_post),
|
||||
// action: this.handleGroupRemovePost,
|
||||
// icon: require('@tabler/icons/icons/trash.svg'),
|
||||
// destructive: true,
|
||||
// });
|
||||
// }
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
setRef = (c: HTMLDivElement) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('click', e => {
|
||||
if (this.node && !this.node.contains(e.target))
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.node && !this.node.contains(e.target as Node))
|
||||
this.setState({ emojiSelectorVisible: false });
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
status.getIn(['pleroma', 'emoji_reactions'], ImmutableList()),
|
||||
(status.getIn(['pleroma', 'emoji_reactions']) || ImmutableList()) as ImmutableList<any>,
|
||||
favouriteCount,
|
||||
status.get('favourited'),
|
||||
status.favourited,
|
||||
allowedEmoji,
|
||||
).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.reactionHeart,
|
||||
'😆': messages.reactionLaughing,
|
||||
'😮': messages.reactionOpenMouth,
|
||||
'😢': messages.reactionCry,
|
||||
'😩': messages.reactionWeary,
|
||||
}[meEmojiReact] || messages.favourite);
|
||||
};
|
||||
|
||||
const meEmojiTitle = intl.formatMessage(meEmojiReact ? reactMessages[meEmojiReact] : messages.favourite);
|
||||
|
||||
const menu = this._makeMenu(publicStatus);
|
||||
let reblogIcon = require('@tabler/icons/icons/repeat.svg');
|
||||
let replyTitle;
|
||||
|
||||
if (status.get('visibility') === 'direct') {
|
||||
if (status.visibility === 'direct') {
|
||||
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');
|
||||
}
|
||||
|
||||
let reblogButton;
|
||||
|
||||
if (me && features.quotePosts) {
|
||||
const reblogMenu = [
|
||||
{
|
||||
text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog),
|
||||
const reblogMenu = [{
|
||||
text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog),
|
||||
action: this.handleReblogClick,
|
||||
icon: require('@tabler/icons/icons/repeat.svg'),
|
||||
},
|
||||
{
|
||||
}, {
|
||||
text: intl.formatMessage(messages.quotePost),
|
||||
action: this.handleQuoteClick,
|
||||
icon: require('@tabler/icons/icons/quote.svg'),
|
||||
},
|
||||
];
|
||||
}];
|
||||
|
||||
reblogButton = (
|
||||
<DropdownMenuContainer
|
||||
items={reblogMenu}
|
||||
const reblogButton = (
|
||||
<StatusActionButton
|
||||
icon={reblogIcon}
|
||||
color='success'
|
||||
disabled={!publicStatus}
|
||||
active={status.get('reblogged')}
|
||||
pressed={status.get('reblogged')}
|
||||
title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)}
|
||||
src={reblogIcon}
|
||||
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}
|
||||
active={status.reblogged}
|
||||
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);
|
||||
} else {
|
||||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
const canShare = ('share' in navigator) && status.get('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>
|
||||
);
|
||||
const canShare = ('share' in navigator) && status.visibility === 'public';
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<IconButton
|
||||
<StatusActionButton
|
||||
title={replyTitle}
|
||||
src={require('@tabler/icons/icons/message-circle.svg')}
|
||||
icon={require('@tabler/icons/icons/message-circle.svg')}
|
||||
onClick={this.handleReplyClick}
|
||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-white'
|
||||
count={replyCount}
|
||||
/>
|
||||
|
||||
{replyCount !== 0 ? (
|
||||
<Link to={`/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`}>
|
||||
<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'>
|
||||
{features.quotePosts && me ? (
|
||||
<DropdownMenuContainer items={reblogMenu} onShiftClick={this.handleReblogClick}>
|
||||
{reblogButton}
|
||||
{reblogCount !== 0 && <Text size='xs' theme='muted' role='presentation' onClick={this.handleOpenReblogsModal}>{reblogCount}</Text>}
|
||||
</div>
|
||||
</DropdownMenuContainer>
|
||||
) : (
|
||||
reblogButton
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={this.setRef}
|
||||
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'
|
||||
// onMouseEnter={this.handleLikeButtonHover}
|
||||
// onMouseLeave={this.handleLikeButtonLeave}
|
||||
>
|
||||
{/* <EmojiSelector
|
||||
onReact={this.handleReactClick}
|
||||
visible={features.emojiReacts && emojiSelectorVisible}
|
||||
{features.emojiReacts ? (
|
||||
<Hoverable
|
||||
component={(
|
||||
<EmojiSelector
|
||||
onReact={this.handleReact}
|
||||
focused={emojiSelectorFocused}
|
||||
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} title={intl.formatMessage(messages.more)} status={status} src={require('@tabler/icons/icons/dots.svg')} direction='right' />
|
||||
</div>
|
||||
<DropdownMenuContainer items={menu} status={status}>
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.more)}
|
||||
icon={require('@tabler/icons/icons/dots.svg')}
|
||||
/>
|
||||
</DropdownMenuContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const me = state.get('me');
|
||||
const account = state.getIn(['accounts', me]);
|
||||
const instance = state.get('instance');
|
||||
const mapStateToProps = (state: RootState) => {
|
||||
const { me, instance } = state;
|
||||
const account = state.accounts.get(me);
|
||||
|
||||
return {
|
||||
me,
|
||||
isStaff: account ? isStaff(account) : false,
|
||||
isAdmin: account ? isAdmin(account) : false,
|
||||
isStaff: account ? account.staff : false,
|
||||
isAdmin: account ? account.admin : false,
|
||||
features: getFeatures(instance),
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { status }) => ({
|
||||
const mapDispatchToProps = (dispatch: Dispatch, { status }: { status: Status}) => ({
|
||||
dispatch,
|
||||
onOpenUnauthorizedModal(action) {
|
||||
onOpenUnauthorizedModal(action: AnyAction) {
|
||||
dispatch(openModal('UNAUTHORIZED', {
|
||||
action,
|
||||
ap_id: status.get('url'),
|
||||
ap_id: status.url,
|
||||
}));
|
||||
},
|
||||
onOpenReblogsModal(username, statusId) {
|
||||
onOpenReblogsModal(username: string, statusId: string) {
|
||||
dispatch(openModal('REBLOGS', {
|
||||
username,
|
||||
statusId,
|
||||
|
@ -734,6 +718,9 @@ const mapDispatchToProps = (dispatch, { status }) => ({
|
|||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
export default withRouter(injectIntl(
|
||||
// @ts-ignore
|
||||
connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true },
|
||||
// @ts-ignore
|
||||
)(StatusActionBar)));
|
|
@ -57,7 +57,7 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
|
|||
)
|
||||
)}
|
||||
|
||||
{/* (account && isStaff(account)) && (
|
||||
{/* (account && account.staff && (
|
||||
<ThumbNavigationLink
|
||||
src={require('@tabler/icons/icons/dashboard.svg')}
|
||||
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';
|
||||
|
||||
interface IIconButton {
|
||||
alt?: string,
|
||||
className?: string,
|
||||
interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
iconClassName?: string,
|
||||
disabled?: boolean,
|
||||
src: string,
|
||||
onClick?: () => void,
|
||||
text?: string,
|
||||
title?: string,
|
||||
transparent?: boolean
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@ export { default as Avatar } from './avatar/avatar';
|
|||
export { default as Button } from './button/button';
|
||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||
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 FormActions } from './form-actions/form-actions';
|
||||
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 ActionButton from 'soapbox/features/ui/components/action_button';
|
||||
import {
|
||||
isStaff,
|
||||
isAdmin,
|
||||
isModerator,
|
||||
isLocal,
|
||||
isRemote,
|
||||
getDomain,
|
||||
} from 'soapbox/utils/accounts';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
|
@ -322,7 +318,7 @@ class Header extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (isRemote(account)) {
|
||||
const domain = getDomain(account);
|
||||
const domain = account.fqn.split('@')[1];
|
||||
|
||||
menu.push(null);
|
||||
|
||||
|
@ -341,10 +337,10 @@ class Header extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
if (isStaff(meAccount)) {
|
||||
if (meAccount.staff) {
|
||||
menu.push(null);
|
||||
|
||||
if (isAdmin(meAccount)) {
|
||||
if (meAccount.admin) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.admin_account, { name: account.get('username') }),
|
||||
to: `/pleroma/admin/#/users/${account.id}/`,
|
||||
|
@ -353,8 +349,8 @@ class Header extends ImmutablePureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
if (account.get('id') !== me && isLocal(account) && isAdmin(meAccount)) {
|
||||
if (isAdmin(account)) {
|
||||
if (account.id !== me && isLocal(account) && meAccount.admin) {
|
||||
if (account.admin) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.demoteToModerator, { name: account.get('username') }),
|
||||
action: this.props.onPromoteToModerator,
|
||||
|
@ -365,7 +361,7 @@ class Header extends ImmutablePureComponent {
|
|||
action: this.props.onDemoteToUser,
|
||||
icon: require('@tabler/icons/icons/arrow-down-circle.svg'),
|
||||
});
|
||||
} else if (isModerator(account)) {
|
||||
} else if (account.moderator) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.promoteToAdmin, { name: account.get('username') }),
|
||||
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'])) {
|
||||
menu.push({
|
||||
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 snackbar from 'soapbox/actions/snackbar';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import { isAdmin } from 'soapbox/utils/accounts';
|
||||
|
||||
import Header from '../components/header';
|
||||
|
||||
|
@ -216,7 +215,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
},
|
||||
|
||||
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') });
|
||||
|
||||
dispatch(promoteToModerator(account.get('id')))
|
||||
|
|
|
@ -7,7 +7,6 @@ import { connect } from 'react-redux';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email_list';
|
||||
import { isAdmin } from 'soapbox/utils/accounts';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
import { parseVersion } from 'soapbox/utils/features';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
@ -139,7 +138,7 @@ class Dashboard extends ImmutablePureComponent {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin(account) && <RegistrationModePicker />}
|
||||
{account.admin && <RegistrationModePicker />}
|
||||
<div className='dashwidgets'>
|
||||
<div className='dashwidget'>
|
||||
<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>
|
||||
</ul>
|
||||
</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>
|
||||
<ul>
|
||||
<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 { isUserTouching } from 'soapbox/is_mobile';
|
||||
import { isStaff, isAdmin } from 'soapbox/utils/accounts';
|
||||
import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
||||
|
@ -65,8 +64,8 @@ const mapStateToProps = state => {
|
|||
|
||||
return {
|
||||
me,
|
||||
isStaff: account ? isStaff(account) : false,
|
||||
isAdmin: account ? isAdmin(account) : false,
|
||||
isStaff: account ? account.staff : false,
|
||||
isAdmin: account ? account.admin : false,
|
||||
features: getFeatures(instance),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -11,7 +11,6 @@ import { openModal } from 'soapbox/actions/modals';
|
|||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||
import InstanceRestrictions from 'soapbox/features/federation_restrictions/components/instance_restrictions';
|
||||
import { makeGetRemoteInstance } from 'soapbox/selectors';
|
||||
import { isAdmin } from 'soapbox/utils/accounts';
|
||||
|
||||
const getRemoteInstance = makeGetRemoteInstance();
|
||||
|
||||
|
@ -20,13 +19,13 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
const mapStateToProps = (state, { host }) => {
|
||||
const me = state.get('me');
|
||||
const account = state.getIn(['accounts', me]);
|
||||
const { me, instance } = state;
|
||||
const account = state.accounts.get(me);
|
||||
|
||||
return {
|
||||
instance: state.get('instance'),
|
||||
instance,
|
||||
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 { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import { isStaff } from 'soapbox/utils/accounts';
|
||||
|
||||
import Account from '../../../components/account';
|
||||
|
||||
|
@ -31,7 +30,7 @@ type IMenuItem = {
|
|||
action?: (event: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
const getAccount: any = makeGetAccount();
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
@ -40,7 +39,7 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
|||
const me = useAppSelector((state) => state.me);
|
||||
const currentAccount = useAppSelector((state) => getAccount(state, me));
|
||||
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 handleLogOut = () => {
|
||||
|
|
|
@ -12,7 +12,7 @@ import { initAccountNoteModal } from 'soapbox/actions/account_notes';
|
|||
import Badge from 'soapbox/components/badge';
|
||||
import { Icon, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
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 ProfileStats from './profile_stats';
|
||||
|
@ -48,9 +48,9 @@ class ProfileInfoPanel extends ImmutablePureComponent {
|
|||
getStaffBadge = () => {
|
||||
const { account } = this.props;
|
||||
|
||||
if (isAdmin(account)) {
|
||||
if (account?.admin) {
|
||||
return <Badge slug='admin' title='Admin' key='staff' />;
|
||||
} else if (isModerator(account)) {
|
||||
} else if (account?.moderator) {
|
||||
return <Badge slug='moderator' title='Moderator' key='staff' />;
|
||||
} else {
|
||||
return null;
|
||||
|
@ -155,7 +155,7 @@ class ProfileInfoPanel extends ImmutablePureComponent {
|
|||
|
||||
{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 && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
|
@ -166,7 +166,7 @@ class ProfileInfoPanel extends ImmutablePureComponent {
|
|||
|
||||
<HStack alignItems='center' space={0.5}>
|
||||
<Text size='sm' theme='muted'>
|
||||
@{getAcct(account, displayFqn)}
|
||||
@{displayFqn ? account.fqn : account.acct}
|
||||
</Text>
|
||||
|
||||
{account.get('locked') && (
|
||||
|
|
|
@ -26,7 +26,6 @@ import HomePage from 'soapbox/pages/home_page';
|
|||
import ProfilePage from 'soapbox/pages/profile_page';
|
||||
import RemoteInstancePage from 'soapbox/pages/remote_instance_page';
|
||||
import StatusPage from 'soapbox/pages/status_page';
|
||||
import { isStaff, isAdmin } from 'soapbox/utils/accounts';
|
||||
import { getAccessToken } from 'soapbox/utils/auth';
|
||||
import { getVapidKey } from 'soapbox/utils/auth';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
@ -495,12 +494,12 @@ class UI extends React.PureComponent {
|
|||
dispatch(fetchChats());
|
||||
}
|
||||
|
||||
if (isStaff(account)) {
|
||||
if (account.staff) {
|
||||
dispatch(fetchReports({ state: 'open' }));
|
||||
dispatch(fetchUsers(['local', 'need_approval']));
|
||||
}
|
||||
|
||||
if (isAdmin(account)) {
|
||||
if (account.admin) {
|
||||
dispatch(fetchConfig());
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ import { connect } from 'react-redux';
|
|||
import { Redirect, Route } from 'react-router-dom';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { isStaff, isAdmin } from 'soapbox/utils/accounts';
|
||||
|
||||
import BundleColumnError from '../components/bundle_column_error';
|
||||
import ColumnForbidden from '../components/column_forbidden';
|
||||
|
@ -111,8 +110,8 @@ class WrappedRoute extends React.Component {
|
|||
const authorized = [
|
||||
account || publicRoute,
|
||||
developerOnly ? settings.get('isDeveloper') : true,
|
||||
staffOnly ? account && isStaff(account) : true,
|
||||
adminOnly ? account && isAdmin(account) : true,
|
||||
staffOnly ? account && account.staff : true,
|
||||
adminOnly ? account && account.admin : true,
|
||||
].every(c => c);
|
||||
|
||||
if (!authorized) {
|
||||
|
|
|
@ -168,4 +168,13 @@ describe('normalizeAccount()', () => {
|
|||
|
||||
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 { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||
import { acctFull } from 'soapbox/utils/accounts';
|
||||
import { unescapeHTML } from 'soapbox/utils/html';
|
||||
import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
|
@ -39,7 +38,7 @@ export const AccountRecord = ImmutableRecord({
|
|||
last_status_at: new Date(),
|
||||
location: '',
|
||||
locked: false,
|
||||
moved: null as EmbeddedEntity<any> | null,
|
||||
moved: null as EmbeddedEntity<any>,
|
||||
note: '',
|
||||
pleroma: ImmutableMap<string, any>(),
|
||||
source: ImmutableMap<string, any>(),
|
||||
|
@ -51,12 +50,15 @@ export const AccountRecord = ImmutableRecord({
|
|||
verified: false,
|
||||
|
||||
// Internal fields
|
||||
admin: false,
|
||||
display_name_html: '',
|
||||
moderator: false,
|
||||
note_emojified: '',
|
||||
note_plain: '',
|
||||
patron: ImmutableMap<string, any>(),
|
||||
relationship: ImmutableList<ImmutableMap<string, any>>(),
|
||||
should_refetch: false,
|
||||
staff: false,
|
||||
});
|
||||
|
||||
// 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>) => {
|
||||
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>) => {
|
||||
|
@ -213,6 +248,7 @@ export const normalizeAccount = (account: Record<string, any>) => {
|
|||
normalizeBirthday(account);
|
||||
normalizeLocation(account);
|
||||
normalizeFqn(account);
|
||||
addStaffFields(account);
|
||||
fixUsername(account);
|
||||
fixDisplayName(account);
|
||||
addInternalFields(account);
|
||||
|
|
|
@ -26,8 +26,8 @@ export const AttachmentRecord = ImmutableRecord({
|
|||
|
||||
// Internal fields
|
||||
// TODO: Remove these? They're set in selectors/index.js
|
||||
account: null,
|
||||
status: null,
|
||||
account: null as any,
|
||||
status: null as any,
|
||||
});
|
||||
|
||||
// Ensure attachments have required fields
|
||||
|
|
|
@ -9,16 +9,30 @@ import {
|
|||
fromJS,
|
||||
} 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/
|
||||
export const NotificationRecord = ImmutableRecord({
|
||||
account: null,
|
||||
chat_message: null, // pleroma:chat_mention
|
||||
account: null as EmbeddedEntity<Account>,
|
||||
chat_message: null as ImmutableMap<string, any> | string | null, // pleroma:chat_mention
|
||||
created_at: new Date(),
|
||||
emoji: null, // pleroma:emoji_reaction
|
||||
emoji: null as string | null, // pleroma:emoji_reaction
|
||||
id: '',
|
||||
status: null,
|
||||
target: null, // move
|
||||
type: '',
|
||||
status: null as EmbeddedEntity<Status>,
|
||||
target: null as EmbeddedEntity<Account>, // move
|
||||
type: '' as NotificationType,
|
||||
});
|
||||
|
||||
export const normalizeNotification = (notification: Record<string, any>) => {
|
||||
|
|
|
@ -16,21 +16,23 @@ import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
|||
import { normalizeMention } from 'soapbox/normalizers/mention';
|
||||
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';
|
||||
|
||||
type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct';
|
||||
|
||||
// https://docs.joinmastodon.org/entities/status/
|
||||
export const StatusRecord = ImmutableRecord({
|
||||
account: null as EmbeddedEntity<Account>,
|
||||
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
||||
application: null as ImmutableMap<string, any> | null,
|
||||
bookmarked: false,
|
||||
card: null as EmbeddedEntity<Card>,
|
||||
card: null as Card | null,
|
||||
content: '',
|
||||
created_at: new Date(),
|
||||
emojis: ImmutableList<Emoji>(),
|
||||
favourited: false,
|
||||
favourites_count: 0,
|
||||
group: null as EmbeddedEntity<any>,
|
||||
in_reply_to_account_id: null as string | null,
|
||||
in_reply_to_id: null as string | null,
|
||||
id: '',
|
||||
|
@ -55,6 +57,7 @@ export const StatusRecord = ImmutableRecord({
|
|||
|
||||
// Internal fields
|
||||
contentHtml: '',
|
||||
expectsCard: false,
|
||||
filtered: false,
|
||||
hidden: false,
|
||||
search_index: '',
|
||||
|
|
|
@ -11,19 +11,18 @@ import {
|
|||
InstanceInfoPanel,
|
||||
InstanceModerationPanel,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
import { isAdmin } from 'soapbox/utils/accounts';
|
||||
import { federationRestrictionsDisclosed } from 'soapbox/utils/state';
|
||||
|
||||
import { Layout } from '../components/ui';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const me = state.get('me');
|
||||
const account = state.getIn(['accounts', me]);
|
||||
const me = state.me;
|
||||
const account = state.accounts.get(me);
|
||||
|
||||
return {
|
||||
me,
|
||||
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 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 minifyAccount = (account: AccountRecord): AccountRecord => {
|
||||
const minifyAccount = (account: AccountRecord): ReducerAccount => {
|
||||
return account.mergeWith((o, n) => n || o, {
|
||||
moved: normalizeId(account.getIn(['moved', 'id'])),
|
||||
});
|
||||
}) as ReducerAccount;
|
||||
};
|
||||
|
||||
const fixAccount = (state: State, account: APIEntity) => {
|
||||
|
@ -194,9 +198,9 @@ const importAdminUser = (state: State, adminUser: ImmutableMap<string, any>): St
|
|||
const account = state.get(id);
|
||||
|
||||
if (!account) {
|
||||
return state.set(id, buildAccount(adminUser));
|
||||
return state.set(id, minifyAccount(buildAccount(adminUser)));
|
||||
} 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:
|
||||
return normalizeAccounts(state, action.accounts);
|
||||
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_EXPAND_SUCCESS:
|
||||
return importAccountsFromChats(state, action.chats);
|
||||
|
|
|
@ -21,33 +21,54 @@ import {
|
|||
ADMIN_USERS_APPROVE_SUCCESS,
|
||||
} from '../actions/admin';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { Config } from 'soapbox/utils/config_db';
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
reports: ImmutableMap(),
|
||||
openReports: ImmutableOrderedSet(),
|
||||
users: ImmutableMap(),
|
||||
latestUsers: ImmutableOrderedSet(),
|
||||
awaitingApproval: ImmutableOrderedSet(),
|
||||
configs: ImmutableList(),
|
||||
reports: ImmutableMap<string, any>(),
|
||||
openReports: ImmutableOrderedSet<string>(),
|
||||
users: ImmutableMap<string, any>(),
|
||||
latestUsers: ImmutableOrderedSet<string>(),
|
||||
awaitingApproval: ImmutableOrderedSet<string>(),
|
||||
configs: ImmutableList<Config>(),
|
||||
needsReboot: false,
|
||||
});
|
||||
|
||||
const FILTER_UNAPPROVED = ['local', 'need_approval'];
|
||||
const FILTER_LATEST = ['local', 'active'];
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
const filtersMatch = (f1, f2) => is(ImmutableSet(f1), ImmutableSet(f2));
|
||||
const toIds = items => items.map(item => item.id);
|
||||
// Umm... based?
|
||||
// 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);
|
||||
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);
|
||||
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)) {
|
||||
return mergeSet(state, 'awaitingApproval', users);
|
||||
} 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)) {
|
||||
return replaceSet(state, 'latestUsers', users);
|
||||
} 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({
|
||||
email: user.email,
|
||||
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 => {
|
||||
maybeImportUnapproved(state, users, filters);
|
||||
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 => {
|
||||
accountIds.forEach(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 => {
|
||||
users.forEach(user => {
|
||||
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 => {
|
||||
reports.forEach(report => {
|
||||
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
|
||||
// hence the need for a new function.
|
||||
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) {
|
||||
case ADMIN_CONFIG_FETCH_SUCCESS:
|
||||
case ADMIN_CONFIG_UPDATE_SUCCESS:
|
||||
return state.set('configs', fromJS(action.configs));
|
||||
return importConfigs(state, action.configs);
|
||||
case ADMIN_REPORTS_FETCH_SUCCESS:
|
||||
return importReports(state, action.reports);
|
||||
case ADMIN_REPORTS_PATCH_REQUEST:
|
|
@ -15,24 +15,31 @@ const AlertRecord = ImmutableRecord({
|
|||
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
|
||||
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
|
||||
const importAlert = (state, alert) => {
|
||||
const importAlert = (state: State, alert: PlainAlert): State => {
|
||||
const key = getNextKey(state);
|
||||
const record = AlertRecord({ ...alert, key });
|
||||
return state.push(record);
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
export default function alerts(state = initialState, action) {
|
||||
export default function alerts(state: State = ImmutableList<Alert>(), action: AnyAction): State {
|
||||
switch(action.type) {
|
||||
case ALERT_SHOW:
|
||||
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 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, {
|
||||
account: normalizeId(status.getIn(['account', 'id'])),
|
||||
reblog: normalizeId(status.getIn(['reblog', 'id'])),
|
||||
poll: normalizeId(status.getIn(['poll', 'id'])),
|
||||
quote: normalizeId(status.getIn(['quote', 'id'])),
|
||||
});
|
||||
}) as ReducerStatus;
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
return normalizeStatus(status).withMutations(status => {
|
||||
fixQuote(status, oldStatus);
|
||||
calculateStatus(status, oldStatus, expandSpoilers);
|
||||
minifyStatus(status);
|
||||
});
|
||||
}) as ReducerStatus;
|
||||
};
|
||||
|
||||
const importStatus = (state: State, status: APIEntity, expandSpoilers: boolean): State =>
|
||||
|
@ -204,13 +211,13 @@ export default function statuses(state = initialState, action: AnyAction): State
|
|||
return state
|
||||
.updateIn(
|
||||
[action.status.get('id'), 'pleroma', 'emoji_reactions'],
|
||||
emojiReacts => simulateEmojiReact(emojiReacts, action.emoji),
|
||||
emojiReacts => simulateEmojiReact(emojiReacts as any, action.emoji),
|
||||
);
|
||||
case UNEMOJI_REACT_REQUEST:
|
||||
return state
|
||||
.updateIn(
|
||||
[action.status.get('id'), 'pleroma', 'emoji_reactions'],
|
||||
emojiReacts => simulateUnEmojiReact(emojiReacts, action.emoji),
|
||||
emojiReacts => simulateUnEmojiReact(emojiReacts as any, action.emoji),
|
||||
);
|
||||
case FAVOURITE_FAIL:
|
||||
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';
|
||||
|
||||
type Account = ReturnType<typeof AccountRecord>;
|
||||
type Attachment = ReturnType<typeof AttachmentRecord>;
|
||||
type Card = ReturnType<typeof CardRecord>;
|
||||
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||
|
@ -24,7 +23,18 @@ type Mention = ReturnType<typeof MentionRecord>;
|
|||
type Notification = ReturnType<typeof NotificationRecord>;
|
||||
type Poll = ReturnType<typeof PollRecord>;
|
||||
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
|
||||
type APIEntity = Record<string, any>;
|
||||
|
|
|
@ -5,12 +5,15 @@ import {
|
|||
SoapboxConfigRecord,
|
||||
} from 'soapbox/normalizers/soapbox/soapbox_config';
|
||||
|
||||
type Me = string | null | false | undefined;
|
||||
|
||||
type PromoPanelItem = ReturnType<typeof PromoPanelItemRecord>;
|
||||
type FooterItem = ReturnType<typeof FooterItemRecord>;
|
||||
type CryptoAddress = ReturnType<typeof CryptoAddressRecord>;
|
||||
type SoapboxConfig = ReturnType<typeof SoapboxConfigRecord>;
|
||||
|
||||
export {
|
||||
Me,
|
||||
PromoPanelItem,
|
||||
FooterItem,
|
||||
CryptoAddress,
|
||||
|
|
|
@ -1,15 +1,11 @@
|
|||
import { fromJS } from 'immutable';
|
||||
import { AccountRecord } from 'soapbox/normalizers';
|
||||
|
||||
import {
|
||||
getDomain,
|
||||
acctFull,
|
||||
isStaff,
|
||||
isAdmin,
|
||||
isModerator,
|
||||
} from '../accounts';
|
||||
|
||||
describe('getDomain', () => {
|
||||
const account = fromJS({
|
||||
const account = AccountRecord({
|
||||
acct: 'alice',
|
||||
url: 'https://party.com/users/alice',
|
||||
});
|
||||
|
@ -17,101 +13,3 @@ describe('getDomain', () => {
|
|||
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,
|
||||
} from '../emoji_reacts';
|
||||
|
||||
const ALLOWED_EMOJI = [
|
||||
const ALLOWED_EMOJI = fromJS([
|
||||
'👍',
|
||||
'❤',
|
||||
'😂',
|
||||
'😯',
|
||||
'😢',
|
||||
'😡',
|
||||
];
|
||||
]);
|
||||
|
||||
describe('filterEmoji', () => {
|
||||
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)', () => {
|
||||
const status = fromJS([]);
|
||||
const status = fromJS({});
|
||||
expect(getReactForStatus(status)).toEqual(undefined);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,27 +1,21 @@
|
|||
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 {
|
||||
const url = account.get('url');
|
||||
const url = account.url;
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getDomain = (account: ImmutableMap<string, any>): string => {
|
||||
const domain = account.get('acct', '').split('@')[1];
|
||||
export const getDomain = (account: Account): string => {
|
||||
const domain = account.acct.split('@')[1];
|
||||
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 => {
|
||||
try {
|
||||
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 => (
|
||||
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 => {
|
||||
const items: any = state.getIn(['user_lists', type, accountId, 'items'], ImmutableOrderedSet());
|
||||
const counter: number = Number(state.getIn(['accounts_counters', accountId, `${type}_count`], 0));
|
||||
|
|
|
@ -6,8 +6,8 @@ import {
|
|||
} from 'immutable';
|
||||
import { trimStart } from 'lodash';
|
||||
|
||||
type Config = ImmutableMap<string, any>;
|
||||
type Policy = ImmutableMap<string, any>;
|
||||
export type Config = ImmutableMap<string, any>;
|
||||
export type Policy = ImmutableMap<string, any>;
|
||||
|
||||
const find = (
|
||||
configs: ImmutableList<Config>,
|
||||
|
|
|
@ -3,31 +3,36 @@ import {
|
|||
List as ImmutableList,
|
||||
} from 'immutable';
|
||||
|
||||
import type { Me } from 'soapbox/types/soapbox';
|
||||
|
||||
// https://emojipedia.org/facebook
|
||||
// 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'))
|
||||
);
|
||||
|
||||
export const mergeEmoji = emojiReacts => (
|
||||
export const mergeEmoji = (emojiReacts: ImmutableList<EmojiReact>): ImmutableList<EmojiReact> => (
|
||||
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;
|
||||
const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.get('name') === '👍');
|
||||
if (likeIndex > -1) {
|
||||
const likeCount = emojiReacts.getIn([likeIndex, 'count']);
|
||||
favourited = favourited || emojiReacts.getIn([likeIndex, 'me'], false);
|
||||
const likeCount = Number(emojiReacts.getIn([likeIndex, 'count']));
|
||||
favourited = favourited || Boolean(emojiReacts.getIn([likeIndex, 'me'], false));
|
||||
return emojiReacts
|
||||
.setIn([likeIndex, 'count'], likeCount + favouritesCount)
|
||||
.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(
|
||||
e => e.get('accounts').filter(
|
||||
a => a.get('id') === account.get('id'),
|
||||
(a: Account) => a.get('id') === account.get('id'),
|
||||
).count() > 0,
|
||||
).count() > 1
|
||||
);
|
||||
|
||||
const inAccounts = (accounts, id) => (
|
||||
const inAccounts = (accounts: ImmutableList<Account>, id: string): boolean => (
|
||||
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();
|
||||
|
||||
return emojiReacts.reduce((acc, cur, idx) => {
|
||||
const accounts = cur.get('accounts', ImmutableList())
|
||||
.filter(a => !hasMultiReactions(acc, a));
|
||||
.filter((a: Account) => !hasMultiReactions(acc, a));
|
||||
|
||||
return acc.set(idx, cur.merge({
|
||||
accounts: accounts,
|
||||
|
@ -65,30 +70,33 @@ export const oneEmojiPerAccount = (emojiReacts, me) => {
|
|||
.reverse();
|
||||
};
|
||||
|
||||
export const filterEmoji = (emojiReacts, allowedEmoji=ALLOWED_EMOJI) => (
|
||||
export const filterEmoji = (emojiReacts: ImmutableList<EmojiReact>, allowedEmoji=ALLOWED_EMOJI): ImmutableList<EmojiReact> => (
|
||||
emojiReacts.filter(emojiReact => (
|
||||
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(
|
||||
emojiReacts, favouritesCount, favourited,
|
||||
))), allowedEmoji));
|
||||
|
||||
export const getReactForStatus = (status, allowedEmoji=ALLOWED_EMOJI) => {
|
||||
return reduceEmoji(
|
||||
export const getReactForStatus = (status: any, allowedEmoji=ALLOWED_EMOJI): string | undefined => {
|
||||
const result = reduceEmoji(
|
||||
status.getIn(['pleroma', 'emoji_reactions'], ImmutableList()),
|
||||
status.get('favourites_count', 0),
|
||||
status.get('favourited'),
|
||||
allowedEmoji,
|
||||
).filter(e => e.get('me') === true)
|
||||
.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);
|
||||
if (idx > -1) {
|
||||
const emojiReact = emojiReacts.get(idx);
|
||||
|
||||
if (idx > -1 && emojiReact) {
|
||||
return emojiReacts.set(idx, emojiReact.merge({
|
||||
count: emojiReact.get('count') + 1,
|
||||
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 =>
|
||||
e.get('name') === emoji && e.get('me') === true);
|
||||
|
||||
if (idx > -1) {
|
||||
const emojiReact = emojiReacts.get(idx);
|
||||
|
||||
if (emojiReact) {
|
||||
const newCount = emojiReact.get('count') - 1;
|
||||
if (newCount < 1) return emojiReacts.delete(idx);
|
||||
return emojiReacts.set(idx, emojiReact.merge({
|
|
@ -121,8 +121,8 @@
|
|||
background: transparent;
|
||||
|
||||
img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 3px;
|
||||
transition: 0.1s;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue