Merge branch 'next-emoji-reacts' into 'next'

Next: emoji reacts part 1

See merge request soapbox-pub/soapbox-fe!1161
next-interactions
Alex Gleason 2 years ago
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}
<Icon
src={icon}
className={classNames({
'h-5 w-5': true,
'text-primary-700 dark:text-white': !isActive,
'text-white': isActive,
})}
/>
<div className='h-5 w-5'>
<Icon
src={icon}
className={classNames({
'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.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
status = status.get('reblog');
if (status.reblog && typeof status.reblog === 'object') {
status = status.reblog;
}
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all');
};
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,
};
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,
}
interface IStatusState {
showMedia: boolean,
statusId?: string,
emojiSelectorFocused: boolean,
mediaWrapperWidth?: number,
}
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 {
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 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,
}
interface IStatusActionBarState {
emojiSelectorVisible: boolean,
}
class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusActionBarState> {
static defaultProps = {
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);
}
e.stopPropagation();
}
handleReactClick = emoji => {
return e => {
const { me, dispatch, onOpenUnauthorizedModal, status } = this.props;
if (me) {
dispatch(simpleEmojiReact(status, emoji));
} else {
onOpenUnauthorizedModal('FAVOURITE');
}
this.setState({ emojiSelectorVisible: false });
handleReact = (emoji: string): void => {
const { me, dispatch, onOpenUnauthorizedModal, status } = this.props;
if (me) {
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;
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) => {
// 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);
// }
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.visibility);
const publicStatus = ['public', 'unlisted'].includes(status.get('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),
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}
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}
onClick={this.handleReblogClick}
/>
);
}
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'),
}];
const reblogButton = (
<StatusActionButton
icon={reblogIcon}
color='success'
disabled={!publicStatus}
title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)}
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
title={replyTitle}
src={require('@tabler/icons/icons/message-circle.svg')}
onClick={this.handleReplyClick}
className='text-gray-400 hover:text-gray-600 dark:hover:text-white'
<StatusActionButton
title={replyTitle}
icon={require('@tabler/icons/icons/message-circle.svg')}
onClick={this.handleReplyClick}
count={replyCount}
/>
{features.quotePosts && me ? (
<DropdownMenuContainer items={reblogMenu} onShiftClick={this.handleReblogClick}>
{reblogButton}
</DropdownMenuContainer>
) : (
reblogButton
)}
{features.emojiReacts ? (
<Hoverable
component={(
<EmojiSelector
onReact={this.handleReact}
focused={emojiSelectorFocused}
onUnfocus={handleEmojiSelectorUnfocus}
/>
)}
>
<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}
/>
)}
{canShare && (
<StatusActionButton
title={intl.formatMessage(messages.share)}
icon={require('@tabler/icons/icons/upload.svg')}
onClick={this.handleShareClick}
/>
)}
{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'>
{reblogButton}
{reblogCount !== 0 && <Text size='xs' theme='muted' role='presentation' onClick={this.handleOpenReblogsModal}>{reblogCount}</Text>}
</div>
<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}
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}
<DropdownMenuContainer items={menu} status={status}>
<StatusActionButton
title={intl.formatMessage(messages.more)}
icon={require('@tabler/icons/icons/dots.svg')}
/>
{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>
{shareButton}
<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>
</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)));

@ -55,9 +55,9 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
paths={['/messages', '/conversations']}
/>
)
)}
)}
{/* (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>;
// Umm... based?
// https://itnext.io/typescript-extract-unpack-a-type-from-a-generic-baca7af14e51
type InnerRecord<R> = R extends ImmutableRecord<infer TProps> ? TProps : never;
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 filtersMatch = (f1, f2) => is(ImmutableSet(f1), ImmutableSet(f2));
const toIds = items => items.map(item => item.id);
const FILTER_UNAPPROVED: Filter[] = ['local', 'need_approval'];
const FILTER_LATEST: Filter[] = ['local', 'active'];
const mergeSet = (state, key, users) => {
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([
'👍',
'❤️',
'😆',
'😮',
'😢',
'😩',
];
]);
type Account = ImmutableMap<string, any>;
type EmojiReact = ImmutableMap<string, any>;
export const sortEmoji = emojiReacts => (
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);
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);
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…
Cancel
Save