mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Profile editing: Field inline messages (#38442)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 hasEmoji = customEmojiCodes.some((code) =>
|
||||
text.includes(`:${code}:`),
|
||||
);
|
||||
return hasLink && hasEmoji;
|
||||
}, [customEmojiCodes, newLabel, newValue]);
|
||||
const hasLinkWithoutProtocol = useMemo(
|
||||
() => isUrlWithoutProtocol(newValue),
|
||||
[newValue],
|
||||
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<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user