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 1477 additions and 96 deletions

Binary file not shown.

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

View file

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

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 { 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}

View file

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

View file

@ -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>
) : (

View file

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

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 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}

View file

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

View file

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

View file

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

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

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

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

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

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

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

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

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

Binary file not shown.

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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;

Binary file not shown.

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;

Binary file not shown.

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;

Binary file not shown.

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;

Binary file not shown.

Binary file not shown.

View file

@ -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',

View file

@ -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 => {

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

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