mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Profile editing: Rearranging and adding fields (#38083)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import type { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import type { FieldData } from '@/mastodon/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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/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}
|
||||
|
||||
@@ -4,11 +4,14 @@ import type { FC } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { EmojiTextAreaField } from '@/mastodon/components/form_fields';
|
||||
import type { TextAreaProps } from '@/mastodon/components/form_fields/text_area_field';
|
||||
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/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>
|
||||
);
|
||||
|
||||
@@ -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 '@/mastodon/components/button';
|
||||
import { Callout } from '@/mastodon/components/callout';
|
||||
import { EmojiTextInputField } from '@/mastodon/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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 '@/mastodon/components/emoji/context';
|
||||
import { normalizeKey } from '@/mastodon/components/hotkeys/utils';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import type { FieldData } from '@/mastodon/reducers/slices/profile_edit';
|
||||
import {
|
||||
patchProfile,
|
||||
selectFieldById,
|
||||
} from '@/mastodon/reducers/slices/profile_edit';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export const VerifiedModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||
/>
|
||||
}
|
||||
noCancelButton
|
||||
wrapperClassName={classes.wrapper}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='account_edit.verified_modal.details'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"account_edit.column_title": "Edit Profile",
|
||||
"account_edit.custom_fields.name": "field",
|
||||
"account_edit.custom_fields.placeholder": "Add your pronouns, external links, or anything else you’d like to share.",
|
||||
"account_edit.custom_fields.reorder_button": "Reorder fields",
|
||||
"account_edit.custom_fields.tip_content": "You can easily add credibility to your Mastodon account by verifying links to any websites you own.",
|
||||
"account_edit.custom_fields.tip_title": "Tip: Adding verified links",
|
||||
"account_edit.custom_fields.title": "Custom fields",
|
||||
@@ -167,10 +168,21 @@
|
||||
"account_edit.field_delete_modal.title": "Delete custom field?",
|
||||
"account_edit.field_edit_modal.add_title": "Add custom field",
|
||||
"account_edit.field_edit_modal.edit_title": "Edit custom field",
|
||||
"account_edit.field_edit_modal.limit_header": "Recommended character limit exceeded",
|
||||
"account_edit.field_edit_modal.limit_message": "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",
|
||||
"account_edit.field_edit_modal.value_hint": "E.g. “example.me”",
|
||||
"account_edit.field_edit_modal.value_label": "Value",
|
||||
"account_edit.field_reorder_modal.drag_cancel": "Dragging was cancelled. Field \"{item}\" was dropped.",
|
||||
"account_edit.field_reorder_modal.drag_end": "Field \"{item}\" was dropped.",
|
||||
"account_edit.field_reorder_modal.drag_instructions": "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.",
|
||||
"account_edit.field_reorder_modal.drag_move": "Field \"{item}\" was moved.",
|
||||
"account_edit.field_reorder_modal.drag_over": "Field \"{item}\" was moved over \"{over}\".",
|
||||
"account_edit.field_reorder_modal.drag_start": "Picked up field \"{item}\".",
|
||||
"account_edit.field_reorder_modal.handle_label": "Drag field \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Rearrange fields",
|
||||
"account_edit.name_modal.add_title": "Add display name",
|
||||
"account_edit.name_modal.edit_title": "Edit display name",
|
||||
"account_edit.profile_tab.button_label": "Customize",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M360-160q-33 0-56.5-23.5T280-240q0-33 23.5-56.5T360-320q33 0 56.5 23.5T440-240q0 33-23.5 56.5T360-160Zm240 0q-33 0-56.5-23.5T520-240q0-33 23.5-56.5T600-320q33 0 56.5 23.5T680-240q0 33-23.5 56.5T600-160ZM360-400q-33 0-56.5-23.5T280-480q0-33 23.5-56.5T360-560q33 0 56.5 23.5T440-480q0 33-23.5 56.5T360-400Zm240 0q-33 0-56.5-23.5T520-480q0-33 23.5-56.5T600-560q33 0 56.5 23.5T680-480q0 33-23.5 56.5T600-400ZM360-640q-33 0-56.5-23.5T280-720q0-33 23.5-56.5T360-800q33 0 56.5 23.5T440-720q0 33-23.5 56.5T360-640Zm240 0q-33 0-56.5-23.5T520-720q0-33 23.5-56.5T600-800q33 0 56.5 23.5T680-720q0 33-23.5 56.5T600-640Z"/></svg>
|
||||
|
After Width: | Height: | Size: 712 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M360-160q-33 0-56.5-23.5T280-240q0-33 23.5-56.5T360-320q33 0 56.5 23.5T440-240q0 33-23.5 56.5T360-160Zm240 0q-33 0-56.5-23.5T520-240q0-33 23.5-56.5T600-320q33 0 56.5 23.5T680-240q0 33-23.5 56.5T600-160ZM360-400q-33 0-56.5-23.5T280-480q0-33 23.5-56.5T360-560q33 0 56.5 23.5T440-480q0 33-23.5 56.5T360-400Zm240 0q-33 0-56.5-23.5T520-480q0-33 23.5-56.5T600-560q33 0 56.5 23.5T680-480q0 33-23.5 56.5T600-400ZM360-640q-33 0-56.5-23.5T280-720q0-33 23.5-56.5T360-800q33 0 56.5 23.5T440-720q0 33-23.5 56.5T360-640Zm240 0q-33 0-56.5-23.5T520-720q0-33 23.5-56.5T600-800q33 0 56.5 23.5T680-720q0 33-23.5 56.5T600-640Z"/></svg>
|
||||
|
After Width: | Height: | Size: 712 B |
@@ -42,6 +42,7 @@
|
||||
"dependencies": {
|
||||
"@csstools/stylelint-formatter-github": "^2.0.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@formatjs/intl-pluralrules": "^5.4.4",
|
||||
|
||||
14
yarn.lock
14
yarn.lock
@@ -1917,6 +1917,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/modifiers@npm:^9.0.0":
|
||||
version: 9.0.0
|
||||
resolution: "@dnd-kit/modifiers@npm:9.0.0"
|
||||
dependencies:
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
tslib: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
"@dnd-kit/core": ^6.3.0
|
||||
react: ">=16.8.0"
|
||||
checksum: 10c0/ca8cc9da8296df10774d779c1611074dc327ccc3c49041c102111c98c7f2b2b73b6af5209c0eef6b2fe978ac63dc2a985efa87c85a8d786577304bd2e64cee1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/sortable@npm:^10.0.0":
|
||||
version: 10.0.0
|
||||
resolution: "@dnd-kit/sortable@npm:10.0.0"
|
||||
@@ -2804,6 +2817,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@csstools/stylelint-formatter-github": "npm:^2.0.0"
|
||||
"@dnd-kit/core": "npm:^6.1.0"
|
||||
"@dnd-kit/modifiers": "npm:^9.0.0"
|
||||
"@dnd-kit/sortable": "npm:^10.0.0"
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
"@eslint/js": "npm:^9.39.2"
|
||||
|
||||
Reference in New Issue
Block a user