Merge remote-tracking branch 'soapbox/next' into edit-posts

This commit is contained in:
marcin mikołajczak 2022-04-29 23:07:34 +02:00
commit d487e34548
67 changed files with 1544 additions and 1685 deletions

View File

@ -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);

View File

@ -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);

View File

@ -6,7 +6,7 @@ import { spring } from 'react-motion';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import { withRouter, RouteComponentProps } from 'react-router-dom'; import { withRouter, RouteComponentProps } from 'react-router-dom';
import { IconButton } from 'soapbox/components/ui'; import { IconButton, Counter } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import Motion from 'soapbox/features/ui/util/optional_motion'; import Motion from 'soapbox/features/ui/util/optional_motion';
@ -24,6 +24,7 @@ export interface MenuItem {
newTab?: boolean, newTab?: boolean,
isLogout?: boolean, isLogout?: boolean,
icon: string, icon: string,
count?: number,
destructive?: boolean, destructive?: boolean,
} }
@ -174,7 +175,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
return <li key={`sep-${i}`} className='dropdown-menu__separator' />; return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
} }
const { text, href, to, newTab, isLogout, icon, destructive } = option; const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
return ( return (
<li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}> <li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
@ -191,7 +192,14 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
data-method={isLogout ? 'delete' : undefined} data-method={isLogout ? 'delete' : undefined}
> >
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />} {icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />}
<span className='truncate'>{text}</span> <span className='truncate'>{text}</span>
{count ? (
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={count} />
</span>
) : null}
</a> </a>
</li> </li>
); );

View File

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Sparklines, SparklinesCurve } from 'react-sparklines'; import { Sparklines, SparklinesCurve } from 'react-sparklines';
@ -11,7 +10,13 @@ import { shortNumberFormat } from '../utils/numbers';
import Permalink from './permalink'; import Permalink from './permalink';
import { HStack, Stack, Text } from './ui'; import { HStack, Stack, Text } from './ui';
const Hashtag = ({ hashtag }) => { import type { Map as ImmutableMap } from 'immutable';
interface IHashtag {
hashtag: ImmutableMap<string, any>,
}
const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
const count = Number(hashtag.getIn(['history', 0, 'accounts'])); const count = Number(hashtag.getIn(['history', 0, 'accounts']));
const brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor')); const brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor'));
@ -41,7 +46,7 @@ const Hashtag = ({ hashtag }) => {
<Sparklines <Sparklines
width={40} width={40}
height={28} height={28}
data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()} data={hashtag.get('history').reverse().map((day: ImmutableMap<string, any>) => day.get('uses')).toArray()}
> >
<SparklinesCurve style={{ fill: 'none' }} color={brandColor} /> <SparklinesCurve style={{ fill: 'none' }} color={brandColor} />
</Sparklines> </Sparklines>
@ -51,8 +56,4 @@ const Hashtag = ({ hashtag }) => {
); );
}; };
Hashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
export default Hashtag; export default Hashtag;

View File

@ -1,5 +1,4 @@
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import PropTypes from 'prop-types';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
@ -13,10 +12,16 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
dispatch(openProfileHoverCard(ref, accountId)); dispatch(openProfileHoverCard(ref, accountId));
}, 600); }, 600);
export const HoverRefWrapper = ({ accountId, children, inline }) => { interface IHoverRefWrapper {
accountId: string,
inline: boolean,
}
/** Makes a profile hover card appear when the wrapped element is hovered. */
export const HoverRefWrapper: React.FC<IHoverRefWrapper> = ({ accountId, children, inline = false }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const ref = useRef(); const ref = useRef<HTMLElement>();
const Elem = inline ? 'span' : 'div'; const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div';
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (!isMobile(window.innerWidth)) { if (!isMobile(window.innerWidth)) {
@ -36,6 +41,7 @@ export const HoverRefWrapper = ({ accountId, children, inline }) => {
return ( return (
<Elem <Elem
// @ts-ignore: not sure how to fix :\
ref={ref} ref={ref}
className='hover-ref-wrapper' className='hover-ref-wrapper'
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
@ -47,14 +53,4 @@ export const HoverRefWrapper = ({ accountId, children, inline }) => {
); );
}; };
HoverRefWrapper.propTypes = {
accountId: PropTypes.string,
children: PropTypes.node,
inline: PropTypes.bool,
};
HoverRefWrapper.defaultProps = {
inline: false,
};
export { HoverRefWrapper as default, showProfileHoverCard }; export { HoverRefWrapper as default, showProfileHoverCard };

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { shortNumberFormat } from 'soapbox/utils/numbers'; import { Counter } from 'soapbox/components/ui';
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> { interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
count: number, count: number,
@ -14,9 +14,11 @@ const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) =
<div className='relative'> <div className='relative'>
<Icon id={icon} {...rest} /> <Icon id={icon} {...rest} />
{count > 0 && <i className='absolute -top-2 -right-2 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'> {count > 0 && (
{shortNumberFormat(count)} <i className='absolute -top-2 -right-2'>
</i>} <Counter count={count} />
</i>
)}
</div> </div>
); );
}; };

View File

@ -1,19 +1,20 @@
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types';
import * as React from 'react'; import * as React from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import Icon from './icon'; import Icon from './icon';
const List = ({ children }) => ( const List: React.FC = ({ children }) => (
<div className='space-y-0.5'>{children}</div> <div className='space-y-0.5'>{children}</div>
); );
List.propTypes = { interface IListItem {
children: PropTypes.node, label: React.ReactNode,
}; hint?: React.ReactNode,
onClick?: () => void,
}
const ListItem = ({ label, hint, children, onClick }) => { const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
const id = uuidv4(); const id = uuidv4();
const domId = `list-group-${id}`; const domId = `list-group-${id}`;
@ -60,11 +61,4 @@ const ListItem = ({ label, hint, children, onClick }) => {
); );
}; };
ListItem.propTypes = {
label: PropTypes.node.isRequired,
hint: PropTypes.node,
children: PropTypes.node,
onClick: PropTypes.func,
};
export { List as default, ListItem }; export { List as default, ListItem };

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -2,7 +2,7 @@ import classNames from 'classnames';
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { Icon, Text } from './ui'; import { Icon, Text, Counter } from './ui';
interface ISidebarNavigationLink { interface ISidebarNavigationLink {
count?: number, count?: number,
@ -44,8 +44,8 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
})} })}
> >
{withCounter && count > 0 ? ( {withCounter && count > 0 ? (
<span className='absolute -top-2 -right-2 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'> <span className='absolute -top-2 -right-2'>
{count} <Counter count={count} />
</span> </span>
) : null} ) : null}

View File

@ -20,7 +20,7 @@ const SidebarNavigation = () => {
const notificationCount = useAppSelector((state) => state.notifications.get('unread')); const notificationCount = useAppSelector((state) => state.notifications.get('unread'));
const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: any, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0)); const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: any, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0));
const followRequestsCount = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'], ImmutableOrderedSet()).count()); const followRequestsCount = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'], ImmutableOrderedSet()).count());
// const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
const baseURL = account ? getBaseURL(account) : ''; const baseURL = account ? getBaseURL(account) : '';
const features = getFeatures(instance); const features = getFeatures(instance);
@ -76,8 +76,7 @@ const SidebarNavigation = () => {
to: '/admin', to: '/admin',
icon: require('@tabler/icons/icons/dashboard.svg'), icon: require('@tabler/icons/icons/dashboard.svg'),
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />, text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
// TODO: let menu items have a counter count: dashboardCount,
// count: dashboardCount,
}); });
} }
@ -160,6 +159,7 @@ const SidebarNavigation = () => {
<DropdownMenu items={menu}> <DropdownMenu items={menu}>
<SidebarNavigationLink <SidebarNavigationLink
icon={require('@tabler/icons/icons/dots-circle-horizontal.svg')} icon={require('@tabler/icons/icons/dots-circle-horizontal.svg')}
count={dashboardCount}
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />} text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
/> />
</DropdownMenu> </DropdownMenu>

View File

@ -646,7 +646,11 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
/> />
{features.quotePosts && me ? ( {features.quotePosts && me ? (
<DropdownMenuContainer items={reblogMenu} onShiftClick={this.handleReblogClick}> <DropdownMenuContainer
items={reblogMenu}
disabled={!publicStatus}
onShiftClick={this.handleReblogClick}
>
{reblogButton} {reblogButton}
</DropdownMenuContainer> </DropdownMenuContainer>
) : ( ) : (

View File

@ -20,9 +20,10 @@ interface ICard {
variant?: 'rounded', variant?: 'rounded',
size?: 'md' | 'lg' | 'xl', size?: 'md' | 'lg' | 'xl',
className?: string, className?: string,
children: React.ReactNode,
} }
const Card: React.FC<ICard> = React.forwardRef(({ children, variant, size = 'md', className, ...filteredProps }, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => ( const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant, size = 'md', className, ...filteredProps }, ref): JSX.Element => (
<div <div
ref={ref} ref={ref}
{...filteredProps} {...filteredProps}

View File

@ -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;

View File

@ -1,7 +1,10 @@
import React from 'react'; import React from 'react';
import Counter from '../counter/counter';
import SvgIcon from './svg-icon'; import SvgIcon from './svg-icon';
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> { interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
className?: string, className?: string,
count?: number, count?: number,
@ -13,8 +16,8 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
const Icon = ({ src, alt, count, size, ...filteredProps }: IIcon): JSX.Element => ( const Icon = ({ src, alt, count, size, ...filteredProps }: IIcon): JSX.Element => (
<div className='relative' data-testid='icon'> <div className='relative' data-testid='icon'>
{count ? ( {count ? (
<span className='absolute -top-2 -right-3 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'> <span className='absolute -top-2 -right-3'>
{count} <Counter count={count} />
</span> </span>
) : null} ) : null}

View File

@ -2,6 +2,7 @@ export { default as Avatar } from './avatar/avatar';
export { default as Button } from './button/button'; export { default as Button } from './button/button';
export { Card, CardBody, CardHeader, CardTitle } from './card/card'; export { Card, CardBody, CardHeader, CardTitle } from './card/card';
export { default as Column } from './column/column'; export { default as Column } from './column/column';
export { default as Counter } from './counter/counter';
export { default as Emoji } from './emoji/emoji'; export { default as Emoji } from './emoji/emoji';
export { default as EmojiSelector } from './emoji-selector/emoji-selector'; export { default as EmojiSelector } from './emoji-selector/emoji-selector';
export { default as Form } from './form/form'; export { default as Form } from './form/form';

View File

@ -9,7 +9,7 @@ interface LayoutType extends React.FC {
} }
const Layout: LayoutType = ({ children }) => ( const Layout: LayoutType = ({ children }) => (
<div className='sm:pt-4 relative pb-36'> <div className='sm:pt-4 relative'>
<div className='max-w-3xl mx-auto sm:px-6 md:max-w-7xl md:px-8 md:grid md:grid-cols-12 md:gap-8'> <div className='max-w-3xl mx-auto sm:px-6 md:max-w-7xl md:px-8 md:grid md:grid-cols-12 md:gap-8'>
{children} {children}
</div> </div>
@ -27,7 +27,7 @@ const Sidebar: React.FC = ({ children }) => (
const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className }) => ( const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className }) => (
<main <main
className={classNames({ className={classNames({
'md:col-span-12 lg:col-span-9 xl:col-span-6 sm:space-y-4': true, 'md:col-span-12 lg:col-span-9 xl:col-span-6 sm:space-y-4 pb-36': true,
}, className)} }, className)}
> >
{children} {children}

View File

@ -11,8 +11,9 @@
} }
[data-reach-tab] { [data-reach-tab] {
@apply flex-1 flex justify-center py-4 px-1 text-center font-medium text-sm @apply flex-1 flex justify-center items-center
text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200; py-4 px-1 text-center font-medium text-sm text-gray-500
dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200;
} }
[data-reach-tab][data-selected] { [data-reach-tab][data-selected] {

View File

@ -9,6 +9,8 @@ import classNames from 'classnames';
import * as React from 'react'; import * as React from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import Counter from '../counter/counter';
import './tabs.css'; import './tabs.css';
const HORIZONTAL_PADDING = 8; const HORIZONTAL_PADDING = 8;
@ -95,6 +97,7 @@ type Item = {
href?: string, href?: string,
to?: string, to?: string,
action?: () => void, action?: () => void,
count?: number,
name: string name: string
} }
interface ITabs { interface ITabs {
@ -118,7 +121,7 @@ const Tabs = ({ items, activeItem }: ITabs) => {
}; };
const renderItem = (item: Item, idx: number) => { const renderItem = (item: Item, idx: number) => {
const { name, text, title } = item; const { name, text, title, count } = item;
return ( return (
<AnimatedTab <AnimatedTab
@ -129,7 +132,15 @@ const Tabs = ({ items, activeItem }: ITabs) => {
title={title} title={title}
index={idx} index={idx}
> >
{text} <div className='relative'>
{count ? (
<span className='absolute -top-2 left-full ml-1'>
<Counter count={count} />
</span>
) : null}
{text}
</div>
</AnimatedTab> </AnimatedTab>
); );
}; };

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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> */}
</>
);
}
}

View File

@ -2,18 +2,18 @@ import { OrderedSet as ImmutableOrderedSet, is } from 'immutable';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { fetchUsers } from 'soapbox/actions/admin'; import { fetchUsers } from 'soapbox/actions/admin';
import compareId from 'soapbox/compare_id'; import compareId from 'soapbox/compare_id';
import { Text, Widget } from 'soapbox/components/ui'; import { Widget } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container'; import AccountContainer from 'soapbox/containers/account_container';
import { useAppSelector } from 'soapbox/hooks'; import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' }, title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' },
expand: { id: 'admin.latest_accounts_panel.expand_message', defaultMessage: 'Click to see {count} more {count, plural, one {account} other {accounts}}' }, expand: { id: 'admin.latest_accounts_panel.more', defaultMessage: 'Click to see {count} {count, plural, one {account} other {accounts}}' },
}); });
interface ILatestAccountsPanel { interface ILatestAccountsPanel {
@ -21,8 +21,9 @@ interface ILatestAccountsPanel {
} }
const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => { const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const accountIds = useAppSelector<ImmutableOrderedSet<string>>((state) => state.admin.get('latestUsers').take(limit)); const accountIds = useAppSelector<ImmutableOrderedSet<string>>((state) => state.admin.get('latestUsers').take(limit));
const hasDates = useAppSelector((state) => accountIds.every(id => !!state.accounts.getIn([id, 'created_at']))); const hasDates = useAppSelector((state) => accountIds.every(id => !!state.accounts.getIn([id, 'created_at'])));
@ -44,18 +45,19 @@ const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
return null; return null;
} }
const expandCount = total - accountIds.size; const handleAction = () => {
history.push('/admin/users');
};
return ( return (
<Widget title={intl.formatMessage(messages.title)}> <Widget
title={intl.formatMessage(messages.title)}
onActionClick={handleAction}
actionTitle={intl.formatMessage(messages.expand, { count: total })}
>
{accountIds.take(limit).map((account) => ( {accountIds.take(limit).map((account) => (
<AccountContainer key={account} id={account} withRelationship={false} withDate /> <AccountContainer key={account} id={account} withRelationship={false} withDate />
))} ))}
{!!expandCount && (
<Link className='wtf-panel__expand-btn' to='/admin/users'>
<Text>{intl.formatMessage(messages.expand, { count: expandCount })}</Text>
</Link>
)}
</Widget> </Widget>
); );
}; };

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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'>&mdash; <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>
);
}
}

View File

@ -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'>
&mdash;
<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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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>
);
};

View File

@ -95,14 +95,14 @@ const messages: Record<NotificationType, { id: string, defaultMessage: string }>
}, },
}; };
const buildMessage = (type: NotificationType, account: Account): JSX.Element => { const buildMessage = (type: NotificationType, account: Account, targetName?: string): JSX.Element => {
const link = buildLink(account); const link = buildLink(account);
return ( return (
<FormattedMessageFixed <FormattedMessageFixed
id={messages[type].id} id={messages[type].id}
defaultMessage={messages[type].defaultMessage} defaultMessage={messages[type].defaultMessage}
values={{ name: link }} values={{ name: link, targetName }}
/> />
); );
}; };
@ -257,7 +257,9 @@ const Notification: React.FC<INotificaton> = (props) => {
} }
}; };
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account) : null; const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : '';
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account, targetName) : null;
return ( return (
<HotKeys handlers={getHandlers()} data-testid='notification'> <HotKeys handlers={getHandlers()} data-testid='notification'>
@ -273,7 +275,7 @@ const Notification: React.FC<INotificaton> = (props) => {
}, },
{ {
name: account && typeof account === 'object' ? account.acct : '', name: account && typeof account === 'object' ? account.acct : '',
targetName: notification.target && typeof notification.target === 'object' ? notification.target.acct : '', targetName,
}), }),
notification.created_at, notification.created_at,
) )

View File

@ -134,7 +134,7 @@ const Preferences = () => {
<ListItem label={<FormattedMessage id='preferences.fields.language_label' defaultMessage='Language' />}> <ListItem label={<FormattedMessage id='preferences.fields.language_label' defaultMessage='Language' />}>
<SelectDropdown <SelectDropdown
items={languages} items={languages}
defaultValue={settings.get('locale')} defaultValue={settings.get('locale') as string | undefined}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['locale'])} onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['locale'])}
/> />
</ListItem> </ListItem>
@ -142,7 +142,7 @@ const Preferences = () => {
<ListItem label={<FormattedMessage id='preferences.fields.media_display_label' defaultMessage='Media display' />}> <ListItem label={<FormattedMessage id='preferences.fields.media_display_label' defaultMessage='Media display' />}>
<SelectDropdown <SelectDropdown
items={displayMediaOptions} items={displayMediaOptions}
defaultValue={settings.get('displayMedia')} defaultValue={settings.get('displayMedia') as string | undefined}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['displayMedia'])} onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['displayMedia'])}
/> />
</ListItem> </ListItem>

View File

@ -21,7 +21,6 @@ import {
SimpleInput, SimpleInput,
SimpleTextarea, SimpleTextarea,
FileChooserLogo, FileChooserLogo,
FormPropTypes,
Checkbox, Checkbox,
} from 'soapbox/features/forms'; } from 'soapbox/features/forms';
import ThemeToggle from 'soapbox/features/ui/components/theme_toggle'; import ThemeToggle from 'soapbox/features/ui/components/theme_toggle';
@ -509,7 +508,7 @@ class ColorWithPicker extends ImmutablePureComponent {
static propTypes = { static propTypes = {
buttonId: PropTypes.string.isRequired, buttonId: PropTypes.string.isRequired,
label: FormPropTypes.label, label: PropTypes.node,
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
} }
@ -559,7 +558,7 @@ export class IconPicker extends ImmutablePureComponent {
static propTypes = { static propTypes = {
icons: PropTypes.object, icons: PropTypes.object,
label: FormPropTypes.label, label: PropTypes.node,
value: PropTypes.string, value: PropTypes.string,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
} }

View File

@ -2,6 +2,7 @@ import classnames from 'classnames';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { HStack } from 'soapbox/components/ui'; import { HStack } from 'soapbox/components/ui';
import { normalizeAttachment } from 'soapbox/normalizers'; import { normalizeAttachment } from 'soapbox/normalizers';
@ -161,6 +162,13 @@ const Card: React.FC<ICard> = ({
let embed: React.ReactNode = ''; let embed: React.ReactNode = '';
const canvas = (
<Blurhash
className='absolute w-full h-full inset-0 -z-10'
hash={card.blurhash}
/>
);
const thumbnail = ( const thumbnail = (
<div <div
style={{ style={{
@ -184,6 +192,7 @@ const Card: React.FC<ICard> = ({
embed = ( embed = (
<div className='status-card__image'> <div className='status-card__image'>
{canvas}
{thumbnail} {thumbnail}
<div className='absolute inset-0 flex items-center justify-center'> <div className='absolute inset-0 flex items-center justify-center'>
@ -226,6 +235,7 @@ const Card: React.FC<ICard> = ({
} else if (card.image) { } else if (card.image) {
embed = ( embed = (
<div className='status-card__image'> <div className='status-card__image'>
{canvas}
{thumbnail} {thumbnail}
</div> </div>
); );

View File

@ -13,7 +13,9 @@ import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis';
import { fetchMarker } from 'soapbox/actions/markers'; import { fetchMarker } from 'soapbox/actions/markers';
import { register as registerPushNotifications } from 'soapbox/actions/push_notifications'; import { register as registerPushNotifications } from 'soapbox/actions/push_notifications';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
import ThumbNavigation from 'soapbox/components/thumb_navigation'; import ThumbNavigation from 'soapbox/components/thumb_navigation';
import { Layout } from 'soapbox/components/ui';
import { useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks'; import { useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
import AdminPage from 'soapbox/pages/admin_page'; import AdminPage from 'soapbox/pages/admin_page';
import DefaultPage from 'soapbox/pages/default_page'; import DefaultPage from 'soapbox/pages/default_page';
@ -26,6 +28,7 @@ import RemoteInstancePage from 'soapbox/pages/remote_instance_page';
import StatusPage from 'soapbox/pages/status_page'; import StatusPage from 'soapbox/pages/status_page';
import { getAccessToken } from 'soapbox/utils/auth'; import { getAccessToken } from 'soapbox/utils/auth';
import { getVapidKey } from 'soapbox/utils/auth'; import { getVapidKey } from 'soapbox/utils/auth';
import { isStandalone } from 'soapbox/utils/state';
import { fetchFollowRequests } from '../../actions/accounts'; import { fetchFollowRequests } from '../../actions/accounts';
import { fetchReports, fetchUsers, fetchConfig } from '../../actions/admin'; import { fetchReports, fetchUsers, fetchConfig } from '../../actions/admin';
@ -91,8 +94,6 @@ import {
ChatPanes, ChatPanes,
ServerInfo, ServerInfo,
Dashboard, Dashboard,
AwaitingApproval,
Reports,
ModerationLog, ModerationLog,
CryptoDonate, CryptoDonate,
ScheduledStatuses, ScheduledStatuses,
@ -172,7 +173,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
// Ex: use /login instead of /auth, but redirect /auth to /login // Ex: use /login instead of /auth, but redirect /auth to /login
return ( return (
<Switch> <Switch>
<WrappedRoute path='/login/external' component={ExternalLogin} publicRoute exact /> <WrappedRoute path='/login/external' page={EmptyPage} component={ExternalLogin} content={children} publicRoute exact />
<WrappedRoute path='/email-confirmation' page={EmptyPage} component={EmailConfirmation} publicRoute exact /> <WrappedRoute path='/email-confirmation' page={EmptyPage} component={EmailConfirmation} publicRoute exact />
<WrappedRoute path='/logout' page={EmptyPage} component={LogoutPage} publicRoute exact /> <WrappedRoute path='/logout' page={EmptyPage} component={LogoutPage} publicRoute exact />
@ -302,8 +303,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<WrappedRoute path='/soapbox/config' adminOnly page={DefaultPage} component={SoapboxConfig} content={children} /> <WrappedRoute path='/soapbox/config' adminOnly page={DefaultPage} component={SoapboxConfig} content={children} />
<WrappedRoute path='/admin' staffOnly page={AdminPage} component={Dashboard} content={children} exact /> <WrappedRoute path='/admin' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
<WrappedRoute path='/admin/approval' staffOnly page={AdminPage} component={AwaitingApproval} content={children} exact /> <WrappedRoute path='/admin/approval' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
<WrappedRoute path='/admin/reports' staffOnly page={AdminPage} component={Reports} content={children} exact /> <WrappedRoute path='/admin/reports' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
<WrappedRoute path='/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact /> <WrappedRoute path='/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact />
<WrappedRoute path='/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact /> <WrappedRoute path='/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
<WrappedRoute path='/info' page={EmptyPage} component={ServerInfo} content={children} /> <WrappedRoute path='/info' page={EmptyPage} component={ServerInfo} content={children} />
@ -346,6 +347,7 @@ const UI: React.FC = ({ children }) => {
const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.get('openId') !== null); const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.get('openId') !== null);
const accessToken = useAppSelector(state => getAccessToken(state)); const accessToken = useAppSelector(state => getAccessToken(state));
const streamingUrl = useAppSelector(state => state.instance.urls.get('streaming_api')); const streamingUrl = useAppSelector(state => state.instance.urls.get('streaming_api'));
const standalone = useAppSelector(isStandalone);
const handleDragEnter = (e: DragEvent) => { const handleDragEnter = (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -649,9 +651,15 @@ const UI: React.FC = ({ children }) => {
<div className='z-10 flex flex-col'> <div className='z-10 flex flex-col'>
<Navbar /> <Navbar />
<SwitchingColumnsArea> <Layout>
{children} <Layout.Sidebar>
</SwitchingColumnsArea> {!standalone && <SidebarNavigation />}
</Layout.Sidebar>
<SwitchingColumnsArea>
{children}
</SwitchingColumnsArea>
</Layout>
{me && floatingActionButton} {me && floatingActionButton}

View File

@ -330,14 +330,6 @@ export function Dashboard() {
return import(/* webpackChunkName: "features/admin" */'../../admin'); return import(/* webpackChunkName: "features/admin" */'../../admin');
} }
export function AwaitingApproval() {
return import(/* webpackChunkName: "features/admin/awaiting_approval" */'../../admin/awaiting_approval');
}
export function Reports() {
return import(/* webpackChunkName: "features/admin/reports" */'../../admin/reports');
}
export function ModerationLog() { export function ModerationLog() {
return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation_log'); return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation_log');
} }
@ -386,10 +378,6 @@ export function LatestAccountsPanel() {
return import(/* webpackChunkName: "features/admin" */'../../admin/components/latest_accounts_panel'); return import(/* webpackChunkName: "features/admin" */'../../admin/components/latest_accounts_panel');
} }
export function AdminNav() {
return import(/* webpackChunkName: "features/admin" */'../../admin/components/admin_nav');
}
export function SidebarMenu() { export function SidebarMenu() {
return import(/* webpackChunkName: "features/ui" */'../../../components/sidebar_menu'); return import(/* webpackChunkName: "features/ui" */'../../../components/sidebar_menu');
} }

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Redirect, Route, useHistory, RouteProps, RouteComponentProps, match as MatchType } from 'react-router-dom'; import { Redirect, Route, useHistory, RouteProps, RouteComponentProps, match as MatchType } from 'react-router-dom';
import { Layout } from 'soapbox/components/ui';
import { useOwnAccount, useSettings } from 'soapbox/hooks'; import { useOwnAccount, useSettings } from 'soapbox/hooks';
import BundleColumnError from '../components/bundle_column_error'; import BundleColumnError from '../components/bundle_column_error';
@ -75,29 +76,19 @@ const WrappedRoute: React.FC<IWrappedRoute> = ({
); );
}; };
const renderLoading = () => { const renderWithLayout = (children: JSX.Element) => (
return ( <>
<ColumnsArea layout={layout}> <Layout.Main>
<ColumnLoading /> {children}
</ColumnsArea> </Layout.Main>
);
};
const renderForbidden = () => { <Layout.Aside />
return ( </>
<ColumnsArea layout={layout}> );
<ColumnForbidden />
</ColumnsArea>
);
};
const renderError = (props: any) => { const renderLoading = () => renderWithLayout(<ColumnLoading />);
return ( const renderForbidden = () => renderWithLayout(<ColumnForbidden />);
<ColumnsArea layout={layout}> const renderError = (props: any) => renderWithLayout(<BundleColumnError {...props} />);
<BundleColumnError {...props} />
</ColumnsArea>
);
};
const loginRedirect = () => { const loginRedirect = () => {
const actualUrl = encodeURIComponent(`${history.location.pathname}${history.location.search}`); const actualUrl = encodeURIComponent(`${history.location.pathname}${history.location.search}`);

View File

@ -109,7 +109,7 @@
"admin.users.user_unsuggested_message": "@{acct} was unsuggested", "admin.users.user_unsuggested_message": "@{acct} was unsuggested",
"admin.users.user_unverified_message": "@{acct} was unverified", "admin.users.user_unverified_message": "@{acct} was unverified",
"admin.users.user_verified_message": "@{acct} was verified", "admin.users.user_verified_message": "@{acct} was verified",
"admin_nav.awaiting_approval": "Awaiting Approval", "admin_nav.awaiting_approval": "Waitlist",
"admin_nav.dashboard": "Dashboard", "admin_nav.dashboard": "Dashboard",
"admin_nav.reports": "Reports", "admin_nav.reports": "Reports",
"alert.unexpected.clear_cookies": "clear cookies and browser data", "alert.unexpected.clear_cookies": "clear cookies and browser data",

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -6,7 +6,6 @@ import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Redirect, withRouter } from 'react-router-dom'; import { Redirect, withRouter } from 'react-router-dom';
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
import LinkFooter from 'soapbox/features/ui/components/link_footer'; import LinkFooter from 'soapbox/features/ui/components/link_footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { import {
@ -127,11 +126,7 @@ class ProfilePage extends ImmutablePureComponent {
} }
return ( return (
<Layout> <>
<Layout.Sidebar>
<SidebarNavigation />
</Layout.Sidebar>
<Layout.Main> <Layout.Main>
<Column label={account ? `@${getAcct(account, displayFqn)}` : null} withHeader={false}> <Column label={account ? `@${getAcct(account, displayFqn)}` : null} withHeader={false}>
<div className='space-y-4'> <div className='space-y-4'>
@ -171,7 +166,7 @@ class ProfilePage extends ImmutablePureComponent {
)} )}
<LinkFooter key='link-footer' /> <LinkFooter key='link-footer' />
</Layout.Aside> </Layout.Aside>
</Layout> </>
); );
} }

View File

@ -2,7 +2,6 @@ import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
import LinkFooter from 'soapbox/features/ui/components/link_footer'; import LinkFooter from 'soapbox/features/ui/components/link_footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { import {
@ -32,11 +31,7 @@ class RemoteInstancePage extends ImmutablePureComponent {
const { children, params: { instance: host }, disclosed, isAdmin } = this.props; const { children, params: { instance: host }, disclosed, isAdmin } = this.props;
return ( return (
<Layout> <>
<Layout.Sidebar>
<SidebarNavigation />
</Layout.Sidebar>
<Layout.Main> <Layout.Main>
{children} {children}
</Layout.Main> </Layout.Main>
@ -55,7 +50,7 @@ class RemoteInstancePage extends ImmutablePureComponent {
)} )}
<LinkFooter key='link-footer' /> <LinkFooter key='link-footer' />
</Layout.Aside> </Layout.Aside>
</Layout> </>
); );
} }

View File

@ -2,7 +2,6 @@ import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
import LinkFooter from 'soapbox/features/ui/components/link_footer'; import LinkFooter from 'soapbox/features/ui/components/link_footer';
import { import {
WhoToFollowPanel, WhoToFollowPanel,
@ -33,11 +32,7 @@ class StatusPage extends ImmutablePureComponent {
const { me, children, showTrendsPanel, showWhoToFollowPanel } = this.props; const { me, children, showTrendsPanel, showWhoToFollowPanel } = this.props;
return ( return (
<Layout> <>
<Layout.Sidebar>
<SidebarNavigation />
</Layout.Sidebar>
<Layout.Main> <Layout.Main>
{children} {children}
</Layout.Main> </Layout.Main>
@ -60,7 +55,7 @@ class StatusPage extends ImmutablePureComponent {
)} )}
<LinkFooter key='link-footer' /> <LinkFooter key='link-footer' />
</Layout.Aside> </Layout.Aside>
</Layout> </>
); );
} }

View File

@ -10,8 +10,9 @@ describe('parseVersion', () => {
const version = '2.7.2 (compatible; Pleroma 2.0.5-6-ga36eb5ea-plerasstodon+dev)'; const version = '2.7.2 (compatible; Pleroma 2.0.5-6-ga36eb5ea-plerasstodon+dev)';
expect(parseVersion(version)).toEqual({ expect(parseVersion(version)).toEqual({
software: 'Pleroma', software: 'Pleroma',
version: '2.0.5-6-ga36eb5ea-plerasstodon+dev', version: '2.0.5-6-ga36eb5ea-plerasstodon',
compatVersion: '2.7.2', compatVersion: '2.7.2',
build: 'dev',
}); });
}); });
@ -32,6 +33,35 @@ describe('parseVersion', () => {
compatVersion: '2.7.2', compatVersion: '2.7.2',
}); });
}); });
it('with a Truth Social version string', () => {
const version = '3.4.1 (compatible; TruthSocial 1.0.0)';
expect(parseVersion(version)).toEqual({
software: 'TruthSocial',
version: '1.0.0',
compatVersion: '3.4.1',
});
});
it('with a Mastodon fork', () => {
const version = '3.5.1+glitch';
expect(parseVersion(version)).toEqual({
software: 'Mastodon',
version: '3.5.1',
compatVersion: '3.5.1',
build: 'glitch',
});
});
it('with a Pleroma fork', () => {
const version = '2.7.2 (compatible; Pleroma 2.4.2+cofe)';
expect(parseVersion(version)).toEqual({
software: 'Pleroma',
version: '2.4.2',
compatVersion: '2.7.2',
build: 'cofe',
});
});
}); });
describe('getFeatures', () => { describe('getFeatures', () => {

View File

@ -3,6 +3,7 @@ import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import gte from 'semver/functions/gte'; import gte from 'semver/functions/gte';
import lt from 'semver/functions/lt'; import lt from 'semver/functions/lt';
import semverParse from 'semver/functions/parse';
import { custom } from 'soapbox/custom'; import { custom } from 'soapbox/custom';
@ -44,6 +45,12 @@ export const PIXELFED = 'Pixelfed';
*/ */
export const TRUTHSOCIAL = 'TruthSocial'; export const TRUTHSOCIAL = 'TruthSocial';
/**
* Soapbox BE, the recommended Pleroma fork for Soapbox.
* @see {@link https://gitlab.com/soapbox-pub/soapbox-be}
*/
export const SOAPBOX = 'soapbox';
/** Parse features for the given instance */ /** Parse features for the given instance */
const getInstanceFeatures = (instance: Instance) => { const getInstanceFeatures = (instance: Instance) => {
const v = parseVersion(instance.version); const v = parseVersion(instance.version);
@ -76,7 +83,10 @@ const getInstanceFeatures = (instance: Instance) => {
* Ability to set one's location on their profile. * Ability to set one's location on their profile.
* @see PATCH /api/v1/accounts/update_credentials * @see PATCH /api/v1/accounts/update_credentials
*/ */
accountLocation: v.software === TRUTHSOCIAL, accountLocation: any([
v.software === PLEROMA && v.build === SOAPBOX && gte(v.version, '2.4.50'),
v.software === TRUTHSOCIAL,
]),
/** /**
* Look up an account by the acct. * Look up an account by the acct.
@ -85,6 +95,7 @@ const getInstanceFeatures = (instance: Instance) => {
accountLookup: any([ accountLookup: any([
v.software === MASTODON && gte(v.compatVersion, '3.4.0'), v.software === MASTODON && gte(v.compatVersion, '3.4.0'),
v.software === PLEROMA && gte(v.version, '2.4.50'), v.software === PLEROMA && gte(v.version, '2.4.50'),
v.software === TRUTHSOCIAL,
]), ]),
/** /**
@ -464,6 +475,8 @@ export const getFeatures = createSelector([
/** Fediverse backend */ /** Fediverse backend */
interface Backend { interface Backend {
/** Build name, if this software is a fork */
build: string | null,
/** Name of the software */ /** Name of the software */
software: string | null, software: string | null,
/** API version number */ /** API version number */
@ -474,19 +487,24 @@ interface Backend {
/** Get information about the software from its version string */ /** Get information about the software from its version string */
export const parseVersion = (version: string): Backend => { export const parseVersion = (version: string): Backend => {
const regex = /^([\w.]*)(?: \(compatible; ([\w]*) (.*)\))?$/; const regex = /^([\w+.]*)(?: \(compatible; ([\w]*) (.*)\))?$/;
const match = regex.exec(version); const match = regex.exec(version);
if (match) { const semver = match ? semverParse(match[3] || match[1]) : null;
const compat = match ? semverParse(match[1]) : null;
if (match && semver && compat) {
return { return {
compatVersion: match[1], build: semver.build[0],
compatVersion: compat.version,
software: match[2] || MASTODON, software: match[2] || MASTODON,
version: match[3] || match[1], version: semver.version,
}; };
} else { } else {
// If we can't parse the version, this is a new and exotic backend. // If we can't parse the version, this is a new and exotic backend.
// Fall back to minimal featureset. // Fall back to minimal featureset.
return { return {
build: null,
compatVersion: '0.0.0', compatVersion: '0.0.0',
software: null, software: null,
version: '0.0.0', version: '0.0.0',

View File

@ -2,7 +2,7 @@ import React from 'react';
import { FormattedNumber } from 'react-intl'; import { FormattedNumber } from 'react-intl';
/** Check if a value is REALLY a number. */ /** Check if a value is REALLY a number. */
export const isNumber = (number: unknown): boolean => typeof number === 'number' && !isNaN(number); export const isNumber = (value: unknown): value is number => typeof value === 'number' && !isNaN(value);
/** Display a number nicely for the UI, eg 1000 becomes 1K. */ /** Display a number nicely for the UI, eg 1000 becomes 1K. */
export const shortNumberFormat = (number: any): React.ReactNode => { export const shortNumberFormat = (number: any): React.ReactNode => {

View File

@ -25,6 +25,7 @@
width: 100%; width: 100%;
&::after { &::after {
@apply text-black dark:text-white;
content: ''; content: '';
display: block; display: block;
font-family: 'Font Awesome 5 Free'; font-family: 'Font Awesome 5 Free';

View File

@ -105,10 +105,6 @@
font-weight: bold; font-weight: bold;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
a {
color: var(--primary-text-color);
}
} }
&__quote { &__quote {
@ -122,7 +118,6 @@
font-size: 12px; font-size: 12px;
a { a {
color: var(--primary-text-color);
text-decoration: none; text-decoration: none;
} }
} }

View File

@ -2,11 +2,7 @@
@apply text-gray-500 dark:text-gray-400 mb-1 text-sm; @apply text-gray-500 dark:text-gray-400 mb-1 text-sm;
&__account { &__account {
@apply text-primary-600 no-underline; @apply text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300 no-underline hover:underline;
&:hover {
@apply underline text-primary-800;
}
} }
} }

View File

@ -338,6 +338,7 @@ code {
input[type=password], input[type=password],
textarea, textarea,
.rfipbtn { .rfipbtn {
@apply border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-800 text-black dark:text-white;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
color: var(--primary-text-color); color: var(--primary-text-color);
@ -643,6 +644,7 @@ code {
} }
.icon-button { .icon-button {
@apply text-black dark:text-white;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
@ -651,7 +653,6 @@ code {
padding: 0; padding: 0;
margin: 0; margin: 0;
background: transparent; background: transparent;
color: var(--primary-text-color);
.svg-icon { .svg-icon {
height: 20px; height: 20px;

View File

@ -81,6 +81,7 @@
"@types/react-helmet": "^6.1.5", "@types/react-helmet": "^6.1.5",
"@types/react-motion": "^0.0.32", "@types/react-motion": "^0.0.32",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/react-sparklines": "^1.7.2",
"@types/react-swipeable-views": "^0.13.1", "@types/react-swipeable-views": "^0.13.1",
"@types/react-toggle": "^4.0.3", "@types/react-toggle": "^4.0.3",
"@types/redux-mock-store": "^1.0.3", "@types/redux-mock-store": "^1.0.3",

View File

@ -2242,6 +2242,13 @@
"@types/history" "^4.7.11" "@types/history" "^4.7.11"
"@types/react" "*" "@types/react" "*"
"@types/react-sparklines@^1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@types/react-sparklines/-/react-sparklines-1.7.2.tgz#c14e80623abd3669a10f18d13f6fb9fbdc322f70"
integrity sha512-N1GwO7Ri5C5fE8+CxhiDntuSw1qYdGytBuedKrCxWpaojXm4WnfygbdBdc5sXGX7feMxDXBy9MNhxoUTwrMl4A==
dependencies:
"@types/react" "*"
"@types/react-swipeable-views@^0.13.1": "@types/react-swipeable-views@^0.13.1":
version "0.13.1" version "0.13.1"
resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.1.tgz#381c8513deef5426623aa851033ff4f4831ae15c" resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.1.tgz#381c8513deef5426623aa851033ff4f4831ae15c"