Merge remote-tracking branch 'soapbox/next' into edit-posts
This commit is contained in:
commit
d487e34548
|
@ -1,68 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
import { decode } from 'blurhash';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { useRef, useEffect } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef BlurhashPropsBase
|
|
||||||
* @property {string?} hash Hash to render
|
|
||||||
* @property {number} width
|
|
||||||
* Width of the blurred region in pixels. Defaults to 32
|
|
||||||
* @property {number} [height]
|
|
||||||
* Height of the blurred region in pixels. Defaults to width
|
|
||||||
* @property {boolean} [dummy]
|
|
||||||
* Whether dummy mode is enabled. If enabled, nothing is rendered
|
|
||||||
* and canvas left untouched
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component that is used to render blurred of blurhash string
|
|
||||||
*
|
|
||||||
* @param {BlurhashProps} param1 Props of the component
|
|
||||||
* @returns Canvas which will render blurred region element to embed
|
|
||||||
*/
|
|
||||||
function Blurhash({
|
|
||||||
hash,
|
|
||||||
width = 32,
|
|
||||||
height = width,
|
|
||||||
dummy = false,
|
|
||||||
...canvasProps
|
|
||||||
}) {
|
|
||||||
const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { current: canvas } = canvasRef;
|
|
||||||
|
|
||||||
// resets canvas
|
|
||||||
canvas.width = canvas.width; // eslint-disable-line no-self-assign
|
|
||||||
|
|
||||||
if (dummy || !hash) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pixels = decode(hash, width, height);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const imageData = new ImageData(pixels, width, height);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Blurhash decoding failure', { err, hash });
|
|
||||||
}
|
|
||||||
}, [dummy, hash, width, height]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Blurhash.propTypes = {
|
|
||||||
hash: PropTypes.string,
|
|
||||||
width: PropTypes.number,
|
|
||||||
height: PropTypes.number,
|
|
||||||
dummy: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default React.memo(Blurhash);
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { decode } from 'blurhash';
|
||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface IBlurhash {
|
||||||
|
/** Hash to render */
|
||||||
|
hash: string | null | undefined,
|
||||||
|
/** Width of the blurred region in pixels. Defaults to 32. */
|
||||||
|
width?: number,
|
||||||
|
/** Height of the blurred region in pixels. Defaults to width. */
|
||||||
|
height?: number,
|
||||||
|
/**
|
||||||
|
* Whether dummy mode is enabled. If enabled, nothing is rendered
|
||||||
|
* and canvas left untouched.
|
||||||
|
*/
|
||||||
|
dummy?: boolean,
|
||||||
|
/** className of the canvas element. */
|
||||||
|
className?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a blurhash in a canvas element.
|
||||||
|
* @see {@link https://blurha.sh/}
|
||||||
|
*/
|
||||||
|
const Blurhash: React.FC<IBlurhash> = ({
|
||||||
|
hash,
|
||||||
|
width = 32,
|
||||||
|
height = width,
|
||||||
|
dummy = false,
|
||||||
|
...canvasProps
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { current: canvas } = canvasRef;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
// resets canvas
|
||||||
|
canvas.width = canvas.width; // eslint-disable-line no-self-assign
|
||||||
|
|
||||||
|
if (dummy || !hash) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pixels = decode(hash, width, height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const imageData = new ImageData(pixels, width, height);
|
||||||
|
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blurhash decoding failure', { err, hash });
|
||||||
|
}
|
||||||
|
}, [dummy, hash, width, height]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(Blurhash);
|
|
@ -6,7 +6,7 @@ import { spring } from 'react-motion';
|
||||||
import Overlay from 'react-overlays/lib/Overlay';
|
import Overlay from 'react-overlays/lib/Overlay';
|
||||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||||
|
|
||||||
import { IconButton } from 'soapbox/components/ui';
|
import { IconButton, Counter } from 'soapbox/components/ui';
|
||||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||||
import Motion from 'soapbox/features/ui/util/optional_motion';
|
import Motion from 'soapbox/features/ui/util/optional_motion';
|
||||||
|
|
||||||
|
@ -24,6 +24,7 @@ export interface MenuItem {
|
||||||
newTab?: boolean,
|
newTab?: boolean,
|
||||||
isLogout?: boolean,
|
isLogout?: boolean,
|
||||||
icon: string,
|
icon: string,
|
||||||
|
count?: number,
|
||||||
destructive?: boolean,
|
destructive?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,7 +175,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
|
||||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, href, to, newTab, isLogout, icon, destructive } = option;
|
const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
|
<li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
|
||||||
|
@ -191,7 +192,14 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
|
||||||
data-method={isLogout ? 'delete' : undefined}
|
data-method={isLogout ? 'delete' : undefined}
|
||||||
>
|
>
|
||||||
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />}
|
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />}
|
||||||
|
|
||||||
<span className='truncate'>{text}</span>
|
<span className='truncate'>{text}</span>
|
||||||
|
|
||||||
|
{count ? (
|
||||||
|
<span className='ml-auto h-5 w-5 flex-none'>
|
||||||
|
<Counter count={count} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||||
|
@ -11,7 +10,13 @@ import { shortNumberFormat } from '../utils/numbers';
|
||||||
import Permalink from './permalink';
|
import Permalink from './permalink';
|
||||||
import { HStack, Stack, Text } from './ui';
|
import { HStack, Stack, Text } from './ui';
|
||||||
|
|
||||||
const Hashtag = ({ hashtag }) => {
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
|
interface IHashtag {
|
||||||
|
hashtag: ImmutableMap<string, any>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
|
||||||
const count = Number(hashtag.getIn(['history', 0, 'accounts']));
|
const count = Number(hashtag.getIn(['history', 0, 'accounts']));
|
||||||
const brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor'));
|
const brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor'));
|
||||||
|
|
||||||
|
@ -41,7 +46,7 @@ const Hashtag = ({ hashtag }) => {
|
||||||
<Sparklines
|
<Sparklines
|
||||||
width={40}
|
width={40}
|
||||||
height={28}
|
height={28}
|
||||||
data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}
|
data={hashtag.get('history').reverse().map((day: ImmutableMap<string, any>) => day.get('uses')).toArray()}
|
||||||
>
|
>
|
||||||
<SparklinesCurve style={{ fill: 'none' }} color={brandColor} />
|
<SparklinesCurve style={{ fill: 'none' }} color={brandColor} />
|
||||||
</Sparklines>
|
</Sparklines>
|
||||||
|
@ -51,8 +56,4 @@ const Hashtag = ({ hashtag }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Hashtag.propTypes = {
|
|
||||||
hashtag: ImmutablePropTypes.map.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Hashtag;
|
export default Hashtag;
|
|
@ -1,5 +1,4 @@
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
|
@ -13,10 +12,16 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
|
||||||
dispatch(openProfileHoverCard(ref, accountId));
|
dispatch(openProfileHoverCard(ref, accountId));
|
||||||
}, 600);
|
}, 600);
|
||||||
|
|
||||||
export const HoverRefWrapper = ({ accountId, children, inline }) => {
|
interface IHoverRefWrapper {
|
||||||
|
accountId: string,
|
||||||
|
inline: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Makes a profile hover card appear when the wrapped element is hovered. */
|
||||||
|
export const HoverRefWrapper: React.FC<IHoverRefWrapper> = ({ accountId, children, inline = false }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const ref = useRef();
|
const ref = useRef<HTMLElement>();
|
||||||
const Elem = inline ? 'span' : 'div';
|
const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div';
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
if (!isMobile(window.innerWidth)) {
|
if (!isMobile(window.innerWidth)) {
|
||||||
|
@ -36,6 +41,7 @@ export const HoverRefWrapper = ({ accountId, children, inline }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Elem
|
<Elem
|
||||||
|
// @ts-ignore: not sure how to fix :\
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className='hover-ref-wrapper'
|
className='hover-ref-wrapper'
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
|
@ -47,14 +53,4 @@ export const HoverRefWrapper = ({ accountId, children, inline }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
HoverRefWrapper.propTypes = {
|
|
||||||
accountId: PropTypes.string,
|
|
||||||
children: PropTypes.node,
|
|
||||||
inline: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
HoverRefWrapper.defaultProps = {
|
|
||||||
inline: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export { HoverRefWrapper as default, showProfileHoverCard };
|
export { HoverRefWrapper as default, showProfileHoverCard };
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
import { Counter } from 'soapbox/components/ui';
|
||||||
|
|
||||||
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
count: number,
|
count: number,
|
||||||
|
@ -14,9 +14,11 @@ const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) =
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
<Icon id={icon} {...rest} />
|
<Icon id={icon} {...rest} />
|
||||||
|
|
||||||
{count > 0 && <i className='absolute -top-2 -right-2 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'>
|
{count > 0 && (
|
||||||
{shortNumberFormat(count)}
|
<i className='absolute -top-2 -right-2'>
|
||||||
</i>}
|
<Counter count={count} />
|
||||||
|
</i>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
|
||||||
const List = ({ children }) => (
|
const List: React.FC = ({ children }) => (
|
||||||
<div className='space-y-0.5'>{children}</div>
|
<div className='space-y-0.5'>{children}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
List.propTypes = {
|
interface IListItem {
|
||||||
children: PropTypes.node,
|
label: React.ReactNode,
|
||||||
};
|
hint?: React.ReactNode,
|
||||||
|
onClick?: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
const ListItem = ({ label, hint, children, onClick }) => {
|
const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const domId = `list-group-${id}`;
|
const domId = `list-group-${id}`;
|
||||||
|
|
||||||
|
@ -60,11 +61,4 @@ const ListItem = ({ label, hint, children, onClick }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ListItem.propTypes = {
|
|
||||||
label: PropTypes.node.isRequired,
|
|
||||||
hint: PropTypes.node,
|
|
||||||
children: PropTypes.node,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export { List as default, ListItem };
|
export { List as default, ListItem };
|
|
@ -1,65 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import IconButton from 'soapbox/components/icon_button';
|
|
||||||
import { FormPropTypes, InputContainer, LabelInputContainer } from 'soapbox/features/forms';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
showPassword: { id: 'forms.show_password', defaultMessage: 'Show password' },
|
|
||||||
hidePassword: { id: 'forms.hide_password', defaultMessage: 'Hide password' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @injectIntl
|
|
||||||
class ShowablePassword extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
label: FormPropTypes.label,
|
|
||||||
className: PropTypes.string,
|
|
||||||
hint: PropTypes.node,
|
|
||||||
error: PropTypes.bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
state = {
|
|
||||||
revealed: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleReveal = () => {
|
|
||||||
if (this.props.onToggleVisibility) {
|
|
||||||
this.props.onToggleVisibility();
|
|
||||||
} else {
|
|
||||||
this.setState({ revealed: !this.state.revealed });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl, hint, error, label, className, ...props } = this.props;
|
|
||||||
const { revealed } = this.state;
|
|
||||||
|
|
||||||
const revealButton = (
|
|
||||||
<IconButton
|
|
||||||
src={revealed ? require('@tabler/icons/icons/eye-off.svg') : require('@tabler/icons/icons/eye.svg')}
|
|
||||||
onClick={this.toggleReveal}
|
|
||||||
title={intl.formatMessage(revealed ? messages.hidePassword : messages.showPassword)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InputContainer {...this.props} extraClass={classNames('showable-password', className)}>
|
|
||||||
{label ? (
|
|
||||||
<LabelInputContainer label={label}>
|
|
||||||
<input {...props} type={revealed ? 'text' : 'password'} />
|
|
||||||
{revealButton}
|
|
||||||
</LabelInputContainer>
|
|
||||||
) : (<>
|
|
||||||
<input {...props} type={revealed ? 'text' : 'password'} />
|
|
||||||
{revealButton}
|
|
||||||
</>)}
|
|
||||||
</InputContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import IconButton from 'soapbox/components/icon_button';
|
||||||
|
import { InputContainer, LabelInputContainer } from 'soapbox/features/forms';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
showPassword: { id: 'forms.show_password', defaultMessage: 'Show password' },
|
||||||
|
hidePassword: { id: 'forms.hide_password', defaultMessage: 'Hide password' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IShowablePassword {
|
||||||
|
label?: React.ReactNode,
|
||||||
|
className?: string,
|
||||||
|
hint?: React.ReactNode,
|
||||||
|
error?: boolean,
|
||||||
|
onToggleVisibility?: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShowablePassword: React.FC<IShowablePassword> = (props) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
|
||||||
|
const { hint, error, label, className, ...rest } = props;
|
||||||
|
|
||||||
|
const toggleReveal = () => {
|
||||||
|
if (props.onToggleVisibility) {
|
||||||
|
props.onToggleVisibility();
|
||||||
|
} else {
|
||||||
|
setRevealed(!revealed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revealButton = (
|
||||||
|
<IconButton
|
||||||
|
src={revealed ? require('@tabler/icons/icons/eye-off.svg') : require('@tabler/icons/icons/eye.svg')}
|
||||||
|
onClick={toggleReveal}
|
||||||
|
title={intl.formatMessage(revealed ? messages.hidePassword : messages.showPassword)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputContainer {...props} extraClass={classNames('showable-password', className)}>
|
||||||
|
{label ? (
|
||||||
|
<LabelInputContainer label={label}>
|
||||||
|
<input {...rest} type={revealed ? 'text' : 'password'} />
|
||||||
|
{revealButton}
|
||||||
|
</LabelInputContainer>
|
||||||
|
) : (<>
|
||||||
|
<input {...rest} type={revealed ? 'text' : 'password'} />
|
||||||
|
{revealButton}
|
||||||
|
</>)}
|
||||||
|
</InputContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShowablePassword;
|
|
@ -2,7 +2,7 @@ import classNames from 'classnames';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { Icon, Text } from './ui';
|
import { Icon, Text, Counter } from './ui';
|
||||||
|
|
||||||
interface ISidebarNavigationLink {
|
interface ISidebarNavigationLink {
|
||||||
count?: number,
|
count?: number,
|
||||||
|
@ -44,8 +44,8 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{withCounter && count > 0 ? (
|
{withCounter && count > 0 ? (
|
||||||
<span className='absolute -top-2 -right-2 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'>
|
<span className='absolute -top-2 -right-2'>
|
||||||
{count}
|
<Counter count={count} />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ const SidebarNavigation = () => {
|
||||||
const notificationCount = useAppSelector((state) => state.notifications.get('unread'));
|
const notificationCount = useAppSelector((state) => state.notifications.get('unread'));
|
||||||
const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: any, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0));
|
const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: any, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0));
|
||||||
const followRequestsCount = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'], ImmutableOrderedSet()).count());
|
const followRequestsCount = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'], ImmutableOrderedSet()).count());
|
||||||
// const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
||||||
|
|
||||||
const baseURL = account ? getBaseURL(account) : '';
|
const baseURL = account ? getBaseURL(account) : '';
|
||||||
const features = getFeatures(instance);
|
const features = getFeatures(instance);
|
||||||
|
@ -76,8 +76,7 @@ const SidebarNavigation = () => {
|
||||||
to: '/admin',
|
to: '/admin',
|
||||||
icon: require('@tabler/icons/icons/dashboard.svg'),
|
icon: require('@tabler/icons/icons/dashboard.svg'),
|
||||||
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
|
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
|
||||||
// TODO: let menu items have a counter
|
count: dashboardCount,
|
||||||
// count: dashboardCount,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,6 +159,7 @@ const SidebarNavigation = () => {
|
||||||
<DropdownMenu items={menu}>
|
<DropdownMenu items={menu}>
|
||||||
<SidebarNavigationLink
|
<SidebarNavigationLink
|
||||||
icon={require('@tabler/icons/icons/dots-circle-horizontal.svg')}
|
icon={require('@tabler/icons/icons/dots-circle-horizontal.svg')}
|
||||||
|
count={dashboardCount}
|
||||||
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
|
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
|
||||||
/>
|
/>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
@ -646,7 +646,11 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{features.quotePosts && me ? (
|
{features.quotePosts && me ? (
|
||||||
<DropdownMenuContainer items={reblogMenu} onShiftClick={this.handleReblogClick}>
|
<DropdownMenuContainer
|
||||||
|
items={reblogMenu}
|
||||||
|
disabled={!publicStatus}
|
||||||
|
onShiftClick={this.handleReblogClick}
|
||||||
|
>
|
||||||
{reblogButton}
|
{reblogButton}
|
||||||
</DropdownMenuContainer>
|
</DropdownMenuContainer>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -20,9 +20,10 @@ interface ICard {
|
||||||
variant?: 'rounded',
|
variant?: 'rounded',
|
||||||
size?: 'md' | 'lg' | 'xl',
|
size?: 'md' | 'lg' | 'xl',
|
||||||
className?: string,
|
className?: string,
|
||||||
|
children: React.ReactNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card: React.FC<ICard> = React.forwardRef(({ children, variant, size = 'md', className, ...filteredProps }, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => (
|
const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant, size = 'md', className, ...filteredProps }, ref): JSX.Element => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...filteredProps}
|
{...filteredProps}
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||||
|
|
||||||
|
interface ICounter {
|
||||||
|
count: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A simple counter for notifications, etc. */
|
||||||
|
const Counter: React.FC<ICounter> = ({ count }) => {
|
||||||
|
return (
|
||||||
|
<span className='block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white dark:ring-slate-800'>
|
||||||
|
{shortNumberFormat(count)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Counter;
|
|
@ -1,7 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import Counter from '../counter/counter';
|
||||||
|
|
||||||
import SvgIcon from './svg-icon';
|
import SvgIcon from './svg-icon';
|
||||||
|
|
||||||
|
|
||||||
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
||||||
className?: string,
|
className?: string,
|
||||||
count?: number,
|
count?: number,
|
||||||
|
@ -13,8 +16,8 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
||||||
const Icon = ({ src, alt, count, size, ...filteredProps }: IIcon): JSX.Element => (
|
const Icon = ({ src, alt, count, size, ...filteredProps }: IIcon): JSX.Element => (
|
||||||
<div className='relative' data-testid='icon'>
|
<div className='relative' data-testid='icon'>
|
||||||
{count ? (
|
{count ? (
|
||||||
<span className='absolute -top-2 -right-3 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'>
|
<span className='absolute -top-2 -right-3'>
|
||||||
{count}
|
<Counter count={count} />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ export { default as Avatar } from './avatar/avatar';
|
||||||
export { default as Button } from './button/button';
|
export { default as Button } from './button/button';
|
||||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||||
export { default as Column } from './column/column';
|
export { default as Column } from './column/column';
|
||||||
|
export { default as Counter } from './counter/counter';
|
||||||
export { default as Emoji } from './emoji/emoji';
|
export { default as Emoji } from './emoji/emoji';
|
||||||
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
|
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
|
||||||
export { default as Form } from './form/form';
|
export { default as Form } from './form/form';
|
||||||
|
|
|
@ -9,7 +9,7 @@ interface LayoutType extends React.FC {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Layout: LayoutType = ({ children }) => (
|
const Layout: LayoutType = ({ children }) => (
|
||||||
<div className='sm:pt-4 relative pb-36'>
|
<div className='sm:pt-4 relative'>
|
||||||
<div className='max-w-3xl mx-auto sm:px-6 md:max-w-7xl md:px-8 md:grid md:grid-cols-12 md:gap-8'>
|
<div className='max-w-3xl mx-auto sm:px-6 md:max-w-7xl md:px-8 md:grid md:grid-cols-12 md:gap-8'>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
@ -27,7 +27,7 @@ const Sidebar: React.FC = ({ children }) => (
|
||||||
const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className }) => (
|
const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className }) => (
|
||||||
<main
|
<main
|
||||||
className={classNames({
|
className={classNames({
|
||||||
'md:col-span-12 lg:col-span-9 xl:col-span-6 sm:space-y-4': true,
|
'md:col-span-12 lg:col-span-9 xl:col-span-6 sm:space-y-4 pb-36': true,
|
||||||
}, className)}
|
}, className)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -11,8 +11,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-reach-tab] {
|
[data-reach-tab] {
|
||||||
@apply flex-1 flex justify-center py-4 px-1 text-center font-medium text-sm
|
@apply flex-1 flex justify-center items-center
|
||||||
text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200;
|
py-4 px-1 text-center font-medium text-sm text-gray-500
|
||||||
|
dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-reach-tab][data-selected] {
|
[data-reach-tab][data-selected] {
|
||||||
|
|
|
@ -9,6 +9,8 @@ import classNames from 'classnames';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Counter from '../counter/counter';
|
||||||
|
|
||||||
import './tabs.css';
|
import './tabs.css';
|
||||||
|
|
||||||
const HORIZONTAL_PADDING = 8;
|
const HORIZONTAL_PADDING = 8;
|
||||||
|
@ -95,6 +97,7 @@ type Item = {
|
||||||
href?: string,
|
href?: string,
|
||||||
to?: string,
|
to?: string,
|
||||||
action?: () => void,
|
action?: () => void,
|
||||||
|
count?: number,
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
interface ITabs {
|
interface ITabs {
|
||||||
|
@ -118,7 +121,7 @@ const Tabs = ({ items, activeItem }: ITabs) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItem = (item: Item, idx: number) => {
|
const renderItem = (item: Item, idx: number) => {
|
||||||
const { name, text, title } = item;
|
const { name, text, title, count } = item;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedTab
|
<AnimatedTab
|
||||||
|
@ -129,7 +132,15 @@ const Tabs = ({ items, activeItem }: ITabs) => {
|
||||||
title={title}
|
title={title}
|
||||||
index={idx}
|
index={idx}
|
||||||
>
|
>
|
||||||
{text}
|
<div className='relative'>
|
||||||
|
{count ? (
|
||||||
|
<span className='absolute -top-2 left-full ml-1'>
|
||||||
|
<Counter count={count} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
</AnimatedTab>
|
</AnimatedTab>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,65 +0,0 @@
|
||||||
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 { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { fetchUsers } from 'soapbox/actions/admin';
|
|
||||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
|
||||||
|
|
||||||
import Column from '../ui/components/column';
|
|
||||||
|
|
||||||
import UnapprovedAccount from './components/unapproved_account';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
heading: { id: 'column.admin.awaiting_approval', defaultMessage: 'Awaiting Approval' },
|
|
||||||
emptyMessage: { id: 'admin.awaiting_approval.empty_message', defaultMessage: 'There is nobody waiting for approval. When a new user signs up, you can review them here.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
accountIds: state.getIn(['admin', 'awaitingApproval']),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class AwaitingApproval extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
accountIds: ImmutablePropTypes.orderedSet.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
isLoading: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(fetchUsers(['local', 'need_approval']))
|
|
||||||
.then(() => this.setState({ isLoading: false }))
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl, accountIds } = this.props;
|
|
||||||
const { isLoading } = this.state;
|
|
||||||
const showLoading = isLoading && accountIds.count() === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column icon='user' label={intl.formatMessage(messages.heading)}>
|
|
||||||
<ScrollableList
|
|
||||||
isLoading={isLoading}
|
|
||||||
showLoading={showLoading}
|
|
||||||
scrollKey='awaiting-approval'
|
|
||||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
|
||||||
>
|
|
||||||
{accountIds.map(id => (
|
|
||||||
<UnapprovedAccount accountId={id} key={id} />
|
|
||||||
))}
|
|
||||||
</ScrollableList>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
import { useRouteMatch } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Tabs } from 'soapbox/components/ui';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
dashboard: { id: 'admin_nav.dashboard', defaultMessage: 'Dashboard' },
|
||||||
|
reports: { id: 'admin_nav.reports', defaultMessage: 'Reports' },
|
||||||
|
waitlist: { id: 'admin_nav.awaiting_approval', defaultMessage: 'Waitlist' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const AdminTabs: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const match = useRouteMatch();
|
||||||
|
|
||||||
|
const approvalCount = useAppSelector(state => state.admin.awaitingApproval.count());
|
||||||
|
const reportsCount = useAppSelector(state => state.admin.openReports.count());
|
||||||
|
|
||||||
|
const tabs = [{
|
||||||
|
name: '/admin',
|
||||||
|
text: intl.formatMessage(messages.dashboard),
|
||||||
|
to: '/admin',
|
||||||
|
}, {
|
||||||
|
name: '/admin/reports',
|
||||||
|
text: intl.formatMessage(messages.reports),
|
||||||
|
to: '/admin/reports',
|
||||||
|
count: reportsCount,
|
||||||
|
}, {
|
||||||
|
name: '/admin/approval',
|
||||||
|
text: intl.formatMessage(messages.waitlist),
|
||||||
|
to: '/admin/approval',
|
||||||
|
count: approvalCount,
|
||||||
|
}];
|
||||||
|
|
||||||
|
return <Tabs items={tabs} activeItem={match.path} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminTabs;
|
|
@ -1,99 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
import IconWithCounter from 'soapbox/components/icon_with_counter';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
|
||||||
instance: state.get('instance'),
|
|
||||||
approvalCount: state.getIn(['admin', 'awaitingApproval']).count(),
|
|
||||||
reportsCount: state.getIn(['admin', 'openReports']).count(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
class AdminNav extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
instance: ImmutablePropTypes.map.isRequired,
|
|
||||||
approvalCount: PropTypes.number,
|
|
||||||
reportsCount: PropTypes.number,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { instance, approvalCount, reportsCount } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className='wtf-panel promo-panel'>
|
|
||||||
<div className='promo-panel__container'>
|
|
||||||
<NavLink className='promo-panel-item' to='/admin'>
|
|
||||||
<Icon src={require('@tabler/icons/icons/dashboard.svg')} className='promo-panel-item__icon' />
|
|
||||||
<FormattedMessage id='admin_nav.dashboard' defaultMessage='Dashboard' />
|
|
||||||
</NavLink>
|
|
||||||
<NavLink className='promo-panel-item' to='/admin/reports'>
|
|
||||||
<IconWithCounter src={require('@tabler/icons/icons/gavel.svg')} count={reportsCount} />
|
|
||||||
<FormattedMessage id='admin_nav.reports' defaultMessage='Reports' />
|
|
||||||
</NavLink>
|
|
||||||
{((instance.get('registrations') && instance.get('approval_required')) || approvalCount > 0) && (
|
|
||||||
<NavLink className='promo-panel-item' to='/admin/approval'>
|
|
||||||
<IconWithCounter src={require('@tabler/icons/icons/user.svg')} count={approvalCount} />
|
|
||||||
<FormattedMessage id='admin_nav.awaiting_approval' defaultMessage='Awaiting Approval' />
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
{/* !instance.get('registrations') && (
|
|
||||||
<NavLink className='promo-panel-item' to='#'>
|
|
||||||
<Icon id='envelope' className='promo-panel-item__icon' fixedWidth />
|
|
||||||
<FormattedMessage id='admin_nav.invites' defaultMessage='Invites' />
|
|
||||||
</NavLink>
|
|
||||||
) */}
|
|
||||||
{/* <NavLink className='promo-panel-item' to='#'>
|
|
||||||
<Icon id='group' className='promo-panel-item__icon' fixedWidth />
|
|
||||||
<FormattedMessage id='admin_nav.registration' defaultMessage='Registration' />
|
|
||||||
</NavLink> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* <div className='wtf-panel promo-panel'>
|
|
||||||
<div className='promo-panel__container'>
|
|
||||||
<NavLink className='promo-panel-item' to='#'>
|
|
||||||
<Icon id='info-circle' className='promo-panel-item__icon' fixedWidth />
|
|
||||||
<FormattedMessage id='admin_nav.site_profile' defaultMessage='Site Profile' />
|
|
||||||
</NavLink>
|
|
||||||
<NavLink className='promo-panel-item' to='#'>
|
|
||||||
<Icon id='paint-brush' className='promo-panel-item__icon' fixedWidth />
|
|
||||||
<FormattedMessage id='admin_nav.branding' defaultMessage='Branding' />
|
|
||||||
</NavLink>
|
|
||||||
<NavLink className='promo-panel-item' to='#'>
|
|
||||||
<Icon id='bars' className='promo-panel-item__icon' fixedWidth />
|
|
||||||
<FormattedMessage id='admin_nav.menus' defaultMessage='Menus' />
|
|
||||||
</NavLink>
|
|
||||||
<NavLink className='promo-panel-item' to='#'>
|
|
||||||
<Icon id='file-o' className='promo-panel-item__icon' fixedWidth />
|
|
||||||
<FormattedMessage id='admin_nav.pages' defaultMessage='Pages' />
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='wtf-panel promo-panel'>
|
|
||||||
<div className='promo-panel__container'>
|
|
||||||
<NavLink className='promo-panel-item' to='#'>
|
|
||||||
<Icon id='fediverse' className='promo-panel-item__icon' fixedWidth />
|
|
||||||
<FormattedMessage id='admin_nav.mrf' defaultMessage='Federation' />
|
|
||||||
</NavLink>
|
|
||||||
<NavLink className='promo-panel-item' to='#'>
|
|
||||||
<Icon id='filter' className='promo-panel-item__icon' fixedWidth />
|
|
||||||
<FormattedMessage id='admin_nav.filtering' defaultMessage='Filtering' />
|
|
||||||
</NavLink>
|
|
||||||
<a className='promo-panel-item' href='/pleroma/admin/#/settings/index' target='_blank'>
|
|
||||||
<Icon id='code' className='promo-panel-item__icon' fixedWidth />
|
|
||||||
<FormattedMessage id='admin_nav.advanced' defaultMessage='Advanced' />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -2,18 +2,18 @@ import { OrderedSet as ImmutableOrderedSet, is } from 'immutable';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchUsers } from 'soapbox/actions/admin';
|
import { fetchUsers } from 'soapbox/actions/admin';
|
||||||
import compareId from 'soapbox/compare_id';
|
import compareId from 'soapbox/compare_id';
|
||||||
import { Text, Widget } from 'soapbox/components/ui';
|
import { Widget } from 'soapbox/components/ui';
|
||||||
import AccountContainer from 'soapbox/containers/account_container';
|
import AccountContainer from 'soapbox/containers/account_container';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' },
|
title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' },
|
||||||
expand: { id: 'admin.latest_accounts_panel.expand_message', defaultMessage: 'Click to see {count} more {count, plural, one {account} other {accounts}}' },
|
expand: { id: 'admin.latest_accounts_panel.more', defaultMessage: 'Click to see {count} {count, plural, one {account} other {accounts}}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ILatestAccountsPanel {
|
interface ILatestAccountsPanel {
|
||||||
|
@ -21,8 +21,9 @@ interface ILatestAccountsPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const history = useHistory();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const accountIds = useAppSelector<ImmutableOrderedSet<string>>((state) => state.admin.get('latestUsers').take(limit));
|
const accountIds = useAppSelector<ImmutableOrderedSet<string>>((state) => state.admin.get('latestUsers').take(limit));
|
||||||
const hasDates = useAppSelector((state) => accountIds.every(id => !!state.accounts.getIn([id, 'created_at'])));
|
const hasDates = useAppSelector((state) => accountIds.every(id => !!state.accounts.getIn([id, 'created_at'])));
|
||||||
|
@ -44,18 +45,19 @@ const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandCount = total - accountIds.size;
|
const handleAction = () => {
|
||||||
|
history.push('/admin/users');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Widget title={intl.formatMessage(messages.title)}>
|
<Widget
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
onActionClick={handleAction}
|
||||||
|
actionTitle={intl.formatMessage(messages.expand, { count: total })}
|
||||||
|
>
|
||||||
{accountIds.take(limit).map((account) => (
|
{accountIds.take(limit).map((account) => (
|
||||||
<AccountContainer key={account} id={account} withRelationship={false} withDate />
|
<AccountContainer key={account} id={account} withRelationship={false} withDate />
|
||||||
))}
|
))}
|
||||||
{!!expandCount && (
|
|
||||||
<Link className='wtf-panel__expand-btn' to='/admin/users'>
|
|
||||||
<Text>{intl.formatMessage(messages.expand, { count: expandCount })}</Text>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,88 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { updateConfig } from 'soapbox/actions/admin';
|
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
|
||||||
import {
|
|
||||||
SimpleForm,
|
|
||||||
FieldsGroup,
|
|
||||||
RadioGroup,
|
|
||||||
RadioItem,
|
|
||||||
} from 'soapbox/features/forms';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
saved: { id: 'admin.dashboard.settings_saved', defaultMessage: 'Settings saved!' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
|
||||||
mode: modeFromInstance(state.get('instance')),
|
|
||||||
});
|
|
||||||
|
|
||||||
const generateConfig = mode => {
|
|
||||||
const configMap = {
|
|
||||||
open: [{ tuple: [':registrations_open', true] }, { tuple: [':account_approval_required', false] }],
|
|
||||||
approval: [{ tuple: [':registrations_open', true] }, { tuple: [':account_approval_required', true] }],
|
|
||||||
closed: [{ tuple: [':registrations_open', false] }],
|
|
||||||
};
|
|
||||||
|
|
||||||
return [{
|
|
||||||
group: ':pleroma',
|
|
||||||
key: ':instance',
|
|
||||||
value: configMap[mode],
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
|
|
||||||
const modeFromInstance = instance => {
|
|
||||||
if (instance.get('approval_required') && instance.get('registrations')) return 'approval';
|
|
||||||
return instance.get('registrations') ? 'open' : 'closed';
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class RegistrationModePicker extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
onChange = e => {
|
|
||||||
const { dispatch, intl } = this.props;
|
|
||||||
const config = generateConfig(e.target.value);
|
|
||||||
dispatch(updateConfig(config)).then(() => {
|
|
||||||
dispatch(snackbar.success(intl.formatMessage(messages.saved)));
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { mode } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SimpleForm>
|
|
||||||
<FieldsGroup>
|
|
||||||
<RadioGroup
|
|
||||||
label={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
|
|
||||||
onChange={this.onChange}
|
|
||||||
>
|
|
||||||
<RadioItem
|
|
||||||
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
|
|
||||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
|
|
||||||
checked={mode === 'open'}
|
|
||||||
value='open'
|
|
||||||
/>
|
|
||||||
<RadioItem
|
|
||||||
label={<FormattedMessage id='admin.dashboard.registration_mode.approval_label' defaultMessage='Approval Required' />}
|
|
||||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.approval_hint' defaultMessage='Users can sign up, but their account only gets activated when an admin approves it.' />}
|
|
||||||
checked={mode === 'approval'}
|
|
||||||
value='approval'
|
|
||||||
/>
|
|
||||||
<RadioItem
|
|
||||||
label={<FormattedMessage id='admin.dashboard.registration_mode.closed_label' defaultMessage='Closed' />}
|
|
||||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.closed_hint' defaultMessage='Nobody can sign up. You can still invite people.' />}
|
|
||||||
checked={mode === 'closed'}
|
|
||||||
value='closed'
|
|
||||||
/>
|
|
||||||
</RadioGroup>
|
|
||||||
</FieldsGroup>
|
|
||||||
</SimpleForm>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { updateConfig } from 'soapbox/actions/admin';
|
||||||
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
|
import {
|
||||||
|
SimpleForm,
|
||||||
|
FieldsGroup,
|
||||||
|
RadioGroup,
|
||||||
|
RadioItem,
|
||||||
|
} from 'soapbox/features/forms';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { Instance } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
type RegistrationMode = 'open' | 'approval' | 'closed';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
saved: { id: 'admin.dashboard.settings_saved', defaultMessage: 'Settings saved!' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateConfig = (mode: RegistrationMode) => {
|
||||||
|
const configMap = {
|
||||||
|
open: [{ tuple: [':registrations_open', true] }, { tuple: [':account_approval_required', false] }],
|
||||||
|
approval: [{ tuple: [':registrations_open', true] }, { tuple: [':account_approval_required', true] }],
|
||||||
|
closed: [{ tuple: [':registrations_open', false] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
return [{
|
||||||
|
group: ':pleroma',
|
||||||
|
key: ':instance',
|
||||||
|
value: configMap[mode],
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeFromInstance = (instance: Instance): RegistrationMode => {
|
||||||
|
if (instance.approval_required && instance.registrations) return 'approval';
|
||||||
|
return instance.registrations ? 'open' : 'closed';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Allows changing the registration mode of the instance, eg "open", "closed", "approval" */
|
||||||
|
const RegistrationModePicker: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const mode = useAppSelector(state => modeFromInstance(state.instance));
|
||||||
|
|
||||||
|
const onChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||||
|
const config = generateConfig(e.target.value as RegistrationMode);
|
||||||
|
dispatch(updateConfig(config)).then(() => {
|
||||||
|
dispatch(snackbar.success(intl.formatMessage(messages.saved)));
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleForm>
|
||||||
|
<FieldsGroup>
|
||||||
|
<RadioGroup
|
||||||
|
label={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
|
||||||
|
onChange={onChange}
|
||||||
|
>
|
||||||
|
<RadioItem
|
||||||
|
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
|
||||||
|
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
|
||||||
|
checked={mode === 'open'}
|
||||||
|
value='open'
|
||||||
|
/>
|
||||||
|
<RadioItem
|
||||||
|
label={<FormattedMessage id='admin.dashboard.registration_mode.approval_label' defaultMessage='Approval Required' />}
|
||||||
|
hint={<FormattedMessage id='admin.dashboard.registration_mode.approval_hint' defaultMessage='Users can sign up, but their account only gets activated when an admin approves it.' />}
|
||||||
|
checked={mode === 'approval'}
|
||||||
|
value='approval'
|
||||||
|
/>
|
||||||
|
<RadioItem
|
||||||
|
label={<FormattedMessage id='admin.dashboard.registration_mode.closed_label' defaultMessage='Closed' />}
|
||||||
|
hint={<FormattedMessage id='admin.dashboard.registration_mode.closed_hint' defaultMessage='Nobody can sign up. You can still invite people.' />}
|
||||||
|
checked={mode === 'closed'}
|
||||||
|
value='closed'
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
</FieldsGroup>
|
||||||
|
</SimpleForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegistrationModePicker;
|
|
@ -1,126 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { closeReports } from 'soapbox/actions/admin';
|
|
||||||
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
|
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
|
||||||
import Avatar from 'soapbox/components/avatar';
|
|
||||||
import { Button } from 'soapbox/components/ui';
|
|
||||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
|
||||||
import Accordion from 'soapbox/features/ui/components/accordion';
|
|
||||||
|
|
||||||
import ReportStatus from './report_status';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
reportClosed: { id: 'admin.reports.report_closed_message', defaultMessage: 'Report on @{name} was closed' },
|
|
||||||
deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
|
|
||||||
deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect()
|
|
||||||
@injectIntl
|
|
||||||
class Report extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
report: ImmutablePropTypes.map.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
accordionExpanded: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
makeMenu = () => {
|
|
||||||
const { intl, report } = this.props;
|
|
||||||
|
|
||||||
return [{
|
|
||||||
text: intl.formatMessage(messages.deactivateUser, { name: report.getIn(['account', 'username']) }),
|
|
||||||
action: this.handleDeactivateUser,
|
|
||||||
icon: require('@tabler/icons/icons/user-off.svg'),
|
|
||||||
}, {
|
|
||||||
text: intl.formatMessage(messages.deleteUser, { name: report.getIn(['account', 'username']) }),
|
|
||||||
action: this.handleDeleteUser,
|
|
||||||
icon: require('@tabler/icons/icons/user-minus.svg'),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCloseReport = () => {
|
|
||||||
const { intl, dispatch, report } = this.props;
|
|
||||||
dispatch(closeReports([report.get('id')])).then(() => {
|
|
||||||
const message = intl.formatMessage(messages.reportClosed, { name: report.getIn(['account', 'username']) });
|
|
||||||
dispatch(snackbar.success(message));
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDeactivateUser = () => {
|
|
||||||
const { intl, dispatch, report } = this.props;
|
|
||||||
const accountId = report.getIn(['account', 'id']);
|
|
||||||
dispatch(deactivateUserModal(intl, accountId, () => this.handleCloseReport()));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDeleteUser = () => {
|
|
||||||
const { intl, dispatch, report } = this.props;
|
|
||||||
const accountId = report.getIn(['account', 'id']);
|
|
||||||
dispatch(deleteUserModal(intl, accountId, () => this.handleCloseReport()));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAccordionToggle = setting => {
|
|
||||||
this.setState({ accordionExpanded: setting });
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { report } = this.props;
|
|
||||||
const { accordionExpanded } = this.state;
|
|
||||||
const menu = this.makeMenu();
|
|
||||||
const statuses = report.get('statuses');
|
|
||||||
const statusCount = statuses.count();
|
|
||||||
const acct = report.getIn(['account', 'acct']);
|
|
||||||
const reporterAcct = report.getIn(['actor', 'acct']);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='admin-report' key={report.get('id')}>
|
|
||||||
<div className='admin-report__avatar'>
|
|
||||||
<Link to={`/@${acct}`} title={acct}>
|
|
||||||
<Avatar account={report.get('account')} size={32} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className='admin-report__content'>
|
|
||||||
<h4 className='admin-report__title'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='admin.reports.report_title'
|
|
||||||
defaultMessage='Report on {acct}'
|
|
||||||
values={{ acct: <Link to={`/@${acct}`} title={acct}>@{acct}</Link> }}
|
|
||||||
/>
|
|
||||||
</h4>
|
|
||||||
<div className='admin-report__statuses'>
|
|
||||||
{statusCount > 0 && (
|
|
||||||
<Accordion
|
|
||||||
headline={`Reported posts (${statusCount})`}
|
|
||||||
expanded={accordionExpanded}
|
|
||||||
onToggle={this.handleAccordionToggle}
|
|
||||||
>
|
|
||||||
{statuses.map(status => <ReportStatus report={report} status={status} key={status.get('id')} />)}
|
|
||||||
</Accordion>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className='admin-report__quote'>
|
|
||||||
{report.get('content', '').length > 0 &&
|
|
||||||
<blockquote className='md' dangerouslySetInnerHTML={{ __html: report.get('content') }} />
|
|
||||||
}
|
|
||||||
<span className='byline'>— <Link to={`/@${reporterAcct}`} title={reporterAcct}>@{reporterAcct}</Link></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='admin-report__actions'>
|
|
||||||
<Button className='button-alternative' size={30} onClick={this.handleCloseReport}>
|
|
||||||
<FormattedMessage id='admin.reports.actions.close' defaultMessage='Close' />
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu className='admin-report__dropdown' items={menu} src={require('@tabler/icons/icons/dots-vertical.svg')} direction='right' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,130 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { closeReports } from 'soapbox/actions/admin';
|
||||||
|
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
|
||||||
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
|
import Avatar from 'soapbox/components/avatar';
|
||||||
|
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
|
||||||
|
import { Button, HStack } from 'soapbox/components/ui';
|
||||||
|
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||||
|
import Accordion from 'soapbox/features/ui/components/accordion';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import ReportStatus from './report_status';
|
||||||
|
|
||||||
|
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
|
import type { Status } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
reportClosed: { id: 'admin.reports.report_closed_message', defaultMessage: 'Report on @{name} was closed' },
|
||||||
|
deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
|
||||||
|
deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IReport {
|
||||||
|
report: ImmutableMap<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Report: React.FC<IReport> = ({ report }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [accordionExpanded, setAccordionExpanded] = useState(false);
|
||||||
|
|
||||||
|
const makeMenu = () => {
|
||||||
|
return [{
|
||||||
|
text: intl.formatMessage(messages.deactivateUser, { name: report.getIn(['account', 'username']) as string }),
|
||||||
|
action: handleDeactivateUser,
|
||||||
|
icon: require('@tabler/icons/icons/user-off.svg'),
|
||||||
|
}, {
|
||||||
|
text: intl.formatMessage(messages.deleteUser, { name: report.getIn(['account', 'username']) as string }),
|
||||||
|
action: handleDeleteUser,
|
||||||
|
icon: require('@tabler/icons/icons/user-minus.svg'),
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseReport = () => {
|
||||||
|
dispatch(closeReports([report.get('id')])).then(() => {
|
||||||
|
const message = intl.formatMessage(messages.reportClosed, { name: report.getIn(['account', 'username']) as string });
|
||||||
|
dispatch(snackbar.success(message));
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivateUser = () => {
|
||||||
|
const accountId = report.getIn(['account', 'id']);
|
||||||
|
dispatch(deactivateUserModal(intl, accountId, () => handleCloseReport()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = () => {
|
||||||
|
const accountId = report.getIn(['account', 'id']) as string;
|
||||||
|
dispatch(deleteUserModal(intl, accountId, () => handleCloseReport()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccordionToggle = (setting: boolean) => {
|
||||||
|
setAccordionExpanded(setting);
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu = makeMenu();
|
||||||
|
const statuses = report.get('statuses') as ImmutableList<Status>;
|
||||||
|
const statusCount = statuses.count();
|
||||||
|
const acct = report.getIn(['account', 'acct']) as string;
|
||||||
|
const reporterAcct = report.getIn(['actor', 'acct']) as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='admin-report' key={report.get('id')}>
|
||||||
|
<div className='admin-report__avatar'>
|
||||||
|
<HoverRefWrapper accountId={report.getIn(['account', 'id']) as string} inline>
|
||||||
|
<Link to={`/@${acct}`} title={acct}>
|
||||||
|
<Avatar account={report.get('account')} size={32} />
|
||||||
|
</Link>
|
||||||
|
</HoverRefWrapper>
|
||||||
|
</div>
|
||||||
|
<div className='admin-report__content'>
|
||||||
|
<h4 className='admin-report__title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.reports.report_title'
|
||||||
|
defaultMessage='Report on {acct}'
|
||||||
|
values={{ acct: (
|
||||||
|
<HoverRefWrapper accountId={report.getIn(['account', 'id']) as string} inline>
|
||||||
|
<Link to={`/@${acct}`} title={acct}>@{acct}</Link>
|
||||||
|
</HoverRefWrapper>
|
||||||
|
) }}
|
||||||
|
/>
|
||||||
|
</h4>
|
||||||
|
<div className='admin-report__statuses'>
|
||||||
|
{statusCount > 0 && (
|
||||||
|
<Accordion
|
||||||
|
headline={`Reported posts (${statusCount})`}
|
||||||
|
expanded={accordionExpanded}
|
||||||
|
onToggle={handleAccordionToggle}
|
||||||
|
>
|
||||||
|
{statuses.map(status => <ReportStatus report={report} status={status} key={status.id} />)}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='admin-report__quote'>
|
||||||
|
{report.get('content', '').length > 0 && (
|
||||||
|
<blockquote className='md' dangerouslySetInnerHTML={{ __html: report.get('content') }} />
|
||||||
|
)}
|
||||||
|
<span className='byline'>
|
||||||
|
—
|
||||||
|
<HoverRefWrapper accountId={report.getIn(['actor', 'id']) as string} inline>
|
||||||
|
<Link to={`/@${reporterAcct}`} title={reporterAcct}>@{reporterAcct}</Link>
|
||||||
|
</HoverRefWrapper>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HStack space={2} alignItems='start'>
|
||||||
|
<Button onClick={handleCloseReport}>
|
||||||
|
<FormattedMessage id='admin.reports.actions.close' defaultMessage='Close' />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu items={menu} src={require('@tabler/icons/icons/dots-vertical.svg')} />
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Report;
|
|
@ -1,129 +0,0 @@
|
||||||
import noop from 'lodash/noop';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
|
||||||
import { deleteStatusModal } from 'soapbox/actions/moderation';
|
|
||||||
import StatusContent from 'soapbox/components/status_content';
|
|
||||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
|
||||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
|
||||||
import { MediaGallery, Video, Audio } from 'soapbox/features/ui/util/async-components';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
viewStatus: { id: 'admin.reports.actions.view_status', defaultMessage: 'View post' },
|
|
||||||
deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect()
|
|
||||||
@injectIntl
|
|
||||||
class ReportStatus extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
status: ImmutablePropTypes.record.isRequired,
|
|
||||||
report: ImmutablePropTypes.map,
|
|
||||||
};
|
|
||||||
|
|
||||||
makeMenu = () => {
|
|
||||||
const { intl, status } = this.props;
|
|
||||||
const acct = status.getIn(['account', 'acct']);
|
|
||||||
|
|
||||||
return [{
|
|
||||||
text: intl.formatMessage(messages.viewStatus, { acct: `@${acct}` }),
|
|
||||||
to: `/@${acct}/posts/${status.get('id')}`,
|
|
||||||
icon: require('@tabler/icons/icons/pencil.svg'),
|
|
||||||
}, {
|
|
||||||
text: intl.formatMessage(messages.deleteStatus, { acct: `@${acct}` }),
|
|
||||||
action: this.handleDeleteStatus,
|
|
||||||
icon: require('@tabler/icons/icons/trash.svg'),
|
|
||||||
destructive: true,
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
getMedia = () => {
|
|
||||||
const { status } = this.props;
|
|
||||||
|
|
||||||
if (status.get('media_attachments').size > 0) {
|
|
||||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
|
||||||
// Do nothing
|
|
||||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
|
||||||
const video = status.getIn(['media_attachments', 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
|
||||||
{Component => (
|
|
||||||
<Component
|
|
||||||
preview={video.get('preview_url')}
|
|
||||||
blurhash={video.get('blurhash')}
|
|
||||||
src={video.get('url')}
|
|
||||||
alt={video.get('description')}
|
|
||||||
aspectRatio={video.getIn(['meta', 'original', 'aspect'])}
|
|
||||||
width={239}
|
|
||||||
height={110}
|
|
||||||
inline
|
|
||||||
sensitive={status.get('sensitive')}
|
|
||||||
onOpenVideo={noop}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Bundle>
|
|
||||||
);
|
|
||||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
|
||||||
const audio = status.getIn(['media_attachments', 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
|
||||||
{Component => (
|
|
||||||
<Component
|
|
||||||
src={audio.get('url')}
|
|
||||||
alt={audio.get('description')}
|
|
||||||
inline
|
|
||||||
sensitive={status.get('sensitive')}
|
|
||||||
onOpenAudio={noop}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Bundle>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
|
|
||||||
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.handleOpenMedia} />}
|
|
||||||
</Bundle>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleOpenMedia = (media, index) => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(openModal('MEDIA', { media, index }));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDeleteStatus = () => {
|
|
||||||
const { intl, dispatch, status } = this.props;
|
|
||||||
const statusId = status.get('id');
|
|
||||||
dispatch(deleteStatusModal(intl, statusId));
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { status } = this.props;
|
|
||||||
const media = this.getMedia();
|
|
||||||
const menu = this.makeMenu();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='admin-report__status'>
|
|
||||||
<div className='admin-report__status-content'>
|
|
||||||
<StatusContent status={status} />
|
|
||||||
{media}
|
|
||||||
</div>
|
|
||||||
<div className='admin-report__status-actions'>
|
|
||||||
<DropdownMenu items={menu} src={require('@tabler/icons/icons/dots-vertical.svg')} direction='right' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,134 @@
|
||||||
|
import noop from 'lodash/noop';
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import { deleteStatusModal } from 'soapbox/actions/moderation';
|
||||||
|
import StatusContent from 'soapbox/components/status_content';
|
||||||
|
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||||
|
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||||
|
import { MediaGallery, Video, Audio } from 'soapbox/features/ui/util/async-components';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
|
import type { Status, Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
viewStatus: { id: 'admin.reports.actions.view_status', defaultMessage: 'View post' },
|
||||||
|
deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IReportStatus {
|
||||||
|
status: Status,
|
||||||
|
report?: ImmutableMap<string, any>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReportStatus: React.FC<IReportStatus> = ({ status }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleOpenMedia = (media: Attachment, index: number) => {
|
||||||
|
dispatch(openModal('MEDIA', { media, index }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteStatus = () => {
|
||||||
|
dispatch(deleteStatusModal(intl, status.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMenu = () => {
|
||||||
|
const acct = status.getIn(['account', 'acct']);
|
||||||
|
|
||||||
|
return [{
|
||||||
|
text: intl.formatMessage(messages.viewStatus, { acct: `@${acct}` }),
|
||||||
|
to: `/@${acct}/posts/${status.get('id')}`,
|
||||||
|
icon: require('@tabler/icons/icons/pencil.svg'),
|
||||||
|
}, {
|
||||||
|
text: intl.formatMessage(messages.deleteStatus, { acct: `@${acct}` }),
|
||||||
|
action: handleDeleteStatus,
|
||||||
|
icon: require('@tabler/icons/icons/trash.svg'),
|
||||||
|
destructive: true,
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMedia = () => {
|
||||||
|
const firstAttachment = status.media_attachments.get(0);
|
||||||
|
|
||||||
|
if (firstAttachment) {
|
||||||
|
if (status.media_attachments.some(item => item.type === 'unknown')) {
|
||||||
|
// Do nothing
|
||||||
|
} else if (firstAttachment.type === 'video') {
|
||||||
|
const video = firstAttachment;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Bundle fetchComponent={Video} >
|
||||||
|
{(Component: any) => (
|
||||||
|
<Component
|
||||||
|
preview={video.preview_url}
|
||||||
|
blurhash={video.blurhash}
|
||||||
|
src={video.url}
|
||||||
|
alt={video.description}
|
||||||
|
aspectRatio={video.meta.getIn(['original', 'aspect'])}
|
||||||
|
width={239}
|
||||||
|
height={110}
|
||||||
|
inline
|
||||||
|
sensitive={status.sensitive}
|
||||||
|
onOpenVideo={noop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bundle>
|
||||||
|
);
|
||||||
|
} else if (firstAttachment.type === 'audio') {
|
||||||
|
const audio = firstAttachment;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Bundle fetchComponent={Audio}>
|
||||||
|
{(Component: any) => (
|
||||||
|
<Component
|
||||||
|
src={audio.url}
|
||||||
|
alt={audio.description}
|
||||||
|
inline
|
||||||
|
sensitive={status.sensitive}
|
||||||
|
onOpenAudio={noop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bundle>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Bundle fetchComponent={MediaGallery}>
|
||||||
|
{(Component: any) => (
|
||||||
|
<Component
|
||||||
|
media={status.media_attachments}
|
||||||
|
sensitive={status.sensitive}
|
||||||
|
height={110}
|
||||||
|
onOpenMedia={handleOpenMedia}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bundle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const media = getMedia();
|
||||||
|
const menu = makeMenu();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='admin-report__status'>
|
||||||
|
<div className='admin-report__status-content'>
|
||||||
|
<StatusContent status={status} />
|
||||||
|
{media}
|
||||||
|
</div>
|
||||||
|
<div className='admin-report__status-actions'>
|
||||||
|
<DropdownMenu
|
||||||
|
items={menu}
|
||||||
|
src={require('@tabler/icons/icons/dots-vertical.svg')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportStatus;
|
|
@ -1,77 +0,0 @@
|
||||||
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 { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { approveUsers } from 'soapbox/actions/admin';
|
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
|
||||||
import IconButton from 'soapbox/components/icon_button';
|
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
|
||||||
|
|
||||||
import { rejectUserModal } from '../../../actions/moderation';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
approved: { id: 'admin.awaiting_approval.approved_message', defaultMessage: '{acct} was approved!' },
|
|
||||||
rejected: { id: 'admin.awaiting_approval.rejected_message', defaultMessage: '{acct} was rejected.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getAccount = makeGetAccount();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { accountId }) => {
|
|
||||||
return {
|
|
||||||
account: getAccount(state, accountId),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(makeMapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class UnapprovedAccount extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
account: ImmutablePropTypes.record.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleApprove = () => {
|
|
||||||
const { dispatch, intl, account } = this.props;
|
|
||||||
dispatch(approveUsers([account.get('id')]))
|
|
||||||
.then(() => {
|
|
||||||
const message = intl.formatMessage(messages.approved, { acct: `@${account.get('acct')}` });
|
|
||||||
dispatch(snackbar.success(message));
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReject = () => {
|
|
||||||
const { dispatch, intl, account } = this.props;
|
|
||||||
|
|
||||||
dispatch(rejectUserModal(intl, account.get('id'), () => {
|
|
||||||
const message = intl.formatMessage(messages.rejected, { acct: `@${account.get('acct')}` });
|
|
||||||
dispatch(snackbar.info(message));
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { account } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='unapproved-account'>
|
|
||||||
<div className='unapproved-account__bio'>
|
|
||||||
<div className='unapproved-account__nickname'>@{account.get('acct')}</div>
|
|
||||||
<blockquote className='md'>{account.getIn(['pleroma', 'admin', 'registration_reason'])}</blockquote>
|
|
||||||
</div>
|
|
||||||
<div className='unapproved-account__actions'>
|
|
||||||
<IconButton src={require('@tabler/icons/icons/check.svg')} onClick={this.handleApprove} />
|
|
||||||
<IconButton src={require('@tabler/icons/icons/x.svg')} onClick={this.handleReject} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { approveUsers } from 'soapbox/actions/admin';
|
||||||
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
|
import IconButton from 'soapbox/components/icon_button';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
import { rejectUserModal } from '../../../actions/moderation';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
approved: { id: 'admin.awaiting_approval.approved_message', defaultMessage: '{acct} was approved!' },
|
||||||
|
rejected: { id: 'admin.awaiting_approval.rejected_message', defaultMessage: '{acct} was rejected.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
interface IUnapprovedAccount {
|
||||||
|
accountId: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Displays an unapproved account for moderation purposes. */
|
||||||
|
const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const account = useAppSelector(state => getAccount(state, accountId));
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
const handleApprove = () => {
|
||||||
|
dispatch(approveUsers([account.id]))
|
||||||
|
.then(() => {
|
||||||
|
const message = intl.formatMessage(messages.approved, { acct: `@${account.acct}` });
|
||||||
|
dispatch(snackbar.success(message));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
dispatch(rejectUserModal(intl, account.id, () => {
|
||||||
|
const message = intl.formatMessage(messages.rejected, { acct: `@${account.acct}` });
|
||||||
|
dispatch(snackbar.info(message));
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='unapproved-account'>
|
||||||
|
<div className='unapproved-account__bio'>
|
||||||
|
<div className='unapproved-account__nickname'>@{account.get('acct')}</div>
|
||||||
|
<blockquote className='md'>{account.pleroma.getIn(['admin', 'registration_reason'], '') as string}</blockquote>
|
||||||
|
</div>
|
||||||
|
<div className='unapproved-account__actions'>
|
||||||
|
<IconButton src={require('@tabler/icons/icons/check.svg')} onClick={handleApprove} />
|
||||||
|
<IconButton src={require('@tabler/icons/icons/x.svg')} onClick={handleReject} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnapprovedAccount;
|
|
@ -1,154 +0,0 @@
|
||||||
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, FormattedMessage, FormattedNumber } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email_list';
|
|
||||||
import { Text } from 'soapbox/components/ui';
|
|
||||||
import sourceCode from 'soapbox/utils/code';
|
|
||||||
import { parseVersion } from 'soapbox/utils/features';
|
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
|
||||||
import { isNumber } from 'soapbox/utils/numbers';
|
|
||||||
|
|
||||||
import Column from '../ui/components/column';
|
|
||||||
|
|
||||||
import RegistrationModePicker from './components/registration_mode_picker';
|
|
||||||
|
|
||||||
// https://stackoverflow.com/a/53230807
|
|
||||||
const download = (response, filename) => {
|
|
||||||
const url = URL.createObjectURL(new Blob([response.data]));
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.setAttribute('download', filename);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
heading: { id: 'column.admin.dashboard', defaultMessage: 'Dashboard' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
|
||||||
const me = state.get('me');
|
|
||||||
|
|
||||||
return {
|
|
||||||
instance: state.get('instance'),
|
|
||||||
supportsEmailList: getFeatures(state.get('instance')).emailList,
|
|
||||||
account: state.getIn(['accounts', me]),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class Dashboard extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
instance: ImmutablePropTypes.map.isRequired,
|
|
||||||
supportsEmailList: PropTypes.bool,
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSubscribersClick = e => {
|
|
||||||
this.props.dispatch(getSubscribersCsv()).then((response) => {
|
|
||||||
download(response, 'subscribers.csv');
|
|
||||||
}).catch(() => {});
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleUnsubscribersClick = e => {
|
|
||||||
this.props.dispatch(getUnsubscribersCsv()).then((response) => {
|
|
||||||
download(response, 'unsubscribers.csv');
|
|
||||||
}).catch(() => {});
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCombinedClick = e => {
|
|
||||||
this.props.dispatch(getCombinedCsv()).then((response) => {
|
|
||||||
download(response, 'combined.csv');
|
|
||||||
}).catch(() => {});
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl, instance, supportsEmailList, account } = this.props;
|
|
||||||
const v = parseVersion(instance.get('version'));
|
|
||||||
const userCount = instance.getIn(['stats', 'user_count']);
|
|
||||||
const mau = instance.getIn(['pleroma', 'stats', 'mau']);
|
|
||||||
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : null;
|
|
||||||
|
|
||||||
if (!account) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column icon='tachometer-alt' label={intl.formatMessage(messages.heading)}>
|
|
||||||
<div className='dashcounters'>
|
|
||||||
{mau && <div className='dashcounter'>
|
|
||||||
<Text align='center' size='2xl' weight='medium'>
|
|
||||||
<FormattedNumber value={mau} />
|
|
||||||
</Text>
|
|
||||||
<Text align='center'>
|
|
||||||
<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />
|
|
||||||
</Text>
|
|
||||||
</div>}
|
|
||||||
<Link className='dashcounter' to='/admin/users'>
|
|
||||||
<Text align='center' size='2xl' weight='medium'>
|
|
||||||
<FormattedNumber value={userCount} />
|
|
||||||
</Text>
|
|
||||||
<Text align='center'>
|
|
||||||
<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />
|
|
||||||
</Text>
|
|
||||||
</Link>
|
|
||||||
{isNumber(retention) && (
|
|
||||||
<div className='dashcounter'>
|
|
||||||
<Text align='center' size='2xl' weight='medium'>
|
|
||||||
{retention}%
|
|
||||||
</Text>
|
|
||||||
<Text align='center'>
|
|
||||||
<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Link className='dashcounter' to='/timeline/local'>
|
|
||||||
<Text align='center' size='2xl' weight='medium'>
|
|
||||||
<FormattedNumber value={instance.getIn(['stats', 'status_count'])} />
|
|
||||||
</Text>
|
|
||||||
<Text align='center'>
|
|
||||||
<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />
|
|
||||||
</Text>
|
|
||||||
</Link>
|
|
||||||
<div className='dashcounter'>
|
|
||||||
<Text align='center' size='2xl' weight='medium'>
|
|
||||||
<FormattedNumber value={instance.getIn(['stats', 'domain_count'])} />
|
|
||||||
</Text>
|
|
||||||
<Text align='center'>
|
|
||||||
<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{account.admin && <RegistrationModePicker />}
|
|
||||||
<div className='dashwidgets'>
|
|
||||||
<div className='dashwidget'>
|
|
||||||
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
|
|
||||||
<ul>
|
|
||||||
<li>{sourceCode.displayName} <span className='pull-right'>{sourceCode.version}</span></li>
|
|
||||||
<li>{v.software} <span className='pull-right'>{v.version}</span></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{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>
|
|
||||||
<li><a href='#' onClick={this.handleUnsubscribersClick} target='_blank'>unsubscribers.csv</a></li>
|
|
||||||
<li><a href='#' onClick={this.handleCombinedClick} target='_blank'>combined.csv</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { Switch, Route } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useOwnAccount } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import Column from '../ui/components/column';
|
||||||
|
|
||||||
|
import AdminTabs from './components/admin-tabs';
|
||||||
|
import Waitlist from './tabs/awaiting-approval';
|
||||||
|
import Dashboard from './tabs/dashboard';
|
||||||
|
import Reports from './tabs/reports';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.admin.dashboard', defaultMessage: 'Dashboard' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const Admin: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const account = useOwnAccount();
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.heading)} withHeader={false}>
|
||||||
|
<AdminTabs />
|
||||||
|
|
||||||
|
<Switch>
|
||||||
|
<Route path='/admin' exact component={Dashboard} />
|
||||||
|
<Route path='/admin/reports' exact component={Reports} />
|
||||||
|
<Route path='/admin/approval' exact component={Waitlist} />
|
||||||
|
</Switch>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Admin;
|
|
@ -1,80 +0,0 @@
|
||||||
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 { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { fetchReports } from 'soapbox/actions/admin';
|
|
||||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
|
||||||
import { makeGetReport } from 'soapbox/selectors';
|
|
||||||
|
|
||||||
import Column from '../ui/components/better_column';
|
|
||||||
|
|
||||||
import Report from './components/report';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
heading: { id: 'column.admin.reports', defaultMessage: 'Reports' },
|
|
||||||
modlog: { id: 'column.admin.reports.menu.moderation_log', defaultMessage: 'Moderation Log' },
|
|
||||||
emptyMessage: { id: 'admin.reports.empty_message', defaultMessage: 'There are no open reports. If a user gets reported, they will show up here.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
|
||||||
const getReport = makeGetReport();
|
|
||||||
const ids = state.getIn(['admin', 'openReports']);
|
|
||||||
|
|
||||||
return {
|
|
||||||
reports: ids.toList().map(id => getReport(state, id)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class Reports extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
reports: ImmutablePropTypes.list.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
isLoading: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
makeColumnMenu = () => {
|
|
||||||
const { intl } = this.props;
|
|
||||||
|
|
||||||
return [{
|
|
||||||
text: intl.formatMessage(messages.modlog),
|
|
||||||
to: '/admin/log',
|
|
||||||
icon: require('@tabler/icons/icons/list.svg'),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(fetchReports())
|
|
||||||
.then(() => this.setState({ isLoading: false }))
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl, reports } = this.props;
|
|
||||||
const { isLoading } = this.state;
|
|
||||||
const showLoading = isLoading && reports.count() === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column icon='gavel' label={intl.formatMessage(messages.heading)} menu={this.makeColumnMenu()}>
|
|
||||||
<ScrollableList
|
|
||||||
isLoading={isLoading}
|
|
||||||
showLoading={showLoading}
|
|
||||||
scrollKey='admin-reports'
|
|
||||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
|
||||||
>
|
|
||||||
{reports.map(report => <Report report={report} key={report.get('id')} />)}
|
|
||||||
</ScrollableList>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { fetchUsers } from 'soapbox/actions/admin';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import UnapprovedAccount from '../components/unapproved_account';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.admin.awaiting_approval', defaultMessage: 'Awaiting Approval' },
|
||||||
|
emptyMessage: { id: 'admin.awaiting_approval.empty_message', defaultMessage: 'There is nobody waiting for approval. When a new user signs up, you can review them here.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const AwaitingApproval: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const accountIds = useAppSelector(state => state.admin.awaitingApproval);
|
||||||
|
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchUsers(['local', 'need_approval']))
|
||||||
|
.then(() => setLoading(false))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showLoading = isLoading && accountIds.count() === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableList
|
||||||
|
isLoading={isLoading}
|
||||||
|
showLoading={showLoading}
|
||||||
|
scrollKey='awaiting-approval'
|
||||||
|
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||||
|
>
|
||||||
|
{accountIds.map(id => (
|
||||||
|
<UnapprovedAccount accountId={id} key={id} />
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AwaitingApproval;
|
|
@ -0,0 +1,146 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email_list';
|
||||||
|
import { Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks';
|
||||||
|
import sourceCode from 'soapbox/utils/code';
|
||||||
|
import { parseVersion } from 'soapbox/utils/features';
|
||||||
|
import { isNumber } from 'soapbox/utils/numbers';
|
||||||
|
|
||||||
|
import RegistrationModePicker from '../components/registration_mode_picker';
|
||||||
|
|
||||||
|
import type { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
/** Download the file from the response instead of opening it in a tab. */
|
||||||
|
// https://stackoverflow.com/a/53230807
|
||||||
|
const download = (response: AxiosResponse, filename: string) => {
|
||||||
|
const url = URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', filename);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const instance = useAppSelector(state => state.instance);
|
||||||
|
const features = useFeatures();
|
||||||
|
const account = useOwnAccount();
|
||||||
|
|
||||||
|
const handleSubscribersClick: React.MouseEventHandler = e => {
|
||||||
|
dispatch(getSubscribersCsv()).then((response) => {
|
||||||
|
download(response, 'subscribers.csv');
|
||||||
|
}).catch(() => {});
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnsubscribersClick: React.MouseEventHandler = e => {
|
||||||
|
dispatch(getUnsubscribersCsv()).then((response) => {
|
||||||
|
download(response, 'unsubscribers.csv');
|
||||||
|
}).catch(() => {});
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCombinedClick: React.MouseEventHandler = e => {
|
||||||
|
dispatch(getCombinedCsv()).then((response) => {
|
||||||
|
download(response, 'combined.csv');
|
||||||
|
}).catch(() => {});
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const v = parseVersion(instance.version);
|
||||||
|
|
||||||
|
const userCount = instance.stats.get('user_count');
|
||||||
|
const statusCount = instance.stats.get('status_count');
|
||||||
|
const domainCount = instance.stats.get('domain_count');
|
||||||
|
|
||||||
|
const mau = instance.pleroma.getIn(['stats', 'mau']) as number | undefined;
|
||||||
|
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : null;
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='dashcounters mt-8'>
|
||||||
|
{isNumber(mau) && (
|
||||||
|
<div className='dashcounter'>
|
||||||
|
<Text align='center' size='2xl' weight='medium'>
|
||||||
|
<FormattedNumber value={mau} />
|
||||||
|
</Text>
|
||||||
|
<Text align='center'>
|
||||||
|
<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isNumber(userCount) && (
|
||||||
|
<Link className='dashcounter' to='/admin/users'>
|
||||||
|
<Text align='center' size='2xl' weight='medium'>
|
||||||
|
<FormattedNumber value={userCount} />
|
||||||
|
</Text>
|
||||||
|
<Text align='center'>
|
||||||
|
<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isNumber(retention) && (
|
||||||
|
<div className='dashcounter'>
|
||||||
|
<Text align='center' size='2xl' weight='medium'>
|
||||||
|
{retention}%
|
||||||
|
</Text>
|
||||||
|
<Text align='center'>
|
||||||
|
<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isNumber(statusCount) && (
|
||||||
|
<Link className='dashcounter' to='/timeline/local'>
|
||||||
|
<Text align='center' size='2xl' weight='medium'>
|
||||||
|
<FormattedNumber value={statusCount} />
|
||||||
|
</Text>
|
||||||
|
<Text align='center'>
|
||||||
|
<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isNumber(domainCount) && (
|
||||||
|
<div className='dashcounter'>
|
||||||
|
<Text align='center' size='2xl' weight='medium'>
|
||||||
|
<FormattedNumber value={domainCount} />
|
||||||
|
</Text>
|
||||||
|
<Text align='center'>
|
||||||
|
<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{account.admin && <RegistrationModePicker />}
|
||||||
|
|
||||||
|
<div className='dashwidgets'>
|
||||||
|
<div className='dashwidget'>
|
||||||
|
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
|
||||||
|
<ul>
|
||||||
|
<li>{sourceCode.displayName} <span className='pull-right'>{sourceCode.version}</span></li>
|
||||||
|
<li>{v.software} <span className='pull-right'>{v.version}</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{features.emailList && account.admin && (
|
||||||
|
<div className='dashwidget'>
|
||||||
|
<h4><FormattedMessage id='admin.dashwidgets.email_list_header' defaultMessage='Email list' /></h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href='#' onClick={handleSubscribersClick} target='_blank'>subscribers.csv</a></li>
|
||||||
|
<li><a href='#' onClick={handleUnsubscribersClick} target='_blank'>unsubscribers.csv</a></li>
|
||||||
|
<li><a href='#' onClick={handleCombinedClick} target='_blank'>combined.csv</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
|
@ -0,0 +1,50 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { fetchReports } from 'soapbox/actions/admin';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
|
import { makeGetReport } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
import Report from '../components/report';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.admin.reports', defaultMessage: 'Reports' },
|
||||||
|
modlog: { id: 'column.admin.reports.menu.moderation_log', defaultMessage: 'Moderation Log' },
|
||||||
|
emptyMessage: { id: 'admin.reports.empty_message', defaultMessage: 'There are no open reports. If a user gets reported, they will show up here.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getReport = makeGetReport();
|
||||||
|
|
||||||
|
const Reports: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const reports = useAppSelector(state => {
|
||||||
|
const ids = state.admin.openReports;
|
||||||
|
return ids.toList().map(id => getReport(state, id));
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchReports())
|
||||||
|
.then(() => setLoading(false))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showLoading = isLoading && reports.count() === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableList
|
||||||
|
isLoading={isLoading}
|
||||||
|
showLoading={showLoading}
|
||||||
|
scrollKey='admin-reports'
|
||||||
|
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||||
|
>
|
||||||
|
{reports.map(report => <Report report={report} key={report.get('id')} />)}
|
||||||
|
</ScrollableList>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Reports;
|
|
@ -1,313 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
import { Select } from '../../components/ui';
|
|
||||||
|
|
||||||
export const FormPropTypes = {
|
|
||||||
label: PropTypes.oneOfType([
|
|
||||||
PropTypes.string,
|
|
||||||
PropTypes.object,
|
|
||||||
PropTypes.node,
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InputContainer = (props) => {
|
|
||||||
const containerClass = classNames('input', {
|
|
||||||
'with_label': props.label,
|
|
||||||
'required': props.required,
|
|
||||||
'boolean': props.type === 'checkbox',
|
|
||||||
'field_with_errors': props.error,
|
|
||||||
}, props.extraClass);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={containerClass}>
|
|
||||||
{props.children}
|
|
||||||
{props.hint && <span className='hint'>{props.hint}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
InputContainer.propTypes = {
|
|
||||||
label: FormPropTypes.label,
|
|
||||||
hint: PropTypes.node,
|
|
||||||
required: PropTypes.bool,
|
|
||||||
type: PropTypes.string,
|
|
||||||
children: PropTypes.node,
|
|
||||||
extraClass: PropTypes.string,
|
|
||||||
error: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LabelInputContainer = ({ label, hint, children, ...props }) => {
|
|
||||||
const [id] = useState(uuidv4());
|
|
||||||
const childrenWithProps = React.Children.map(children, child => (
|
|
||||||
React.cloneElement(child, { id: id, key: id })
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='label_input'>
|
|
||||||
<label htmlFor={id}>{label}</label>
|
|
||||||
<div className='label_input__wrapper'>
|
|
||||||
{childrenWithProps}
|
|
||||||
</div>
|
|
||||||
{hint && <span className='hint'>{hint}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
LabelInputContainer.propTypes = {
|
|
||||||
label: FormPropTypes.label.isRequired,
|
|
||||||
hint: PropTypes.node,
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LabelInput = ({ label, dispatch, ...props }) => (
|
|
||||||
<LabelInputContainer label={label}>
|
|
||||||
<input {...props} />
|
|
||||||
</LabelInputContainer>
|
|
||||||
);
|
|
||||||
|
|
||||||
LabelInput.propTypes = {
|
|
||||||
label: FormPropTypes.label.isRequired,
|
|
||||||
dispatch: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LabelTextarea = ({ label, dispatch, ...props }) => (
|
|
||||||
<LabelInputContainer label={label}>
|
|
||||||
<textarea {...props} />
|
|
||||||
</LabelInputContainer>
|
|
||||||
);
|
|
||||||
|
|
||||||
LabelTextarea.propTypes = {
|
|
||||||
label: FormPropTypes.label.isRequired,
|
|
||||||
dispatch: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SimpleInput extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
label: FormPropTypes.label,
|
|
||||||
hint: PropTypes.node,
|
|
||||||
error: PropTypes.bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { hint, error, ...props } = this.props;
|
|
||||||
const Input = this.props.label ? LabelInput : 'input';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InputContainer {...this.props}>
|
|
||||||
<Input {...props} />
|
|
||||||
</InputContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SimpleTextarea extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
label: FormPropTypes.label,
|
|
||||||
hint: PropTypes.node,
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { hint, ...props } = this.props;
|
|
||||||
const Input = this.props.label ? LabelTextarea : 'textarea';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InputContainer {...this.props}>
|
|
||||||
<Input {...props} />
|
|
||||||
</InputContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SimpleForm extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
children: PropTypes.node,
|
|
||||||
className: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
acceptCharset: 'UTF-8',
|
|
||||||
onSubmit: e => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
onSubmit = e => {
|
|
||||||
this.props.onSubmit(e);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { className, children, onSubmit, ...props } = this.props;
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className={classNames('simple_form', className)}
|
|
||||||
method='post'
|
|
||||||
onSubmit={this.onSubmit}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FieldsGroup = ({ children }) => (
|
|
||||||
<div className='fields-group'>{children}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
FieldsGroup.propTypes = {
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Checkbox = props => (
|
|
||||||
<SimpleInput type='checkbox' {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export class RadioGroup extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
label: FormPropTypes.label,
|
|
||||||
children: PropTypes.node,
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { label, children, onChange } = this.props;
|
|
||||||
|
|
||||||
const childrenWithProps = React.Children.map(children, child =>
|
|
||||||
React.cloneElement(child, { onChange }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='input with_floating_label radio_buttons'>
|
|
||||||
<div className='label_input'>
|
|
||||||
<label>{label}</label>
|
|
||||||
<ul>{childrenWithProps}</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RadioItem extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
label: FormPropTypes.label,
|
|
||||||
hint: PropTypes.node,
|
|
||||||
value: PropTypes.string.isRequired,
|
|
||||||
checked: PropTypes.bool.isRequired,
|
|
||||||
onChange: PropTypes.func,
|
|
||||||
dispatch: PropTypes.func,
|
|
||||||
}
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
checked: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
state = {
|
|
||||||
id: uuidv4(),
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { label, hint, dispatch, ...props } = this.props;
|
|
||||||
const { id } = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className='radio'>
|
|
||||||
<label htmlFor={id}>
|
|
||||||
<input id={id} type='radio' {...props} /> {label}
|
|
||||||
{hint && <span className='hint'>{hint}</span>}
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SelectDropdown extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
label: FormPropTypes.label,
|
|
||||||
hint: PropTypes.node,
|
|
||||||
items: PropTypes.object.isRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { label, hint, items, ...props } = this.props;
|
|
||||||
|
|
||||||
const optionElems = Object.keys(items).map(item => (
|
|
||||||
<option key={item} value={item}>{items[item]}</option>
|
|
||||||
));
|
|
||||||
|
|
||||||
const selectElem = <Select {...props}>{optionElems}</Select>;
|
|
||||||
|
|
||||||
return label ? (
|
|
||||||
<LabelInputContainer label={label} hint={hint}>{selectElem}</LabelInputContainer>
|
|
||||||
) : selectElem;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextInput = props => (
|
|
||||||
<SimpleInput type='text' {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export const FileChooser = props => (
|
|
||||||
<SimpleInput type='file' {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
FileChooser.defaultProps = {
|
|
||||||
accept: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FileChooserLogo = props => (
|
|
||||||
<SimpleInput type='file' {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
FileChooserLogo.defaultProps = {
|
|
||||||
accept: ['image/svg', 'image/png'],
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export class CopyableInput extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
value: PropTypes.string,
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputRef = c => {
|
|
||||||
this.input = c;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCopyClick = e => {
|
|
||||||
if (!this.input) return;
|
|
||||||
|
|
||||||
this.input.select();
|
|
||||||
this.input.setSelectionRange(0, 99999);
|
|
||||||
|
|
||||||
document.execCommand('copy');
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { value } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='copyable-input'>
|
|
||||||
<input ref={this.setInputRef} type='text' value={value} readOnly />
|
|
||||||
<button className='p-2 text-white bg-primary-600' onClick={this.handleCopyClick}>
|
|
||||||
<FormattedMessage id='forms.copy' defaultMessage='Copy' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,271 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import { Text, Select } from '../../components/ui';
|
||||||
|
|
||||||
|
interface IInputContainer {
|
||||||
|
label?: React.ReactNode,
|
||||||
|
hint?: React.ReactNode,
|
||||||
|
required?: boolean,
|
||||||
|
type?: string,
|
||||||
|
extraClass?: string,
|
||||||
|
error?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputContainer: React.FC<IInputContainer> = (props) => {
|
||||||
|
const containerClass = classNames('input', {
|
||||||
|
'with_label': props.label,
|
||||||
|
'required': props.required,
|
||||||
|
'boolean': props.type === 'checkbox',
|
||||||
|
'field_with_errors': props.error,
|
||||||
|
}, props.extraClass);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClass}>
|
||||||
|
{props.children}
|
||||||
|
{props.hint && <span className='hint'>{props.hint}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ILabelInputContainer {
|
||||||
|
label?: React.ReactNode,
|
||||||
|
hint?: React.ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LabelInputContainer: React.FC<ILabelInputContainer> = ({ label, hint, children }) => {
|
||||||
|
const [id] = useState(uuidv4());
|
||||||
|
const childrenWithProps = React.Children.map(children, child => (
|
||||||
|
// @ts-ignore: not sure how to get the right type here
|
||||||
|
React.cloneElement(child, { id: id, key: id })
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='label_input'>
|
||||||
|
<label htmlFor={id}>{label}</label>
|
||||||
|
<div className='label_input__wrapper'>
|
||||||
|
{childrenWithProps}
|
||||||
|
</div>
|
||||||
|
{hint && <span className='hint'>{hint}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ILabelInput {
|
||||||
|
label?: React.ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LabelInput: React.FC<ILabelInput> = ({ label, ...props }) => (
|
||||||
|
<LabelInputContainer label={label}>
|
||||||
|
<input {...props} />
|
||||||
|
</LabelInputContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ILabelTextarea {
|
||||||
|
label?: React.ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LabelTextarea: React.FC<ILabelTextarea> = ({ label, ...props }) => (
|
||||||
|
<LabelInputContainer label={label}>
|
||||||
|
<textarea {...props} />
|
||||||
|
</LabelInputContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ISimpleInput {
|
||||||
|
type: string,
|
||||||
|
label?: React.ReactNode,
|
||||||
|
hint?: React.ReactNode,
|
||||||
|
error?: boolean,
|
||||||
|
onChange?: React.ChangeEventHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimpleInput: React.FC<ISimpleInput> = (props) => {
|
||||||
|
const { hint, label, error, ...rest } = props;
|
||||||
|
const Input = label ? LabelInput : 'input';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputContainer {...props}>
|
||||||
|
<Input {...rest} />
|
||||||
|
</InputContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ISimpleTextarea {
|
||||||
|
label?: React.ReactNode,
|
||||||
|
hint?: React.ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimpleTextarea: React.FC<ISimpleTextarea> = (props) => {
|
||||||
|
const { hint, label, ...rest } = props;
|
||||||
|
const Input = label ? LabelTextarea : 'textarea';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputContainer {...props}>
|
||||||
|
<Input {...rest} />
|
||||||
|
</InputContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ISimpleForm {
|
||||||
|
className?: string,
|
||||||
|
onSubmit?: React.FormEventHandler,
|
||||||
|
acceptCharset?: string,
|
||||||
|
style?: React.CSSProperties,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimpleForm: React.FC<ISimpleForm> = (props) => {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onSubmit = () => {},
|
||||||
|
acceptCharset = 'UTF-8',
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const handleSubmit: React.FormEventHandler = e => {
|
||||||
|
onSubmit(e);
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={classNames('simple_form', className)}
|
||||||
|
method='post'
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
acceptCharset={acceptCharset}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FieldsGroup: React.FC = ({ children }) => (
|
||||||
|
<div className='fields-group'>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Checkbox: React.FC = (props) => (
|
||||||
|
<SimpleInput type='checkbox' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
interface IRadioGroup {
|
||||||
|
label?: React.ReactNode,
|
||||||
|
onChange?: React.ChangeEventHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RadioGroup: React.FC<IRadioGroup> = (props) => {
|
||||||
|
const { label, children, onChange } = props;
|
||||||
|
|
||||||
|
const childrenWithProps = React.Children.map(children, child =>
|
||||||
|
// @ts-ignore
|
||||||
|
React.cloneElement(child, { onChange }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='input with_floating_label radio_buttons'>
|
||||||
|
<div className='label_input'>
|
||||||
|
<label>{label}</label>
|
||||||
|
<ul>{childrenWithProps}</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IRadioItem {
|
||||||
|
label?: React.ReactNode,
|
||||||
|
hint?: React.ReactNode,
|
||||||
|
value: string,
|
||||||
|
checked: boolean,
|
||||||
|
onChange?: React.ChangeEventHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RadioItem: React.FC<IRadioItem> = (props) => {
|
||||||
|
const { current: id } = useRef<string>(uuidv4());
|
||||||
|
const { label, hint, checked = false, ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className='radio'>
|
||||||
|
<label htmlFor={id}>
|
||||||
|
<input id={id} type='radio' checked={checked} {...rest} />
|
||||||
|
<Text>{label}</Text>
|
||||||
|
{hint && <span className='hint'>{hint}</span>}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ISelectDropdown {
|
||||||
|
label?: React.ReactNode,
|
||||||
|
hint?: React.ReactNode,
|
||||||
|
items: Record<string, string>,
|
||||||
|
defaultValue?: string,
|
||||||
|
onChange?: React.ChangeEventHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectDropdown: React.FC<ISelectDropdown> = (props) => {
|
||||||
|
const { label, hint, items, ...rest } = props;
|
||||||
|
|
||||||
|
const optionElems = Object.keys(items).map(item => (
|
||||||
|
<option key={item} value={item}>{items[item]}</option>
|
||||||
|
));
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const selectElem = <Select {...rest}>{optionElems}</Select>;
|
||||||
|
|
||||||
|
return label ? (
|
||||||
|
<LabelInputContainer label={label} hint={hint}>{selectElem}</LabelInputContainer>
|
||||||
|
) : selectElem;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ITextInput {
|
||||||
|
onChange?: React.ChangeEventHandler,
|
||||||
|
placeholder?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextInput: React.FC<ITextInput> = props => (
|
||||||
|
<SimpleInput type='text' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FileChooser : React.FC = (props) => (
|
||||||
|
<SimpleInput type='file' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
FileChooser.defaultProps = {
|
||||||
|
accept: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileChooserLogo: React.FC = props => (
|
||||||
|
<SimpleInput type='file' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
FileChooserLogo.defaultProps = {
|
||||||
|
accept: ['image/svg', 'image/png'],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ICopyableInput {
|
||||||
|
value: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
|
||||||
|
const node = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleCopyClick: React.MouseEventHandler = () => {
|
||||||
|
if (!node.current) return;
|
||||||
|
|
||||||
|
node.current.select();
|
||||||
|
node.current.setSelectionRange(0, 99999);
|
||||||
|
|
||||||
|
document.execCommand('copy');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='copyable-input'>
|
||||||
|
<input ref={node} type='text' value={value} readOnly />
|
||||||
|
<button className='p-2 text-white bg-primary-600' onClick={handleCopyClick}>
|
||||||
|
<FormattedMessage id='forms.copy' defaultMessage='Copy' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -95,14 +95,14 @@ const messages: Record<NotificationType, { id: string, defaultMessage: string }>
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildMessage = (type: NotificationType, account: Account): JSX.Element => {
|
const buildMessage = (type: NotificationType, account: Account, targetName?: string): JSX.Element => {
|
||||||
const link = buildLink(account);
|
const link = buildLink(account);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormattedMessageFixed
|
<FormattedMessageFixed
|
||||||
id={messages[type].id}
|
id={messages[type].id}
|
||||||
defaultMessage={messages[type].defaultMessage}
|
defaultMessage={messages[type].defaultMessage}
|
||||||
values={{ name: link }}
|
values={{ name: link, targetName }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -257,7 +257,9 @@ const Notification: React.FC<INotificaton> = (props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account) : null;
|
const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : '';
|
||||||
|
|
||||||
|
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account, targetName) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={getHandlers()} data-testid='notification'>
|
<HotKeys handlers={getHandlers()} data-testid='notification'>
|
||||||
|
@ -273,7 +275,7 @@ const Notification: React.FC<INotificaton> = (props) => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: account && typeof account === 'object' ? account.acct : '',
|
name: account && typeof account === 'object' ? account.acct : '',
|
||||||
targetName: notification.target && typeof notification.target === 'object' ? notification.target.acct : '',
|
targetName,
|
||||||
}),
|
}),
|
||||||
notification.created_at,
|
notification.created_at,
|
||||||
)
|
)
|
||||||
|
|
|
@ -134,7 +134,7 @@ const Preferences = () => {
|
||||||
<ListItem label={<FormattedMessage id='preferences.fields.language_label' defaultMessage='Language' />}>
|
<ListItem label={<FormattedMessage id='preferences.fields.language_label' defaultMessage='Language' />}>
|
||||||
<SelectDropdown
|
<SelectDropdown
|
||||||
items={languages}
|
items={languages}
|
||||||
defaultValue={settings.get('locale')}
|
defaultValue={settings.get('locale') as string | undefined}
|
||||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['locale'])}
|
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['locale'])}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
@ -142,7 +142,7 @@ const Preferences = () => {
|
||||||
<ListItem label={<FormattedMessage id='preferences.fields.media_display_label' defaultMessage='Media display' />}>
|
<ListItem label={<FormattedMessage id='preferences.fields.media_display_label' defaultMessage='Media display' />}>
|
||||||
<SelectDropdown
|
<SelectDropdown
|
||||||
items={displayMediaOptions}
|
items={displayMediaOptions}
|
||||||
defaultValue={settings.get('displayMedia')}
|
defaultValue={settings.get('displayMedia') as string | undefined}
|
||||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['displayMedia'])}
|
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['displayMedia'])}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
|
@ -21,7 +21,6 @@ import {
|
||||||
SimpleInput,
|
SimpleInput,
|
||||||
SimpleTextarea,
|
SimpleTextarea,
|
||||||
FileChooserLogo,
|
FileChooserLogo,
|
||||||
FormPropTypes,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
} from 'soapbox/features/forms';
|
} from 'soapbox/features/forms';
|
||||||
import ThemeToggle from 'soapbox/features/ui/components/theme_toggle';
|
import ThemeToggle from 'soapbox/features/ui/components/theme_toggle';
|
||||||
|
@ -509,7 +508,7 @@ class ColorWithPicker extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
buttonId: PropTypes.string.isRequired,
|
buttonId: PropTypes.string.isRequired,
|
||||||
label: FormPropTypes.label,
|
label: PropTypes.node,
|
||||||
value: PropTypes.string.isRequired,
|
value: PropTypes.string.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
}
|
}
|
||||||
|
@ -559,7 +558,7 @@ export class IconPicker extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
icons: PropTypes.object,
|
icons: PropTypes.object,
|
||||||
label: FormPropTypes.label,
|
label: PropTypes.node,
|
||||||
value: PropTypes.string,
|
value: PropTypes.string,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import classnames from 'classnames';
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import Blurhash from 'soapbox/components/blurhash';
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
import { HStack } from 'soapbox/components/ui';
|
import { HStack } from 'soapbox/components/ui';
|
||||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
|
@ -161,6 +162,13 @@ const Card: React.FC<ICard> = ({
|
||||||
|
|
||||||
let embed: React.ReactNode = '';
|
let embed: React.ReactNode = '';
|
||||||
|
|
||||||
|
const canvas = (
|
||||||
|
<Blurhash
|
||||||
|
className='absolute w-full h-full inset-0 -z-10'
|
||||||
|
hash={card.blurhash}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const thumbnail = (
|
const thumbnail = (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
@ -184,6 +192,7 @@ const Card: React.FC<ICard> = ({
|
||||||
|
|
||||||
embed = (
|
embed = (
|
||||||
<div className='status-card__image'>
|
<div className='status-card__image'>
|
||||||
|
{canvas}
|
||||||
{thumbnail}
|
{thumbnail}
|
||||||
|
|
||||||
<div className='absolute inset-0 flex items-center justify-center'>
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
@ -226,6 +235,7 @@ const Card: React.FC<ICard> = ({
|
||||||
} else if (card.image) {
|
} else if (card.image) {
|
||||||
embed = (
|
embed = (
|
||||||
<div className='status-card__image'>
|
<div className='status-card__image'>
|
||||||
|
{canvas}
|
||||||
{thumbnail}
|
{thumbnail}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -13,7 +13,9 @@ import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis';
|
||||||
import { fetchMarker } from 'soapbox/actions/markers';
|
import { fetchMarker } from 'soapbox/actions/markers';
|
||||||
import { register as registerPushNotifications } from 'soapbox/actions/push_notifications';
|
import { register as registerPushNotifications } from 'soapbox/actions/push_notifications';
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
||||||
import ThumbNavigation from 'soapbox/components/thumb_navigation';
|
import ThumbNavigation from 'soapbox/components/thumb_navigation';
|
||||||
|
import { Layout } from 'soapbox/components/ui';
|
||||||
import { useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
|
import { useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
|
||||||
import AdminPage from 'soapbox/pages/admin_page';
|
import AdminPage from 'soapbox/pages/admin_page';
|
||||||
import DefaultPage from 'soapbox/pages/default_page';
|
import DefaultPage from 'soapbox/pages/default_page';
|
||||||
|
@ -26,6 +28,7 @@ import RemoteInstancePage from 'soapbox/pages/remote_instance_page';
|
||||||
import StatusPage from 'soapbox/pages/status_page';
|
import StatusPage from 'soapbox/pages/status_page';
|
||||||
import { getAccessToken } from 'soapbox/utils/auth';
|
import { getAccessToken } from 'soapbox/utils/auth';
|
||||||
import { getVapidKey } from 'soapbox/utils/auth';
|
import { getVapidKey } from 'soapbox/utils/auth';
|
||||||
|
import { isStandalone } from 'soapbox/utils/state';
|
||||||
|
|
||||||
import { fetchFollowRequests } from '../../actions/accounts';
|
import { fetchFollowRequests } from '../../actions/accounts';
|
||||||
import { fetchReports, fetchUsers, fetchConfig } from '../../actions/admin';
|
import { fetchReports, fetchUsers, fetchConfig } from '../../actions/admin';
|
||||||
|
@ -91,8 +94,6 @@ import {
|
||||||
ChatPanes,
|
ChatPanes,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
Dashboard,
|
Dashboard,
|
||||||
AwaitingApproval,
|
|
||||||
Reports,
|
|
||||||
ModerationLog,
|
ModerationLog,
|
||||||
CryptoDonate,
|
CryptoDonate,
|
||||||
ScheduledStatuses,
|
ScheduledStatuses,
|
||||||
|
@ -172,7 +173,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
||||||
// Ex: use /login instead of /auth, but redirect /auth to /login
|
// Ex: use /login instead of /auth, but redirect /auth to /login
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<WrappedRoute path='/login/external' component={ExternalLogin} publicRoute exact />
|
<WrappedRoute path='/login/external' page={EmptyPage} component={ExternalLogin} content={children} publicRoute exact />
|
||||||
<WrappedRoute path='/email-confirmation' page={EmptyPage} component={EmailConfirmation} publicRoute exact />
|
<WrappedRoute path='/email-confirmation' page={EmptyPage} component={EmailConfirmation} publicRoute exact />
|
||||||
<WrappedRoute path='/logout' page={EmptyPage} component={LogoutPage} publicRoute exact />
|
<WrappedRoute path='/logout' page={EmptyPage} component={LogoutPage} publicRoute exact />
|
||||||
|
|
||||||
|
@ -302,8 +303,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
||||||
<WrappedRoute path='/soapbox/config' adminOnly page={DefaultPage} component={SoapboxConfig} content={children} />
|
<WrappedRoute path='/soapbox/config' adminOnly page={DefaultPage} component={SoapboxConfig} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/admin' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
<WrappedRoute path='/admin' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||||
<WrappedRoute path='/admin/approval' staffOnly page={AdminPage} component={AwaitingApproval} content={children} exact />
|
<WrappedRoute path='/admin/approval' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||||
<WrappedRoute path='/admin/reports' staffOnly page={AdminPage} component={Reports} content={children} exact />
|
<WrappedRoute path='/admin/reports' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||||
<WrappedRoute path='/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact />
|
<WrappedRoute path='/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact />
|
||||||
<WrappedRoute path='/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
|
<WrappedRoute path='/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
|
||||||
<WrappedRoute path='/info' page={EmptyPage} component={ServerInfo} content={children} />
|
<WrappedRoute path='/info' page={EmptyPage} component={ServerInfo} content={children} />
|
||||||
|
@ -346,6 +347,7 @@ const UI: React.FC = ({ children }) => {
|
||||||
const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.get('openId') !== null);
|
const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.get('openId') !== null);
|
||||||
const accessToken = useAppSelector(state => getAccessToken(state));
|
const accessToken = useAppSelector(state => getAccessToken(state));
|
||||||
const streamingUrl = useAppSelector(state => state.instance.urls.get('streaming_api'));
|
const streamingUrl = useAppSelector(state => state.instance.urls.get('streaming_api'));
|
||||||
|
const standalone = useAppSelector(isStandalone);
|
||||||
|
|
||||||
const handleDragEnter = (e: DragEvent) => {
|
const handleDragEnter = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -649,9 +651,15 @@ const UI: React.FC = ({ children }) => {
|
||||||
<div className='z-10 flex flex-col'>
|
<div className='z-10 flex flex-col'>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<SwitchingColumnsArea>
|
<Layout>
|
||||||
{children}
|
<Layout.Sidebar>
|
||||||
</SwitchingColumnsArea>
|
{!standalone && <SidebarNavigation />}
|
||||||
|
</Layout.Sidebar>
|
||||||
|
|
||||||
|
<SwitchingColumnsArea>
|
||||||
|
{children}
|
||||||
|
</SwitchingColumnsArea>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
{me && floatingActionButton}
|
{me && floatingActionButton}
|
||||||
|
|
||||||
|
|
|
@ -330,14 +330,6 @@ export function Dashboard() {
|
||||||
return import(/* webpackChunkName: "features/admin" */'../../admin');
|
return import(/* webpackChunkName: "features/admin" */'../../admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AwaitingApproval() {
|
|
||||||
return import(/* webpackChunkName: "features/admin/awaiting_approval" */'../../admin/awaiting_approval');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Reports() {
|
|
||||||
return import(/* webpackChunkName: "features/admin/reports" */'../../admin/reports');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModerationLog() {
|
export function ModerationLog() {
|
||||||
return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation_log');
|
return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation_log');
|
||||||
}
|
}
|
||||||
|
@ -386,10 +378,6 @@ export function LatestAccountsPanel() {
|
||||||
return import(/* webpackChunkName: "features/admin" */'../../admin/components/latest_accounts_panel');
|
return import(/* webpackChunkName: "features/admin" */'../../admin/components/latest_accounts_panel');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminNav() {
|
|
||||||
return import(/* webpackChunkName: "features/admin" */'../../admin/components/admin_nav');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarMenu() {
|
export function SidebarMenu() {
|
||||||
return import(/* webpackChunkName: "features/ui" */'../../../components/sidebar_menu');
|
return import(/* webpackChunkName: "features/ui" */'../../../components/sidebar_menu');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Redirect, Route, useHistory, RouteProps, RouteComponentProps, match as MatchType } from 'react-router-dom';
|
import { Redirect, Route, useHistory, RouteProps, RouteComponentProps, match as MatchType } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Layout } from 'soapbox/components/ui';
|
||||||
import { useOwnAccount, useSettings } from 'soapbox/hooks';
|
import { useOwnAccount, useSettings } from 'soapbox/hooks';
|
||||||
|
|
||||||
import BundleColumnError from '../components/bundle_column_error';
|
import BundleColumnError from '../components/bundle_column_error';
|
||||||
|
@ -75,29 +76,19 @@ const WrappedRoute: React.FC<IWrappedRoute> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderLoading = () => {
|
const renderWithLayout = (children: JSX.Element) => (
|
||||||
return (
|
<>
|
||||||
<ColumnsArea layout={layout}>
|
<Layout.Main>
|
||||||
<ColumnLoading />
|
{children}
|
||||||
</ColumnsArea>
|
</Layout.Main>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderForbidden = () => {
|
<Layout.Aside />
|
||||||
return (
|
</>
|
||||||
<ColumnsArea layout={layout}>
|
);
|
||||||
<ColumnForbidden />
|
|
||||||
</ColumnsArea>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderError = (props: any) => {
|
const renderLoading = () => renderWithLayout(<ColumnLoading />);
|
||||||
return (
|
const renderForbidden = () => renderWithLayout(<ColumnForbidden />);
|
||||||
<ColumnsArea layout={layout}>
|
const renderError = (props: any) => renderWithLayout(<BundleColumnError {...props} />);
|
||||||
<BundleColumnError {...props} />
|
|
||||||
</ColumnsArea>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loginRedirect = () => {
|
const loginRedirect = () => {
|
||||||
const actualUrl = encodeURIComponent(`${history.location.pathname}${history.location.search}`);
|
const actualUrl = encodeURIComponent(`${history.location.pathname}${history.location.search}`);
|
||||||
|
|
|
@ -109,7 +109,7 @@
|
||||||
"admin.users.user_unsuggested_message": "@{acct} was unsuggested",
|
"admin.users.user_unsuggested_message": "@{acct} was unsuggested",
|
||||||
"admin.users.user_unverified_message": "@{acct} was unverified",
|
"admin.users.user_unverified_message": "@{acct} was unverified",
|
||||||
"admin.users.user_verified_message": "@{acct} was verified",
|
"admin.users.user_verified_message": "@{acct} was verified",
|
||||||
"admin_nav.awaiting_approval": "Awaiting Approval",
|
"admin_nav.awaiting_approval": "Waitlist",
|
||||||
"admin_nav.dashboard": "Dashboard",
|
"admin_nav.dashboard": "Dashboard",
|
||||||
"admin_nav.reports": "Reports",
|
"admin_nav.reports": "Reports",
|
||||||
"alert.unexpected.clear_cookies": "clear cookies and browser data",
|
"alert.unexpected.clear_cookies": "clear cookies and browser data",
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
|
|
||||||
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
|
||||||
import {
|
|
||||||
AdminNav,
|
|
||||||
LatestAccountsPanel,
|
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
|
||||||
|
|
||||||
import LinkFooter from '../features/ui/components/link_footer';
|
|
||||||
|
|
||||||
export default
|
|
||||||
class AdminPage extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { children } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='page page--admin'>
|
|
||||||
<div className='page__columns'>
|
|
||||||
<div className='columns-area__panels'>
|
|
||||||
|
|
||||||
<div className='columns-area__panels__pane columns-area__panels__pane--left'>
|
|
||||||
<div className='columns-area__panels__pane__inner'>
|
|
||||||
<BundleContainer fetchComponent={AdminNav}>
|
|
||||||
{Component => <Component />}
|
|
||||||
</BundleContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='columns-area__panels__main'>
|
|
||||||
<div className='columns-area'>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='columns-area__panels__pane columns-area__panels__pane--right'>
|
|
||||||
<div className='columns-area__panels__pane__inner'>
|
|
||||||
<BundleContainer fetchComponent={LatestAccountsPanel}>
|
|
||||||
{Component => <Component limit={5} />}
|
|
||||||
</BundleContainer>
|
|
||||||
<LinkFooter />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Layout } from 'soapbox/components/ui';
|
||||||
|
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||||
|
import {
|
||||||
|
LatestAccountsPanel,
|
||||||
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
|
||||||
|
import LinkFooter from '../features/ui/components/link_footer';
|
||||||
|
|
||||||
|
const AdminPage: React.FC = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout.Main>
|
||||||
|
{children}
|
||||||
|
</Layout.Main>
|
||||||
|
|
||||||
|
<Layout.Aside>
|
||||||
|
<BundleContainer fetchComponent={LatestAccountsPanel}>
|
||||||
|
{Component => <Component limit={5} />}
|
||||||
|
</BundleContainer>
|
||||||
|
|
||||||
|
<LinkFooter />
|
||||||
|
</Layout.Aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminPage;
|
|
@ -1,66 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
|
||||||
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
|
||||||
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
|
||||||
import {
|
|
||||||
WhoToFollowPanel,
|
|
||||||
TrendsPanel,
|
|
||||||
SignUpPanel,
|
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
|
||||||
|
|
||||||
import { Layout } from '../components/ui';
|
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
|
||||||
const me = state.get('me');
|
|
||||||
const features = getFeatures(state.get('instance'));
|
|
||||||
|
|
||||||
return {
|
|
||||||
me,
|
|
||||||
showTrendsPanel: features.trends,
|
|
||||||
showWhoToFollowPanel: features.suggestions,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
class DefaultPage extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { me, children, showTrendsPanel, showWhoToFollowPanel } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Layout.Sidebar>
|
|
||||||
<SidebarNavigation />
|
|
||||||
</Layout.Sidebar>
|
|
||||||
|
|
||||||
<Layout.Main>
|
|
||||||
{children}
|
|
||||||
</Layout.Main>
|
|
||||||
|
|
||||||
<Layout.Aside>
|
|
||||||
{!me && (
|
|
||||||
<BundleContainer fetchComponent={SignUpPanel}>
|
|
||||||
{Component => <Component key='sign-up-panel' />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
{showTrendsPanel && (
|
|
||||||
<BundleContainer fetchComponent={TrendsPanel}>
|
|
||||||
{Component => <Component limit={3} key='trends-panel' />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
{showWhoToFollowPanel && (
|
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
|
||||||
{Component => <Component limit={5} key='wtf-panel' />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
<LinkFooter key='link-footer' />
|
|
||||||
</Layout.Aside>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
||||||
|
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||||
|
import {
|
||||||
|
WhoToFollowPanel,
|
||||||
|
TrendsPanel,
|
||||||
|
SignUpPanel,
|
||||||
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
import { useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||||
|
import { isStandalone } from 'soapbox/utils/state';
|
||||||
|
|
||||||
|
import { Layout } from '../components/ui';
|
||||||
|
|
||||||
|
const DefaultPage: React.FC = ({ children }) => {
|
||||||
|
const me = useAppSelector(state => state.me);
|
||||||
|
const standalone = useAppSelector(isStandalone);
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout.Main>
|
||||||
|
{children}
|
||||||
|
</Layout.Main>
|
||||||
|
|
||||||
|
<Layout.Aside>
|
||||||
|
{!me && !standalone && (
|
||||||
|
<BundleContainer fetchComponent={SignUpPanel}>
|
||||||
|
{Component => <Component key='sign-up-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
{features.trends && (
|
||||||
|
<BundleContainer fetchComponent={TrendsPanel}>
|
||||||
|
{Component => <Component limit={3} key='trends-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
{features.suggestions && (
|
||||||
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
|
{Component => <Component limit={5} key='wtf-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
<LinkFooter key='link-footer' />
|
||||||
|
</Layout.Aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DefaultPage;
|
|
@ -1,24 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
|
|
||||||
import { Layout } from '../components/ui';
|
|
||||||
|
|
||||||
export default class DefaultPage extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { children } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Layout.Sidebar />
|
|
||||||
|
|
||||||
<Layout.Main>
|
|
||||||
{children}
|
|
||||||
</Layout.Main>
|
|
||||||
|
|
||||||
<Layout.Aside />
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Layout } from '../components/ui';
|
||||||
|
|
||||||
|
const EmptyPage: React.FC = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout.Main>
|
||||||
|
{children}
|
||||||
|
</Layout.Main>
|
||||||
|
|
||||||
|
<Layout.Aside />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmptyPage;
|
|
@ -1,123 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
|
||||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
|
||||||
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
|
||||||
import {
|
|
||||||
WhoToFollowPanel,
|
|
||||||
TrendsPanel,
|
|
||||||
SignUpPanel,
|
|
||||||
PromoPanel,
|
|
||||||
FundingPanel,
|
|
||||||
CryptoDonatePanel,
|
|
||||||
BirthdayPanel,
|
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
|
||||||
// import GroupSidebarPanel from '../features/groups/sidebar_panel';
|
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
|
||||||
|
|
||||||
import Avatar from '../components/avatar';
|
|
||||||
import { Card, CardBody, Layout } from '../components/ui';
|
|
||||||
import ComposeFormContainer from '../features/compose/containers/compose_form_container';
|
|
||||||
import BundleContainer from '../features/ui/containers/bundle_container';
|
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
|
||||||
const me = state.get('me');
|
|
||||||
const soapbox = getSoapboxConfig(state);
|
|
||||||
const hasPatron = soapbox.getIn(['extensions', 'patron', 'enabled']);
|
|
||||||
const hasCrypto = typeof soapbox.getIn(['cryptoAddresses', 0, 'ticker']) === 'string';
|
|
||||||
const cryptoLimit = soapbox.getIn(['cryptoDonatePanel', 'limit']);
|
|
||||||
const features = getFeatures(state.get('instance'));
|
|
||||||
|
|
||||||
return {
|
|
||||||
me,
|
|
||||||
account: state.getIn(['accounts', me]),
|
|
||||||
hasPatron,
|
|
||||||
hasCrypto,
|
|
||||||
cryptoLimit,
|
|
||||||
features,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
class HomePage extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.composeBlock = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { me, children, account, features, hasPatron, hasCrypto, cryptoLimit } = this.props;
|
|
||||||
|
|
||||||
const acct = account ? account.get('acct') : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Layout.Sidebar>
|
|
||||||
<SidebarNavigation />
|
|
||||||
</Layout.Sidebar>
|
|
||||||
|
|
||||||
<Layout.Main className='divide-y divide-gray-200 divide-solid sm:divide-none'>
|
|
||||||
{me && <Card variant='rounded' ref={this.composeBlock}>
|
|
||||||
<CardBody>
|
|
||||||
<div className='flex items-start space-x-4'>
|
|
||||||
<Link to={`/@${acct}`}>
|
|
||||||
<Avatar account={account} size={46} />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<ComposeFormContainer
|
|
||||||
shouldCondense
|
|
||||||
autoFocus={false}
|
|
||||||
clickableAreaRef={this.composeBlock}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>}
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</Layout.Main>
|
|
||||||
|
|
||||||
<Layout.Aside>
|
|
||||||
{!me && (
|
|
||||||
<BundleContainer fetchComponent={SignUpPanel}>
|
|
||||||
{Component => <Component />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
{features.trends && (
|
|
||||||
<BundleContainer fetchComponent={TrendsPanel}>
|
|
||||||
{Component => <Component limit={3} />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
{hasPatron && (
|
|
||||||
<BundleContainer fetchComponent={FundingPanel}>
|
|
||||||
{Component => <Component />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
{hasCrypto && cryptoLimit > 0 && (
|
|
||||||
<BundleContainer fetchComponent={CryptoDonatePanel}>
|
|
||||||
{Component => <Component limit={cryptoLimit} />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
<BundleContainer fetchComponent={PromoPanel}>
|
|
||||||
{Component => <Component />}
|
|
||||||
</BundleContainer>
|
|
||||||
{features.birthdays && (
|
|
||||||
<BundleContainer fetchComponent={BirthdayPanel}>
|
|
||||||
{Component => <Component limit={10} />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
{features.suggestions && (
|
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
|
||||||
{Component => <Component limit={5} />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
<LinkFooter key='link-footer' />
|
|
||||||
</Layout.Aside>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
||||||
|
import {
|
||||||
|
WhoToFollowPanel,
|
||||||
|
TrendsPanel,
|
||||||
|
SignUpPanel,
|
||||||
|
PromoPanel,
|
||||||
|
FundingPanel,
|
||||||
|
CryptoDonatePanel,
|
||||||
|
BirthdayPanel,
|
||||||
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
import { useAppSelector, useOwnAccount, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import Avatar from '../components/avatar';
|
||||||
|
import { Card, CardBody, Layout } from '../components/ui';
|
||||||
|
import ComposeFormContainer from '../features/compose/containers/compose_form_container';
|
||||||
|
import BundleContainer from '../features/ui/containers/bundle_container';
|
||||||
|
// import GroupSidebarPanel from '../features/groups/sidebar_panel';
|
||||||
|
|
||||||
|
const HomePage: React.FC = ({ children }) => {
|
||||||
|
const me = useAppSelector(state => state.me);
|
||||||
|
const account = useOwnAccount();
|
||||||
|
const features = useFeatures();
|
||||||
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
|
||||||
|
const composeBlock = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const hasPatron = soapboxConfig.extensions.getIn(['patron', 'enabled']) === true;
|
||||||
|
const hasCrypto = typeof soapboxConfig.cryptoAddresses.getIn([0, 'ticker']) === 'string';
|
||||||
|
const cryptoLimit = soapboxConfig.cryptoDonatePanel.get('limit');
|
||||||
|
|
||||||
|
const acct = account ? account.acct : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout.Main className='divide-y divide-gray-200 divide-solid sm:divide-none'>
|
||||||
|
{me && (
|
||||||
|
<Card variant='rounded' ref={composeBlock}>
|
||||||
|
<CardBody>
|
||||||
|
<div className='flex items-start space-x-4'>
|
||||||
|
<Link to={`/@${acct}`}>
|
||||||
|
<Avatar account={account} size={46} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<ComposeFormContainer
|
||||||
|
// @ts-ignore
|
||||||
|
shouldCondense
|
||||||
|
autoFocus={false}
|
||||||
|
clickableAreaRef={composeBlock}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Layout.Main>
|
||||||
|
|
||||||
|
<Layout.Aside>
|
||||||
|
{!me && (
|
||||||
|
<BundleContainer fetchComponent={SignUpPanel}>
|
||||||
|
{Component => <Component />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
{features.trends && (
|
||||||
|
<BundleContainer fetchComponent={TrendsPanel}>
|
||||||
|
{Component => <Component limit={3} />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
{hasPatron && (
|
||||||
|
<BundleContainer fetchComponent={FundingPanel}>
|
||||||
|
{Component => <Component />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
{hasCrypto && cryptoLimit && cryptoLimit > 0 && (
|
||||||
|
<BundleContainer fetchComponent={CryptoDonatePanel}>
|
||||||
|
{Component => <Component limit={cryptoLimit} />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
<BundleContainer fetchComponent={PromoPanel}>
|
||||||
|
{Component => <Component />}
|
||||||
|
</BundleContainer>
|
||||||
|
{features.birthdays && (
|
||||||
|
<BundleContainer fetchComponent={BirthdayPanel}>
|
||||||
|
{Component => <Component limit={10} />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
{features.suggestions && (
|
||||||
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
|
{Component => <Component limit={5} />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
<LinkFooter key='link-footer' />
|
||||||
|
</Layout.Aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
|
@ -6,7 +6,6 @@ import { FormattedMessage } from 'react-intl';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Redirect, withRouter } from 'react-router-dom';
|
import { Redirect, withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
|
||||||
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
||||||
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||||
import {
|
import {
|
||||||
|
@ -127,11 +126,7 @@ class ProfilePage extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<>
|
||||||
<Layout.Sidebar>
|
|
||||||
<SidebarNavigation />
|
|
||||||
</Layout.Sidebar>
|
|
||||||
|
|
||||||
<Layout.Main>
|
<Layout.Main>
|
||||||
<Column label={account ? `@${getAcct(account, displayFqn)}` : null} withHeader={false}>
|
<Column label={account ? `@${getAcct(account, displayFqn)}` : null} withHeader={false}>
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
|
@ -171,7 +166,7 @@ class ProfilePage extends ImmutablePureComponent {
|
||||||
)}
|
)}
|
||||||
<LinkFooter key='link-footer' />
|
<LinkFooter key='link-footer' />
|
||||||
</Layout.Aside>
|
</Layout.Aside>
|
||||||
</Layout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,6 @@ import React from 'react';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
|
||||||
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
||||||
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||||
import {
|
import {
|
||||||
|
@ -32,11 +31,7 @@ class RemoteInstancePage extends ImmutablePureComponent {
|
||||||
const { children, params: { instance: host }, disclosed, isAdmin } = this.props;
|
const { children, params: { instance: host }, disclosed, isAdmin } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<>
|
||||||
<Layout.Sidebar>
|
|
||||||
<SidebarNavigation />
|
|
||||||
</Layout.Sidebar>
|
|
||||||
|
|
||||||
<Layout.Main>
|
<Layout.Main>
|
||||||
{children}
|
{children}
|
||||||
</Layout.Main>
|
</Layout.Main>
|
||||||
|
@ -55,7 +50,7 @@ class RemoteInstancePage extends ImmutablePureComponent {
|
||||||
)}
|
)}
|
||||||
<LinkFooter key='link-footer' />
|
<LinkFooter key='link-footer' />
|
||||||
</Layout.Aside>
|
</Layout.Aside>
|
||||||
</Layout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,6 @@ import React from 'react';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
|
||||||
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
||||||
import {
|
import {
|
||||||
WhoToFollowPanel,
|
WhoToFollowPanel,
|
||||||
|
@ -33,11 +32,7 @@ class StatusPage extends ImmutablePureComponent {
|
||||||
const { me, children, showTrendsPanel, showWhoToFollowPanel } = this.props;
|
const { me, children, showTrendsPanel, showWhoToFollowPanel } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<>
|
||||||
<Layout.Sidebar>
|
|
||||||
<SidebarNavigation />
|
|
||||||
</Layout.Sidebar>
|
|
||||||
|
|
||||||
<Layout.Main>
|
<Layout.Main>
|
||||||
{children}
|
{children}
|
||||||
</Layout.Main>
|
</Layout.Main>
|
||||||
|
@ -60,7 +55,7 @@ class StatusPage extends ImmutablePureComponent {
|
||||||
)}
|
)}
|
||||||
<LinkFooter key='link-footer' />
|
<LinkFooter key='link-footer' />
|
||||||
</Layout.Aside>
|
</Layout.Aside>
|
||||||
</Layout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,8 +10,9 @@ describe('parseVersion', () => {
|
||||||
const version = '2.7.2 (compatible; Pleroma 2.0.5-6-ga36eb5ea-plerasstodon+dev)';
|
const version = '2.7.2 (compatible; Pleroma 2.0.5-6-ga36eb5ea-plerasstodon+dev)';
|
||||||
expect(parseVersion(version)).toEqual({
|
expect(parseVersion(version)).toEqual({
|
||||||
software: 'Pleroma',
|
software: 'Pleroma',
|
||||||
version: '2.0.5-6-ga36eb5ea-plerasstodon+dev',
|
version: '2.0.5-6-ga36eb5ea-plerasstodon',
|
||||||
compatVersion: '2.7.2',
|
compatVersion: '2.7.2',
|
||||||
|
build: 'dev',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -32,6 +33,35 @@ describe('parseVersion', () => {
|
||||||
compatVersion: '2.7.2',
|
compatVersion: '2.7.2',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('with a Truth Social version string', () => {
|
||||||
|
const version = '3.4.1 (compatible; TruthSocial 1.0.0)';
|
||||||
|
expect(parseVersion(version)).toEqual({
|
||||||
|
software: 'TruthSocial',
|
||||||
|
version: '1.0.0',
|
||||||
|
compatVersion: '3.4.1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with a Mastodon fork', () => {
|
||||||
|
const version = '3.5.1+glitch';
|
||||||
|
expect(parseVersion(version)).toEqual({
|
||||||
|
software: 'Mastodon',
|
||||||
|
version: '3.5.1',
|
||||||
|
compatVersion: '3.5.1',
|
||||||
|
build: 'glitch',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with a Pleroma fork', () => {
|
||||||
|
const version = '2.7.2 (compatible; Pleroma 2.4.2+cofe)';
|
||||||
|
expect(parseVersion(version)).toEqual({
|
||||||
|
software: 'Pleroma',
|
||||||
|
version: '2.4.2',
|
||||||
|
compatVersion: '2.7.2',
|
||||||
|
build: 'cofe',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getFeatures', () => {
|
describe('getFeatures', () => {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import gte from 'semver/functions/gte';
|
import gte from 'semver/functions/gte';
|
||||||
import lt from 'semver/functions/lt';
|
import lt from 'semver/functions/lt';
|
||||||
|
import semverParse from 'semver/functions/parse';
|
||||||
|
|
||||||
import { custom } from 'soapbox/custom';
|
import { custom } from 'soapbox/custom';
|
||||||
|
|
||||||
|
@ -44,6 +45,12 @@ export const PIXELFED = 'Pixelfed';
|
||||||
*/
|
*/
|
||||||
export const TRUTHSOCIAL = 'TruthSocial';
|
export const TRUTHSOCIAL = 'TruthSocial';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soapbox BE, the recommended Pleroma fork for Soapbox.
|
||||||
|
* @see {@link https://gitlab.com/soapbox-pub/soapbox-be}
|
||||||
|
*/
|
||||||
|
export const SOAPBOX = 'soapbox';
|
||||||
|
|
||||||
/** Parse features for the given instance */
|
/** Parse features for the given instance */
|
||||||
const getInstanceFeatures = (instance: Instance) => {
|
const getInstanceFeatures = (instance: Instance) => {
|
||||||
const v = parseVersion(instance.version);
|
const v = parseVersion(instance.version);
|
||||||
|
@ -76,7 +83,10 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
* Ability to set one's location on their profile.
|
* Ability to set one's location on their profile.
|
||||||
* @see PATCH /api/v1/accounts/update_credentials
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
*/
|
*/
|
||||||
accountLocation: v.software === TRUTHSOCIAL,
|
accountLocation: any([
|
||||||
|
v.software === PLEROMA && v.build === SOAPBOX && gte(v.version, '2.4.50'),
|
||||||
|
v.software === TRUTHSOCIAL,
|
||||||
|
]),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up an account by the acct.
|
* Look up an account by the acct.
|
||||||
|
@ -85,6 +95,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
accountLookup: any([
|
accountLookup: any([
|
||||||
v.software === MASTODON && gte(v.compatVersion, '3.4.0'),
|
v.software === MASTODON && gte(v.compatVersion, '3.4.0'),
|
||||||
v.software === PLEROMA && gte(v.version, '2.4.50'),
|
v.software === PLEROMA && gte(v.version, '2.4.50'),
|
||||||
|
v.software === TRUTHSOCIAL,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -464,6 +475,8 @@ export const getFeatures = createSelector([
|
||||||
|
|
||||||
/** Fediverse backend */
|
/** Fediverse backend */
|
||||||
interface Backend {
|
interface Backend {
|
||||||
|
/** Build name, if this software is a fork */
|
||||||
|
build: string | null,
|
||||||
/** Name of the software */
|
/** Name of the software */
|
||||||
software: string | null,
|
software: string | null,
|
||||||
/** API version number */
|
/** API version number */
|
||||||
|
@ -474,19 +487,24 @@ interface Backend {
|
||||||
|
|
||||||
/** Get information about the software from its version string */
|
/** Get information about the software from its version string */
|
||||||
export const parseVersion = (version: string): Backend => {
|
export const parseVersion = (version: string): Backend => {
|
||||||
const regex = /^([\w.]*)(?: \(compatible; ([\w]*) (.*)\))?$/;
|
const regex = /^([\w+.]*)(?: \(compatible; ([\w]*) (.*)\))?$/;
|
||||||
const match = regex.exec(version);
|
const match = regex.exec(version);
|
||||||
|
|
||||||
if (match) {
|
const semver = match ? semverParse(match[3] || match[1]) : null;
|
||||||
|
const compat = match ? semverParse(match[1]) : null;
|
||||||
|
|
||||||
|
if (match && semver && compat) {
|
||||||
return {
|
return {
|
||||||
compatVersion: match[1],
|
build: semver.build[0],
|
||||||
|
compatVersion: compat.version,
|
||||||
software: match[2] || MASTODON,
|
software: match[2] || MASTODON,
|
||||||
version: match[3] || match[1],
|
version: semver.version,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// If we can't parse the version, this is a new and exotic backend.
|
// If we can't parse the version, this is a new and exotic backend.
|
||||||
// Fall back to minimal featureset.
|
// Fall back to minimal featureset.
|
||||||
return {
|
return {
|
||||||
|
build: null,
|
||||||
compatVersion: '0.0.0',
|
compatVersion: '0.0.0',
|
||||||
software: null,
|
software: null,
|
||||||
version: '0.0.0',
|
version: '0.0.0',
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import { FormattedNumber } from 'react-intl';
|
import { FormattedNumber } from 'react-intl';
|
||||||
|
|
||||||
/** Check if a value is REALLY a number. */
|
/** Check if a value is REALLY a number. */
|
||||||
export const isNumber = (number: unknown): boolean => typeof number === 'number' && !isNaN(number);
|
export const isNumber = (value: unknown): value is number => typeof value === 'number' && !isNaN(value);
|
||||||
|
|
||||||
/** Display a number nicely for the UI, eg 1000 becomes 1K. */
|
/** Display a number nicely for the UI, eg 1000 becomes 1K. */
|
||||||
export const shortNumberFormat = (number: any): React.ReactNode => {
|
export const shortNumberFormat = (number: any): React.ReactNode => {
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
|
@apply text-black dark:text-white;
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
font-family: 'Font Awesome 5 Free';
|
font-family: 'Font Awesome 5 Free';
|
||||||
|
|
|
@ -105,10 +105,6 @@
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--primary-text-color);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__quote {
|
&__quote {
|
||||||
|
@ -122,7 +118,6 @@
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--primary-text-color);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,7 @@
|
||||||
@apply text-gray-500 dark:text-gray-400 mb-1 text-sm;
|
@apply text-gray-500 dark:text-gray-400 mb-1 text-sm;
|
||||||
|
|
||||||
&__account {
|
&__account {
|
||||||
@apply text-primary-600 no-underline;
|
@apply text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300 no-underline hover:underline;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@apply underline text-primary-800;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -338,6 +338,7 @@ code {
|
||||||
input[type=password],
|
input[type=password],
|
||||||
textarea,
|
textarea,
|
||||||
.rfipbtn {
|
.rfipbtn {
|
||||||
|
@apply border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-800 text-black dark:text-white;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
|
@ -643,6 +644,7 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
|
@apply text-black dark:text-white;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
@ -651,7 +653,6 @@ code {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--primary-text-color);
|
|
||||||
|
|
||||||
.svg-icon {
|
.svg-icon {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|
|
@ -81,6 +81,7 @@
|
||||||
"@types/react-helmet": "^6.1.5",
|
"@types/react-helmet": "^6.1.5",
|
||||||
"@types/react-motion": "^0.0.32",
|
"@types/react-motion": "^0.0.32",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"@types/react-sparklines": "^1.7.2",
|
||||||
"@types/react-swipeable-views": "^0.13.1",
|
"@types/react-swipeable-views": "^0.13.1",
|
||||||
"@types/react-toggle": "^4.0.3",
|
"@types/react-toggle": "^4.0.3",
|
||||||
"@types/redux-mock-store": "^1.0.3",
|
"@types/redux-mock-store": "^1.0.3",
|
||||||
|
|
|
@ -2242,6 +2242,13 @@
|
||||||
"@types/history" "^4.7.11"
|
"@types/history" "^4.7.11"
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
|
"@types/react-sparklines@^1.7.2":
|
||||||
|
version "1.7.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react-sparklines/-/react-sparklines-1.7.2.tgz#c14e80623abd3669a10f18d13f6fb9fbdc322f70"
|
||||||
|
integrity sha512-N1GwO7Ri5C5fE8+CxhiDntuSw1qYdGytBuedKrCxWpaojXm4WnfygbdBdc5sXGX7feMxDXBy9MNhxoUTwrMl4A==
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react-swipeable-views@^0.13.1":
|
"@types/react-swipeable-views@^0.13.1":
|
||||||
version "0.13.1"
|
version "0.13.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.1.tgz#381c8513deef5426623aa851033ff4f4831ae15c"
|
resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.1.tgz#381c8513deef5426623aa851033ff4f4831ae15c"
|
||||||
|
|
Loading…
Reference in New Issue