Profile editing: Field inline messages (#38442)

This commit is contained in:
Echo
2026-03-27 13:41:54 +01:00
committed by GitHub
parent 9b6f877be5
commit 57a4f6b6ec
7 changed files with 122 additions and 80 deletions

View File

@@ -7,15 +7,11 @@ import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import type { FieldStatus } from '../form_fields/form_field_wrapper';
import { Icon } from '../icon';
import classes from './styles.module.css';
export interface FieldStatus {
variant: 'error' | 'warning' | 'info' | 'success';
message?: string;
}
const iconMap: Record<FieldStatus['variant'], React.FunctionComponent> = {
error: ErrorIcon,
warning: WarningIcon,

View File

@@ -2,11 +2,9 @@ import type {
ChangeEvent,
ChangeEventHandler,
ComponentPropsWithoutRef,
Dispatch,
FC,
ReactNode,
RefObject,
SetStateAction,
} from 'react';
import { useCallback, useId, useRef } from 'react';
@@ -25,7 +23,7 @@ import { TextInput } from './text_input_field';
export type EmojiInputProps = {
value?: string;
onChange?: Dispatch<SetStateAction<string>>;
onChange?: (newValue: string) => void;
counterMax?: number;
recommended?: boolean;
} & Omit<CommonFieldWrapperProps, 'wrapperClassName'>;
@@ -138,12 +136,15 @@ const EmojiFieldWrapper: FC<
const handlePickEmoji = useCallback(
(emoji: string) => {
onChange?.((prev) => {
const position = inputRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
if (!value) {
onChange?.('');
return;
}
const position = inputRef.current?.selectionStart ?? value.length;
const newValue = insertEmojiAtPosition(value, emoji, position);
onChange?.(newValue);
},
[onChange, inputRef],
[inputRef, value, onChange],
);
const handleChange = useCallback(

View File

@@ -4,10 +4,10 @@ import type { ReactNode, FC } from 'react';
import { createContext, useId } from 'react';
import { A11yLiveRegion } from 'mastodon/components/a11y_live_region';
import type { FieldStatus } from 'mastodon/components/callout_inline';
import { CalloutInline } from 'mastodon/components/callout_inline';
import classes from './fieldset.module.scss';
import type { FieldStatus } from './form_field_wrapper';
import { getFieldStatus } from './form_field_wrapper';
import formFieldWrapperClasses from './form_field_wrapper.module.scss';

View File

@@ -8,7 +8,6 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { A11yLiveRegion } from 'mastodon/components/a11y_live_region';
import type { FieldStatus } from 'mastodon/components/callout_inline';
import { CalloutInline } from 'mastodon/components/callout_inline';
import { FieldsetNameContext } from './fieldset';
@@ -20,11 +19,16 @@ export interface InputProps {
'aria-describedby'?: string;
}
export interface FieldStatus {
variant: 'error' | 'warning' | 'info' | 'success';
message?: string;
}
interface FieldWrapperProps {
label: ReactNode;
hint?: ReactNode;
required?: boolean;
status?: FieldStatus['variant'] | FieldStatus;
status?: FieldStatus['variant'] | FieldStatus | null;
inputId?: string;
describedById?: string;
inputPlacement?: 'inline-start' | 'inline-end';

View File

@@ -1,3 +1,4 @@
export type { FieldStatus } from './form_field_wrapper';
export { FormFieldWrapper } from './form_field_wrapper';
export { FormStack } from './form_stack';
export { Fieldset } from './fieldset';

View File

@@ -1,11 +1,5 @@
import {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import type { FC } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
import type { FC, FocusEventHandler } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@@ -13,7 +7,7 @@ import type { Map as ImmutableMap } from 'immutable';
import { closeModal } from '@/mastodon/actions/modal';
import { Button } from '@/mastodon/components/button';
import { Callout } from '@/mastodon/components/callout';
import type { FieldStatus } from '@/mastodon/components/form_fields';
import { EmojiTextInputField } from '@/mastodon/components/form_fields';
import {
removeField,
@@ -71,6 +65,26 @@ const messages = defineMessages({
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,
@@ -124,34 +138,86 @@ export const EditFieldModal = forwardRef<
const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits);
const isPending = useAppSelector((state) => state.profileEdit.isPending);
const disabled =
!newLabel.trim() ||
!newValue.trim() ||
!isDirty ||
!nameLimit ||
!valueLimit ||
newLabel.length > nameLimit ||
newValue.length > valueLimit;
const [fieldStatuses, setFieldStatuses] = useState<{
label?: FieldStatus;
value?: FieldStatus;
}>({});
const customEmojiCodes = useAppSelector(selectEmojiCodes);
const hasLinkAndEmoji = useMemo(() => {
const text = `${newLabel} ${newValue}`; // Combine text, as we're searching it all.
const hasLink = /https?:\/\//.test(text);
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) =>
text.includes(`:${code}:`),
value.includes(`:${code}:`),
);
return hasLink && hasEmoji;
}, [customEmojiCodes, newLabel, newValue]);
const hasLinkWithoutProtocol = useMemo(
() => isUrlWithoutProtocol(newValue),
[newValue],
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<HTMLInputElement> = 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 (disabled || isPending) {
if (isPending) {
return;
}
const labelStatus = checkField(newLabel);
const valueStatus = checkField(newValue);
if (labelStatus || valueStatus) {
setFieldStatuses({
label: labelStatus ?? undefined,
value: valueStatus ?? undefined,
});
return;
}
void dispatch(
updateField({ id: fieldKey, name: newLabel, value: newValue }),
).then(() => {
@@ -163,7 +229,7 @@ export const EditFieldModal = forwardRef<
}),
);
});
}, [disabled, dispatch, fieldKey, isPending, newLabel, newValue]);
}, [checkField, dispatch, fieldKey, isPending, newLabel, newValue]);
useImperativeHandle(
ref,
@@ -198,60 +264,33 @@ export const EditFieldModal = forwardRef<
confirm={intl.formatMessage(messages.save)}
onConfirm={handleSave}
updating={isPending}
disabled={disabled}
className={classes.wrapper}
>
<EmojiTextInputField
name='label'
value={newLabel}
onChange={setNewLabel}
onBlur={handleBlur}
label={intl.formatMessage(messages.editLabelField)}
hint={intl.formatMessage(messages.editLabelHint)}
status={fieldStatuses.label}
maxLength={nameLimit}
counterMax={RECOMMENDED_LIMIT}
recommended
/>
<EmojiTextInputField
name='value'
value={newValue}
onChange={setNewValue}
onBlur={handleBlur}
label={intl.formatMessage(messages.editValueField)}
hint={intl.formatMessage(messages.editValueHint)}
status={fieldStatuses.value}
maxLength={valueLimit}
counterMax={RECOMMENDED_LIMIT}
recommended
/>
{hasLinkAndEmoji && (
<Callout variant='warning'>
<FormattedMessage
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.'
/>
</Callout>
)}
{(newLabel.length > RECOMMENDED_LIMIT ||
newValue.length > RECOMMENDED_LIMIT) && (
<Callout variant='warning'>
<FormattedMessage
id='account_edit.field_edit_modal.limit_warning'
defaultMessage='Recommended character limit exceeded. Mobile users might not see your field in full.'
/>
</Callout>
)}
{hasLinkWithoutProtocol && (
<Callout variant='warning'>
<FormattedMessage
id='account_edit.field_edit_modal.url_warning'
defaultMessage='To add a link, please include {protocol} at the beginning.'
description='{protocol} is https://'
values={{
protocol: <code>https://</code>,
}}
/>
</Callout>
)}
</ConfirmationModal>
);
});

View File

@@ -173,7 +173,7 @@
"account_edit.field_edit_modal.discard_confirm": "Discard",
"account_edit.field_edit_modal.discard_message": "You have unsaved changes. Are you sure you want to discard them?",
"account_edit.field_edit_modal.edit_title": "Edit custom field",
"account_edit.field_edit_modal.limit_warning": "Recommended character limit exceeded. Mobile users might not see your field in full.",
"account_edit.field_edit_modal.length_warning": "Recommended character limit exceeded. Mobile users might not see your field in full.",
"account_edit.field_edit_modal.link_emoji_warning": "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.",
"account_edit.field_edit_modal.name_hint": "E.g. “Personal website”",
"account_edit.field_edit_modal.name_label": "Label",
@@ -700,6 +700,7 @@
"footer.source_code": "View source code",
"footer.status": "Status",
"footer.terms_of_service": "Terms of service",
"form_error.blank": "Field cannot be blank.",
"form_field.optional": "(optional)",
"generic.saved": "Saved",
"getting_started.heading": "Getting started",