Profile editing: Custom fields (deleting, editing) (#38066)
This commit is contained in:
parent
6d9c16e4a1
commit
dae0926c1f
@ -10,6 +10,7 @@ export {
|
|||||||
type ComboboxItemState,
|
type ComboboxItemState,
|
||||||
} from './combobox_field';
|
} from './combobox_field';
|
||||||
export { CopyLinkField } from './copy_link_field';
|
export { CopyLinkField } from './copy_link_field';
|
||||||
|
export { EmojiTextInputField, EmojiTextAreaField } from './emoji_text_field';
|
||||||
export { RadioButtonField, RadioButton } from './radio_button_field';
|
export { RadioButtonField, RadioButton } from './radio_button_field';
|
||||||
export { ToggleField, Toggle } from './toggle_field';
|
export { ToggleField, Toggle } from './toggle_field';
|
||||||
export { SelectField, Select } from './select_field';
|
export { SelectField, Select } from './select_field';
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
|
||||||
|
|
||||||
import classes from '../styles.module.scss';
|
|
||||||
|
|
||||||
export const CharCounter = polymorphicForwardRef<
|
|
||||||
'p',
|
|
||||||
{ currentLength: number; maxLength: number }
|
|
||||||
>(({ currentLength, maxLength, as: Component = 'p' }, ref) => (
|
|
||||||
<Component
|
|
||||||
ref={ref}
|
|
||||||
className={classNames(
|
|
||||||
classes.counter,
|
|
||||||
currentLength > maxLength && classes.counterError,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FormattedMessage
|
|
||||||
id='account_edit.char_counter'
|
|
||||||
defaultMessage='{currentLength}/{maxLength} characters'
|
|
||||||
values={{ currentLength, maxLength }}
|
|
||||||
/>
|
|
||||||
</Component>
|
|
||||||
));
|
|
||||||
CharCounter.displayName = 'CharCounter';
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
|
|
||||||
import { isPlainObject } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
|
|
||||||
|
|
||||||
export const EmojiPicker: FC<{ onPick: (emoji: string) => void }> = ({
|
|
||||||
onPick,
|
|
||||||
}) => {
|
|
||||||
const handlePick = useCallback(
|
|
||||||
(emoji: unknown) => {
|
|
||||||
if (isPlainObject(emoji)) {
|
|
||||||
if ('native' in emoji && typeof emoji.native === 'string') {
|
|
||||||
onPick(emoji.native);
|
|
||||||
} else if (
|
|
||||||
'shortcode' in emoji &&
|
|
||||||
typeof emoji.shortcode === 'string'
|
|
||||||
) {
|
|
||||||
onPick(`:${emoji.shortcode}:`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onPick],
|
|
||||||
);
|
|
||||||
return <EmojiPickerDropdown onPickEmoji={handlePick} />;
|
|
||||||
};
|
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { openModal } from '@/mastodon/actions/modal';
|
||||||
|
import { useAppDispatch } from '@/mastodon/store';
|
||||||
|
|
||||||
|
import { EditButton, DeleteIconButton } from './edit_button';
|
||||||
|
|
||||||
|
export const AccountFieldActions: FC<{ item: string; id: string }> = ({
|
||||||
|
item,
|
||||||
|
id,
|
||||||
|
}) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
||||||
|
modalProps: { fieldKey: id },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [dispatch, id]);
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'ACCOUNT_EDIT_FIELD_DELETE',
|
||||||
|
modalProps: { fieldKey: id },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EditButton item={item} edit onClick={handleEdit} />
|
||||||
|
<DeleteIconButton item={item} onClick={handleDelete} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -9,6 +9,7 @@ import type { ModalType } from '@/mastodon/actions/modal';
|
|||||||
import { openModal } from '@/mastodon/actions/modal';
|
import { openModal } from '@/mastodon/actions/modal';
|
||||||
import { Avatar } from '@/mastodon/components/avatar';
|
import { Avatar } from '@/mastodon/components/avatar';
|
||||||
import { Button } from '@/mastodon/components/button';
|
import { Button } from '@/mastodon/components/button';
|
||||||
|
import { DismissibleCallout } from '@/mastodon/components/callout/dismissible';
|
||||||
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||||
@ -20,6 +21,7 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
|||||||
|
|
||||||
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
|
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
|
||||||
import { EditButton } from './components/edit_button';
|
import { EditButton } from './components/edit_button';
|
||||||
|
import { AccountFieldActions } from './components/field_actions';
|
||||||
import { AccountEditSection } from './components/section';
|
import { AccountEditSection } from './components/section';
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
@ -54,6 +56,14 @@ export const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Add your pronouns, external links, or anything else you’d like to share.',
|
'Add your pronouns, external links, or anything else you’d like to share.',
|
||||||
},
|
},
|
||||||
|
customFieldsName: {
|
||||||
|
id: 'account_edit.custom_fields.name',
|
||||||
|
defaultMessage: 'field',
|
||||||
|
},
|
||||||
|
customFieldsTipTitle: {
|
||||||
|
id: 'account_edit.custom_fields.tip_title',
|
||||||
|
defaultMessage: 'Tip: Adding verified links',
|
||||||
|
},
|
||||||
featuredHashtagsTitle: {
|
featuredHashtagsTitle: {
|
||||||
id: 'account_edit.featured_hashtags.title',
|
id: 'account_edit.featured_hashtags.title',
|
||||||
defaultMessage: 'Featured hashtags',
|
defaultMessage: 'Featured hashtags',
|
||||||
@ -101,6 +111,9 @@ export const AccountEdit: FC = () => {
|
|||||||
const handleBioEdit = useCallback(() => {
|
const handleBioEdit = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_BIO');
|
handleOpenModal('ACCOUNT_EDIT_BIO');
|
||||||
}, [handleOpenModal]);
|
}, [handleOpenModal]);
|
||||||
|
const handleCustomFieldsVerifiedHelp = useCallback(() => {
|
||||||
|
handleOpenModal('ACCOUNT_EDIT_VERIFY_LINKS');
|
||||||
|
}, [handleOpenModal]);
|
||||||
const handleProfileDisplayEdit = useCallback(() => {
|
const handleProfileDisplayEdit = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_PROFILE_DISPLAY');
|
handleOpenModal('ACCOUNT_EDIT_PROFILE_DISPLAY');
|
||||||
}, [handleOpenModal]);
|
}, [handleOpenModal]);
|
||||||
@ -123,6 +136,7 @@ export const AccountEdit: FC = () => {
|
|||||||
const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
|
const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
|
||||||
const hasName = !!profile.displayName;
|
const hasName = !!profile.displayName;
|
||||||
const hasBio = !!profile.bio;
|
const hasBio = !!profile.bio;
|
||||||
|
const hasFields = profile.fields.length > 0;
|
||||||
const hasTags = profile.featuredTags.length > 0;
|
const hasTags = profile.featuredTags.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -171,8 +185,48 @@ export const AccountEdit: FC = () => {
|
|||||||
<AccountEditSection
|
<AccountEditSection
|
||||||
title={messages.customFieldsTitle}
|
title={messages.customFieldsTitle}
|
||||||
description={messages.customFieldsPlaceholder}
|
description={messages.customFieldsPlaceholder}
|
||||||
showDescription
|
showDescription={!hasFields}
|
||||||
/>
|
>
|
||||||
|
<ol>
|
||||||
|
{profile.fields.map((field) => (
|
||||||
|
<li key={field.id} className={classes.field}>
|
||||||
|
<div>
|
||||||
|
<EmojiHTML
|
||||||
|
htmlString={field.name}
|
||||||
|
className={classes.fieldName}
|
||||||
|
{...htmlHandlers}
|
||||||
|
/>
|
||||||
|
<EmojiHTML htmlString={field.value} {...htmlHandlers} />
|
||||||
|
</div>
|
||||||
|
<AccountFieldActions
|
||||||
|
item={intl.formatMessage(messages.customFieldsName)}
|
||||||
|
id={field.id}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<Button
|
||||||
|
onClick={handleCustomFieldsVerifiedHelp}
|
||||||
|
className={classes.verifiedLinkHelpButton}
|
||||||
|
plain
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.custom_fields.verified_hint'
|
||||||
|
defaultMessage='How do I add a verified link?'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
{!hasFields && (
|
||||||
|
<DismissibleCallout
|
||||||
|
id='profile_edit_fields_tip'
|
||||||
|
title={intl.formatMessage(messages.customFieldsTipTitle)}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.custom_fields.tip_content'
|
||||||
|
defaultMessage='You can easily add credibility to your Mastodon account by verifying links to any websites you own.'
|
||||||
|
/>
|
||||||
|
</DismissibleCallout>
|
||||||
|
)}
|
||||||
|
</AccountEditSection>
|
||||||
|
|
||||||
<AccountEditSection
|
<AccountEditSection
|
||||||
title={messages.featuredHashtagsTitle}
|
title={messages.featuredHashtagsTitle}
|
||||||
|
|||||||
@ -1,20 +1,14 @@
|
|||||||
import { useCallback, useId, useRef, useState } from 'react';
|
import { useCallback, useId, useState } from 'react';
|
||||||
import type { ChangeEventHandler, FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { TextArea } from '@/mastodon/components/form_fields';
|
import { EmojiTextAreaField } from '@/mastodon/components/form_fields';
|
||||||
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
|
|
||||||
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||||
import { ConfirmationModal } 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 { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
|
||||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
|
|
||||||
import classes from '../styles.module.scss';
|
|
||||||
|
|
||||||
import { CharCounter } from './char_counter';
|
|
||||||
import { EmojiPicker } from './emoji_picker';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
addTitle: {
|
addTitle: {
|
||||||
id: 'account_edit.bio_modal.add_title',
|
id: 'account_edit.bio_modal.add_title',
|
||||||
@ -30,30 +24,23 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const MAX_BIO_LENGTH = 500;
|
|
||||||
|
|
||||||
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const counterId = useId();
|
|
||||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
const { profile: { bio } = {}, isPending } = useAppSelector(
|
const { profile: { bio } = {}, isPending } = useAppSelector(
|
||||||
(state) => state.profileEdit,
|
(state) => state.profileEdit,
|
||||||
);
|
);
|
||||||
const [newBio, setNewBio] = useState(bio ?? '');
|
const [newBio, setNewBio] = useState(bio ?? '');
|
||||||
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
const maxLength = useAppSelector(
|
||||||
(event) => {
|
(state) =>
|
||||||
setNewBio(event.currentTarget.value);
|
state.server.getIn([
|
||||||
},
|
'server',
|
||||||
[],
|
'configuration',
|
||||||
|
'accounts',
|
||||||
|
'max_note_length',
|
||||||
|
]) as number | undefined,
|
||||||
);
|
);
|
||||||
const handlePickEmoji = useCallback((emoji: string) => {
|
|
||||||
setNewBio((prev) => {
|
|
||||||
const position = textAreaRef.current?.selectionStart ?? prev.length;
|
|
||||||
return insertEmojiAtPosition(prev, emoji, position);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
@ -70,27 +57,18 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
|||||||
onConfirm={handleSave}
|
onConfirm={handleSave}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
updating={isPending}
|
updating={isPending}
|
||||||
disabled={newBio.length > MAX_BIO_LENGTH}
|
disabled={!!maxLength && newBio.length > maxLength}
|
||||||
noFocusButton
|
noFocusButton
|
||||||
>
|
>
|
||||||
<div className={classes.inputWrapper}>
|
<EmojiTextAreaField
|
||||||
<TextArea
|
label=''
|
||||||
value={newBio}
|
value={newBio}
|
||||||
ref={textAreaRef}
|
onChange={setNewBio}
|
||||||
onChange={handleChange}
|
aria-labelledby={titleId}
|
||||||
className={classes.inputText}
|
maxLength={maxLength}
|
||||||
aria-labelledby={titleId}
|
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||||
aria-describedby={counterId}
|
autoFocus
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
autoSize
|
||||||
autoFocus
|
|
||||||
autoSize
|
|
||||||
/>
|
|
||||||
<EmojiPicker onPick={handlePickEmoji} />
|
|
||||||
</div>
|
|
||||||
<CharCounter
|
|
||||||
currentLength={newBio.length}
|
|
||||||
maxLength={MAX_BIO_LENGTH}
|
|
||||||
id={counterId}
|
|
||||||
/>
|
/>
|
||||||
</ConfirmationModal>
|
</ConfirmationModal>
|
||||||
);
|
);
|
||||||
@ -0,0 +1,175 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
|
import { Button } from '@/mastodon/components/button';
|
||||||
|
import { EmojiTextInputField } from '@/mastodon/components/form_fields';
|
||||||
|
import {
|
||||||
|
removeField,
|
||||||
|
selectFieldById,
|
||||||
|
updateField,
|
||||||
|
} from '@/mastodon/reducers/slices/profile_edit';
|
||||||
|
import {
|
||||||
|
createAppSelector,
|
||||||
|
useAppDispatch,
|
||||||
|
useAppSelector,
|
||||||
|
} from '@/mastodon/store';
|
||||||
|
|
||||||
|
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
|
||||||
|
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||||
|
import { DialogModal } from '../../ui/components/dialog_modal';
|
||||||
|
|
||||||
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
editTitle: {
|
||||||
|
id: 'account_edit.field_edit_modal.edit_title',
|
||||||
|
defaultMessage: 'Edit custom field',
|
||||||
|
},
|
||||||
|
addTitle: {
|
||||||
|
id: 'account_edit.field_edit_modal.add_title',
|
||||||
|
defaultMessage: 'Add custom field',
|
||||||
|
},
|
||||||
|
editLabelField: {
|
||||||
|
id: 'account_edit.field_edit_modal.name_label',
|
||||||
|
defaultMessage: 'Label',
|
||||||
|
},
|
||||||
|
editLabelHint: {
|
||||||
|
id: 'account_edit.field_edit_modal.name_hint',
|
||||||
|
defaultMessage: 'E.g. “Personal website”',
|
||||||
|
},
|
||||||
|
editValueField: {
|
||||||
|
id: 'account_edit.field_edit_modal.value_label',
|
||||||
|
defaultMessage: 'Value',
|
||||||
|
},
|
||||||
|
editValueHint: {
|
||||||
|
id: 'account_edit.field_edit_modal.value_hint',
|
||||||
|
defaultMessage: 'E.g. “example.me”',
|
||||||
|
},
|
||||||
|
save: {
|
||||||
|
id: 'account_edit.save',
|
||||||
|
defaultMessage: 'Save',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectFieldLimits = createAppSelector(
|
||||||
|
[
|
||||||
|
(state) =>
|
||||||
|
state.server.getIn(['server', 'configuration', 'accounts']) as
|
||||||
|
| ImmutableMap<string, number>
|
||||||
|
| undefined,
|
||||||
|
],
|
||||||
|
(accounts) => ({
|
||||||
|
nameLimit: accounts?.get('profile_field_name_limit'),
|
||||||
|
valueLimit: accounts?.get('profile_field_value_limit'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
|
||||||
|
onClose,
|
||||||
|
fieldKey,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const field = useAppSelector((state) => selectFieldById(state, fieldKey));
|
||||||
|
const [newLabel, setNewLabel] = useState(field?.name ?? '');
|
||||||
|
const [newValue, setNewValue] = useState(field?.value ?? '');
|
||||||
|
|
||||||
|
const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits);
|
||||||
|
const isPending = useAppSelector((state) => state.profileEdit.isPending);
|
||||||
|
|
||||||
|
const disabled =
|
||||||
|
!nameLimit ||
|
||||||
|
!valueLimit ||
|
||||||
|
newLabel.length > nameLimit ||
|
||||||
|
newValue.length > valueLimit;
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (disabled || isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void dispatch(
|
||||||
|
updateField({ id: fieldKey, name: newLabel, value: newValue }),
|
||||||
|
).then(onClose);
|
||||||
|
}, [disabled, dispatch, fieldKey, isPending, newLabel, newValue, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationModal
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
field
|
||||||
|
? intl.formatMessage(messages.editTitle)
|
||||||
|
: intl.formatMessage(messages.addTitle)
|
||||||
|
}
|
||||||
|
confirm={intl.formatMessage(messages.save)}
|
||||||
|
onConfirm={handleSave}
|
||||||
|
updating={isPending}
|
||||||
|
disabled={disabled}
|
||||||
|
className={classes.wrapper}
|
||||||
|
>
|
||||||
|
<EmojiTextInputField
|
||||||
|
value={newLabel}
|
||||||
|
onChange={setNewLabel}
|
||||||
|
label={intl.formatMessage(messages.editLabelField)}
|
||||||
|
hint={intl.formatMessage(messages.editLabelHint)}
|
||||||
|
maxLength={nameLimit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EmojiTextInputField
|
||||||
|
value={newValue}
|
||||||
|
onChange={setNewValue}
|
||||||
|
label={intl.formatMessage(messages.editValueField)}
|
||||||
|
hint={intl.formatMessage(messages.editValueHint)}
|
||||||
|
maxLength={valueLimit}
|
||||||
|
/>
|
||||||
|
</ConfirmationModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeleteFieldModal: FC<DialogModalProps & { fieldKey: string }> = ({
|
||||||
|
onClose,
|
||||||
|
fieldKey,
|
||||||
|
}) => {
|
||||||
|
const isPending = useAppSelector((state) => state.profileEdit.isPending);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
void dispatch(removeField({ key: fieldKey })).then(onClose);
|
||||||
|
}, [dispatch, fieldKey, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogModal
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.field_delete_modal.title'
|
||||||
|
defaultMessage='Delete custom field?'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
buttons={
|
||||||
|
<Button dangerous onClick={handleDelete} disabled={isPending}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.field_delete_modal.delete_button'
|
||||||
|
defaultMessage='Delete'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.field_delete_modal.confirm'
|
||||||
|
defaultMessage='Are you sure you want to delete this custom field? This action can’t be undone.'
|
||||||
|
tagName='p'
|
||||||
|
/>
|
||||||
|
</DialogModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RearrangeFieldsModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||||
|
return (
|
||||||
|
<DialogModal onClose={onClose} title='Not implemented yet'>
|
||||||
|
<p>Not implemented yet</p>
|
||||||
|
</DialogModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
export * from './bio_modal';
|
||||||
|
export * from './fields_modals';
|
||||||
|
export * from './name_modal';
|
||||||
|
export * from './profile_display_modal';
|
||||||
|
export * from './verified_modal';
|
||||||
@ -1,20 +1,14 @@
|
|||||||
import { useCallback, useId, useRef, useState } from 'react';
|
import { useCallback, useId, useState } from 'react';
|
||||||
import type { ChangeEventHandler, FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { TextInput } from '@/mastodon/components/form_fields';
|
import { EmojiTextInputField } from '@/mastodon/components/form_fields';
|
||||||
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
|
|
||||||
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||||
import { ConfirmationModal } 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 { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
|
||||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
|
|
||||||
import classes from '../styles.module.scss';
|
|
||||||
|
|
||||||
import { CharCounter } from './char_counter';
|
|
||||||
import { EmojiPicker } from './emoji_picker';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
addTitle: {
|
addTitle: {
|
||||||
id: 'account_edit.name_modal.add_title',
|
id: 'account_edit.name_modal.add_title',
|
||||||
@ -30,30 +24,24 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 30;
|
|
||||||
|
|
||||||
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const counterId = useId();
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const { profile: { displayName } = {}, isPending } = useAppSelector(
|
const { profile: { displayName } = {}, isPending } = useAppSelector(
|
||||||
(state) => state.profileEdit,
|
(state) => state.profileEdit,
|
||||||
);
|
);
|
||||||
const [newName, setNewName] = useState(displayName ?? '');
|
const maxLength = useAppSelector(
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
(state) =>
|
||||||
(event) => {
|
state.server.getIn([
|
||||||
setNewName(event.currentTarget.value);
|
'server',
|
||||||
},
|
'configuration',
|
||||||
[],
|
'accounts',
|
||||||
|
'max_display_name_length',
|
||||||
|
]) as number | undefined,
|
||||||
);
|
);
|
||||||
const handlePickEmoji = useCallback((emoji: string) => {
|
|
||||||
setNewName((prev) => {
|
const [newName, setNewName] = useState(displayName ?? '');
|
||||||
const position = inputRef.current?.selectionStart ?? prev.length;
|
|
||||||
return insertEmojiAtPosition(prev, emoji, position);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
@ -70,27 +58,18 @@ export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
|||||||
onConfirm={handleSave}
|
onConfirm={handleSave}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
updating={isPending}
|
updating={isPending}
|
||||||
disabled={newName.length > MAX_NAME_LENGTH}
|
disabled={!!maxLength && newName.length > maxLength}
|
||||||
noCloseOnConfirm
|
noCloseOnConfirm
|
||||||
noFocusButton
|
noFocusButton
|
||||||
>
|
>
|
||||||
<div className={classes.inputWrapper}>
|
<EmojiTextInputField
|
||||||
<TextInput
|
value={newName}
|
||||||
value={newName}
|
onChange={setNewName}
|
||||||
ref={inputRef}
|
aria-labelledby={titleId}
|
||||||
onChange={handleChange}
|
maxLength={maxLength}
|
||||||
className={classes.inputText}
|
label=''
|
||||||
aria-labelledby={titleId}
|
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||||
aria-describedby={counterId}
|
autoFocus
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<EmojiPicker onPick={handlePickEmoji} />
|
|
||||||
</div>
|
|
||||||
<CharCounter
|
|
||||||
currentLength={newName.length}
|
|
||||||
maxLength={MAX_NAME_LENGTH}
|
|
||||||
id={counterId}
|
|
||||||
/>
|
/>
|
||||||
</ConfirmationModal>
|
</ConfirmationModal>
|
||||||
);
|
);
|
||||||
@ -12,7 +12,8 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
|||||||
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||||
import { DialogModal } from '../../ui/components/dialog_modal';
|
import { DialogModal } from '../../ui/components/dialog_modal';
|
||||||
import { messages } from '../index';
|
import { messages } from '../index';
|
||||||
import classes from '../styles.module.scss';
|
|
||||||
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => {
|
export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleInputWrapper {
|
||||||
|
> div {
|
||||||
|
padding: 12px 0;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
border-top: 1px solid var(--color-border-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.verifiedSteps {
|
||||||
|
font-size: 15px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
counter-increment: steps;
|
||||||
|
padding-left: 34px;
|
||||||
|
margin-top: 24px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: counter(steps);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
list-style: none;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-style: dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.icon) {
|
||||||
|
width: 1.4em;
|
||||||
|
height: 1.4em;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[open] :global(.icon) {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { CopyLinkField } from '@/mastodon/components/form_fields/copy_link_field';
|
||||||
|
import { Icon } from '@/mastodon/components/icon';
|
||||||
|
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||||
|
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
|
||||||
|
|
||||||
|
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||||
|
import { DialogModal } from '../../ui/components/dialog_modal';
|
||||||
|
|
||||||
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
|
const selectAccountUrl = createAppSelector(
|
||||||
|
[(state) => state.meta.get('me') as string, (state) => state.accounts],
|
||||||
|
(accountId, accounts) => {
|
||||||
|
const account = accounts.get(accountId);
|
||||||
|
return account?.get('url') ?? '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const VerifiedModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||||
|
const accountUrl = useAppSelector(selectAccountUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogModal
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.verified_modal.title'
|
||||||
|
defaultMessage='How to add a verified link'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
noCancelButton
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.verified_modal.details'
|
||||||
|
defaultMessage='Add credibility to your Mastodon profile by verifying links to personal websites. Here’s how it works:'
|
||||||
|
tagName='p'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ol className={classes.verifiedSteps}>
|
||||||
|
<li>
|
||||||
|
<CopyLinkField
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.verified_modal.step1.header'
|
||||||
|
defaultMessage='Copy the HTML code below and paste into the header of your website'
|
||||||
|
tagName='h2'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
value={`<a rel="me" href="${accountUrl}">Mastodon</a>`}
|
||||||
|
/>
|
||||||
|
<details className={classes.details}>
|
||||||
|
<summary>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.verified_modal.invisible_link.summary'
|
||||||
|
defaultMessage='How do I make the link invisible?'
|
||||||
|
/>
|
||||||
|
<Icon icon={ExpandArrowIcon} id='arrow' />
|
||||||
|
</summary>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.verified_modal.invisible_link.details'
|
||||||
|
defaultMessage='Add the link to your header. The important part is rel="me" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.'
|
||||||
|
values={{ tag: <code><a></code> }}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.verified_modal.step2.header'
|
||||||
|
defaultMessage='Add your website as a custom field'
|
||||||
|
tagName='h2'
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.verified_modal.step2.details'
|
||||||
|
defaultMessage='If you’ve already added your website as a custom field, you’ll need to delete and re-add it to trigger verification.'
|
||||||
|
tagName='p'
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</DialogModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -24,6 +24,38 @@
|
|||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
padding: 12px 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldName {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verifiedLinkHelpButton {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:global(.button) {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Featured Tags Page
|
// Featured Tags Page
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
@ -58,48 +90,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modals
|
|
||||||
|
|
||||||
.inputWrapper {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override input styles
|
|
||||||
.inputWrapper .inputText {
|
|
||||||
font-size: 15px;
|
|
||||||
padding-right: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.inputText {
|
|
||||||
min-height: 82px;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
// 160px is approx the height of the modal header and footer
|
|
||||||
max-height: calc(80vh - 160px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputWrapper :global(.emoji-picker-dropdown) {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 10px;
|
|
||||||
right: 8px;
|
|
||||||
height: 24px;
|
|
||||||
z-index: 1;
|
|
||||||
|
|
||||||
:global(.icon-button) {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggleInputWrapper {
|
|
||||||
> div {
|
|
||||||
padding: 12px 0;
|
|
||||||
|
|
||||||
&:not(:first-child) {
|
|
||||||
border-top: 1px solid var(--color-border-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Column component
|
// Column component
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
@ -195,14 +185,3 @@ textarea.inputText {
|
|||||||
.sectionSubtitle {
|
.sectionSubtitle {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counter component
|
|
||||||
|
|
||||||
.counter {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counterError {
|
|
||||||
color: var(--color-text-error);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -13,23 +13,26 @@ export interface BaseConfirmationModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConfirmationModalProps {
|
||||||
|
title: React.ReactNode;
|
||||||
|
titleId?: string;
|
||||||
|
message?: React.ReactNode;
|
||||||
|
confirm: React.ReactNode;
|
||||||
|
cancel?: React.ReactNode;
|
||||||
|
secondary?: React.ReactNode;
|
||||||
|
onSecondary?: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
noCloseOnConfirm?: boolean;
|
||||||
|
extraContent?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
updating?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
noFocusButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const ConfirmationModal: React.FC<
|
export const ConfirmationModal: React.FC<
|
||||||
{
|
ConfirmationModalProps & BaseConfirmationModalProps
|
||||||
title: React.ReactNode;
|
|
||||||
titleId?: string;
|
|
||||||
message?: React.ReactNode;
|
|
||||||
confirm: React.ReactNode;
|
|
||||||
cancel?: React.ReactNode;
|
|
||||||
secondary?: React.ReactNode;
|
|
||||||
onSecondary?: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
noCloseOnConfirm?: boolean;
|
|
||||||
extraContent?: React.ReactNode;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
updating?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
noFocusButton?: boolean;
|
|
||||||
} & BaseConfirmationModalProps
|
|
||||||
> = ({
|
> = ({
|
||||||
title,
|
title,
|
||||||
titleId,
|
titleId,
|
||||||
@ -42,6 +45,7 @@ export const ConfirmationModal: React.FC<
|
|||||||
onSecondary,
|
onSecondary,
|
||||||
extraContent,
|
extraContent,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
updating,
|
updating,
|
||||||
disabled,
|
disabled,
|
||||||
noCloseOnConfirm = false,
|
noCloseOnConfirm = false,
|
||||||
@ -62,7 +66,7 @@ export const ConfirmationModal: React.FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalShell>
|
<ModalShell>
|
||||||
<ModalShellBody>
|
<ModalShellBody className={className}>
|
||||||
<h1 id={titleId}>{title}</h1>
|
<h1 id={titleId}>{title}</h1>
|
||||||
{message && <p>{message}</p>}
|
{message && <p>{message}</p>}
|
||||||
|
|
||||||
|
|||||||
@ -15,11 +15,10 @@ interface DialogModalProps {
|
|||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
formClassName?: string;
|
wrapperClassName?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
noCancelButton?: boolean;
|
noCancelButton?: boolean;
|
||||||
onSave?: () => void;
|
buttons?: ReactNode;
|
||||||
saveLabel?: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DialogModal: FC<DialogModalProps> = ({
|
export const DialogModal: FC<DialogModalProps> = ({
|
||||||
@ -27,16 +26,13 @@ export const DialogModal: FC<DialogModalProps> = ({
|
|||||||
title,
|
title,
|
||||||
onClose,
|
onClose,
|
||||||
description,
|
description,
|
||||||
formClassName,
|
wrapperClassName,
|
||||||
children,
|
children,
|
||||||
noCancelButton = false,
|
noCancelButton = false,
|
||||||
onSave,
|
buttons,
|
||||||
saveLabel,
|
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const showButtons = !noCancelButton || onSave;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('modal-root__modal dialog-modal', className)}>
|
<div className={classNames('modal-root__modal dialog-modal', className)}>
|
||||||
<div className='dialog-modal__header'>
|
<div className='dialog-modal__header'>
|
||||||
@ -61,13 +57,16 @@ export const DialogModal: FC<DialogModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={classNames('dialog-modal__content__form', formClassName)}
|
className={classNames(
|
||||||
|
'dialog-modal__content__form',
|
||||||
|
wrapperClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showButtons && (
|
{(buttons || !noCancelButton) && (
|
||||||
<div className='dialog-modal__content__actions'>
|
<div className='dialog-modal__content__actions'>
|
||||||
{!noCancelButton && (
|
{!noCancelButton && (
|
||||||
<Button onClick={onClose} secondary>
|
<Button onClick={onClose} secondary>
|
||||||
@ -77,16 +76,7 @@ export const DialogModal: FC<DialogModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onSave && (
|
{buttons}
|
||||||
<Button onClick={onClose}>
|
|
||||||
{saveLabel ?? (
|
|
||||||
<FormattedMessage
|
|
||||||
id='confirmation_modal.cancel'
|
|
||||||
defaultMessage='Cancel'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -94,11 +94,20 @@ export const MODAL_COMPONENTS = {
|
|||||||
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
||||||
'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
|
'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
|
||||||
'ACCOUNT_FIELD_OVERFLOW': () => import('@/mastodon/features/account_timeline/modals/field_modal').then(module => ({ default: module.AccountFieldModal })),
|
'ACCOUNT_FIELD_OVERFLOW': () => import('@/mastodon/features/account_timeline/modals/field_modal').then(module => ({ default: module.AccountFieldModal })),
|
||||||
'ACCOUNT_EDIT_NAME': () => import('@/mastodon/features/account_edit/components/name_modal').then(module => ({ default: module.NameModal })),
|
'ACCOUNT_EDIT_NAME': accountEditModal('NameModal'),
|
||||||
'ACCOUNT_EDIT_BIO': () => import('@/mastodon/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })),
|
'ACCOUNT_EDIT_BIO': accountEditModal('BioModal'),
|
||||||
'ACCOUNT_EDIT_PROFILE_DISPLAY': () => import('@/mastodon/features/account_edit/components/profile_display_modal').then(module => ({ default: module.ProfileDisplayModal })),
|
'ACCOUNT_EDIT_PROFILE_DISPLAY': accountEditModal('ProfileDisplayModal'),
|
||||||
|
'ACCOUNT_EDIT_VERIFY_LINKS': accountEditModal('VerifiedModal'),
|
||||||
|
'ACCOUNT_EDIT_FIELD_EDIT': accountEditModal('EditFieldModal'),
|
||||||
|
'ACCOUNT_EDIT_FIELD_DELETE': accountEditModal('DeleteFieldModal'),
|
||||||
|
'ACCOUNT_EDIT_FIELDS_REORDER': accountEditModal('ReorderFieldsModal'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @arg {keyof import('@/mastodon/features/account_edit/modals')} type */
|
||||||
|
function accountEditModal(type) {
|
||||||
|
return () => import('@/mastodon/features/account_edit/modals').then(module => ({ default: module[type] }));
|
||||||
|
}
|
||||||
|
|
||||||
export default class ModalRoot extends PureComponent {
|
export default class ModalRoot extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
|||||||
@ -149,16 +149,28 @@
|
|||||||
"account_edit.button.add": "Add {item}",
|
"account_edit.button.add": "Add {item}",
|
||||||
"account_edit.button.delete": "Delete {item}",
|
"account_edit.button.delete": "Delete {item}",
|
||||||
"account_edit.button.edit": "Edit {item}",
|
"account_edit.button.edit": "Edit {item}",
|
||||||
"account_edit.char_counter": "{currentLength}/{maxLength} characters",
|
|
||||||
"account_edit.column_button": "Done",
|
"account_edit.column_button": "Done",
|
||||||
"account_edit.column_title": "Edit Profile",
|
"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.placeholder": "Add your pronouns, external links, or anything else you’d like to share.",
|
||||||
|
"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",
|
"account_edit.custom_fields.title": "Custom fields",
|
||||||
|
"account_edit.custom_fields.verified_hint": "How do I add a verified link?",
|
||||||
"account_edit.display_name.placeholder": "Your display name is how your name appears on your profile and in timelines.",
|
"account_edit.display_name.placeholder": "Your display name is how your name appears on your profile and in timelines.",
|
||||||
"account_edit.display_name.title": "Display name",
|
"account_edit.display_name.title": "Display name",
|
||||||
"account_edit.featured_hashtags.item": "hashtags",
|
"account_edit.featured_hashtags.item": "hashtags",
|
||||||
"account_edit.featured_hashtags.placeholder": "Help others identify, and have quick access to, your favorite topics.",
|
"account_edit.featured_hashtags.placeholder": "Help others identify, and have quick access to, your favorite topics.",
|
||||||
"account_edit.featured_hashtags.title": "Featured hashtags",
|
"account_edit.featured_hashtags.title": "Featured hashtags",
|
||||||
|
"account_edit.field_delete_modal.confirm": "Are you sure you want to delete this custom field? This action can’t be undone.",
|
||||||
|
"account_edit.field_delete_modal.delete_button": "Delete",
|
||||||
|
"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.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.name_modal.add_title": "Add display name",
|
"account_edit.name_modal.add_title": "Add display name",
|
||||||
"account_edit.name_modal.edit_title": "Edit display name",
|
"account_edit.name_modal.edit_title": "Edit display name",
|
||||||
"account_edit.profile_tab.button_label": "Customize",
|
"account_edit.profile_tab.button_label": "Customize",
|
||||||
@ -173,6 +185,13 @@
|
|||||||
"account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.",
|
"account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.",
|
||||||
"account_edit.profile_tab.title": "Profile tab settings",
|
"account_edit.profile_tab.title": "Profile tab settings",
|
||||||
"account_edit.save": "Save",
|
"account_edit.save": "Save",
|
||||||
|
"account_edit.verified_modal.details": "Add credibility to your Mastodon profile by verifying links to personal websites. Here’s how it works:",
|
||||||
|
"account_edit.verified_modal.invisible_link.details": "Add the link to your header. The important part is rel=\"me\" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.",
|
||||||
|
"account_edit.verified_modal.invisible_link.summary": "How do I make the link invisible?",
|
||||||
|
"account_edit.verified_modal.step1.header": "Copy the HTML code below and paste into the header of your website",
|
||||||
|
"account_edit.verified_modal.step2.details": "If you’ve already added your website as a custom field, you’ll need to delete and re-add it to trigger verification.",
|
||||||
|
"account_edit.verified_modal.step2.header": "Add your website as a custom field",
|
||||||
|
"account_edit.verified_modal.title": "How to add a verified link",
|
||||||
"account_edit_tags.add_tag": "Add #{tagName}",
|
"account_edit_tags.add_tag": "Add #{tagName}",
|
||||||
"account_edit_tags.column_title": "Edit featured hashtags",
|
"account_edit_tags.column_title": "Edit featured hashtags",
|
||||||
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.",
|
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.",
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
apiPostFeaturedTag,
|
apiPostFeaturedTag,
|
||||||
} from '@/mastodon/api/accounts';
|
} from '@/mastodon/api/accounts';
|
||||||
import { apiGetSearch } from '@/mastodon/api/search';
|
import { apiGetSearch } from '@/mastodon/api/search';
|
||||||
|
import type { ApiAccountFieldJSON } from '@/mastodon/api_types/accounts';
|
||||||
import type {
|
import type {
|
||||||
ApiProfileJSON,
|
ApiProfileJSON,
|
||||||
ApiProfileUpdateParams,
|
ApiProfileUpdateParams,
|
||||||
@ -23,20 +24,25 @@ import type {
|
|||||||
import type { AppDispatch } from '@/mastodon/store';
|
import type { AppDispatch } from '@/mastodon/store';
|
||||||
import {
|
import {
|
||||||
createAppAsyncThunk,
|
createAppAsyncThunk,
|
||||||
|
createAppSelector,
|
||||||
createDataLoadingThunk,
|
createDataLoadingThunk,
|
||||||
} from '@/mastodon/store/typed_functions';
|
} from '@/mastodon/store/typed_functions';
|
||||||
|
import { hashObjectArray } from '@/mastodon/utils/hash';
|
||||||
import type { SnakeToCamelCase } from '@/mastodon/utils/types';
|
import type { SnakeToCamelCase } from '@/mastodon/utils/types';
|
||||||
|
|
||||||
type ProfileData = {
|
type ProfileData = {
|
||||||
[Key in keyof Omit<
|
[Key in keyof Omit<
|
||||||
ApiProfileJSON,
|
ApiProfileJSON,
|
||||||
'note' | 'featured_tags'
|
'note' | 'fields' | 'featured_tags'
|
||||||
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
|
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
|
||||||
} & {
|
} & {
|
||||||
bio: ApiProfileJSON['note'];
|
bio: ApiProfileJSON['note'];
|
||||||
|
fields: FieldData[];
|
||||||
featuredTags: TagData[];
|
featuredTags: TagData[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FieldData = ApiAccountFieldJSON & { id: string };
|
||||||
|
|
||||||
export type TagData = {
|
export type TagData = {
|
||||||
[Key in keyof Omit<
|
[Key in keyof Omit<
|
||||||
ApiFeaturedTagJSON,
|
ApiFeaturedTagJSON,
|
||||||
@ -186,7 +192,7 @@ const transformProfile = (result: ApiProfileJSON): ProfileData => ({
|
|||||||
id: result.id,
|
id: result.id,
|
||||||
displayName: result.display_name,
|
displayName: result.display_name,
|
||||||
bio: result.note,
|
bio: result.note,
|
||||||
fields: result.fields,
|
fields: hashObjectArray(result.fields),
|
||||||
avatar: result.avatar,
|
avatar: result.avatar,
|
||||||
avatarStatic: result.avatar_static,
|
avatarStatic: result.avatar_static,
|
||||||
avatarDescription: result.avatar_description,
|
avatarDescription: result.avatar_description,
|
||||||
@ -218,6 +224,83 @@ export const patchProfile = createDataLoadingThunk(
|
|||||||
{ useLoadingBar: false },
|
{ useLoadingBar: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const selectFieldById = createAppSelector(
|
||||||
|
[(state) => state.profileEdit.profile?.fields, (_, id?: string) => id],
|
||||||
|
(fields, fieldId) => {
|
||||||
|
if (!fields || !fieldId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return fields.find((field) => field.id === fieldId) ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateField = createAppAsyncThunk(
|
||||||
|
`${profileEditSlice.name}/updateField`,
|
||||||
|
async (
|
||||||
|
arg: { id?: string; name: string; value: string },
|
||||||
|
{ getState, dispatch },
|
||||||
|
) => {
|
||||||
|
const fields = getState().profileEdit.profile?.fields;
|
||||||
|
if (!fields) {
|
||||||
|
throw new Error('Profile fields not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxFields = getState().server.getIn([
|
||||||
|
'server',
|
||||||
|
'configuration',
|
||||||
|
'accounts',
|
||||||
|
'max_fields',
|
||||||
|
]) as number | undefined;
|
||||||
|
if (maxFields && fields.length >= maxFields && !arg.id) {
|
||||||
|
throw new Error('Maximum number of profile fields reached');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the field data if there is an ID, otherwise append a new field.
|
||||||
|
const newFields: Pick<ApiAccountFieldJSON, 'name' | 'value'>[] = [];
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.id === arg.id) {
|
||||||
|
newFields.push({ name: arg.name, value: arg.value });
|
||||||
|
} else {
|
||||||
|
newFields.push({ name: field.name, value: field.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!arg.id) {
|
||||||
|
newFields.push({ name: arg.name, value: arg.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatch(
|
||||||
|
patchProfile({
|
||||||
|
fields_attributes: newFields,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const removeField = createAppAsyncThunk(
|
||||||
|
`${profileEditSlice.name}/removeField`,
|
||||||
|
async (arg: { key: string }, { getState, dispatch }) => {
|
||||||
|
const fields = getState().profileEdit.profile?.fields;
|
||||||
|
if (!fields) {
|
||||||
|
throw new Error('Profile fields not found');
|
||||||
|
}
|
||||||
|
const field = fields.find((f) => f.id === arg.key);
|
||||||
|
if (!field) {
|
||||||
|
throw new Error('Field not found');
|
||||||
|
}
|
||||||
|
const newFields = fields
|
||||||
|
.filter((f) => f.id !== arg.key)
|
||||||
|
.map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
value: f.value,
|
||||||
|
}));
|
||||||
|
await dispatch(
|
||||||
|
patchProfile({
|
||||||
|
fields_attributes: newFields,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchFeaturedTags = createDataLoadingThunk(
|
export const fetchFeaturedTags = createDataLoadingThunk(
|
||||||
`${profileEditSlice.name}/fetchFeaturedTags`,
|
`${profileEditSlice.name}/fetchFeaturedTags`,
|
||||||
apiGetCurrentFeaturedTags,
|
apiGetCurrentFeaturedTags,
|
||||||
|
|||||||
48
app/javascript/mastodon/utils/hash.test.ts
Normal file
48
app/javascript/mastodon/utils/hash.test.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { cyrb32, hashObjectArray } from './hash';
|
||||||
|
|
||||||
|
describe('cyrb32', () => {
|
||||||
|
const input = 'mastodon';
|
||||||
|
|
||||||
|
it('returns a base-36 lowercase 1-6 character string', () => {
|
||||||
|
const hash = cyrb32(input);
|
||||||
|
expect(hash).toMatch(/^[0-9a-z]{1,6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same output for same input and seed', () => {
|
||||||
|
const a = cyrb32(input, 1);
|
||||||
|
const b = cyrb32(input, 1);
|
||||||
|
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different hashes for different seeds', () => {
|
||||||
|
const a = cyrb32(input, 1);
|
||||||
|
const b = cyrb32(input, 2);
|
||||||
|
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hashObjectArray', () => {
|
||||||
|
const input = [
|
||||||
|
{ name: 'Alice', value: 'Developer' },
|
||||||
|
{ name: 'Bob', value: 'Designer' },
|
||||||
|
{ name: 'Alice', value: 'Developer' }, // Duplicate
|
||||||
|
];
|
||||||
|
|
||||||
|
it('returns an array of the same length with unique hash keys', () => {
|
||||||
|
const result = hashObjectArray(input);
|
||||||
|
expect(result).toHaveLength(input.length);
|
||||||
|
|
||||||
|
const ids = result.map((obj) => obj.id);
|
||||||
|
const uniqueIds = new Set(ids);
|
||||||
|
expect(uniqueIds.size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows custom key names for the hash', () => {
|
||||||
|
const result = hashObjectArray(input, 'hashKey');
|
||||||
|
expect(result[0]).toHaveProperty('hashKey');
|
||||||
|
});
|
||||||
|
});
|
||||||
39
app/javascript/mastodon/utils/hash.ts
Normal file
39
app/javascript/mastodon/utils/hash.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Fast insecure hash function.
|
||||||
|
* @param str String to hash.
|
||||||
|
* @param seed Optional seed value for different hash outputs of the same string.
|
||||||
|
* @returns Base-36 hash (1-6 characters, typically 5-6).
|
||||||
|
*/
|
||||||
|
export function cyrb32(str: string, seed = 0) {
|
||||||
|
let h1 = 0xdeadbeef ^ seed;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h1 = Math.imul(h1 ^ str.charCodeAt(i), 0x9e3779b1);
|
||||||
|
}
|
||||||
|
return ((h1 ^ (h1 >>> 16)) >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes an array of objects into a new array where each object has a unique hash key.
|
||||||
|
* @param array Array of objects to hash.
|
||||||
|
* @param key Key name to use for the hash in the resulting objects (default: 'id').
|
||||||
|
*/
|
||||||
|
export function hashObjectArray<
|
||||||
|
TObj extends object,
|
||||||
|
TKey extends string = 'id',
|
||||||
|
>(array: TObj[], key = 'id' as TKey): (TObj & Record<TKey, string>)[] {
|
||||||
|
const keySet = new Set<string>();
|
||||||
|
|
||||||
|
return array.map((obj) => {
|
||||||
|
const json = JSON.stringify(obj);
|
||||||
|
let seed = 0;
|
||||||
|
let hash = cyrb32(json, seed);
|
||||||
|
while (keySet.has(hash)) {
|
||||||
|
hash = cyrb32(json, ++seed);
|
||||||
|
}
|
||||||
|
keySet.add(hash);
|
||||||
|
return {
|
||||||
|
...obj,
|
||||||
|
[key]: hash,
|
||||||
|
} as TObj & Record<TKey, string>;
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user