diff --git a/app/soapbox/components/dropdown_menu.tsx b/app/soapbox/components/dropdown_menu.tsx
index 4bd747d0a..18258c0f0 100644
--- a/app/soapbox/components/dropdown_menu.tsx
+++ b/app/soapbox/components/dropdown_menu.tsx
@@ -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;
}
- const { text, href, to, newTab, isLogout, icon, destructive } = option;
+ const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
return (
@@ -191,7 +192,14 @@ class DropdownMenu extends React.PureComponent
{icon && }
+
{text}
+
+ {count ? (
+
+
+
+ ) : null}
);
diff --git a/app/soapbox/components/icon_with_counter.tsx b/app/soapbox/components/icon_with_counter.tsx
index 0e09503cf..d0fd093a6 100644
--- a/app/soapbox/components/icon_with_counter.tsx
+++ b/app/soapbox/components/icon_with_counter.tsx
@@ -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 {
count: number,
@@ -14,9 +14,11 @@ const IconWithCounter: React.FC = ({ icon, count, ...rest }) =
- {count > 0 &&
- {shortNumberFormat(count)}
- }
+ {count > 0 && (
+
+
+
+ )}
);
};
diff --git a/app/soapbox/components/showable_password.js b/app/soapbox/components/showable_password.js
deleted file mode 100644
index a8ebb0786..000000000
--- a/app/soapbox/components/showable_password.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl } from 'react-intl';
-
-import IconButton from 'soapbox/components/icon_button';
-import { FormPropTypes, InputContainer, LabelInputContainer } from 'soapbox/features/forms';
-
-const messages = defineMessages({
- showPassword: { id: 'forms.show_password', defaultMessage: 'Show password' },
- hidePassword: { id: 'forms.hide_password', defaultMessage: 'Hide password' },
-});
-
-export default @injectIntl
-class ShowablePassword extends ImmutablePureComponent {
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- label: FormPropTypes.label,
- className: PropTypes.string,
- hint: PropTypes.node,
- error: PropTypes.bool,
- }
-
- state = {
- revealed: false,
- }
-
- toggleReveal = () => {
- if (this.props.onToggleVisibility) {
- this.props.onToggleVisibility();
- } else {
- this.setState({ revealed: !this.state.revealed });
- }
- }
-
- render() {
- const { intl, hint, error, label, className, ...props } = this.props;
- const { revealed } = this.state;
-
- const revealButton = (
-
- );
-
- return (
-
- {label ? (
-
-
- {revealButton}
-
- ) : (<>
-
- {revealButton}
- >)}
-
- );
- }
-
-}
diff --git a/app/soapbox/components/showable_password.tsx b/app/soapbox/components/showable_password.tsx
new file mode 100644
index 000000000..40835decd
--- /dev/null
+++ b/app/soapbox/components/showable_password.tsx
@@ -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 = (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 = (
+
+ );
+
+ return (
+
+ {label ? (
+
+
+ {revealButton}
+
+ ) : (<>
+
+ {revealButton}
+ >)}
+
+ );
+};
+
+export default ShowablePassword;
diff --git a/app/soapbox/components/sidebar-navigation-link.tsx b/app/soapbox/components/sidebar-navigation-link.tsx
index fc63372e7..9dbedd46a 100644
--- a/app/soapbox/components/sidebar-navigation-link.tsx
+++ b/app/soapbox/components/sidebar-navigation-link.tsx
@@ -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 ? (
-
- {count}
+
+
) : null}
diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx
index 132b439df..9809d99c1 100644
--- a/app/soapbox/components/sidebar-navigation.tsx
+++ b/app/soapbox/components/sidebar-navigation.tsx
@@ -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: ,
- // TODO: let menu items have a counter
- // count: dashboardCount,
+ count: dashboardCount,
});
}
@@ -160,6 +159,7 @@ const SidebarNavigation = () => {
}
/>
diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx
index 412c1985b..1c72c4841 100644
--- a/app/soapbox/components/ui/card/card.tsx
+++ b/app/soapbox/components/ui/card/card.tsx
@@ -20,9 +20,10 @@ interface ICard {
variant?: 'rounded',
size?: 'md' | 'lg' | 'xl',
className?: string,
+ children: React.ReactNode,
}
-const Card: React.FC = React.forwardRef(({ children, variant, size = 'md', className, ...filteredProps }, ref: React.ForwardedRef): JSX.Element => (
+const Card = React.forwardRef(({ children, variant, size = 'md', className, ...filteredProps }, ref): JSX.Element => (
= ({ count }) => {
+ return (
+
+ {shortNumberFormat(count)}
+
+ );
+};
+
+export default Counter;
diff --git a/app/soapbox/components/ui/icon/icon.tsx b/app/soapbox/components/ui/icon/icon.tsx
index 224d15a9b..07b3ca831 100644
--- a/app/soapbox/components/ui/icon/icon.tsx
+++ b/app/soapbox/components/ui/icon/icon.tsx
@@ -1,7 +1,10 @@
import React from 'react';
+import Counter from '../counter/counter';
+
import SvgIcon from './svg-icon';
+
interface IIcon extends Pick
, 'strokeWidth'> {
className?: string,
count?: number,
@@ -13,8 +16,8 @@ interface IIcon extends Pick, 'strokeWidth'> {
const Icon = ({ src, alt, count, size, ...filteredProps }: IIcon): JSX.Element => (
{count ? (
-
- {count}
+
+
) : null}
diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts
index fa60595f5..127547f5f 100644
--- a/app/soapbox/components/ui/index.ts
+++ b/app/soapbox/components/ui/index.ts
@@ -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';
diff --git a/app/soapbox/components/ui/tabs/tabs.css b/app/soapbox/components/ui/tabs/tabs.css
index 02f096825..180641acf 100644
--- a/app/soapbox/components/ui/tabs/tabs.css
+++ b/app/soapbox/components/ui/tabs/tabs.css
@@ -11,8 +11,9 @@
}
[data-reach-tab] {
- @apply flex-1 flex justify-center py-4 px-1 text-center font-medium text-sm
- text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200;
+ @apply flex-1 flex justify-center items-center
+ py-4 px-1 text-center font-medium text-sm text-gray-500
+ dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200;
}
[data-reach-tab][data-selected] {
diff --git a/app/soapbox/components/ui/tabs/tabs.tsx b/app/soapbox/components/ui/tabs/tabs.tsx
index 183d74369..a0dfe7abb 100644
--- a/app/soapbox/components/ui/tabs/tabs.tsx
+++ b/app/soapbox/components/ui/tabs/tabs.tsx
@@ -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 (
{
title={title}
index={idx}
>
- {text}
+
+ {count ? (
+
+
+
+ ) : null}
+
+ {text}
+
);
};
diff --git a/app/soapbox/features/admin/awaiting_approval.js b/app/soapbox/features/admin/awaiting_approval.js
deleted file mode 100644
index fa0e538ff..000000000
--- a/app/soapbox/features/admin/awaiting_approval.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connect } from 'react-redux';
-
-import { fetchUsers } from 'soapbox/actions/admin';
-import ScrollableList from 'soapbox/components/scrollable_list';
-
-import Column from '../ui/components/column';
-
-import UnapprovedAccount from './components/unapproved_account';
-
-const messages = defineMessages({
- heading: { id: 'column.admin.awaiting_approval', defaultMessage: 'Awaiting Approval' },
- emptyMessage: { id: 'admin.awaiting_approval.empty_message', defaultMessage: 'There is nobody waiting for approval. When a new user signs up, you can review them here.' },
-});
-
-const mapStateToProps = state => ({
- accountIds: state.getIn(['admin', 'awaitingApproval']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class AwaitingApproval extends ImmutablePureComponent {
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- accountIds: ImmutablePropTypes.orderedSet.isRequired,
- };
-
- state = {
- isLoading: true,
- }
-
- componentDidMount() {
- const { dispatch } = this.props;
- dispatch(fetchUsers(['local', 'need_approval']))
- .then(() => this.setState({ isLoading: false }))
- .catch(() => {});
- }
-
- render() {
- const { intl, accountIds } = this.props;
- const { isLoading } = this.state;
- const showLoading = isLoading && accountIds.count() === 0;
-
- return (
-
-
- {accountIds.map(id => (
-
- ))}
-
-
- );
- }
-
-}
diff --git a/app/soapbox/features/admin/components/admin-tabs.tsx b/app/soapbox/features/admin/components/admin-tabs.tsx
new file mode 100644
index 000000000..4602cd0aa
--- /dev/null
+++ b/app/soapbox/features/admin/components/admin-tabs.tsx
@@ -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 ;
+};
+
+export default AdminTabs;
diff --git a/app/soapbox/features/admin/components/admin_nav.js b/app/soapbox/features/admin/components/admin_nav.js
deleted file mode 100644
index de7b0d337..000000000
--- a/app/soapbox/features/admin/components/admin_nav.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import { NavLink } from 'react-router-dom';
-
-import Icon from 'soapbox/components/icon';
-import IconWithCounter from 'soapbox/components/icon_with_counter';
-
-const mapStateToProps = (state, props) => ({
- instance: state.get('instance'),
- approvalCount: state.getIn(['admin', 'awaitingApproval']).count(),
- reportsCount: state.getIn(['admin', 'openReports']).count(),
-});
-
-export default @connect(mapStateToProps)
-class AdminNav extends React.PureComponent {
-
- static propTypes = {
- instance: ImmutablePropTypes.map.isRequired,
- approvalCount: PropTypes.number,
- reportsCount: PropTypes.number,
- };
-
- render() {
- const { instance, approvalCount, reportsCount } = this.props;
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
- {((instance.get('registrations') && instance.get('approval_required')) || approvalCount > 0) && (
-
-
-
-
- )}
- {/* !instance.get('registrations') && (
-
-
-
-
- ) */}
- {/*
-
-
- */}
-
-
- {/*
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
*/}
- >
- );
- }
-
-}
diff --git a/app/soapbox/features/admin/components/latest_accounts_panel.tsx b/app/soapbox/features/admin/components/latest_accounts_panel.tsx
index b1e9a441b..26b383713 100644
--- a/app/soapbox/features/admin/components/latest_accounts_panel.tsx
+++ b/app/soapbox/features/admin/components/latest_accounts_panel.tsx
@@ -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 = ({ limit = 5 }) => {
- const dispatch = useAppDispatch();
const intl = useIntl();
+ const history = useHistory();
+ const dispatch = useAppDispatch();
const accountIds = useAppSelector>((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 = ({ limit = 5 }) => {
return null;
}
- const expandCount = total - accountIds.size;
+ const handleAction = () => {
+ history.push('/admin/users');
+ };
return (
-
+
{accountIds.take(limit).map((account) => (
))}
- {!!expandCount && (
-
- {intl.formatMessage(messages.expand, { count: expandCount })}
-
- )}
);
};
diff --git a/app/soapbox/features/admin/components/registration_mode_picker.js b/app/soapbox/features/admin/components/registration_mode_picker.js
deleted file mode 100644
index 275d9b0c0..000000000
--- a/app/soapbox/features/admin/components/registration_mode_picker.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-
-import { updateConfig } from 'soapbox/actions/admin';
-import snackbar from 'soapbox/actions/snackbar';
-import {
- SimpleForm,
- FieldsGroup,
- RadioGroup,
- RadioItem,
-} from 'soapbox/features/forms';
-
-const messages = defineMessages({
- saved: { id: 'admin.dashboard.settings_saved', defaultMessage: 'Settings saved!' },
-});
-
-const mapStateToProps = (state, props) => ({
- mode: modeFromInstance(state.get('instance')),
-});
-
-const generateConfig = mode => {
- const configMap = {
- open: [{ tuple: [':registrations_open', true] }, { tuple: [':account_approval_required', false] }],
- approval: [{ tuple: [':registrations_open', true] }, { tuple: [':account_approval_required', true] }],
- closed: [{ tuple: [':registrations_open', false] }],
- };
-
- return [{
- group: ':pleroma',
- key: ':instance',
- value: configMap[mode],
- }];
-};
-
-const modeFromInstance = instance => {
- if (instance.get('approval_required') && instance.get('registrations')) return 'approval';
- return instance.get('registrations') ? 'open' : 'closed';
-};
-
-export default @connect(mapStateToProps)
-@injectIntl
-class RegistrationModePicker extends ImmutablePureComponent {
-
- onChange = e => {
- const { dispatch, intl } = this.props;
- const config = generateConfig(e.target.value);
- dispatch(updateConfig(config)).then(() => {
- dispatch(snackbar.success(intl.formatMessage(messages.saved)));
- }).catch(() => {});
- }
-
- render() {
- const { mode } = this.props;
-
- return (
-
-
- }
- onChange={this.onChange}
- >
- }
- hint={}
- checked={mode === 'open'}
- value='open'
- />
- }
- hint={}
- checked={mode === 'approval'}
- value='approval'
- />
- }
- hint={}
- checked={mode === 'closed'}
- value='closed'
- />
-
-
-
- );
- }
-
-}
diff --git a/app/soapbox/features/admin/components/registration_mode_picker.tsx b/app/soapbox/features/admin/components/registration_mode_picker.tsx
new file mode 100644
index 000000000..5808ace63
--- /dev/null
+++ b/app/soapbox/features/admin/components/registration_mode_picker.tsx
@@ -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 = e => {
+ const config = generateConfig(e.target.value as RegistrationMode);
+ dispatch(updateConfig(config)).then(() => {
+ dispatch(snackbar.success(intl.formatMessage(messages.saved)));
+ }).catch(() => {});
+ };
+
+ return (
+
+
+ }
+ onChange={onChange}
+ >
+ }
+ hint={}
+ checked={mode === 'open'}
+ value='open'
+ />
+ }
+ hint={}
+ checked={mode === 'approval'}
+ value='approval'
+ />
+ }
+ hint={}
+ checked={mode === 'closed'}
+ value='closed'
+ />
+
+
+
+ );
+};
+
+export default RegistrationModePicker;
diff --git a/app/soapbox/features/admin/components/report.js b/app/soapbox/features/admin/components/report.js
deleted file mode 100644
index 08fe61f22..000000000
--- a/app/soapbox/features/admin/components/report.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
-import { connect } from 'react-redux';
-import { Link } from 'react-router-dom';
-
-import { closeReports } from 'soapbox/actions/admin';
-import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
-import snackbar from 'soapbox/actions/snackbar';
-import Avatar from 'soapbox/components/avatar';
-import { Button } from 'soapbox/components/ui';
-import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
-import Accordion from 'soapbox/features/ui/components/accordion';
-
-import ReportStatus from './report_status';
-
-const messages = defineMessages({
- reportClosed: { id: 'admin.reports.report_closed_message', defaultMessage: 'Report on @{name} was closed' },
- deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
- deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' },
-});
-
-export default @connect()
-@injectIntl
-class Report extends ImmutablePureComponent {
-
- static propTypes = {
- report: ImmutablePropTypes.map.isRequired,
- };
-
- state = {
- accordionExpanded: false,
- };
-
- makeMenu = () => {
- const { intl, report } = this.props;
-
- return [{
- text: intl.formatMessage(messages.deactivateUser, { name: report.getIn(['account', 'username']) }),
- action: this.handleDeactivateUser,
- icon: require('@tabler/icons/icons/user-off.svg'),
- }, {
- text: intl.formatMessage(messages.deleteUser, { name: report.getIn(['account', 'username']) }),
- action: this.handleDeleteUser,
- icon: require('@tabler/icons/icons/user-minus.svg'),
- }];
- }
-
- handleCloseReport = () => {
- const { intl, dispatch, report } = this.props;
- dispatch(closeReports([report.get('id')])).then(() => {
- const message = intl.formatMessage(messages.reportClosed, { name: report.getIn(['account', 'username']) });
- dispatch(snackbar.success(message));
- }).catch(() => {});
- }
-
- handleDeactivateUser = () => {
- const { intl, dispatch, report } = this.props;
- const accountId = report.getIn(['account', 'id']);
- dispatch(deactivateUserModal(intl, accountId, () => this.handleCloseReport()));
- }
-
- handleDeleteUser = () => {
- const { intl, dispatch, report } = this.props;
- const accountId = report.getIn(['account', 'id']);
- dispatch(deleteUserModal(intl, accountId, () => this.handleCloseReport()));
- }
-
- handleAccordionToggle = setting => {
- this.setState({ accordionExpanded: setting });
- }
-
- render() {
- const { report } = this.props;
- const { accordionExpanded } = this.state;
- const menu = this.makeMenu();
- const statuses = report.get('statuses');
- const statusCount = statuses.count();
- const acct = report.getIn(['account', 'acct']);
- const reporterAcct = report.getIn(['actor', 'acct']);
-
- return (
-
-
-
-
- @{acct} }}
- />
-
-
- {statusCount > 0 && (
-
- {statuses.map(status => )}
-
- )}
-
-
- {report.get('content', '').length > 0 &&
-
- }
-
— @{reporterAcct}
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/soapbox/features/admin/components/report.tsx b/app/soapbox/features/admin/components/report.tsx
new file mode 100644
index 000000000..16e99bb7c
--- /dev/null
+++ b/app/soapbox/features/admin/components/report.tsx
@@ -0,0 +1,118 @@
+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 { Button } 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;
+}
+
+const Report: React.FC = ({ 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;
+ const statusCount = statuses.count();
+ const acct = report.getIn(['account', 'acct']) as string;
+ const reporterAcct = report.getIn(['actor', 'acct']) as string;
+
+ return (
+
+
+
+
+ @{acct} }}
+ />
+
+
+ {statusCount > 0 && (
+
+ {statuses.map(status => )}
+
+ )}
+
+
+ {report.get('content', '').length > 0 &&
+
+ }
+
— @{reporterAcct}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Report;
diff --git a/app/soapbox/features/admin/components/report_status.js b/app/soapbox/features/admin/components/report_status.js
deleted file mode 100644
index 721dfdfb2..000000000
--- a/app/soapbox/features/admin/components/report_status.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import noop from 'lodash/noop';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { injectIntl, defineMessages } from 'react-intl';
-import { connect } from 'react-redux';
-
-import { openModal } from 'soapbox/actions/modals';
-import { deleteStatusModal } from 'soapbox/actions/moderation';
-import StatusContent from 'soapbox/components/status_content';
-import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
-import Bundle from 'soapbox/features/ui/components/bundle';
-import { MediaGallery, Video, Audio } from 'soapbox/features/ui/util/async-components';
-
-const messages = defineMessages({
- viewStatus: { id: 'admin.reports.actions.view_status', defaultMessage: 'View post' },
- deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' },
-});
-
-export default @connect()
-@injectIntl
-class ReportStatus extends ImmutablePureComponent {
-
- static propTypes = {
- status: ImmutablePropTypes.record.isRequired,
- report: ImmutablePropTypes.map,
- };
-
- makeMenu = () => {
- const { intl, status } = this.props;
- const acct = status.getIn(['account', 'acct']);
-
- return [{
- text: intl.formatMessage(messages.viewStatus, { acct: `@${acct}` }),
- to: `/@${acct}/posts/${status.get('id')}`,
- icon: require('@tabler/icons/icons/pencil.svg'),
- }, {
- text: intl.formatMessage(messages.deleteStatus, { acct: `@${acct}` }),
- action: this.handleDeleteStatus,
- icon: require('@tabler/icons/icons/trash.svg'),
- destructive: true,
- }];
- }
-
- getMedia = () => {
- const { status } = this.props;
-
- if (status.get('media_attachments').size > 0) {
- if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
- // Do nothing
- } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
- const video = status.getIn(['media_attachments', 0]);
-
- return (
-
- {Component => (
-
- )}
-
- );
- } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
- const audio = status.getIn(['media_attachments', 0]);
-
- return (
-
- {Component => (
-
- )}
-
- );
- } else {
- return (
-
- {Component => }
-
- );
- }
- }
-
- return null;
- }
-
- handleOpenMedia = (media, index) => {
- const { dispatch } = this.props;
- dispatch(openModal('MEDIA', { media, index }));
- }
-
- handleDeleteStatus = () => {
- const { intl, dispatch, status } = this.props;
- const statusId = status.get('id');
- dispatch(deleteStatusModal(intl, statusId));
- }
-
- render() {
- const { status } = this.props;
- const media = this.getMedia();
- const menu = this.makeMenu();
-
- return (
-
- );
- }
-
-}
diff --git a/app/soapbox/features/admin/components/report_status.tsx b/app/soapbox/features/admin/components/report_status.tsx
new file mode 100644
index 000000000..00755a6c4
--- /dev/null
+++ b/app/soapbox/features/admin/components/report_status.tsx
@@ -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,
+}
+
+const ReportStatus: React.FC = ({ 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 (
+
+ {(Component: any) => (
+
+ )}
+
+ );
+ } else if (firstAttachment.type === 'audio') {
+ const audio = firstAttachment;
+
+ return (
+
+ {(Component: any) => (
+
+ )}
+
+ );
+ } else {
+ return (
+
+ {(Component: any) => (
+
+ )}
+
+ );
+ }
+ }
+
+ return null;
+ };
+
+ const media = getMedia();
+ const menu = makeMenu();
+
+ return (
+
+ );
+};
+
+export default ReportStatus;
diff --git a/app/soapbox/features/admin/components/unapproved_account.js b/app/soapbox/features/admin/components/unapproved_account.js
deleted file mode 100644
index 80ca4821d..000000000
--- a/app/soapbox/features/admin/components/unapproved_account.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connect } from 'react-redux';
-
-import { approveUsers } from 'soapbox/actions/admin';
-import snackbar from 'soapbox/actions/snackbar';
-import IconButton from 'soapbox/components/icon_button';
-import { makeGetAccount } from 'soapbox/selectors';
-
-import { rejectUserModal } from '../../../actions/moderation';
-
-const messages = defineMessages({
- approved: { id: 'admin.awaiting_approval.approved_message', defaultMessage: '{acct} was approved!' },
- rejected: { id: 'admin.awaiting_approval.rejected_message', defaultMessage: '{acct} was rejected.' },
-});
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, { accountId }) => {
- return {
- account: getAccount(state, accountId),
- };
- };
-
- return mapStateToProps;
-};
-
-export default @connect(makeMapStateToProps)
-@injectIntl
-class UnapprovedAccount extends ImmutablePureComponent {
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- account: ImmutablePropTypes.record.isRequired,
- };
-
- handleApprove = () => {
- const { dispatch, intl, account } = this.props;
- dispatch(approveUsers([account.get('id')]))
- .then(() => {
- const message = intl.formatMessage(messages.approved, { acct: `@${account.get('acct')}` });
- dispatch(snackbar.success(message));
- })
- .catch(() => {});
- }
-
- handleReject = () => {
- const { dispatch, intl, account } = this.props;
-
- dispatch(rejectUserModal(intl, account.get('id'), () => {
- const message = intl.formatMessage(messages.rejected, { acct: `@${account.get('acct')}` });
- dispatch(snackbar.info(message));
- }));
- }
-
- render() {
- const { account } = this.props;
-
- return (
-
-
-
@{account.get('acct')}
-
{account.getIn(['pleroma', 'admin', 'registration_reason'])}
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/soapbox/features/admin/components/unapproved_account.tsx b/app/soapbox/features/admin/components/unapproved_account.tsx
new file mode 100644
index 000000000..f000bb173
--- /dev/null
+++ b/app/soapbox/features/admin/components/unapproved_account.tsx
@@ -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 = ({ 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 (
+
+
+
@{account.get('acct')}
+
{account.pleroma.getIn(['admin', 'registration_reason'], '') as string}
+
+
+
+
+
+
+ );
+};
+
+export default UnapprovedAccount;
diff --git a/app/soapbox/features/admin/index.js b/app/soapbox/features/admin/index.js
deleted file mode 100644
index 93a38c53f..000000000
--- a/app/soapbox/features/admin/index.js
+++ /dev/null
@@ -1,154 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
-import { connect } from 'react-redux';
-import { Link } from 'react-router-dom';
-
-import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email_list';
-import { Text } from 'soapbox/components/ui';
-import sourceCode from 'soapbox/utils/code';
-import { parseVersion } from 'soapbox/utils/features';
-import { getFeatures } from 'soapbox/utils/features';
-import { isNumber } from 'soapbox/utils/numbers';
-
-import Column from '../ui/components/column';
-
-import RegistrationModePicker from './components/registration_mode_picker';
-
-// https://stackoverflow.com/a/53230807
-const download = (response, filename) => {
- const url = URL.createObjectURL(new Blob([response.data]));
- const link = document.createElement('a');
- link.href = url;
- link.setAttribute('download', filename);
- document.body.appendChild(link);
- link.click();
- link.remove();
-};
-
-const messages = defineMessages({
- heading: { id: 'column.admin.dashboard', defaultMessage: 'Dashboard' },
-});
-
-const mapStateToProps = (state, props) => {
- const me = state.get('me');
-
- return {
- instance: state.get('instance'),
- supportsEmailList: getFeatures(state.get('instance')).emailList,
- account: state.getIn(['accounts', me]),
- };
-};
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Dashboard extends ImmutablePureComponent {
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- instance: ImmutablePropTypes.map.isRequired,
- supportsEmailList: PropTypes.bool,
- account: ImmutablePropTypes.record,
- };
-
- handleSubscribersClick = e => {
- this.props.dispatch(getSubscribersCsv()).then((response) => {
- download(response, 'subscribers.csv');
- }).catch(() => {});
- e.preventDefault();
- }
-
- handleUnsubscribersClick = e => {
- this.props.dispatch(getUnsubscribersCsv()).then((response) => {
- download(response, 'unsubscribers.csv');
- }).catch(() => {});
- e.preventDefault();
- }
-
- handleCombinedClick = e => {
- this.props.dispatch(getCombinedCsv()).then((response) => {
- download(response, 'combined.csv');
- }).catch(() => {});
- e.preventDefault();
- }
-
- render() {
- const { intl, instance, supportsEmailList, account } = this.props;
- const v = parseVersion(instance.get('version'));
- const userCount = instance.getIn(['stats', 'user_count']);
- const mau = instance.getIn(['pleroma', 'stats', 'mau']);
- const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : null;
-
- if (!account) return null;
-
- return (
-
-
- {mau &&
-
-
-
-
-
-
-
}
-
-
-
-
-
-
-
-
- {isNumber(retention) && (
-
-
- {retention}%
-
-
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {account.admin && }
-
-
-
-
- - {sourceCode.displayName} {sourceCode.version}
- - {v.software} {v.version}
-
-
- {supportsEmailList && account.admin &&
}
-
-
- );
- }
-
-}
diff --git a/app/soapbox/features/admin/index.tsx b/app/soapbox/features/admin/index.tsx
new file mode 100644
index 000000000..dae7fc2e7
--- /dev/null
+++ b/app/soapbox/features/admin/index.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Admin;
diff --git a/app/soapbox/features/admin/reports.js b/app/soapbox/features/admin/reports.js
deleted file mode 100644
index e7402ad2e..000000000
--- a/app/soapbox/features/admin/reports.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connect } from 'react-redux';
-
-import { fetchReports } from 'soapbox/actions/admin';
-import ScrollableList from 'soapbox/components/scrollable_list';
-import { makeGetReport } from 'soapbox/selectors';
-
-import Column from '../ui/components/better_column';
-
-import Report from './components/report';
-
-const messages = defineMessages({
- heading: { id: 'column.admin.reports', defaultMessage: 'Reports' },
- modlog: { id: 'column.admin.reports.menu.moderation_log', defaultMessage: 'Moderation Log' },
- emptyMessage: { id: 'admin.reports.empty_message', defaultMessage: 'There are no open reports. If a user gets reported, they will show up here.' },
-});
-
-const mapStateToProps = state => {
- const getReport = makeGetReport();
- const ids = state.getIn(['admin', 'openReports']);
-
- return {
- reports: ids.toList().map(id => getReport(state, id)),
- };
-};
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Reports extends ImmutablePureComponent {
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- reports: ImmutablePropTypes.list.isRequired,
- };
-
- state = {
- isLoading: true,
- }
-
- makeColumnMenu = () => {
- const { intl } = this.props;
-
- return [{
- text: intl.formatMessage(messages.modlog),
- to: '/admin/log',
- icon: require('@tabler/icons/icons/list.svg'),
- }];
- }
-
- componentDidMount() {
- const { dispatch } = this.props;
- dispatch(fetchReports())
- .then(() => this.setState({ isLoading: false }))
- .catch(() => {});
- }
-
- render() {
- const { intl, reports } = this.props;
- const { isLoading } = this.state;
- const showLoading = isLoading && reports.count() === 0;
-
- return (
-
-
- {reports.map(report => )}
-
-
- );
- }
-
-}
diff --git a/app/soapbox/features/admin/tabs/awaiting-approval.tsx b/app/soapbox/features/admin/tabs/awaiting-approval.tsx
new file mode 100644
index 000000000..6f79897a6
--- /dev/null
+++ b/app/soapbox/features/admin/tabs/awaiting-approval.tsx
@@ -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 (
+
+ {accountIds.map(id => (
+
+ ))}
+
+ );
+};
+
+export default AwaitingApproval;
diff --git a/app/soapbox/features/admin/tabs/dashboard.tsx b/app/soapbox/features/admin/tabs/dashboard.tsx
new file mode 100644
index 000000000..248885b21
--- /dev/null
+++ b/app/soapbox/features/admin/tabs/dashboard.tsx
@@ -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 (
+ <>
+
+ {isNumber(mau) && (
+
+
+
+
+
+
+
+
+ )}
+ {isNumber(userCount) && (
+
+
+
+
+
+
+
+
+ )}
+ {isNumber(retention) && (
+
+
+ {retention}%
+
+
+
+
+
+ )}
+ {isNumber(statusCount) && (
+
+
+
+
+
+
+
+
+ )}
+ {isNumber(domainCount) && (
+
+
+
+
+
+
+
+
+ )}
+
+
+ {account.admin && }
+
+
+
+
+
+ - {sourceCode.displayName} {sourceCode.version}
+ - {v.software} {v.version}
+
+
+ {features.emailList && account.admin && (
+
+ )}
+
+ >
+ );
+};
+
+export default Dashboard;
diff --git a/app/soapbox/features/admin/tabs/reports.tsx b/app/soapbox/features/admin/tabs/reports.tsx
new file mode 100644
index 000000000..78cc7a6de
--- /dev/null
+++ b/app/soapbox/features/admin/tabs/reports.tsx
@@ -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 (
+
+ {reports.map(report => )}
+
+ );
+};
+
+export default Reports;
diff --git a/app/soapbox/features/forms/index.js b/app/soapbox/features/forms/index.js
deleted file mode 100644
index 5cc640a25..000000000
--- a/app/soapbox/features/forms/index.js
+++ /dev/null
@@ -1,313 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { FormattedMessage } from 'react-intl';
-import { v4 as uuidv4 } from 'uuid';
-
-import { Select } from '../../components/ui';
-
-export const FormPropTypes = {
- label: PropTypes.oneOfType([
- PropTypes.string,
- PropTypes.object,
- PropTypes.node,
- ]),
-};
-
-export const InputContainer = (props) => {
- const containerClass = classNames('input', {
- 'with_label': props.label,
- 'required': props.required,
- 'boolean': props.type === 'checkbox',
- 'field_with_errors': props.error,
- }, props.extraClass);
-
- return (
-
- {props.children}
- {props.hint && {props.hint}}
-
- );
-};
-
-InputContainer.propTypes = {
- label: FormPropTypes.label,
- hint: PropTypes.node,
- required: PropTypes.bool,
- type: PropTypes.string,
- children: PropTypes.node,
- extraClass: PropTypes.string,
- error: PropTypes.bool,
-};
-
-export const LabelInputContainer = ({ label, hint, children, ...props }) => {
- const [id] = useState(uuidv4());
- const childrenWithProps = React.Children.map(children, child => (
- React.cloneElement(child, { id: id, key: id })
- ));
-
- return (
-
-
-
- {childrenWithProps}
-
- {hint &&
{hint}}
-
- );
-};
-
-LabelInputContainer.propTypes = {
- label: FormPropTypes.label.isRequired,
- hint: PropTypes.node,
- children: PropTypes.node,
-};
-
-export const LabelInput = ({ label, dispatch, ...props }) => (
-
-
-
-);
-
-LabelInput.propTypes = {
- label: FormPropTypes.label.isRequired,
- dispatch: PropTypes.func,
-};
-
-export const LabelTextarea = ({ label, dispatch, ...props }) => (
-
-
-
-);
-
-LabelTextarea.propTypes = {
- label: FormPropTypes.label.isRequired,
- dispatch: PropTypes.func,
-};
-
-export class SimpleInput extends ImmutablePureComponent {
-
- static propTypes = {
- label: FormPropTypes.label,
- hint: PropTypes.node,
- error: PropTypes.bool,
- }
-
- render() {
- const { hint, error, ...props } = this.props;
- const Input = this.props.label ? LabelInput : 'input';
-
- return (
-
-
-
- );
- }
-
-}
-
-export class SimpleTextarea extends ImmutablePureComponent {
-
- static propTypes = {
- label: FormPropTypes.label,
- hint: PropTypes.node,
- }
-
- render() {
- const { hint, ...props } = this.props;
- const Input = this.props.label ? LabelTextarea : 'textarea';
-
- return (
-
-
-
- );
- }
-
-}
-
-export class SimpleForm extends ImmutablePureComponent {
-
- static propTypes = {
- children: PropTypes.node,
- className: PropTypes.string,
- };
-
- static defaultProps = {
- acceptCharset: 'UTF-8',
- onSubmit: e => {},
- };
-
- onSubmit = e => {
- this.props.onSubmit(e);
- e.preventDefault();
- }
-
- render() {
- const { className, children, onSubmit, ...props } = this.props;
- return (
-
- );
- }
-
-}
-
-export const FieldsGroup = ({ children }) => (
- {children}
-);
-
-FieldsGroup.propTypes = {
- children: PropTypes.node,
-};
-
-export const Checkbox = props => (
-
-);
-
-export class RadioGroup extends ImmutablePureComponent {
-
- static propTypes = {
- label: FormPropTypes.label,
- children: PropTypes.node,
- }
-
- render() {
- const { label, children, onChange } = this.props;
-
- const childrenWithProps = React.Children.map(children, child =>
- React.cloneElement(child, { onChange }),
- );
-
- return (
-
- );
- }
-
-}
-
-export class RadioItem extends ImmutablePureComponent {
-
- static propTypes = {
- label: FormPropTypes.label,
- hint: PropTypes.node,
- value: PropTypes.string.isRequired,
- checked: PropTypes.bool.isRequired,
- onChange: PropTypes.func,
- dispatch: PropTypes.func,
- }
-
- static defaultProps = {
- checked: false,
- }
-
- state = {
- id: uuidv4(),
- }
-
- render() {
- const { label, hint, dispatch, ...props } = this.props;
- const { id } = this.state;
-
- return (
-
-
-
- );
- }
-
-}
-
-export class SelectDropdown extends ImmutablePureComponent {
-
- static propTypes = {
- label: FormPropTypes.label,
- hint: PropTypes.node,
- items: PropTypes.object.isRequired,
- }
-
- render() {
- const { label, hint, items, ...props } = this.props;
-
- const optionElems = Object.keys(items).map(item => (
-
- ));
-
- const selectElem = ;
-
- return label ? (
- {selectElem}
- ) : selectElem;
- }
-
-}
-
-export const TextInput = props => (
-
-);
-
-export const FileChooser = props => (
-
-);
-
-FileChooser.defaultProps = {
- accept: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
-};
-
-export const FileChooserLogo = props => (
-
-);
-
-FileChooserLogo.defaultProps = {
- accept: ['image/svg', 'image/png'],
-};
-
-
-export class CopyableInput extends ImmutablePureComponent {
-
- static propTypes = {
- value: PropTypes.string,
- }
-
- setInputRef = c => {
- this.input = c;
- }
-
- handleCopyClick = e => {
- if (!this.input) return;
-
- this.input.select();
- this.input.setSelectionRange(0, 99999);
-
- document.execCommand('copy');
- }
-
- render() {
- const { value } = this.props;
-
- return (
-
-
-
-
- );
- }
-
-}
diff --git a/app/soapbox/features/forms/index.tsx b/app/soapbox/features/forms/index.tsx
new file mode 100644
index 000000000..3a7bed2de
--- /dev/null
+++ b/app/soapbox/features/forms/index.tsx
@@ -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 = (props) => {
+ const containerClass = classNames('input', {
+ 'with_label': props.label,
+ 'required': props.required,
+ 'boolean': props.type === 'checkbox',
+ 'field_with_errors': props.error,
+ }, props.extraClass);
+
+ return (
+
+ {props.children}
+ {props.hint && {props.hint}}
+
+ );
+};
+
+interface ILabelInputContainer {
+ label?: React.ReactNode,
+ hint?: React.ReactNode,
+}
+
+export const LabelInputContainer: React.FC = ({ 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 (
+
+
+
+ {childrenWithProps}
+
+ {hint &&
{hint}}
+
+ );
+};
+
+interface ILabelInput {
+ label?: React.ReactNode,
+}
+
+export const LabelInput: React.FC = ({ label, ...props }) => (
+
+
+
+);
+
+interface ILabelTextarea {
+ label?: React.ReactNode,
+}
+
+export const LabelTextarea: React.FC = ({ label, ...props }) => (
+
+
+
+);
+
+interface ISimpleInput {
+ type: string,
+ label?: React.ReactNode,
+ hint?: React.ReactNode,
+ error?: boolean,
+ onChange?: React.ChangeEventHandler,
+}
+
+export const SimpleInput: React.FC = (props) => {
+ const { hint, label, error, ...rest } = props;
+ const Input = label ? LabelInput : 'input';
+
+ return (
+
+
+
+ );
+};
+
+interface ISimpleTextarea {
+ label?: React.ReactNode,
+ hint?: React.ReactNode,
+}
+
+export const SimpleTextarea: React.FC = (props) => {
+ const { hint, label, ...rest } = props;
+ const Input = label ? LabelTextarea : 'textarea';
+
+ return (
+
+
+
+ );
+};
+
+interface ISimpleForm {
+ className?: string,
+ onSubmit?: React.FormEventHandler,
+ acceptCharset?: string,
+ style?: React.CSSProperties,
+}
+
+export const SimpleForm: React.FC = (props) => {
+ const {
+ className,
+ children,
+ onSubmit = () => {},
+ acceptCharset = 'UTF-8',
+ ...rest
+ } = props;
+
+ const handleSubmit: React.FormEventHandler = e => {
+ onSubmit(e);
+ e.preventDefault();
+ };
+
+ return (
+
+ );
+};
+
+export const FieldsGroup: React.FC = ({ children }) => (
+ {children}
+);
+
+export const Checkbox: React.FC = (props) => (
+
+);
+
+interface IRadioGroup {
+ label?: React.ReactNode,
+ onChange?: React.ChangeEventHandler,
+}
+
+export const RadioGroup: React.FC = (props) => {
+ const { label, children, onChange } = props;
+
+ const childrenWithProps = React.Children.map(children, child =>
+ // @ts-ignore
+ React.cloneElement(child, { onChange }),
+ );
+
+ return (
+
+ );
+};
+
+interface IRadioItem {
+ label?: React.ReactNode,
+ hint?: React.ReactNode,
+ value: string,
+ checked: boolean,
+ onChange?: React.ChangeEventHandler,
+}
+
+export const RadioItem: React.FC = (props) => {
+ const { current: id } = useRef(uuidv4());
+ const { label, hint, checked = false, ...rest } = props;
+
+ return (
+
+
+
+ );
+};
+
+interface ISelectDropdown {
+ label?: React.ReactNode,
+ hint?: React.ReactNode,
+ items: Record,
+ defaultValue?: string,
+ onChange?: React.ChangeEventHandler,
+}
+
+export const SelectDropdown: React.FC = (props) => {
+ const { label, hint, items, ...rest } = props;
+
+ const optionElems = Object.keys(items).map(item => (
+
+ ));
+
+ // @ts-ignore
+ const selectElem = ;
+
+ return label ? (
+ {selectElem}
+ ) : selectElem;
+};
+
+interface ITextInput {
+ onChange?: React.ChangeEventHandler,
+ placeholder?: string,
+}
+
+export const TextInput: React.FC = props => (
+
+);
+
+export const FileChooser : React.FC = (props) => (
+
+);
+
+FileChooser.defaultProps = {
+ accept: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
+};
+
+export const FileChooserLogo: React.FC = props => (
+
+);
+
+FileChooserLogo.defaultProps = {
+ accept: ['image/svg', 'image/png'],
+};
+
+interface ICopyableInput {
+ value: string,
+}
+
+export const CopyableInput: React.FC = ({ value }) => {
+ const node = useRef(null);
+
+ const handleCopyClick: React.MouseEventHandler = () => {
+ if (!node.current) return;
+
+ node.current.select();
+ node.current.setSelectionRange(0, 99999);
+
+ document.execCommand('copy');
+ };
+
+ return (
+
+
+
+
+ );
+};
diff --git a/app/soapbox/features/preferences/index.tsx b/app/soapbox/features/preferences/index.tsx
index 54d8e4171..2574b01af 100644
--- a/app/soapbox/features/preferences/index.tsx
+++ b/app/soapbox/features/preferences/index.tsx
@@ -134,7 +134,7 @@ const Preferences = () => {
}>
) => onSelectChange(event, ['locale'])}
/>
@@ -142,7 +142,7 @@ const Preferences = () => {
}>
) => onSelectChange(event, ['displayMedia'])}
/>
diff --git a/app/soapbox/features/soapbox_config/index.js b/app/soapbox/features/soapbox_config/index.js
index 16cff42f3..8c5a8816c 100644
--- a/app/soapbox/features/soapbox_config/index.js
+++ b/app/soapbox/features/soapbox_config/index.js
@@ -21,7 +21,6 @@ import {
SimpleInput,
SimpleTextarea,
FileChooserLogo,
- FormPropTypes,
Checkbox,
} from 'soapbox/features/forms';
import ThemeToggle from 'soapbox/features/ui/components/theme_toggle';
@@ -509,7 +508,7 @@ class ColorWithPicker extends ImmutablePureComponent {
static propTypes = {
buttonId: PropTypes.string.isRequired,
- label: FormPropTypes.label,
+ label: PropTypes.node,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
}
@@ -559,7 +558,7 @@ export class IconPicker extends ImmutablePureComponent {
static propTypes = {
icons: PropTypes.object,
- label: FormPropTypes.label,
+ label: PropTypes.node,
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
}
diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx
index 25c060bfe..8f6a9d959 100644
--- a/app/soapbox/features/ui/index.tsx
+++ b/app/soapbox/features/ui/index.tsx
@@ -91,8 +91,6 @@ import {
ChatPanes,
ServerInfo,
Dashboard,
- AwaitingApproval,
- Reports,
ModerationLog,
CryptoDonate,
ScheduledStatuses,
@@ -302,8 +300,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
-
-
+
+
diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts
index cd202afb5..7deaa2651 100644
--- a/app/soapbox/features/ui/util/async-components.ts
+++ b/app/soapbox/features/ui/util/async-components.ts
@@ -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');
}
diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json
index cb8fc8414..c3698e9fd 100644
--- a/app/soapbox/locales/en.json
+++ b/app/soapbox/locales/en.json
@@ -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",
diff --git a/app/soapbox/pages/admin_page.js b/app/soapbox/pages/admin_page.js
deleted file mode 100644
index 0b9362ca9..000000000
--- a/app/soapbox/pages/admin_page.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
-import {
- AdminNav,
- LatestAccountsPanel,
-} from 'soapbox/features/ui/util/async-components';
-
-import LinkFooter from '../features/ui/components/link_footer';
-
-export default
-class AdminPage extends ImmutablePureComponent {
-
- render() {
- const { children } = this.props;
-
- return (
-
-
-
-
-
-
-
- {Component => }
-
-
-
-
-
-
-
-
-
- {Component => }
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/soapbox/pages/admin_page.tsx b/app/soapbox/pages/admin_page.tsx
new file mode 100644
index 000000000..f67454c91
--- /dev/null
+++ b/app/soapbox/pages/admin_page.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+
+import SidebarNavigation from 'soapbox/components/sidebar-navigation';
+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 (
+
+
+
+
+
+
+ {children}
+
+
+
+
+ {Component => }
+
+
+
+
+
+ );
+};
+
+export default AdminPage;
diff --git a/app/soapbox/pages/default_page.js b/app/soapbox/pages/default_page.js
deleted file mode 100644
index 05ae7d2d5..000000000
--- a/app/soapbox/pages/default_page.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-
-import SidebarNavigation from 'soapbox/components/sidebar-navigation';
-import LinkFooter from 'soapbox/features/ui/components/link_footer';
-import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
-import {
- WhoToFollowPanel,
- TrendsPanel,
- SignUpPanel,
-} from 'soapbox/features/ui/util/async-components';
-import { getFeatures } from 'soapbox/utils/features';
-
-import { Layout } from '../components/ui';
-
-const mapStateToProps = state => {
- const me = state.get('me');
- const features = getFeatures(state.get('instance'));
-
- return {
- me,
- showTrendsPanel: features.trends,
- showWhoToFollowPanel: features.suggestions,
- };
-};
-
-export default @connect(mapStateToProps)
-class DefaultPage extends ImmutablePureComponent {
-
- render() {
- const { me, children, showTrendsPanel, showWhoToFollowPanel } = this.props;
-
- return (
-
-
-
-
-
-
- {children}
-
-
-
- {!me && (
-
- {Component => }
-
- )}
- {showTrendsPanel && (
-
- {Component => }
-
- )}
- {showWhoToFollowPanel && (
-
- {Component => }
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/soapbox/pages/default_page.tsx b/app/soapbox/pages/default_page.tsx
new file mode 100644
index 000000000..90106d2c8
--- /dev/null
+++ b/app/soapbox/pages/default_page.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+
+import SidebarNavigation from 'soapbox/components/sidebar-navigation';
+import LinkFooter from 'soapbox/features/ui/components/link_footer';
+import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
+import {
+ WhoToFollowPanel,
+ TrendsPanel,
+ SignUpPanel,
+} from 'soapbox/features/ui/util/async-components';
+import { useAppSelector, useFeatures } from 'soapbox/hooks';
+
+import { Layout } from '../components/ui';
+
+const DefaultPage: React.FC = ({ children }) => {
+ const me = useAppSelector(state => state.me);
+ const features = useFeatures();
+
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+ {!me && (
+
+ {Component => }
+
+ )}
+ {features.trends && (
+
+ {Component => }
+
+ )}
+ {features.suggestions && (
+
+ {Component => }
+
+ )}
+
+
+
+ );
+};
+
+export default DefaultPage;
diff --git a/app/soapbox/pages/empty_page.js b/app/soapbox/pages/empty_page.js
deleted file mode 100644
index 042fd6c41..000000000
--- a/app/soapbox/pages/empty_page.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-import { Layout } from '../components/ui';
-
-export default class DefaultPage extends ImmutablePureComponent {
-
- render() {
- const { children } = this.props;
-
- return (
-
-
-
-
- {children}
-
-
-
-
- );
- }
-
-}
diff --git a/app/soapbox/pages/empty_page.tsx b/app/soapbox/pages/empty_page.tsx
new file mode 100644
index 000000000..3804b0812
--- /dev/null
+++ b/app/soapbox/pages/empty_page.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+
+import { Layout } from '../components/ui';
+
+const EmptyPage: React.FC = ({ children }) => {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+};
+
+export default EmptyPage;
diff --git a/app/soapbox/pages/home_page.js b/app/soapbox/pages/home_page.js
deleted file mode 100644
index 68b41961a..000000000
--- a/app/soapbox/pages/home_page.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-import { Link } from 'react-router-dom';
-
-import { getSoapboxConfig } from 'soapbox/actions/soapbox';
-import SidebarNavigation from 'soapbox/components/sidebar-navigation';
-import LinkFooter from 'soapbox/features/ui/components/link_footer';
-import {
- WhoToFollowPanel,
- TrendsPanel,
- SignUpPanel,
- PromoPanel,
- FundingPanel,
- CryptoDonatePanel,
- BirthdayPanel,
-} from 'soapbox/features/ui/util/async-components';
-// import GroupSidebarPanel from '../features/groups/sidebar_panel';
-import { getFeatures } from 'soapbox/utils/features';
-
-import Avatar from '../components/avatar';
-import { Card, CardBody, Layout } from '../components/ui';
-import ComposeFormContainer from '../features/compose/containers/compose_form_container';
-import BundleContainer from '../features/ui/containers/bundle_container';
-
-const mapStateToProps = state => {
- const me = state.get('me');
- const soapbox = getSoapboxConfig(state);
- const hasPatron = soapbox.getIn(['extensions', 'patron', 'enabled']);
- const hasCrypto = typeof soapbox.getIn(['cryptoAddresses', 0, 'ticker']) === 'string';
- const cryptoLimit = soapbox.getIn(['cryptoDonatePanel', 'limit']);
- const features = getFeatures(state.get('instance'));
-
- return {
- me,
- account: state.getIn(['accounts', me]),
- hasPatron,
- hasCrypto,
- cryptoLimit,
- features,
- };
-};
-
-export default @connect(mapStateToProps)
-class HomePage extends ImmutablePureComponent {
-
- constructor(props) {
- super(props);
- this.composeBlock = React.createRef();
- }
-
- render() {
- const { me, children, account, features, hasPatron, hasCrypto, cryptoLimit } = this.props;
-
- const acct = account ? account.get('acct') : '';
-
- return (
-
-
-
-
-
-
- {me &&
-
-
-
- }
-
- {children}
-
-
-
- {!me && (
-
- {Component => }
-
- )}
- {features.trends && (
-
- {Component => }
-
- )}
- {hasPatron && (
-
- {Component => }
-
- )}
- {hasCrypto && cryptoLimit > 0 && (
-
- {Component => }
-
- )}
-
- {Component => }
-
- {features.birthdays && (
-
- {Component => }
-
- )}
- {features.suggestions && (
-
- {Component => }
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/soapbox/pages/home_page.tsx b/app/soapbox/pages/home_page.tsx
new file mode 100644
index 000000000..31b26b657
--- /dev/null
+++ b/app/soapbox/pages/home_page.tsx
@@ -0,0 +1,106 @@
+import React, { useRef } from 'react';
+import { Link } from 'react-router-dom';
+
+import SidebarNavigation from 'soapbox/components/sidebar-navigation';
+import LinkFooter from 'soapbox/features/ui/components/link_footer';
+import {
+ WhoToFollowPanel,
+ TrendsPanel,
+ SignUpPanel,
+ PromoPanel,
+ FundingPanel,
+ CryptoDonatePanel,
+ BirthdayPanel,
+} from 'soapbox/features/ui/util/async-components';
+import { 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(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 (
+
+
+
+
+
+
+ {me && (
+
+
+
+
+
+ )}
+
+ {children}
+
+
+
+ {!me && (
+
+ {Component => }
+
+ )}
+ {features.trends && (
+
+ {Component => }
+
+ )}
+ {hasPatron && (
+
+ {Component => }
+
+ )}
+ {hasCrypto && cryptoLimit && cryptoLimit > 0 && (
+
+ {Component => }
+
+ )}
+
+ {Component => }
+
+ {features.birthdays && (
+
+ {Component => }
+
+ )}
+ {features.suggestions && (
+
+ {Component => }
+
+ )}
+
+
+
+ );
+};
+
+export default HomePage;
diff --git a/app/soapbox/utils/numbers.tsx b/app/soapbox/utils/numbers.tsx
index ba5e3cbe4..db04b30d2 100644
--- a/app/soapbox/utils/numbers.tsx
+++ b/app/soapbox/utils/numbers.tsx
@@ -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 => {