diff --git a/app/soapbox/components/blurhash.js b/app/soapbox/components/blurhash.js deleted file mode 100644 index 7a7620699..000000000 --- a/app/soapbox/components/blurhash.js +++ /dev/null @@ -1,68 +0,0 @@ -// @ts-check - -import { decode } from 'blurhash'; -import PropTypes from 'prop-types'; -import React, { useRef, useEffect } from 'react'; - -/** - * @typedef BlurhashPropsBase - * @property {string?} hash Hash to render - * @property {number} width - * Width of the blurred region in pixels. Defaults to 32 - * @property {number} [height] - * Height of the blurred region in pixels. Defaults to width - * @property {boolean} [dummy] - * Whether dummy mode is enabled. If enabled, nothing is rendered - * and canvas left untouched - */ - -/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */ - -/** - * Component that is used to render blurred of blurhash string - * - * @param {BlurhashProps} param1 Props of the component - * @returns Canvas which will render blurred region element to embed - */ -function Blurhash({ - hash, - width = 32, - height = width, - dummy = false, - ...canvasProps -}) { - const canvasRef = /** @type {import('react').MutableRefObject} */ (useRef()); - - useEffect(() => { - const { current: canvas } = canvasRef; - - // resets canvas - canvas.width = canvas.width; // eslint-disable-line no-self-assign - - if (dummy || !hash) return; - - try { - const pixels = decode(hash, width, height); - const ctx = canvas.getContext('2d'); - const imageData = new ImageData(pixels, width, height); - - // @ts-ignore - ctx.putImageData(imageData, 0, 0); - } catch (err) { - console.error('Blurhash decoding failure', { err, hash }); - } - }, [dummy, hash, width, height]); - - return ( - - ); -} - -Blurhash.propTypes = { - hash: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - dummy: PropTypes.bool, -}; - -export default React.memo(Blurhash); diff --git a/app/soapbox/components/blurhash.tsx b/app/soapbox/components/blurhash.tsx new file mode 100644 index 000000000..cdf5cbf9e --- /dev/null +++ b/app/soapbox/components/blurhash.tsx @@ -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 = ({ + hash, + width = 32, + height = width, + dummy = false, + ...canvasProps +}) => { + const canvasRef = useRef(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 ( + + ); +}; + +export default React.memo(Blurhash); 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/hashtag.js b/app/soapbox/components/hashtag.tsx similarity index 84% rename from app/soapbox/components/hashtag.js rename to app/soapbox/components/hashtag.tsx index 1b19bbff3..06d44d467 100644 --- a/app/soapbox/components/hashtag.js +++ b/app/soapbox/components/hashtag.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import { useSelector } from 'react-redux'; import { Sparklines, SparklinesCurve } from 'react-sparklines'; @@ -11,7 +10,13 @@ import { shortNumberFormat } from '../utils/numbers'; import Permalink from './permalink'; import { HStack, Stack, Text } from './ui'; -const Hashtag = ({ hashtag }) => { +import type { Map as ImmutableMap } from 'immutable'; + +interface IHashtag { + hashtag: ImmutableMap, +} + +const Hashtag: React.FC = ({ hashtag }) => { const count = Number(hashtag.getIn(['history', 0, 'accounts'])); const brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor')); @@ -41,7 +46,7 @@ const Hashtag = ({ hashtag }) => { day.get('uses')).toArray()} + data={hashtag.get('history').reverse().map((day: ImmutableMap) => day.get('uses')).toArray()} > @@ -51,8 +56,4 @@ const Hashtag = ({ hashtag }) => { ); }; -Hashtag.propTypes = { - hashtag: ImmutablePropTypes.map.isRequired, -}; - export default Hashtag; diff --git a/app/soapbox/components/hover_ref_wrapper.js b/app/soapbox/components/hover_ref_wrapper.tsx similarity index 73% rename from app/soapbox/components/hover_ref_wrapper.js rename to app/soapbox/components/hover_ref_wrapper.tsx index 64af2a370..239c27580 100644 --- a/app/soapbox/components/hover_ref_wrapper.js +++ b/app/soapbox/components/hover_ref_wrapper.tsx @@ -1,5 +1,4 @@ import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; import React, { useRef } from 'react'; import { useDispatch } from 'react-redux'; @@ -13,10 +12,16 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => { dispatch(openProfileHoverCard(ref, accountId)); }, 600); -export const HoverRefWrapper = ({ accountId, children, inline }) => { +interface IHoverRefWrapper { + accountId: string, + inline: boolean, +} + +/** Makes a profile hover card appear when the wrapped element is hovered. */ +export const HoverRefWrapper: React.FC = ({ accountId, children, inline = false }) => { const dispatch = useDispatch(); - const ref = useRef(); - const Elem = inline ? 'span' : 'div'; + const ref = useRef(); + const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div'; const handleMouseEnter = () => { if (!isMobile(window.innerWidth)) { @@ -36,6 +41,7 @@ export const HoverRefWrapper = ({ accountId, children, inline }) => { return ( { ); }; -HoverRefWrapper.propTypes = { - accountId: PropTypes.string, - children: PropTypes.node, - inline: PropTypes.bool, -}; - -HoverRefWrapper.defaultProps = { - inline: false, -}; - export { HoverRefWrapper as default, showProfileHoverCard }; 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/list.js b/app/soapbox/components/list.tsx similarity index 83% rename from app/soapbox/components/list.js rename to app/soapbox/components/list.tsx index 02905efea..404fb8aa5 100644 --- a/app/soapbox/components/list.js +++ b/app/soapbox/components/list.tsx @@ -1,19 +1,20 @@ import classNames from 'classnames'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { v4 as uuidv4 } from 'uuid'; import Icon from './icon'; -const List = ({ children }) => ( +const List: React.FC = ({ children }) => (
    {children}
    ); -List.propTypes = { - children: PropTypes.node, -}; +interface IListItem { + label: React.ReactNode, + hint?: React.ReactNode, + onClick?: () => void, +} -const ListItem = ({ label, hint, children, onClick }) => { +const ListItem: React.FC = ({ label, hint, children, onClick }) => { const id = uuidv4(); const domId = `list-group-${id}`; @@ -60,11 +61,4 @@ const ListItem = ({ label, hint, children, onClick }) => { ); }; -ListItem.propTypes = { - label: PropTypes.node.isRequired, - hint: PropTypes.node, - children: PropTypes.node, - onClick: PropTypes.func, -}; - export { List as default, ListItem }; 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/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index ed250ff4d..650ddd6bc 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -646,7 +646,11 @@ class StatusActionBar extends ImmutablePureComponent {features.quotePosts && me ? ( - + {reblogButton} ) : ( 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/layout/layout.tsx b/app/soapbox/components/ui/layout/layout.tsx index d22afe0a1..7c6401def 100644 --- a/app/soapbox/components/ui/layout/layout.tsx +++ b/app/soapbox/components/ui/layout/layout.tsx @@ -9,7 +9,7 @@ interface LayoutType extends React.FC { } const Layout: LayoutType = ({ children }) => ( -
    +
    {children}
    @@ -27,7 +27,7 @@ const Sidebar: React.FC = ({ children }) => ( const Main: React.FC> = ({ children, className }) => (
    {children} 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..6d3da6009 --- /dev/null +++ b/app/soapbox/features/admin/components/report.tsx @@ -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; +} + +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 ( -
    -
    - - {media} -
    -
    - -
    -
    - ); - } - -} 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 ( +
    +
    + + {media} +
    +
    + +
    +
    + ); +}; + +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 }) => ( - -