mirror of
https://github.com/mastodon/mastodon.git
synced 2026-08-24 23:14:59 -05:00
Profile editing: Rearranging and adding fields (#38083)
Some checks failed
Bundler Audit / security (push) Has been cancelled
Check i18n / check-i18n (push) Has been cancelled
Chromatic / Check for relevant changes (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Crowdin / Upload translations / upload-translations (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
CSS Linting / lint (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Chromatic / Run Chromatic (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.19.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Has been cancelled
Build nightly container image / compute-suffix (push) Has been cancelled
Build nightly container image / build-image (push) Has been cancelled
Build nightly container image / build-image-streaming (push) Has been cancelled
Some checks failed
Bundler Audit / security (push) Has been cancelled
Check i18n / check-i18n (push) Has been cancelled
Chromatic / Check for relevant changes (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Crowdin / Upload translations / upload-translations (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
CSS Linting / lint (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Chromatic / Run Chromatic (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.19.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.29) (push) Has been cancelled
Build nightly container image / compute-suffix (push) Has been cancelled
Build nightly container image / build-image (push) Has been cancelled
Build nightly container image / build-image-streaming (push) Has been cancelled
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 |
Reference in New Issue
Block a user