From 27b7fc4f3066f3cda343f8678441a40ad4327763 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 28 Apr 2022 11:36:45 -0500 Subject: [PATCH 01/28] Convert admin/index to TSX --- app/soapbox/features/admin/index.js | 154 --------------------------- app/soapbox/features/admin/index.tsx | 151 ++++++++++++++++++++++++++ app/soapbox/utils/numbers.tsx | 2 +- 3 files changed, 152 insertions(+), 155 deletions(-) delete mode 100644 app/soapbox/features/admin/index.js create mode 100644 app/soapbox/features/admin/index.tsx 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..948c9b5c9 --- /dev/null +++ b/app/soapbox/features/admin/index.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { defineMessages, useIntl, 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 Column from '../ui/components/column'; + +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 messages = defineMessages({ + heading: { id: 'column.admin.dashboard', defaultMessage: 'Dashboard' }, +}); + +const Dashboard: React.FC = () => { + const intl = useIntl(); + 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/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 => { From 4cf9fab13c98255ed3370836ba338b12942ddfa8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 28 Apr 2022 11:44:12 -0500 Subject: [PATCH 02/28] RegistrationModePicker: convert to TSX --- .../components/registration_mode_picker.js | 88 ------------------- .../components/registration_mode_picker.tsx | 86 ++++++++++++++++++ 2 files changed, 86 insertions(+), 88 deletions(-) delete mode 100644 app/soapbox/features/admin/components/registration_mode_picker.js create mode 100644 app/soapbox/features/admin/components/registration_mode_picker.tsx 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; From cee9d45b9df30bb54daa668c6c834b8224dc467a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 28 Apr 2022 11:47:35 -0500 Subject: [PATCH 03/28] RadioItem: fix dark mode --- app/soapbox/features/forms/index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/forms/index.js b/app/soapbox/features/forms/index.js index 5cc640a25..06bddefa9 100644 --- a/app/soapbox/features/forms/index.js +++ b/app/soapbox/features/forms/index.js @@ -5,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { FormattedMessage } from 'react-intl'; import { v4 as uuidv4 } from 'uuid'; -import { Select } from '../../components/ui'; +import { Text, Select } from '../../components/ui'; export const FormPropTypes = { label: PropTypes.oneOfType([ @@ -224,7 +224,8 @@ export class RadioItem extends ImmutablePureComponent { return (
  • From 283213b5ba6d6f39fa48ad7b9024c87cd75c6da0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 28 Apr 2022 12:43:36 -0500 Subject: [PATCH 04/28] Convert legacy forms to TypeScript --- app/soapbox/components/showable_password.js | 65 ---- app/soapbox/components/showable_password.tsx | 58 ++++ app/soapbox/features/forms/index.js | 314 ------------------- app/soapbox/features/forms/index.tsx | 264 ++++++++++++++++ app/soapbox/features/preferences/index.tsx | 4 +- app/soapbox/features/soapbox_config/index.js | 5 +- 6 files changed, 326 insertions(+), 384 deletions(-) delete mode 100644 app/soapbox/components/showable_password.js create mode 100644 app/soapbox/components/showable_password.tsx delete mode 100644 app/soapbox/features/forms/index.js create mode 100644 app/soapbox/features/forms/index.tsx 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/features/forms/index.js b/app/soapbox/features/forms/index.js deleted file mode 100644 index 06bddefa9..000000000 --- a/app/soapbox/features/forms/index.js +++ /dev/null @@ -1,314 +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 { Text, 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 }) => ( - -