import { forwardRef, useCallback, useImperativeHandle, useMemo, useState, } from 'react'; import type { FC, FocusEventHandler } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { closeModal } from '@/mastodon/actions/modal'; import { Button } from '@/mastodon/components/button'; import type { FieldStatus } from '@/mastodon/components/form_fields'; import { EmojiTextInputField } from '@/mastodon/components/form_fields'; import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis'; import { removeField, selectFieldById, updateField, } from '@/mastodon/reducers/slices/profile_edit'; import { createAppSelector, useAppDispatch, useAppSelector, } from '@/mastodon/store'; import { isUrlWithoutProtocol } from '@/mastodon/utils/checks'; import { ConfirmationModal } from '../../ui/components/confirmation_modals'; import type { DialogModalProps } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal'; import classes from './styles.module.scss'; const messages = defineMessages({ editTitle: { id: 'account_edit.field_edit_modal.edit_title', defaultMessage: 'Edit custom field', }, addTitle: { id: 'account_edit.field_edit_modal.add_title', defaultMessage: 'Add custom field', }, editLabelField: { id: 'account_edit.field_edit_modal.name_label', defaultMessage: 'Label', }, editLabelHint: { id: 'account_edit.field_edit_modal.name_hint', defaultMessage: 'E.g. “Personal website”', }, editValueField: { id: 'account_edit.field_edit_modal.value_label', defaultMessage: 'Value', }, editValueHint: { id: 'account_edit.field_edit_modal.value_hint', defaultMessage: 'E.g. “https://example.me”', }, save: { id: 'account_edit.save', defaultMessage: 'Save', }, discardMessage: { id: 'account_edit.field_edit_modal.discard_message', defaultMessage: 'You have unsaved changes. Are you sure you want to discard them?', }, discardConfirm: { id: 'account_edit.field_edit_modal.discard_confirm', defaultMessage: 'Discard', }, errorBlank: { id: 'form_error.blank', defaultMessage: 'Field cannot be blank.', }, warningLength: { id: 'account_edit.field_edit_modal.length_warning', defaultMessage: 'Recommended character limit exceeded. Mobile users might not see your field in full.', }, warningUrlEmoji: { id: 'account_edit.field_edit_modal.link_emoji_warning', defaultMessage: 'We recommend against the use of custom emoji in combination with urls. Custom fields containing both will display as text only instead of as a link, in order to prevent user confusion.', }, warningUrlProtocol: { id: 'account_edit.field_edit_modal.url_warning', defaultMessage: 'To add a link, please include {protocol} at the beginning.', description: '{protocol} is https://', }, }); // We have two different values- the hard limit set by the server, // and the soft limit for mobile display. const selectFieldLimits = createAppSelector( [(state) => state.server.server.item?.configuration.accounts], (accounts) => ({ nameLimit: accounts?.profile_field_name_limit, valueLimit: accounts?.profile_field_value_limit, }), ); const RECOMMENDED_LIMIT = 40; interface ConfirmationMessage { message: string; confirm: string; props: { fieldKey?: string; lastLabel: string; lastValue: string }; } interface ModalRef { getCloseConfirmationMessage: () => null | ConfirmationMessage; } export const EditFieldModal = forwardRef< ModalRef, DialogModalProps & { fieldKey?: string; lastLabel?: string; lastValue?: string; } >(({ onClose, fieldKey, lastLabel, lastValue }, ref) => { const intl = useIntl(); const field = useAppSelector((state) => selectFieldById(state, fieldKey)); const oldLabel = lastLabel ?? field?.name; const oldValue = lastValue ?? field?.value; const [newLabel, setNewLabel] = useState(oldLabel ?? ''); const [newValue, setNewValue] = useState(oldValue ?? ''); const isDirty = newLabel !== oldLabel || newValue !== oldValue; const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits); const isPending = useAppSelector((state) => state.profileEdit.isPending); const [fieldStatuses, setFieldStatuses] = useState<{ label?: FieldStatus; value?: FieldStatus; }>({}); const customEmojis = useCustomEmojis(); const customEmojiCodes = useMemo( () => Object.keys(customEmojis ?? {}), [customEmojis], ); const checkField = useCallback( (value: string): FieldStatus | null => { if (!value.trim()) { return { variant: 'error', message: intl.formatMessage(messages.errorBlank), }; } if (value.length > RECOMMENDED_LIMIT) { return { variant: 'warning', message: intl.formatMessage(messages.warningLength, { max: RECOMMENDED_LIMIT, }), }; } const hasLink = /https?:\/\//.test(value); const hasEmoji = customEmojiCodes.some((code) => value.includes(`:${code}:`), ); if (hasLink && hasEmoji) { return { variant: 'warning', message: intl.formatMessage(messages.warningUrlEmoji), }; } if (isUrlWithoutProtocol(value)) { return { variant: 'warning', message: intl.formatMessage(messages.warningUrlProtocol, { protocol: 'https://', }), }; } return null; }, [customEmojiCodes, intl], ); const handleBlur: FocusEventHandler = useCallback( (event) => { const { name, value } = event.target; const result = checkField(value); if (name !== 'label' && name !== 'value') { return; } setFieldStatuses((statuses) => ({ ...statuses, [name]: result ?? undefined, })); }, [checkField], ); const dispatch = useAppDispatch(); const handleSave = useCallback(() => { if (isPending) { return; } const labelStatus = checkField(newLabel); const valueStatus = checkField(newValue); if (labelStatus?.variant === 'error' || valueStatus?.variant === 'error') { setFieldStatuses({ label: labelStatus ?? undefined, value: valueStatus ?? undefined, }); return; } void dispatch( updateField({ id: fieldKey, name: newLabel, value: newValue }), ).then(() => { // Close without confirmation. dispatch( closeModal({ modalType: 'ACCOUNT_EDIT_FIELD_EDIT', ignoreFocus: false, }), ); }); }, [checkField, dispatch, fieldKey, isPending, newLabel, newValue]); useImperativeHandle( ref, () => ({ getCloseConfirmationMessage: () => { if (!newLabel || !newValue || !isDirty) { return null; } return { message: intl.formatMessage(messages.discardMessage), confirm: intl.formatMessage(messages.discardConfirm), props: { fieldKey, lastLabel: newLabel, lastValue: newValue, }, }; }, }), [fieldKey, intl, isDirty, newLabel, newValue], ); return ( ); }); EditFieldModal.displayName = 'EditFieldModal'; export const DeleteFieldModal: FC = ({ onClose, fieldKey, }) => { const isPending = useAppSelector((state) => state.profileEdit.isPending); const dispatch = useAppDispatch(); const handleDelete = useCallback(() => { void dispatch(removeField({ key: fieldKey })).then(onClose); }, [dispatch, fieldKey, onClose]); return ( } buttons={ } > ); };