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 { 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 Motion from 'soapbox/features/ui/util/optional_motion';
|
||||
|
||||
|
@ -24,6 +24,7 @@ export interface MenuItem {
|
|||
newTab?: boolean,
|
||||
isLogout?: boolean,
|
||||
icon: string,
|
||||
count?: number,
|
||||
destructive?: boolean,
|
||||
}
|
||||
|
||||
|
@ -174,7 +175,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
|
|||
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 (
|
||||
<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}
|
||||
>
|
||||
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />}
|
||||
|
||||
<span className='truncate'>{text}</span>
|
||||
|
||||
{count ? (
|
||||
<span className='ml-auto h-5 w-5 flex-none'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||
|
@ -11,7 +10,13 @@ import { shortNumberFormat } from '../utils/numbers';
|
|||
import Permalink from './permalink';
|
||||
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 brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor'));
|
||||
|
||||
|
@ -41,7 +46,7 @@ const Hashtag = ({ hashtag }) => {
|
|||
<Sparklines
|
||||
width={40}
|
||||
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} />
|
||||
</Sparklines>
|
||||
|
@ -51,8 +56,4 @@ const Hashtag = ({ hashtag }) => {
|
|||
);
|
||||
};
|
||||
|
||||
Hashtag.propTypes = {
|
||||
hashtag: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
export default Hashtag;
|
|
@ -1,5 +1,4 @@
|
|||
import { debounce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
|
@ -13,10 +12,16 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
|
|||
dispatch(openProfileHoverCard(ref, accountId));
|
||||
}, 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 ref = useRef();
|
||||
const Elem = inline ? 'span' : 'div';
|
||||
const ref = useRef<HTMLElement>();
|
||||
const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div';
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile(window.innerWidth)) {
|
||||
|
@ -36,6 +41,7 @@ export const HoverRefWrapper = ({ accountId, children, inline }) => {
|
|||
|
||||
return (
|
||||
<Elem
|
||||
// @ts-ignore: not sure how to fix :\
|
||||
ref={ref}
|
||||
className='hover-ref-wrapper'
|
||||
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 };
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
import { Counter } from 'soapbox/components/ui';
|
||||
|
||||
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
||||
count: number,
|
||||
|
@ -14,9 +14,11 @@ const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) =
|
|||
<div className='relative'>
|
||||
<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'>
|
||||
{shortNumberFormat(count)}
|
||||
</i>}
|
||||
{count > 0 && (
|
||||
<i className='absolute -top-2 -right-2'>
|
||||
<Counter count={count} />
|
||||
</i>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,19 +1,20 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as React from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import Icon from './icon';
|
||||
|
||||
const List = ({ children }) => (
|
||||
const List: React.FC = ({ children }) => (
|
||||
<div className='space-y-0.5'>{children}</div>
|
||||
);
|
||||
|
||||
List.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
interface IListItem {
|
||||
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 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 };
|
|
@ -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 { NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon, Text } from './ui';
|
||||
import { Icon, Text, Counter } from './ui';
|
||||
|
||||
interface ISidebarNavigationLink {
|
||||
count?: number,
|
||||
|
@ -44,8 +44,8 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
|
|||
})}
|
||||
>
|
||||
{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'>
|
||||
{count}
|
||||
<span className='absolute -top-2 -right-2'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ const SidebarNavigation = () => {
|
|||
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 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 features = getFeatures(instance);
|
||||
|
@ -76,8 +76,7 @@ const SidebarNavigation = () => {
|
|||
to: '/admin',
|
||||
icon: require('@tabler/icons/icons/dashboard.svg'),
|
||||
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}>
|
||||
<SidebarNavigationLink
|
||||
icon={require('@tabler/icons/icons/dots-circle-horizontal.svg')}
|
||||
count={dashboardCount}
|
||||
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
|
|
@ -646,7 +646,11 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
/>
|
||||
|
||||
{features.quotePosts && me ? (
|
||||
<DropdownMenuContainer items={reblogMenu} onShiftClick={this.handleReblogClick}>
|
||||
<DropdownMenuContainer
|
||||
items={reblogMenu}
|
||||
disabled={!publicStatus}
|
||||
onShiftClick={this.handleReblogClick}
|
||||
>
|
||||
{reblogButton}
|
||||
</DropdownMenuContainer>
|
||||
) : (
|
||||
|
|
|
@ -20,9 +20,10 @@ interface ICard {
|
|||
variant?: 'rounded',
|
||||
size?: 'md' | 'lg' | 'xl',
|
||||
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
|
||||
ref={ref}
|
||||
{...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 Counter from '../counter/counter';
|
||||
|
||||
import SvgIcon from './svg-icon';
|
||||
|
||||
|
||||
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
||||
className?: string,
|
||||
count?: number,
|
||||
|
@ -13,8 +16,8 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
|||
const Icon = ({ src, alt, count, size, ...filteredProps }: IIcon): JSX.Element => (
|
||||
<div className='relative' data-testid='icon'>
|
||||
{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'>
|
||||
{count}
|
||||
<span className='absolute -top-2 -right-3'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ export { default as Avatar } from './avatar/avatar';
|
|||
export { default as Button } from './button/button';
|
||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||
export { default as Column } from './column/column';
|
||||
export { default as Counter } from './counter/counter';
|
||||
export { default as Emoji } from './emoji/emoji';
|
||||
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
|
||||
export { default as Form } from './form/form';
|
||||
|
|
|
@ -9,7 +9,7 @@ interface LayoutType extends React.FC {
|
|||
}
|
||||
|
||||
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'>
|
||||
{children}
|
||||
</div>
|
||||
|
@ -27,7 +27,7 @@ const Sidebar: React.FC = ({ children }) => (
|
|||
const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className }) => (
|
||||
<main
|
||||
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)}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -11,8 +11,9 @@
|
|||
}
|
||||
|
||||
[data-reach-tab] {
|
||||
@apply flex-1 flex justify-center 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;
|
||||
@apply flex-1 flex justify-center items-center
|
||||
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] {
|
||||
|
|
|
@ -9,6 +9,8 @@ import classNames from 'classnames';
|
|||
import * as React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Counter from '../counter/counter';
|
||||
|
||||
import './tabs.css';
|
||||
|
||||
const HORIZONTAL_PADDING = 8;
|
||||
|
@ -95,6 +97,7 @@ type Item = {
|
|||
href?: string,
|
||||
to?: string,
|
||||
action?: () => void,
|
||||
count?: number,
|
||||
name: string
|
||||
}
|
||||
interface ITabs {
|
||||
|
@ -118,7 +121,7 @@ const Tabs = ({ items, activeItem }: ITabs) => {
|
|||
};
|
||||
|
||||
const renderItem = (item: Item, idx: number) => {
|
||||
const { name, text, title } = item;
|
||||
const { name, text, title, count } = item;
|
||||
|
||||
return (
|
||||
<AnimatedTab
|
||||
|
@ -129,7 +132,15 @@ const Tabs = ({ items, activeItem }: ITabs) => {
|
|||
title={title}
|
||||
index={idx}
|
||||
>
|
||||
<div className='relative'>
|
||||
{count ? (
|
||||
<span className='absolute -top-2 left-full ml-1'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{text}
|
||||
</div>
|
||||
</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 { useEffect } from 'react';
|
||||
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 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 { useAppSelector } from 'soapbox/hooks';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
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 {
|
||||
|
@ -21,8 +21,9 @@ interface ILatestAccountsPanel {
|
|||
}
|
||||
|
||||
const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
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'])));
|
||||
|
@ -44,18 +45,19 @@ const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const expandCount = total - accountIds.size;
|
||||
const handleAction = () => {
|
||||
history.push('/admin/users');
|
||||
};
|
||||
|
||||
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) => (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
||||
return (
|
||||
<FormattedMessageFixed
|
||||
id={messages[type].id}
|
||||
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 (
|
||||
<HotKeys handlers={getHandlers()} data-testid='notification'>
|
||||
|
@ -273,7 +275,7 @@ const Notification: React.FC<INotificaton> = (props) => {
|
|||
},
|
||||
{
|
||||
name: account && typeof account === 'object' ? account.acct : '',
|
||||
targetName: notification.target && typeof notification.target === 'object' ? notification.target.acct : '',
|
||||
targetName,
|
||||
}),
|
||||
notification.created_at,
|
||||
)
|
||||
|
|
|
@ -134,7 +134,7 @@ const Preferences = () => {
|
|||
<ListItem label={<FormattedMessage id='preferences.fields.language_label' defaultMessage='Language' />}>
|
||||
<SelectDropdown
|
||||
items={languages}
|
||||
defaultValue={settings.get('locale')}
|
||||
defaultValue={settings.get('locale') as string | undefined}
|
||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['locale'])}
|
||||
/>
|
||||
</ListItem>
|
||||
|
@ -142,7 +142,7 @@ const Preferences = () => {
|
|||
<ListItem label={<FormattedMessage id='preferences.fields.media_display_label' defaultMessage='Media display' />}>
|
||||
<SelectDropdown
|
||||
items={displayMediaOptions}
|
||||
defaultValue={settings.get('displayMedia')}
|
||||
defaultValue={settings.get('displayMedia') as string | undefined}
|
||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['displayMedia'])}
|
||||
/>
|
||||
</ListItem>
|
||||
|
|
|
@ -21,7 +21,6 @@ import {
|
|||
SimpleInput,
|
||||
SimpleTextarea,
|
||||
FileChooserLogo,
|
||||
FormPropTypes,
|
||||
Checkbox,
|
||||
} from 'soapbox/features/forms';
|
||||
import ThemeToggle from 'soapbox/features/ui/components/theme_toggle';
|
||||
|
@ -509,7 +508,7 @@ class ColorWithPicker extends ImmutablePureComponent {
|
|||
|
||||
static propTypes = {
|
||||
buttonId: PropTypes.string.isRequired,
|
||||
label: FormPropTypes.label,
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
@ -559,7 +558,7 @@ export class IconPicker extends ImmutablePureComponent {
|
|||
|
||||
static propTypes = {
|
||||
icons: PropTypes.object,
|
||||
label: FormPropTypes.label,
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import classnames from 'classnames';
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||
|
@ -161,6 +162,13 @@ const Card: React.FC<ICard> = ({
|
|||
|
||||
let embed: React.ReactNode = '';
|
||||
|
||||
const canvas = (
|
||||
<Blurhash
|
||||
className='absolute w-full h-full inset-0 -z-10'
|
||||
hash={card.blurhash}
|
||||
/>
|
||||
);
|
||||
|
||||
const thumbnail = (
|
||||
<div
|
||||
style={{
|
||||
|
@ -184,6 +192,7 @@ const Card: React.FC<ICard> = ({
|
|||
|
||||
embed = (
|
||||
<div className='status-card__image'>
|
||||
{canvas}
|
||||
{thumbnail}
|
||||
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
|
@ -226,6 +235,7 @@ const Card: React.FC<ICard> = ({
|
|||
} else if (card.image) {
|
||||
embed = (
|
||||
<div className='status-card__image'>
|
||||
{canvas}
|
||||
{thumbnail}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -13,7 +13,9 @@ import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis';
|
|||
import { fetchMarker } from 'soapbox/actions/markers';
|
||||
import { register as registerPushNotifications } from 'soapbox/actions/push_notifications';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
||||
import ThumbNavigation from 'soapbox/components/thumb_navigation';
|
||||
import { Layout } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
|
||||
import AdminPage from 'soapbox/pages/admin_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 { getAccessToken } from 'soapbox/utils/auth';
|
||||
import { getVapidKey } from 'soapbox/utils/auth';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
|
||||
import { fetchFollowRequests } from '../../actions/accounts';
|
||||
import { fetchReports, fetchUsers, fetchConfig } from '../../actions/admin';
|
||||
|
@ -91,8 +94,6 @@ import {
|
|||
ChatPanes,
|
||||
ServerInfo,
|
||||
Dashboard,
|
||||
AwaitingApproval,
|
||||
Reports,
|
||||
ModerationLog,
|
||||
CryptoDonate,
|
||||
ScheduledStatuses,
|
||||
|
@ -172,7 +173,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
|||
// Ex: use /login instead of /auth, but redirect /auth to /login
|
||||
return (
|
||||
<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='/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='/admin' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||
<WrappedRoute path='/admin/approval' staffOnly page={AdminPage} component={AwaitingApproval} content={children} exact />
|
||||
<WrappedRoute path='/admin/reports' staffOnly page={AdminPage} component={Reports} content={children} exact />
|
||||
<WrappedRoute path='/admin/approval' staffOnly page={AdminPage} component={Dashboard} 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/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
|
||||
<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 accessToken = useAppSelector(state => getAccessToken(state));
|
||||
const streamingUrl = useAppSelector(state => state.instance.urls.get('streaming_api'));
|
||||
const standalone = useAppSelector(isStandalone);
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
@ -649,9 +651,15 @@ const UI: React.FC = ({ children }) => {
|
|||
<div className='z-10 flex flex-col'>
|
||||
<Navbar />
|
||||
|
||||
<Layout>
|
||||
<Layout.Sidebar>
|
||||
{!standalone && <SidebarNavigation />}
|
||||
</Layout.Sidebar>
|
||||
|
||||
<SwitchingColumnsArea>
|
||||
{children}
|
||||
</SwitchingColumnsArea>
|
||||
</Layout>
|
||||
|
||||
{me && floatingActionButton}
|
||||
|
||||
|
|
|
@ -330,14 +330,6 @@ export function Dashboard() {
|
|||
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() {
|
||||
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');
|
||||
}
|
||||
|
||||
export function AdminNav() {
|
||||
return import(/* webpackChunkName: "features/admin" */'../../admin/components/admin_nav');
|
||||
}
|
||||
|
||||
export function SidebarMenu() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../../../components/sidebar_menu');
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
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 BundleColumnError from '../components/bundle_column_error';
|
||||
|
@ -75,29 +76,19 @@ const WrappedRoute: React.FC<IWrappedRoute> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const renderLoading = () => {
|
||||
return (
|
||||
<ColumnsArea layout={layout}>
|
||||
<ColumnLoading />
|
||||
</ColumnsArea>
|
||||
);
|
||||
};
|
||||
const renderWithLayout = (children: JSX.Element) => (
|
||||
<>
|
||||
<Layout.Main>
|
||||
{children}
|
||||
</Layout.Main>
|
||||
|
||||
const renderForbidden = () => {
|
||||
return (
|
||||
<ColumnsArea layout={layout}>
|
||||
<ColumnForbidden />
|
||||
</ColumnsArea>
|
||||
<Layout.Aside />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderError = (props: any) => {
|
||||
return (
|
||||
<ColumnsArea layout={layout}>
|
||||
<BundleColumnError {...props} />
|
||||
</ColumnsArea>
|
||||
);
|
||||
};
|
||||
const renderLoading = () => renderWithLayout(<ColumnLoading />);
|
||||
const renderForbidden = () => renderWithLayout(<ColumnForbidden />);
|
||||
const renderError = (props: any) => renderWithLayout(<BundleColumnError {...props} />);
|
||||
|
||||
const loginRedirect = () => {
|
||||
const actualUrl = encodeURIComponent(`${history.location.pathname}${history.location.search}`);
|
||||
|
|
|
@ -109,7 +109,7 @@
|
|||
"admin.users.user_unsuggested_message": "@{acct} was unsuggested",
|
||||
"admin.users.user_unverified_message": "@{acct} was unverified",
|
||||
"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.reports": "Reports",
|
||||
"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 { Redirect, withRouter } from 'react-router-dom';
|
||||
|
||||
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 {
|
||||
|
@ -127,11 +126,7 @@ class ProfilePage extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout.Sidebar>
|
||||
<SidebarNavigation />
|
||||
</Layout.Sidebar>
|
||||
|
||||
<>
|
||||
<Layout.Main>
|
||||
<Column label={account ? `@${getAcct(account, displayFqn)}` : null} withHeader={false}>
|
||||
<div className='space-y-4'>
|
||||
|
@ -171,7 +166,7 @@ class ProfilePage extends ImmutablePureComponent {
|
|||
)}
|
||||
<LinkFooter key='link-footer' />
|
||||
</Layout.Aside>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ 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 {
|
||||
|
@ -32,11 +31,7 @@ class RemoteInstancePage extends ImmutablePureComponent {
|
|||
const { children, params: { instance: host }, disclosed, isAdmin } = this.props;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout.Sidebar>
|
||||
<SidebarNavigation />
|
||||
</Layout.Sidebar>
|
||||
|
||||
<>
|
||||
<Layout.Main>
|
||||
{children}
|
||||
</Layout.Main>
|
||||
|
@ -55,7 +50,7 @@ class RemoteInstancePage extends ImmutablePureComponent {
|
|||
)}
|
||||
<LinkFooter key='link-footer' />
|
||||
</Layout.Aside>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ 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 {
|
||||
WhoToFollowPanel,
|
||||
|
@ -33,11 +32,7 @@ class StatusPage extends ImmutablePureComponent {
|
|||
const { me, children, showTrendsPanel, showWhoToFollowPanel } = this.props;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout.Sidebar>
|
||||
<SidebarNavigation />
|
||||
</Layout.Sidebar>
|
||||
|
||||
<>
|
||||
<Layout.Main>
|
||||
{children}
|
||||
</Layout.Main>
|
||||
|
@ -60,7 +55,7 @@ class StatusPage extends ImmutablePureComponent {
|
|||
)}
|
||||
<LinkFooter key='link-footer' />
|
||||
</Layout.Aside>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -10,8 +10,9 @@ describe('parseVersion', () => {
|
|||
const version = '2.7.2 (compatible; Pleroma 2.0.5-6-ga36eb5ea-plerasstodon+dev)';
|
||||
expect(parseVersion(version)).toEqual({
|
||||
software: 'Pleroma',
|
||||
version: '2.0.5-6-ga36eb5ea-plerasstodon+dev',
|
||||
version: '2.0.5-6-ga36eb5ea-plerasstodon',
|
||||
compatVersion: '2.7.2',
|
||||
build: 'dev',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -32,6 +33,35 @@ describe('parseVersion', () => {
|
|||
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', () => {
|
||||
|
|
|
@ -3,6 +3,7 @@ import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
|||
import { createSelector } from 'reselect';
|
||||
import gte from 'semver/functions/gte';
|
||||
import lt from 'semver/functions/lt';
|
||||
import semverParse from 'semver/functions/parse';
|
||||
|
||||
import { custom } from 'soapbox/custom';
|
||||
|
||||
|
@ -44,6 +45,12 @@ export const PIXELFED = 'Pixelfed';
|
|||
*/
|
||||
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 */
|
||||
const getInstanceFeatures = (instance: Instance) => {
|
||||
const v = parseVersion(instance.version);
|
||||
|
@ -76,7 +83,10 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
* Ability to set one's location on their profile.
|
||||
* @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.
|
||||
|
@ -85,6 +95,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
accountLookup: any([
|
||||
v.software === MASTODON && gte(v.compatVersion, '3.4.0'),
|
||||
v.software === PLEROMA && gte(v.version, '2.4.50'),
|
||||
v.software === TRUTHSOCIAL,
|
||||
]),
|
||||
|
||||
/**
|
||||
|
@ -464,6 +475,8 @@ export const getFeatures = createSelector([
|
|||
|
||||
/** Fediverse backend */
|
||||
interface Backend {
|
||||
/** Build name, if this software is a fork */
|
||||
build: string | null,
|
||||
/** Name of the software */
|
||||
software: string | null,
|
||||
/** API version number */
|
||||
|
@ -474,19 +487,24 @@ interface Backend {
|
|||
|
||||
/** Get information about the software from its version string */
|
||||
export const parseVersion = (version: string): Backend => {
|
||||
const regex = /^([\w.]*)(?: \(compatible; ([\w]*) (.*)\))?$/;
|
||||
const regex = /^([\w+.]*)(?: \(compatible; ([\w]*) (.*)\))?$/;
|
||||
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 {
|
||||
compatVersion: match[1],
|
||||
build: semver.build[0],
|
||||
compatVersion: compat.version,
|
||||
software: match[2] || MASTODON,
|
||||
version: match[3] || match[1],
|
||||
version: semver.version,
|
||||
};
|
||||
} else {
|
||||
// If we can't parse the version, this is a new and exotic backend.
|
||||
// Fall back to minimal featureset.
|
||||
return {
|
||||
build: null,
|
||||
compatVersion: '0.0.0',
|
||||
software: null,
|
||||
version: '0.0.0',
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { FormattedNumber } from 'react-intl';
|
||||
|
||||
/** 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. */
|
||||
export const shortNumberFormat = (number: any): React.ReactNode => {
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
width: 100%;
|
||||
|
||||
&::after {
|
||||
@apply text-black dark:text-white;
|
||||
content: '';
|
||||
display: block;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
|
|
|
@ -105,10 +105,6 @@
|
|||
font-weight: bold;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
a {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__quote {
|
||||
|
@ -122,7 +118,6 @@
|
|||
font-size: 12px;
|
||||
|
||||
a {
|
||||
color: var(--primary-text-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,7 @@
|
|||
@apply text-gray-500 dark:text-gray-400 mb-1 text-sm;
|
||||
|
||||
&__account {
|
||||
@apply text-primary-600 no-underline;
|
||||
|
||||
&:hover {
|
||||
@apply underline text-primary-800;
|
||||
}
|
||||
@apply text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300 no-underline hover:underline;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -338,6 +338,7 @@ code {
|
|||
input[type=password],
|
||||
textarea,
|
||||
.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;
|
||||
font-size: 16px;
|
||||
color: var(--primary-text-color);
|
||||
|
@ -643,6 +644,7 @@ code {
|
|||
}
|
||||
|
||||
.icon-button {
|
||||
@apply text-black dark:text-white;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
@ -651,7 +653,6 @@ code {
|
|||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--primary-text-color);
|
||||
|
||||
.svg-icon {
|
||||
height: 20px;
|
||||
|
|
|
@ -81,6 +81,7 @@
|
|||
"@types/react-helmet": "^6.1.5",
|
||||
"@types/react-motion": "^0.0.32",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-sparklines": "^1.7.2",
|
||||
"@types/react-swipeable-views": "^0.13.1",
|
||||
"@types/react-toggle": "^4.0.3",
|
||||
"@types/redux-mock-store": "^1.0.3",
|
||||
|
|
|
@ -2242,6 +2242,13 @@
|
|||
"@types/history" "^4.7.11"
|
||||
"@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":
|
||||
version "0.13.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.1.tgz#381c8513deef5426623aa851033ff4f4831ae15c"
|
||||
|
|
Loading…
Reference in New Issue