From 131b9acfab0a750bf006f58360ea15f96c564379 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 27 Mar 2026 13:41:54 +0100 Subject: [PATCH] [Glitch] Profile editing: Field inline messages Port 57a4f6b6eca3ddfd18e28fb00bf698a9e5dceefb to glitch-soc Signed-off-by: Claire --- .../components/callout_inline/index.tsx | 6 +- .../form_fields/emoji_text_field.tsx | 17 +- .../components/form_fields/fieldset.tsx | 2 +- .../form_fields/form_field_wrapper.tsx | 8 +- .../glitch/components/form_fields/index.ts | 1 + .../account_edit/modals/fields_modals.tsx | 165 +++++++++++------- 6 files changed, 120 insertions(+), 79 deletions(-) diff --git a/app/javascript/flavours/glitch/components/callout_inline/index.tsx b/app/javascript/flavours/glitch/components/callout_inline/index.tsx index e2e6791963..b8c571e4ea 100644 --- a/app/javascript/flavours/glitch/components/callout_inline/index.tsx +++ b/app/javascript/flavours/glitch/components/callout_inline/index.tsx @@ -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 = { error: ErrorIcon, warning: WarningIcon, diff --git a/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx b/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx index 5eb3e24ae4..7e0b45abb4 100644 --- a/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx @@ -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>; + onChange?: (newValue: string) => void; counterMax?: number; recommended?: boolean; } & Omit; @@ -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( diff --git a/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx b/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx index 6a8aa8cccd..68b175c9d8 100644 --- a/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx @@ -4,10 +4,10 @@ import type { ReactNode, FC } from 'react'; import { createContext, useId } from 'react'; 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 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'; diff --git a/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx b/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx index 24ec3764ee..7d3b91791c 100644 --- a/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx @@ -8,7 +8,6 @@ import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; 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 { 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'; diff --git a/app/javascript/flavours/glitch/components/form_fields/index.ts b/app/javascript/flavours/glitch/components/form_fields/index.ts index 97fb90cf56..5f89c03ece 100644 --- a/app/javascript/flavours/glitch/components/form_fields/index.ts +++ b/app/javascript/flavours/glitch/components/form_fields/index.ts @@ -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'; diff --git a/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx b/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx index a82ef854f0..0432c08471 100644 --- a/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx +++ b/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx @@ -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 '@/flavours/glitch/actions/modal'; 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 { 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 = 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} > - - {hasLinkAndEmoji && ( - - - - )} - - {(newLabel.length > RECOMMENDED_LIMIT || - newValue.length > RECOMMENDED_LIMIT) && ( - - - - )} - - {hasLinkWithoutProtocol && ( - - https://, - }} - /> - - )} ); });