Merge remote-tracking branch 'soapbox/next' into edit-posts
This commit is contained in:
commit
d487e34548
67 changed files with 1477 additions and 96 deletions
Binary file not shown.
59
app/soapbox/components/blurhash.tsx
Normal file
59
app/soapbox/components/blurhash.tsx
Normal 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);
|
|
@ -6,7 +6,7 @@ import { spring } from 'react-motion';
|
|||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import { IconButton, Counter } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import Motion from 'soapbox/features/ui/util/optional_motion';
|
||||
|
||||
|
@ -24,6 +24,7 @@ export interface MenuItem {
|
|||
newTab?: boolean,
|
||||
isLogout?: boolean,
|
||||
icon: string,
|
||||
count?: number,
|
||||
destructive?: boolean,
|
||||
}
|
||||
|
||||
|
@ -174,7 +175,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
|
|||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, href, to, newTab, isLogout, icon, destructive } = option;
|
||||
const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
|
||||
|
||||
return (
|
||||
<li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
|
||||
|
@ -191,7 +192,14 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
|
|||
data-method={isLogout ? 'delete' : undefined}
|
||||
>
|
||||
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />}
|
||||
|
||||
<span className='truncate'>{text}</span>
|
||||
|
||||
{count ? (
|
||||
<span className='ml-auto h-5 w-5 flex-none'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
import { Counter } from 'soapbox/components/ui';
|
||||
|
||||
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
||||
count: number,
|
||||
|
@ -14,9 +14,11 @@ const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) =
|
|||
<div className='relative'>
|
||||
<Icon id={icon} {...rest} />
|
||||
|
||||
{count > 0 && <i className='absolute -top-2 -right-2 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'>
|
||||
{shortNumberFormat(count)}
|
||||
</i>}
|
||||
{count > 0 && (
|
||||
<i className='absolute -top-2 -right-2'>
|
||||
<Counter count={count} />
|
||||
</i>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
Binary file not shown.
Binary file not shown.
58
app/soapbox/components/showable_password.tsx
Normal file
58
app/soapbox/components/showable_password.tsx
Normal 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;
|
|
@ -2,7 +2,7 @@ import classNames from 'classnames';
|
|||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon, Text } from './ui';
|
||||
import { Icon, Text, Counter } from './ui';
|
||||
|
||||
interface ISidebarNavigationLink {
|
||||
count?: number,
|
||||
|
@ -44,8 +44,8 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
|
|||
})}
|
||||
>
|
||||
{withCounter && count > 0 ? (
|
||||
<span className='absolute -top-2 -right-2 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'>
|
||||
{count}
|
||||
<span className='absolute -top-2 -right-2'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ const SidebarNavigation = () => {
|
|||
const notificationCount = useAppSelector((state) => state.notifications.get('unread'));
|
||||
const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: any, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0));
|
||||
const followRequestsCount = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'], ImmutableOrderedSet()).count());
|
||||
// const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
||||
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
||||
|
||||
const baseURL = account ? getBaseURL(account) : '';
|
||||
const features = getFeatures(instance);
|
||||
|
@ -76,8 +76,7 @@ const SidebarNavigation = () => {
|
|||
to: '/admin',
|
||||
icon: require('@tabler/icons/icons/dashboard.svg'),
|
||||
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
|
||||
// TODO: let menu items have a counter
|
||||
// count: dashboardCount,
|
||||
count: dashboardCount,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -160,6 +159,7 @@ const SidebarNavigation = () => {
|
|||
<DropdownMenu items={menu}>
|
||||
<SidebarNavigationLink
|
||||
icon={require('@tabler/icons/icons/dots-circle-horizontal.svg')}
|
||||
count={dashboardCount}
|
||||
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
|
|
@ -646,7 +646,11 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
/>
|
||||
|
||||
{features.quotePosts && me ? (
|
||||
<DropdownMenuContainer items={reblogMenu} onShiftClick={this.handleReblogClick}>
|
||||
<DropdownMenuContainer
|
||||
items={reblogMenu}
|
||||
disabled={!publicStatus}
|
||||
onShiftClick={this.handleReblogClick}
|
||||
>
|
||||
{reblogButton}
|
||||
</DropdownMenuContainer>
|
||||
) : (
|
||||
|
|
|
@ -20,9 +20,10 @@ interface ICard {
|
|||
variant?: 'rounded',
|
||||
size?: 'md' | 'lg' | 'xl',
|
||||
className?: string,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
const Card: React.FC<ICard> = React.forwardRef(({ children, variant, size = 'md', className, ...filteredProps }, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => (
|
||||
const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant, size = 'md', className, ...filteredProps }, ref): JSX.Element => (
|
||||
<div
|
||||
ref={ref}
|
||||
{...filteredProps}
|
||||
|
|
18
app/soapbox/components/ui/counter/counter.tsx
Normal file
18
app/soapbox/components/ui/counter/counter.tsx
Normal 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;
|
|
@ -1,7 +1,10 @@
|
|||
import React from 'react';
|
||||
|
||||
import Counter from '../counter/counter';
|
||||
|
||||
import SvgIcon from './svg-icon';
|
||||
|
||||
|
||||
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
||||
className?: string,
|
||||
count?: number,
|
||||
|
@ -13,8 +16,8 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
|||
const Icon = ({ src, alt, count, size, ...filteredProps }: IIcon): JSX.Element => (
|
||||
<div className='relative' data-testid='icon'>
|
||||
{count ? (
|
||||
<span className='absolute -top-2 -right-3 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'>
|
||||
{count}
|
||||
<span className='absolute -top-2 -right-3'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ export { default as Avatar } from './avatar/avatar';
|
|||
export { default as Button } from './button/button';
|
||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||
export { default as Column } from './column/column';
|
||||
export { default as Counter } from './counter/counter';
|
||||
export { default as Emoji } from './emoji/emoji';
|
||||
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
|
||||
export { default as Form } from './form/form';
|
||||
|
|
|
@ -9,7 +9,7 @@ interface LayoutType extends React.FC {
|
|||
}
|
||||
|
||||
const Layout: LayoutType = ({ children }) => (
|
||||
<div className='sm:pt-4 relative pb-36'>
|
||||
<div className='sm:pt-4 relative'>
|
||||
<div className='max-w-3xl mx-auto sm:px-6 md:max-w-7xl md:px-8 md:grid md:grid-cols-12 md:gap-8'>
|
||||
{children}
|
||||
</div>
|
||||
|
@ -27,7 +27,7 @@ const Sidebar: React.FC = ({ children }) => (
|
|||
const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className }) => (
|
||||
<main
|
||||
className={classNames({
|
||||
'md:col-span-12 lg:col-span-9 xl:col-span-6 sm:space-y-4': true,
|
||||
'md:col-span-12 lg:col-span-9 xl:col-span-6 sm:space-y-4 pb-36': true,
|
||||
}, className)}
|
||||
>
|
||||
{children}
|
||||
|
|
Binary file not shown.
|
@ -9,6 +9,8 @@ import classNames from 'classnames';
|
|||
import * as React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Counter from '../counter/counter';
|
||||
|
||||
import './tabs.css';
|
||||
|
||||
const HORIZONTAL_PADDING = 8;
|
||||
|
@ -95,6 +97,7 @@ type Item = {
|
|||
href?: string,
|
||||
to?: string,
|
||||
action?: () => void,
|
||||
count?: number,
|
||||
name: string
|
||||
}
|
||||
interface ITabs {
|
||||
|
@ -118,7 +121,7 @@ const Tabs = ({ items, activeItem }: ITabs) => {
|
|||
};
|
||||
|
||||
const renderItem = (item: Item, idx: number) => {
|
||||
const { name, text, title } = item;
|
||||
const { name, text, title, count } = item;
|
||||
|
||||
return (
|
||||
<AnimatedTab
|
||||
|
@ -129,7 +132,15 @@ const Tabs = ({ items, activeItem }: ITabs) => {
|
|||
title={title}
|
||||
index={idx}
|
||||
>
|
||||
{text}
|
||||
<div className='relative'>
|
||||
{count ? (
|
||||
<span className='absolute -top-2 left-full ml-1'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{text}
|
||||
</div>
|
||||
</AnimatedTab>
|
||||
);
|
||||
};
|
||||
|
|
Binary file not shown.
40
app/soapbox/features/admin/components/admin-tabs.tsx
Normal file
40
app/soapbox/features/admin/components/admin-tabs.tsx
Normal 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;
|
Binary file not shown.
|
@ -2,18 +2,18 @@ import { OrderedSet as ImmutableOrderedSet, is } from 'immutable';
|
|||
import React, { useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { fetchUsers } from 'soapbox/actions/admin';
|
||||
import compareId from 'soapbox/compare_id';
|
||||
import { Text, Widget } from 'soapbox/components/ui';
|
||||
import { Widget } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' },
|
||||
expand: { id: 'admin.latest_accounts_panel.expand_message', defaultMessage: 'Click to see {count} more {count, plural, one {account} other {accounts}}' },
|
||||
expand: { id: 'admin.latest_accounts_panel.more', defaultMessage: 'Click to see {count} {count, plural, one {account} other {accounts}}' },
|
||||
});
|
||||
|
||||
interface ILatestAccountsPanel {
|
||||
|
@ -21,8 +21,9 @@ interface ILatestAccountsPanel {
|
|||
}
|
||||
|
||||
const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const accountIds = useAppSelector<ImmutableOrderedSet<string>>((state) => state.admin.get('latestUsers').take(limit));
|
||||
const hasDates = useAppSelector((state) => accountIds.every(id => !!state.accounts.getIn([id, 'created_at'])));
|
||||
|
@ -44,18 +45,19 @@ const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const expandCount = total - accountIds.size;
|
||||
const handleAction = () => {
|
||||
history.push('/admin/users');
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget title={intl.formatMessage(messages.title)}>
|
||||
<Widget
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onActionClick={handleAction}
|
||||
actionTitle={intl.formatMessage(messages.expand, { count: total })}
|
||||
>
|
||||
{accountIds.take(limit).map((account) => (
|
||||
<AccountContainer key={account} id={account} withRelationship={false} withDate />
|
||||
))}
|
||||
{!!expandCount && (
|
||||
<Link className='wtf-panel__expand-btn' to='/admin/users'>
|
||||
<Text>{intl.formatMessage(messages.expand, { count: expandCount })}</Text>
|
||||
</Link>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
|
Binary file not shown.
|
@ -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;
|
Binary file not shown.
130
app/soapbox/features/admin/components/report.tsx
Normal file
130
app/soapbox/features/admin/components/report.tsx
Normal 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'>
|
||||
—
|
||||
<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;
|
Binary file not shown.
134
app/soapbox/features/admin/components/report_status.tsx
Normal file
134
app/soapbox/features/admin/components/report_status.tsx
Normal 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;
|
Binary file not shown.
63
app/soapbox/features/admin/components/unapproved_account.tsx
Normal file
63
app/soapbox/features/admin/components/unapproved_account.tsx
Normal 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;
|
Binary file not shown.
37
app/soapbox/features/admin/index.tsx
Normal file
37
app/soapbox/features/admin/index.tsx
Normal 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;
|
Binary file not shown.
44
app/soapbox/features/admin/tabs/awaiting-approval.tsx
Normal file
44
app/soapbox/features/admin/tabs/awaiting-approval.tsx
Normal 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;
|
146
app/soapbox/features/admin/tabs/dashboard.tsx
Normal file
146
app/soapbox/features/admin/tabs/dashboard.tsx
Normal 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;
|
50
app/soapbox/features/admin/tabs/reports.tsx
Normal file
50
app/soapbox/features/admin/tabs/reports.tsx
Normal 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;
|
Binary file not shown.
271
app/soapbox/features/forms/index.tsx
Normal file
271
app/soapbox/features/forms/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -95,14 +95,14 @@ const messages: Record<NotificationType, { id: string, defaultMessage: string }>
|
|||
},
|
||||
};
|
||||
|
||||
const buildMessage = (type: NotificationType, account: Account): JSX.Element => {
|
||||
const buildMessage = (type: NotificationType, account: Account, targetName?: string): JSX.Element => {
|
||||
const link = buildLink(account);
|
||||
|
||||
return (
|
||||
<FormattedMessageFixed
|
||||
id={messages[type].id}
|
||||
defaultMessage={messages[type].defaultMessage}
|
||||
values={{ name: link }}
|
||||
values={{ name: link, targetName }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -257,7 +257,9 @@ const Notification: React.FC<INotificaton> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account) : null;
|
||||
const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : '';
|
||||
|
||||
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account, targetName) : null;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={getHandlers()} data-testid='notification'>
|
||||
|
@ -273,7 +275,7 @@ const Notification: React.FC<INotificaton> = (props) => {
|
|||
},
|
||||
{
|
||||
name: account && typeof account === 'object' ? account.acct : '',
|
||||
targetName: notification.target && typeof notification.target === 'object' ? notification.target.acct : '',
|
||||
targetName,
|
||||
}),
|
||||
notification.created_at,
|
||||
)
|
||||
|
|
|
@ -134,7 +134,7 @@ const Preferences = () => {
|
|||
<ListItem label={<FormattedMessage id='preferences.fields.language_label' defaultMessage='Language' />}>
|
||||
<SelectDropdown
|
||||
items={languages}
|
||||
defaultValue={settings.get('locale')}
|
||||
defaultValue={settings.get('locale') as string | undefined}
|
||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['locale'])}
|
||||
/>
|
||||
</ListItem>
|
||||
|
@ -142,7 +142,7 @@ const Preferences = () => {
|
|||
<ListItem label={<FormattedMessage id='preferences.fields.media_display_label' defaultMessage='Media display' />}>
|
||||
<SelectDropdown
|
||||
items={displayMediaOptions}
|
||||
defaultValue={settings.get('displayMedia')}
|
||||
defaultValue={settings.get('displayMedia') as string | undefined}
|
||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['displayMedia'])}
|
||||
/>
|
||||
</ListItem>
|
||||
|
|
Binary file not shown.
|
@ -2,6 +2,7 @@ import classnames from 'classnames';
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||
|
@ -161,6 +162,13 @@ const Card: React.FC<ICard> = ({
|
|||
|
||||
let embed: React.ReactNode = '';
|
||||
|
||||
const canvas = (
|
||||
<Blurhash
|
||||
className='absolute w-full h-full inset-0 -z-10'
|
||||
hash={card.blurhash}
|
||||
/>
|
||||
);
|
||||
|
||||
const thumbnail = (
|
||||
<div
|
||||
style={{
|
||||
|
@ -184,6 +192,7 @@ const Card: React.FC<ICard> = ({
|
|||
|
||||
embed = (
|
||||
<div className='status-card__image'>
|
||||
{canvas}
|
||||
{thumbnail}
|
||||
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
|
@ -226,6 +235,7 @@ const Card: React.FC<ICard> = ({
|
|||
} else if (card.image) {
|
||||
embed = (
|
||||
<div className='status-card__image'>
|
||||
{canvas}
|
||||
{thumbnail}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -13,7 +13,9 @@ import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis';
|
|||
import { fetchMarker } from 'soapbox/actions/markers';
|
||||
import { register as registerPushNotifications } from 'soapbox/actions/push_notifications';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
||||
import ThumbNavigation from 'soapbox/components/thumb_navigation';
|
||||
import { Layout } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
|
||||
import AdminPage from 'soapbox/pages/admin_page';
|
||||
import DefaultPage from 'soapbox/pages/default_page';
|
||||
|
@ -26,6 +28,7 @@ import RemoteInstancePage from 'soapbox/pages/remote_instance_page';
|
|||
import StatusPage from 'soapbox/pages/status_page';
|
||||
import { getAccessToken } from 'soapbox/utils/auth';
|
||||
import { getVapidKey } from 'soapbox/utils/auth';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
|
||||
import { fetchFollowRequests } from '../../actions/accounts';
|
||||
import { fetchReports, fetchUsers, fetchConfig } from '../../actions/admin';
|
||||
|
@ -91,8 +94,6 @@ import {
|
|||
ChatPanes,
|
||||
ServerInfo,
|
||||
Dashboard,
|
||||
AwaitingApproval,
|
||||
Reports,
|
||||
ModerationLog,
|
||||
CryptoDonate,
|
||||
ScheduledStatuses,
|
||||
|
@ -172,7 +173,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
|||
// Ex: use /login instead of /auth, but redirect /auth to /login
|
||||
return (
|
||||
<Switch>
|
||||
<WrappedRoute path='/login/external' component={ExternalLogin} publicRoute exact />
|
||||
<WrappedRoute path='/login/external' page={EmptyPage} component={ExternalLogin} content={children} publicRoute exact />
|
||||
<WrappedRoute path='/email-confirmation' page={EmptyPage} component={EmailConfirmation} publicRoute exact />
|
||||
<WrappedRoute path='/logout' page={EmptyPage} component={LogoutPage} publicRoute exact />
|
||||
|
||||
|
@ -302,8 +303,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
|||
<WrappedRoute path='/soapbox/config' adminOnly page={DefaultPage} component={SoapboxConfig} content={children} />
|
||||
|
||||
<WrappedRoute path='/admin' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||
<WrappedRoute path='/admin/approval' staffOnly page={AdminPage} component={AwaitingApproval} content={children} exact />
|
||||
<WrappedRoute path='/admin/reports' staffOnly page={AdminPage} component={Reports} content={children} exact />
|
||||
<WrappedRoute path='/admin/approval' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||
<WrappedRoute path='/admin/reports' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||
<WrappedRoute path='/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact />
|
||||
<WrappedRoute path='/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
|
||||
<WrappedRoute path='/info' page={EmptyPage} component={ServerInfo} content={children} />
|
||||
|
@ -346,6 +347,7 @@ const UI: React.FC = ({ children }) => {
|
|||
const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.get('openId') !== null);
|
||||
const accessToken = useAppSelector(state => getAccessToken(state));
|
||||
const streamingUrl = useAppSelector(state => state.instance.urls.get('streaming_api'));
|
||||
const standalone = useAppSelector(isStandalone);
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
@ -649,9 +651,15 @@ const UI: React.FC = ({ children }) => {
|
|||
<div className='z-10 flex flex-col'>
|
||||
<Navbar />
|
||||
|
||||
<SwitchingColumnsArea>
|
||||
{children}
|
||||
</SwitchingColumnsArea>
|
||||
<Layout>
|
||||
<Layout.Sidebar>
|
||||
{!standalone && <SidebarNavigation />}
|
||||
</Layout.Sidebar>
|
||||
|
||||
<SwitchingColumnsArea>
|
||||
{children}
|
||||
</SwitchingColumnsArea>
|
||||
</Layout>
|
||||
|
||||
{me && floatingActionButton}
|
||||
|
||||
|
|
|
@ -330,14 +330,6 @@ export function Dashboard() {
|
|||
return import(/* webpackChunkName: "features/admin" */'../../admin');
|
||||
}
|
||||
|
||||
export function AwaitingApproval() {
|
||||
return import(/* webpackChunkName: "features/admin/awaiting_approval" */'../../admin/awaiting_approval');
|
||||
}
|
||||
|
||||
export function Reports() {
|
||||
return import(/* webpackChunkName: "features/admin/reports" */'../../admin/reports');
|
||||
}
|
||||
|
||||
export function ModerationLog() {
|
||||
return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation_log');
|
||||
}
|
||||
|
@ -386,10 +378,6 @@ export function LatestAccountsPanel() {
|
|||
return import(/* webpackChunkName: "features/admin" */'../../admin/components/latest_accounts_panel');
|
||||
}
|
||||
|
||||
export function AdminNav() {
|
||||
return import(/* webpackChunkName: "features/admin" */'../../admin/components/admin_nav');
|
||||
}
|
||||
|
||||
export function SidebarMenu() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../../../components/sidebar_menu');
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Redirect, Route, useHistory, RouteProps, RouteComponentProps, match as MatchType } from 'react-router-dom';
|
||||
|
||||
import { Layout } from 'soapbox/components/ui';
|
||||
import { useOwnAccount, useSettings } from 'soapbox/hooks';
|
||||
|
||||
import BundleColumnError from '../components/bundle_column_error';
|
||||
|
@ -75,29 +76,19 @@ const WrappedRoute: React.FC<IWrappedRoute> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const renderLoading = () => {
|
||||
return (
|
||||
<ColumnsArea layout={layout}>
|
||||
<ColumnLoading />
|
||||
</ColumnsArea>
|
||||
);
|
||||
};
|
||||
const renderWithLayout = (children: JSX.Element) => (
|
||||
<>
|
||||
<Layout.Main>
|
||||
{children}
|
||||
</Layout.Main>
|
||||
|
||||
const renderForbidden = () => {
|
||||
return (
|
||||
<ColumnsArea layout={layout}>
|
||||
<ColumnForbidden />
|
||||
</ColumnsArea>
|
||||
);
|
||||
};
|
||||
<Layout.Aside />
|
||||
</>
|
||||
);
|
||||
|
||||
const renderError = (props: any) => {
|
||||
return (
|
||||
<ColumnsArea layout={layout}>
|
||||
<BundleColumnError {...props} />
|
||||
</ColumnsArea>
|
||||
);
|
||||
};
|
||||
const renderLoading = () => renderWithLayout(<ColumnLoading />);
|
||||
const renderForbidden = () => renderWithLayout(<ColumnForbidden />);
|
||||
const renderError = (props: any) => renderWithLayout(<BundleColumnError {...props} />);
|
||||
|
||||
const loginRedirect = () => {
|
||||
const actualUrl = encodeURIComponent(`${history.location.pathname}${history.location.search}`);
|
||||
|
|
|
@ -109,7 +109,7 @@
|
|||
"admin.users.user_unsuggested_message": "@{acct} was unsuggested",
|
||||
"admin.users.user_unverified_message": "@{acct} was unverified",
|
||||
"admin.users.user_verified_message": "@{acct} was verified",
|
||||
"admin_nav.awaiting_approval": "Awaiting Approval",
|
||||
"admin_nav.awaiting_approval": "Waitlist",
|
||||
"admin_nav.dashboard": "Dashboard",
|
||||
"admin_nav.reports": "Reports",
|
||||
"alert.unexpected.clear_cookies": "clear cookies and browser data",
|
||||
|
|
Binary file not shown.
29
app/soapbox/pages/admin_page.tsx
Normal file
29
app/soapbox/pages/admin_page.tsx
Normal 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;
|
Binary file not shown.
48
app/soapbox/pages/default_page.tsx
Normal file
48
app/soapbox/pages/default_page.tsx
Normal 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;
|
Binary file not shown.
17
app/soapbox/pages/empty_page.tsx
Normal file
17
app/soapbox/pages/empty_page.tsx
Normal 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;
|
Binary file not shown.
101
app/soapbox/pages/home_page.tsx
Normal file
101
app/soapbox/pages/home_page.tsx
Normal 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;
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -3,6 +3,7 @@ import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
|||
import { createSelector } from 'reselect';
|
||||
import gte from 'semver/functions/gte';
|
||||
import lt from 'semver/functions/lt';
|
||||
import semverParse from 'semver/functions/parse';
|
||||
|
||||
import { custom } from 'soapbox/custom';
|
||||
|
||||
|
@ -44,6 +45,12 @@ export const PIXELFED = 'Pixelfed';
|
|||
*/
|
||||
export const TRUTHSOCIAL = 'TruthSocial';
|
||||
|
||||
/**
|
||||
* Soapbox BE, the recommended Pleroma fork for Soapbox.
|
||||
* @see {@link https://gitlab.com/soapbox-pub/soapbox-be}
|
||||
*/
|
||||
export const SOAPBOX = 'soapbox';
|
||||
|
||||
/** Parse features for the given instance */
|
||||
const getInstanceFeatures = (instance: Instance) => {
|
||||
const v = parseVersion(instance.version);
|
||||
|
@ -76,7 +83,10 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
* Ability to set one's location on their profile.
|
||||
* @see PATCH /api/v1/accounts/update_credentials
|
||||
*/
|
||||
accountLocation: v.software === TRUTHSOCIAL,
|
||||
accountLocation: any([
|
||||
v.software === PLEROMA && v.build === SOAPBOX && gte(v.version, '2.4.50'),
|
||||
v.software === TRUTHSOCIAL,
|
||||
]),
|
||||
|
||||
/**
|
||||
* Look up an account by the acct.
|
||||
|
@ -85,6 +95,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
accountLookup: any([
|
||||
v.software === MASTODON && gte(v.compatVersion, '3.4.0'),
|
||||
v.software === PLEROMA && gte(v.version, '2.4.50'),
|
||||
v.software === TRUTHSOCIAL,
|
||||
]),
|
||||
|
||||
/**
|
||||
|
@ -464,6 +475,8 @@ export const getFeatures = createSelector([
|
|||
|
||||
/** Fediverse backend */
|
||||
interface Backend {
|
||||
/** Build name, if this software is a fork */
|
||||
build: string | null,
|
||||
/** Name of the software */
|
||||
software: string | null,
|
||||
/** API version number */
|
||||
|
@ -474,19 +487,24 @@ interface Backend {
|
|||
|
||||
/** Get information about the software from its version string */
|
||||
export const parseVersion = (version: string): Backend => {
|
||||
const regex = /^([\w.]*)(?: \(compatible; ([\w]*) (.*)\))?$/;
|
||||
const regex = /^([\w+.]*)(?: \(compatible; ([\w]*) (.*)\))?$/;
|
||||
const match = regex.exec(version);
|
||||
|
||||
if (match) {
|
||||
const semver = match ? semverParse(match[3] || match[1]) : null;
|
||||
const compat = match ? semverParse(match[1]) : null;
|
||||
|
||||
if (match && semver && compat) {
|
||||
return {
|
||||
compatVersion: match[1],
|
||||
build: semver.build[0],
|
||||
compatVersion: compat.version,
|
||||
software: match[2] || MASTODON,
|
||||
version: match[3] || match[1],
|
||||
version: semver.version,
|
||||
};
|
||||
} else {
|
||||
// If we can't parse the version, this is a new and exotic backend.
|
||||
// Fall back to minimal featureset.
|
||||
return {
|
||||
build: null,
|
||||
compatVersion: '0.0.0',
|
||||
software: null,
|
||||
version: '0.0.0',
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { FormattedNumber } from 'react-intl';
|
||||
|
||||
/** Check if a value is REALLY a number. */
|
||||
export const isNumber = (number: unknown): boolean => typeof number === 'number' && !isNaN(number);
|
||||
export const isNumber = (value: unknown): value is number => typeof value === 'number' && !isNaN(value);
|
||||
|
||||
/** Display a number nicely for the UI, eg 1000 becomes 1K. */
|
||||
export const shortNumberFormat = (number: any): React.ReactNode => {
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
width: 100%;
|
||||
|
||||
&::after {
|
||||
@apply text-black dark:text-white;
|
||||
content: '';
|
||||
display: block;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
|
|
|
@ -105,10 +105,6 @@
|
|||
font-weight: bold;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
a {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__quote {
|
||||
|
@ -122,7 +118,6 @@
|
|||
font-size: 12px;
|
||||
|
||||
a {
|
||||
color: var(--primary-text-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,7 @@
|
|||
@apply text-gray-500 dark:text-gray-400 mb-1 text-sm;
|
||||
|
||||
&__account {
|
||||
@apply text-primary-600 no-underline;
|
||||
|
||||
&:hover {
|
||||
@apply underline text-primary-800;
|
||||
}
|
||||
@apply text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300 no-underline hover:underline;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -338,6 +338,7 @@ code {
|
|||
input[type=password],
|
||||
textarea,
|
||||
.rfipbtn {
|
||||
@apply border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-800 text-black dark:text-white;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
color: var(--primary-text-color);
|
||||
|
@ -643,6 +644,7 @@ code {
|
|||
}
|
||||
|
||||
.icon-button {
|
||||
@apply text-black dark:text-white;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
@ -651,7 +653,6 @@ code {
|
|||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--primary-text-color);
|
||||
|
||||
.svg-icon {
|
||||
height: 20px;
|
||||
|
|
|
@ -81,6 +81,7 @@
|
|||
"@types/react-helmet": "^6.1.5",
|
||||
"@types/react-motion": "^0.0.32",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-sparklines": "^1.7.2",
|
||||
"@types/react-swipeable-views": "^0.13.1",
|
||||
"@types/react-toggle": "^4.0.3",
|
||||
"@types/redux-mock-store": "^1.0.3",
|
||||
|
|
|
@ -2242,6 +2242,13 @@
|
|||
"@types/history" "^4.7.11"
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-sparklines@^1.7.2":
|
||||
version "1.7.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-sparklines/-/react-sparklines-1.7.2.tgz#c14e80623abd3669a10f18d13f6fb9fbdc322f70"
|
||||
integrity sha512-N1GwO7Ri5C5fE8+CxhiDntuSw1qYdGytBuedKrCxWpaojXm4WnfygbdBdc5sXGX7feMxDXBy9MNhxoUTwrMl4A==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-swipeable-views@^0.13.1":
|
||||
version "0.13.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.1.tgz#381c8513deef5426623aa851033ff4f4831ae15c"
|
||||
|
|
Loading…
Reference in a new issue