mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
[Glitch] Profile editing: Field inline messages
Port 57a4f6b6ec to glitch-soc
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
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 InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||||
import WarningIcon from '@/material-icons/400-24px/warning.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 { Icon } from '../icon';
|
||||||
|
|
||||||
import classes from './styles.module.css';
|
import classes from './styles.module.css';
|
||||||
|
|
||||||
export interface FieldStatus {
|
|
||||||
variant: 'error' | 'warning' | 'info' | 'success';
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconMap: Record<FieldStatus['variant'], React.FunctionComponent> = {
|
const iconMap: Record<FieldStatus['variant'], React.FunctionComponent> = {
|
||||||
error: ErrorIcon,
|
error: ErrorIcon,
|
||||||
warning: WarningIcon,
|
warning: WarningIcon,
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import type {
|
|||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
ChangeEventHandler,
|
ChangeEventHandler,
|
||||||
ComponentPropsWithoutRef,
|
ComponentPropsWithoutRef,
|
||||||
Dispatch,
|
|
||||||
FC,
|
FC,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
SetStateAction,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useCallback, useId, useRef } from 'react';
|
import { useCallback, useId, useRef } from 'react';
|
||||||
|
|
||||||
@@ -25,7 +23,7 @@ import { TextInput } from './text_input_field';
|
|||||||
|
|
||||||
export type EmojiInputProps = {
|
export type EmojiInputProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: Dispatch<SetStateAction<string>>;
|
onChange?: (newValue: string) => void;
|
||||||
counterMax?: number;
|
counterMax?: number;
|
||||||
recommended?: boolean;
|
recommended?: boolean;
|
||||||
} & Omit<CommonFieldWrapperProps, 'wrapperClassName'>;
|
} & Omit<CommonFieldWrapperProps, 'wrapperClassName'>;
|
||||||
@@ -138,12 +136,15 @@ const EmojiFieldWrapper: FC<
|
|||||||
|
|
||||||
const handlePickEmoji = useCallback(
|
const handlePickEmoji = useCallback(
|
||||||
(emoji: string) => {
|
(emoji: string) => {
|
||||||
onChange?.((prev) => {
|
if (!value) {
|
||||||
const position = inputRef.current?.selectionStart ?? prev.length;
|
onChange?.('');
|
||||||
return insertEmojiAtPosition(prev, emoji, position);
|
return;
|
||||||
});
|
}
|
||||||
|
const position = inputRef.current?.selectionStart ?? value.length;
|
||||||
|
const newValue = insertEmojiAtPosition(value, emoji, position);
|
||||||
|
onChange?.(newValue);
|
||||||
},
|
},
|
||||||
[onChange, inputRef],
|
[inputRef, value, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import type { ReactNode, FC } from 'react';
|
|||||||
import { createContext, useId } from 'react';
|
import { createContext, useId } from 'react';
|
||||||
|
|
||||||
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
|
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
|
||||||
import type { FieldStatus } from 'flavours/glitch/components/callout_inline';
|
|
||||||
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
|
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
|
||||||
|
|
||||||
import classes from './fieldset.module.scss';
|
import classes from './fieldset.module.scss';
|
||||||
|
import type { FieldStatus } from './form_field_wrapper';
|
||||||
import { getFieldStatus } from './form_field_wrapper';
|
import { getFieldStatus } from './form_field_wrapper';
|
||||||
import formFieldWrapperClasses from './form_field_wrapper.module.scss';
|
import formFieldWrapperClasses from './form_field_wrapper.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
|
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
|
||||||
import type { FieldStatus } from 'flavours/glitch/components/callout_inline';
|
|
||||||
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
|
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
|
||||||
|
|
||||||
import { FieldsetNameContext } from './fieldset';
|
import { FieldsetNameContext } from './fieldset';
|
||||||
@@ -20,11 +19,16 @@ export interface InputProps {
|
|||||||
'aria-describedby'?: string;
|
'aria-describedby'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FieldStatus {
|
||||||
|
variant: 'error' | 'warning' | 'info' | 'success';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface FieldWrapperProps {
|
interface FieldWrapperProps {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
hint?: ReactNode;
|
hint?: ReactNode;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
status?: FieldStatus['variant'] | FieldStatus;
|
status?: FieldStatus['variant'] | FieldStatus | null;
|
||||||
inputId?: string;
|
inputId?: string;
|
||||||
describedById?: string;
|
describedById?: string;
|
||||||
inputPlacement?: 'inline-start' | 'inline-end';
|
inputPlacement?: 'inline-start' | 'inline-end';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export type { FieldStatus } from './form_field_wrapper';
|
||||||
export { FormFieldWrapper } from './form_field_wrapper';
|
export { FormFieldWrapper } from './form_field_wrapper';
|
||||||
export { FormStack } from './form_stack';
|
export { FormStack } from './form_stack';
|
||||||
export { Fieldset } from './fieldset';
|
export { Fieldset } from './fieldset';
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import {
|
import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
|
||||||
forwardRef,
|
import type { FC, FocusEventHandler } from 'react';
|
||||||
useCallback,
|
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
@@ -13,7 +7,7 @@ import type { Map as ImmutableMap } from 'immutable';
|
|||||||
|
|
||||||
import { closeModal } from '@/flavours/glitch/actions/modal';
|
import { closeModal } from '@/flavours/glitch/actions/modal';
|
||||||
import { Button } from '@/flavours/glitch/components/button';
|
import { Button } from '@/flavours/glitch/components/button';
|
||||||
import { Callout } from '@/flavours/glitch/components/callout';
|
import type { FieldStatus } from '@/flavours/glitch/components/form_fields';
|
||||||
import { EmojiTextInputField } from '@/flavours/glitch/components/form_fields';
|
import { EmojiTextInputField } from '@/flavours/glitch/components/form_fields';
|
||||||
import {
|
import {
|
||||||
removeField,
|
removeField,
|
||||||
@@ -71,6 +65,26 @@ const messages = defineMessages({
|
|||||||
id: 'account_edit.field_edit_modal.discard_confirm',
|
id: 'account_edit.field_edit_modal.discard_confirm',
|
||||||
defaultMessage: 'Discard',
|
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,
|
// 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 { nameLimit, valueLimit } = useAppSelector(selectFieldLimits);
|
||||||
const isPending = useAppSelector((state) => state.profileEdit.isPending);
|
const isPending = useAppSelector((state) => state.profileEdit.isPending);
|
||||||
|
|
||||||
const disabled =
|
const [fieldStatuses, setFieldStatuses] = useState<{
|
||||||
!newLabel.trim() ||
|
label?: FieldStatus;
|
||||||
!newValue.trim() ||
|
value?: FieldStatus;
|
||||||
!isDirty ||
|
}>({});
|
||||||
!nameLimit ||
|
|
||||||
!valueLimit ||
|
|
||||||
newLabel.length > nameLimit ||
|
|
||||||
newValue.length > valueLimit;
|
|
||||||
|
|
||||||
const customEmojiCodes = useAppSelector(selectEmojiCodes);
|
const customEmojiCodes = useAppSelector(selectEmojiCodes);
|
||||||
const hasLinkAndEmoji = useMemo(() => {
|
const checkField = useCallback(
|
||||||
const text = `${newLabel} ${newValue}`; // Combine text, as we're searching it all.
|
(value: string): FieldStatus | null => {
|
||||||
const hasLink = /https?:\/\//.test(text);
|
if (!value.trim()) {
|
||||||
const hasEmoji = customEmojiCodes.some((code) =>
|
return {
|
||||||
text.includes(`:${code}:`),
|
variant: 'error',
|
||||||
);
|
message: intl.formatMessage(messages.errorBlank),
|
||||||
return hasLink && hasEmoji;
|
};
|
||||||
}, [customEmojiCodes, newLabel, newValue]);
|
}
|
||||||
const hasLinkWithoutProtocol = useMemo(
|
|
||||||
() => isUrlWithoutProtocol(newValue),
|
if (value.length > RECOMMENDED_LIMIT) {
|
||||||
[newValue],
|
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 dispatch = useAppDispatch();
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
if (disabled || isPending) {
|
if (isPending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labelStatus = checkField(newLabel);
|
||||||
|
const valueStatus = checkField(newValue);
|
||||||
|
if (labelStatus || valueStatus) {
|
||||||
|
setFieldStatuses({
|
||||||
|
label: labelStatus ?? undefined,
|
||||||
|
value: valueStatus ?? undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void dispatch(
|
void dispatch(
|
||||||
updateField({ id: fieldKey, name: newLabel, value: newValue }),
|
updateField({ id: fieldKey, name: newLabel, value: newValue }),
|
||||||
).then(() => {
|
).then(() => {
|
||||||
@@ -163,7 +229,7 @@ export const EditFieldModal = forwardRef<
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [disabled, dispatch, fieldKey, isPending, newLabel, newValue]);
|
}, [checkField, dispatch, fieldKey, isPending, newLabel, newValue]);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
@@ -198,60 +264,33 @@ export const EditFieldModal = forwardRef<
|
|||||||
confirm={intl.formatMessage(messages.save)}
|
confirm={intl.formatMessage(messages.save)}
|
||||||
onConfirm={handleSave}
|
onConfirm={handleSave}
|
||||||
updating={isPending}
|
updating={isPending}
|
||||||
disabled={disabled}
|
|
||||||
className={classes.wrapper}
|
className={classes.wrapper}
|
||||||
>
|
>
|
||||||
<EmojiTextInputField
|
<EmojiTextInputField
|
||||||
|
name='label'
|
||||||
value={newLabel}
|
value={newLabel}
|
||||||
onChange={setNewLabel}
|
onChange={setNewLabel}
|
||||||
|
onBlur={handleBlur}
|
||||||
label={intl.formatMessage(messages.editLabelField)}
|
label={intl.formatMessage(messages.editLabelField)}
|
||||||
hint={intl.formatMessage(messages.editLabelHint)}
|
hint={intl.formatMessage(messages.editLabelHint)}
|
||||||
|
status={fieldStatuses.label}
|
||||||
maxLength={nameLimit}
|
maxLength={nameLimit}
|
||||||
counterMax={RECOMMENDED_LIMIT}
|
counterMax={RECOMMENDED_LIMIT}
|
||||||
recommended
|
recommended
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EmojiTextInputField
|
<EmojiTextInputField
|
||||||
|
name='value'
|
||||||
value={newValue}
|
value={newValue}
|
||||||
onChange={setNewValue}
|
onChange={setNewValue}
|
||||||
|
onBlur={handleBlur}
|
||||||
label={intl.formatMessage(messages.editValueField)}
|
label={intl.formatMessage(messages.editValueField)}
|
||||||
hint={intl.formatMessage(messages.editValueHint)}
|
hint={intl.formatMessage(messages.editValueHint)}
|
||||||
|
status={fieldStatuses.value}
|
||||||
maxLength={valueLimit}
|
maxLength={valueLimit}
|
||||||
counterMax={RECOMMENDED_LIMIT}
|
counterMax={RECOMMENDED_LIMIT}
|
||||||
recommended
|
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>
|
</ConfirmationModal>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user