[Glitch] Profile editing: Rearranging and adding fields

Port eb848d082a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Echo
2026-03-06 13:57:06 +01:00
committed by Claire
parent a4af7a531d
commit f4fb7d6b7a
12 changed files with 566 additions and 43 deletions

View File

@@ -26,13 +26,13 @@ export const Simple: Story = {};
export const WithMaxLength: Story = {
args: {
maxLength: 20,
counterMax: 20,
},
};
export const WithRecommended: Story = {
args: {
maxLength: 20,
counterMax: 20,
recommended: true,
},
};
@@ -52,7 +52,7 @@ export const TextArea: Story = {
value={value}
onChange={setValue}
label='Label'
maxLength={100}
counterMax={100}
/>
);
},

View File

@@ -26,7 +26,7 @@ import { TextInput } from './text_input_field';
export type EmojiInputProps = {
value?: string;
onChange?: Dispatch<SetStateAction<string>>;
maxLength?: number;
counterMax?: number;
recommended?: boolean;
} & Omit<CommonFieldWrapperProps, 'wrapperClassName'>;
@@ -39,6 +39,7 @@ export const EmojiTextInputField: FC<
hint,
hasError,
maxLength,
counterMax = maxLength,
recommended,
disabled,
...otherProps
@@ -49,7 +50,7 @@ export const EmojiTextInputField: FC<
label,
hint,
hasError,
maxLength,
counterMax,
recommended,
disabled,
inputRef,
@@ -63,6 +64,7 @@ export const EmojiTextInputField: FC<
<TextInput
{...inputProps}
{...otherProps}
maxLength={maxLength}
value={value}
ref={inputRef}
/>
@@ -78,7 +80,8 @@ export const EmojiTextAreaField: FC<
value,
label,
maxLength,
recommended = false,
counterMax = maxLength,
recommended,
disabled,
hint,
hasError,
@@ -90,7 +93,7 @@ export const EmojiTextAreaField: FC<
label,
hint,
hasError,
maxLength,
counterMax,
recommended,
disabled,
inputRef: textareaRef,
@@ -104,6 +107,7 @@ export const EmojiTextAreaField: FC<
<TextArea
{...otherProps}
{...inputProps}
maxLength={maxLength}
value={value}
ref={textareaRef}
/>
@@ -126,7 +130,7 @@ const EmojiFieldWrapper: FC<
children,
disabled,
inputRef,
maxLength,
counterMax,
recommended = false,
...otherProps
}) => {
@@ -159,10 +163,10 @@ const EmojiFieldWrapper: FC<
<>
{children({ ...inputProps, onChange: handleChange })}
<EmojiPickerButton onPick={handlePickEmoji} disabled={disabled} />
{maxLength && (
{counterMax && (
<CharacterCounter
currentString={value ?? ''}
maxLength={maxLength}
maxLength={counterMax}
recommended={recommended}
id={counterId}
/>

View File

@@ -0,0 +1,29 @@
import type { FC } from 'react';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import type { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
import type { FieldData } from '@/flavours/glitch/reducers/slices/profile_edit';
import classes from '../styles.module.scss';
export const AccountField: FC<
FieldData & Partial<ReturnType<typeof useElementHandledLink>>
> = ({ onElement, ...field }) => {
return (
<>
<EmojiHTML
as='h2'
htmlString={field.name}
className={classes.fieldName}
onElement={onElement}
/>
<EmojiHTML
as='p'
htmlString={field.value}
className={classes.fieldValue}
onElement={onElement}
/>
</>
);
};

View File

@@ -21,6 +21,7 @@ import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
import { EditButton } from './components/edit_button';
import { AccountField } from './components/field';
import { AccountFieldActions } from './components/field_actions';
import { AccountEditSection } from './components/section';
import classes from './styles.module.scss';
@@ -99,6 +100,16 @@ export const AccountEdit: FC = () => {
void dispatch(fetchProfile());
}, [dispatch]);
const maxFieldCount = useAppSelector(
(state) =>
(state.server.getIn([
'server',
'configuration',
'accounts',
'max_profile_fields',
]) as number | undefined) ?? 4,
);
const handleOpenModal = useCallback(
(type: ModalType, props?: Record<string, unknown>) => {
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
@@ -111,6 +122,12 @@ export const AccountEdit: FC = () => {
const handleBioEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_BIO');
}, [handleOpenModal]);
const handleCustomFieldAdd = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT');
}, [handleOpenModal]);
const handleCustomFieldReorder = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_FIELDS_REORDER');
}, [handleOpenModal]);
const handleCustomFieldsVerifiedHelp = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_VERIFY_LINKS');
}, [handleOpenModal]);
@@ -186,25 +203,44 @@ export const AccountEdit: FC = () => {
title={messages.customFieldsTitle}
description={messages.customFieldsPlaceholder}
showDescription={!hasFields}
>
<ol>
{profile.fields.map((field) => (
<li key={field.id} className={classes.field}>
<div>
<EmojiHTML
htmlString={field.name}
className={classes.fieldName}
{...htmlHandlers}
buttons={
<>
{profile.fields.length > 1 && (
<Button
className={classes.editButton}
onClick={handleCustomFieldReorder}
>
<FormattedMessage
id='account_edit.custom_fields.reorder_button'
defaultMessage='Reorder fields'
/>
<EmojiHTML htmlString={field.value} {...htmlHandlers} />
</div>
<AccountFieldActions
item={intl.formatMessage(messages.customFieldsName)}
id={field.id}
</Button>
)}
{hasFields && (
<EditButton
item={messages.customFieldsName}
onClick={handleCustomFieldAdd}
disabled={profile.fields.length >= maxFieldCount}
/>
</li>
))}
</ol>
)}
</>
}
>
{hasFields && (
<ol>
{profile.fields.map((field) => (
<li key={field.id} className={classes.field}>
<div>
<AccountField {...field} {...htmlHandlers} />
</div>
<AccountFieldActions
item={intl.formatMessage(messages.customFieldsName)}
id={field.id}
/>
</li>
))}
</ol>
)}
<Button
onClick={handleCustomFieldsVerifiedHelp}
className={classes.verifiedLinkHelpButton}

View File

@@ -4,11 +4,14 @@ import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { EmojiTextAreaField } from '@/flavours/glitch/components/form_fields';
import type { TextAreaProps } from '@/flavours/glitch/components/form_fields/text_area_field';
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import classes from './styles.module.scss';
const messages = defineMessages({
addTitle: {
id: 'account_edit.bio_modal.add_title',
@@ -49,6 +52,12 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
}
}, [dispatch, isPending, newBio, onClose]);
// TypeScript isn't correctly picking up minRows when on the element directly.
const textAreaProps = {
autoSize: true,
minRows: 3,
} as const satisfies TextAreaProps;
return (
<ConfirmationModal
title={intl.formatMessage(bio ? messages.editTitle : messages.addTitle)}
@@ -66,9 +75,10 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
onChange={setNewBio}
aria-labelledby={titleId}
maxLength={maxLength}
className={classes.bioField}
{...textAreaProps}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus
autoSize
/>
</ConfirmationModal>
);

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@@ -6,6 +6,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable';
import { Button } from '@/flavours/glitch/components/button';
import { Callout } from '@/flavours/glitch/components/callout';
import { EmojiTextInputField } from '@/flavours/glitch/components/form_fields';
import {
removeField,
@@ -49,12 +50,18 @@ const messages = defineMessages({
id: 'account_edit.field_edit_modal.value_hint',
defaultMessage: 'E.g. “example.me”',
},
limitHeader: {
id: 'account_edit.field_edit_modal.limit_header',
defaultMessage: 'Recommended character limit exceeded',
},
save: {
id: 'account_edit.save',
defaultMessage: 'Save',
},
});
// We have two different values- the hard limit set by the server,
// and the soft limit for mobile display.
const selectFieldLimits = createAppSelector(
[
(state) =>
@@ -68,6 +75,13 @@ const selectFieldLimits = createAppSelector(
}),
);
const RECOMMENDED_LIMIT = 40;
const selectEmojiCodes = createAppSelector(
[(state) => state.custom_emojis],
(emojis) => emojis.map((emoji) => emoji.get('shortcode')).toArray(),
);
export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
onClose,
fieldKey,
@@ -86,6 +100,16 @@ export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
newLabel.length > nameLimit ||
newValue.length > valueLimit;
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 dispatch = useAppDispatch();
const handleSave = useCallback(() => {
if (disabled || isPending) {
@@ -116,6 +140,8 @@ export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
label={intl.formatMessage(messages.editLabelField)}
hint={intl.formatMessage(messages.editLabelHint)}
maxLength={nameLimit}
counterMax={RECOMMENDED_LIMIT}
recommended
/>
<EmojiTextInputField
@@ -124,7 +150,31 @@ export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
label={intl.formatMessage(messages.editValueField)}
hint={intl.formatMessage(messages.editValueHint)}
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'
title={intl.formatMessage(messages.limitHeader)}
>
<FormattedMessage
id='account_edit.field_edit_modal.limit_message'
defaultMessage='Mobile users might not see your field in full.'
/>
</Callout>
)}
</ConfirmationModal>
);
};
@@ -165,11 +215,3 @@ export const DeleteFieldModal: FC<DialogModalProps & { fieldKey: string }> = ({
</DialogModal>
);
};
export const RearrangeFieldsModal: FC<DialogModalProps> = ({ onClose }) => {
return (
<DialogModal onClose={onClose} title='Not implemented yet'>
<p>Not implemented yet</p>
</DialogModal>
);
};

View File

@@ -0,0 +1,322 @@
import type { FC, KeyboardEventHandler } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import type {
DragEndEvent,
ScreenReaderInstructions,
Announcements,
Active,
} from '@dnd-kit/core';
import {
useSensors,
useSensor,
PointerSensor,
KeyboardSensor,
DndContext,
closestCenter,
} from '@dnd-kit/core';
import {
restrictToVerticalAxis,
restrictToParentElement,
} from '@dnd-kit/modifiers';
import {
arrayMove,
sortableKeyboardCoordinates,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import { normalizeKey } from '@/flavours/glitch/components/hotkeys/utils';
import { Icon } from '@/flavours/glitch/components/icon';
import type { FieldData } from '@/flavours/glitch/reducers/slices/profile_edit';
import {
patchProfile,
selectFieldById,
} from '@/flavours/glitch/reducers/slices/profile_edit';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/flavours/glitch/store';
import DragIndicatorIcon from '@/material-icons/400-24px/drag_indicator.svg?react';
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { AccountField } from '../components/field';
import classes from './styles.module.scss';
const messages = defineMessages({
rearrangeTitle: {
id: 'account_edit.field_reorder_modal.title',
defaultMessage: 'Rearrange fields',
},
handleLabel: {
id: 'account_edit.field_reorder_modal.handle_label',
defaultMessage: 'Drag field "{item}"',
},
screenReaderInstructions: {
id: 'account_edit.field_reorder_modal.drag_instructions',
defaultMessage:
'To rearrange custom fields, press space or enter. While dragging, use the arrow keys to move the field up or down. Press space or enter again to drop the field in its new position, or press escape to cancel.',
},
onDragStart: {
id: 'account_edit.field_reorder_modal.drag_start',
defaultMessage: 'Picked up field "{item}".',
},
onDragMove: {
id: 'account_edit.field_reorder_modal.drag_move',
defaultMessage: 'Field "{item}" was moved.',
},
onDragMoveOver: {
id: 'account_edit.field_reorder_modal.drag_over',
defaultMessage: 'Field "{item}" was moved over "{over}".',
},
onDragEnd: {
id: 'account_edit.field_reorder_modal.drag_end',
defaultMessage: 'Field "{item}" was dropped.',
},
onDragCancel: {
id: 'account_edit.field_reorder_modal.drag_cancel',
defaultMessage: 'Dragging was cancelled. Field "{item}" was dropped.',
},
save: {
id: 'account_edit.save',
defaultMessage: 'Save',
},
});
const selectFields = createAppSelector(
[(state) => state.profileEdit],
({ isPending, profile }) => ({
isPending: isPending,
fields: profile?.fields ?? [],
}),
);
export const ReorderFieldsModal: FC<DialogModalProps> = ({ onClose }) => {
const intl = useIntl();
const { fields, isPending } = useAppSelector(selectFields);
const [fieldKeys, setFieldKeys] = useState<string[]>(
fields.map((field) => field.id),
);
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = useCallback(() => {
setIsDragging(true);
}, []);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setFieldKeys((prev) => {
if (!over) {
return prev;
}
const oldIndex = prev.indexOf(active.id as string);
const newIndex = prev.indexOf(over.id as string);
return arrayMove(prev, oldIndex, newIndex);
});
setIsDragging(false);
}, []);
// Combines the Escape shortcut for closing the modal and for cancelling the drag, depending on the current state.
const handleEscape: KeyboardEventHandler = useCallback(
(event) => {
const key = normalizeKey(event.key);
if (key === 'Escape') {
// Stops propagation to avoid triggering the handler in ModalRoot.
event.stopPropagation();
// Trigger the drag cancel here, since onDragCancel triggers before this handler.
if (isDragging) {
setIsDragging(false);
} else {
onClose();
}
}
},
[isDragging, onClose],
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const accessibility: {
screenReaderInstructions: ScreenReaderInstructions;
announcements: Announcements;
} = useMemo(
() => ({
screenReaderInstructions: {
draggable: intl.formatMessage(messages.screenReaderInstructions),
},
announcements: {
onDragStart({ active }) {
return intl.formatMessage(messages.onDragStart, {
item: labelFromActive(active),
});
},
onDragOver({ active, over }) {
if (over && active.id !== over.id) {
return intl.formatMessage(messages.onDragMoveOver, {
item: labelFromActive(active),
over: labelFromActive(over),
});
}
return intl.formatMessage(messages.onDragMove, {
item: labelFromActive(active),
});
},
onDragEnd({ active }) {
return intl.formatMessage(messages.onDragEnd, {
item: labelFromActive(active),
});
},
onDragCancel({ active }) {
return intl.formatMessage(messages.onDragCancel, {
item: labelFromActive(active),
});
},
},
}),
[intl],
);
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
const newFields: Pick<FieldData, 'name' | 'value'>[] = [];
for (const key of fieldKeys) {
const field = fields.find((f) => f.id === key);
if (!field) {
console.warn(`Field with id ${key} not found, closing modal.`);
onClose();
return;
}
newFields.push({ name: field.name, value: field.value });
void dispatch(patchProfile({ fields_attributes: newFields })).then(
onClose,
);
}
}, [dispatch, fieldKeys, fields, onClose]);
const emojis = useAppSelector((state) => state.custom_emojis);
return (
// Add a wrapper here in the capture phase, so that it can be intercepted before the window listener in ModalRoot.
<div onKeyUpCapture={handleEscape}>
<ConfirmationModal
onClose={onClose}
title={intl.formatMessage(messages.rearrangeTitle)}
confirm={intl.formatMessage(messages.save)}
onConfirm={handleSave}
className={classes.wrapper}
updating={isPending}
noFocusButton
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
accessibility={accessibility}
>
<SortableContext
items={fieldKeys}
strategy={verticalListSortingStrategy}
disabled={isPending}
>
<CustomEmojiProvider emojis={emojis}>
<ol>
{fieldKeys.map((key) => (
<ReorderFieldItem key={key} id={key} />
))}
</ol>
</CustomEmojiProvider>
</SortableContext>
</DndContext>
</ConfirmationModal>
</div>
);
};
const ReorderFieldItem: FC<{ id: string }> = ({ id }) => {
const intl = useIntl();
const field = useAppSelector((state) => selectFieldById(state, id));
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
newIndex,
overIndex,
} = useSortable({
id,
data: {
label: field?.name ?? id,
},
});
if (!field) {
return null;
}
const style = {
transform: CSS.Translate.toString(transform),
transition,
};
return (
<li
ref={setNodeRef}
className={classNames(
classes.field,
isDragging && classes.fieldDragging,
!isDragging && newIndex > 0 && classes.fieldNotFirst,
!isDragging && newIndex + 1 === overIndex && classes.fieldActiveUnder,
)}
style={style}
>
<Icon
icon={DragIndicatorIcon}
id='drag'
className={classes.fieldHandle}
aria-label={intl.formatMessage(messages.handleLabel, {
item: field.name,
})}
{...listeners}
{...attributes}
/>
<AccountField {...field} />
</li>
);
};
function labelFromActive(item: Pick<Active, 'id' | 'data'>) {
if (item.data.current?.label) {
return item.data.current.label as string;
}
return item.id as string;
}

View File

@@ -1,5 +1,6 @@
export * from './bio_modal';
export * from './fields_modals';
export * from './fields_reorder_modal';
export * from './name_modal';
export * from './profile_display_modal';
export * from './verified_modal';

View File

@@ -66,7 +66,7 @@ export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
value={newName}
onChange={setNewName}
aria-labelledby={titleId}
maxLength={maxLength}
counterMax={maxLength}
label=''
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus

View File

@@ -2,6 +2,12 @@
display: flex;
gap: 16px;
flex-direction: column;
font-size: 15px;
}
.bioField {
// 160px is approx the height of the modal header and footer
max-height: calc(80vh - 160px);
}
.toggleInputWrapper {
@@ -14,6 +20,66 @@
}
}
.field {
padding: 12px 0;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 12px;
touch-action: none;
position: relative;
}
.fieldNotFirst::before,
.fieldActiveUnder::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 1px;
background: var(--color-border-primary);
}
.fieldNotFirst::before {
top: 0;
}
.fieldActiveUnder::after {
bottom: -1px; // -1px to cover the border of the next field
}
.fieldHandle {
grid-row: span 2;
padding: 8px 0;
align-self: center;
border-radius: 4px;
background: var(--color-bg-secondary);
cursor: grab;
transition: background 0.2s ease-in-out;
&:hover {
background: var(--color-bg-brand-softer);
}
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
}
.fieldDragging {
cursor: grabbing;
p {
opacity: 0.7;
}
.fieldHandle {
cursor: grabbing;
background: var(--color-bg-brand-soft);
}
}
.verifiedSteps {
font-size: 15px;

View File

@@ -33,6 +33,7 @@ export const VerifiedModal: FC<DialogModalProps> = ({ onClose }) => {
/>
}
noCancelButton
wrapperClassName={classes.wrapper}
>
<FormattedMessage
id='account_edit.verified_modal.details'

View File

@@ -35,11 +35,6 @@
}
}
.fieldName {
color: var(--color-text-secondary);
font-size: 13px;
}
.verifiedLinkHelpButton {
font-size: 13px;
font-weight: 600;
@@ -127,6 +122,11 @@
&:hover {
background-color: var(--color-bg-brand-softer);
}
&:disabled {
background-color: var(--color-bg-primary);
opacity: 0.5;
}
}
svg {
@@ -141,6 +141,18 @@
--hover-icon-color: var(--color-text-on-error-base);
}
// Field component
.fieldName {
color: var(--color-text-secondary);
font-size: 13px;
}
.fieldValue {
color: var(--color-text-primary);
font-size: 15px;
}
// Item list component
.itemList {