[Glitch] Profile editing: Custom fields (deleting, editing)

Port dae0926c1f to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Echo
2026-03-05 11:48:19 +01:00
committed by Claire
parent fa3794ea27
commit 7575226495
19 changed files with 721 additions and 238 deletions

View File

@@ -10,6 +10,7 @@ export {
type ComboboxItemState,
} from './combobox_field';
export { CopyLinkField } from './copy_link_field';
export { EmojiTextInputField, EmojiTextAreaField } from './emoji_text_field';
export { RadioButtonField, RadioButton } from './radio_button_field';
export { ToggleField, Toggle } from './toggle_field';
export { SelectField, Select } from './select_field';

View File

@@ -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';

View File

@@ -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} />;
};

View File

@@ -0,0 +1,37 @@
import type { FC } from 'react';
import { useCallback } from 'react';
import { openModal } from '@/flavours/glitch/actions/modal';
import { useAppDispatch } from '@/flavours/glitch/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} />
</>
);
};

View File

@@ -9,6 +9,7 @@ import type { ModalType } from '@/flavours/glitch/actions/modal';
import { openModal } from '@/flavours/glitch/actions/modal';
import { Avatar } from '@/flavours/glitch/components/avatar';
import { Button } from '@/flavours/glitch/components/button';
import { DismissibleCallout } from '@/flavours/glitch/components/callout/dismissible';
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
@@ -20,6 +21,7 @@ import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
import { EditButton } from './components/edit_button';
import { AccountFieldActions } from './components/field_actions';
import { AccountEditSection } from './components/section';
import classes from './styles.module.scss';
@@ -54,6 +56,14 @@ export const messages = defineMessages({
defaultMessage:
'Add your pronouns, external links, or anything else youd 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: {
id: 'account_edit.featured_hashtags.title',
defaultMessage: 'Featured hashtags',
@@ -101,6 +111,9 @@ export const AccountEdit: FC = () => {
const handleBioEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_BIO');
}, [handleOpenModal]);
const handleCustomFieldsVerifiedHelp = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_VERIFY_LINKS');
}, [handleOpenModal]);
const handleProfileDisplayEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_PROFILE_DISPLAY');
}, [handleOpenModal]);
@@ -123,6 +136,7 @@ export const AccountEdit: FC = () => {
const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
const hasName = !!profile.displayName;
const hasBio = !!profile.bio;
const hasFields = profile.fields.length > 0;
const hasTags = profile.featuredTags.length > 0;
return (
@@ -171,8 +185,48 @@ export const AccountEdit: FC = () => {
<AccountEditSection
title={messages.customFieldsTitle}
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
title={messages.featuredHashtagsTitle}

View File

@@ -1,20 +1,14 @@
import { useCallback, useId, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { useCallback, useId, useState } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { TextArea } from '@/flavours/glitch/components/form_fields';
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
import { EmojiTextAreaField } from '@/flavours/glitch/components/form_fields';
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import classes from '../styles.module.scss';
import { CharCounter } from './char_counter';
import { EmojiPicker } from './emoji_picker';
const messages = defineMessages({
addTitle: {
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 }) => {
const intl = useIntl();
const titleId = useId();
const counterId = useId();
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const { profile: { bio } = {}, isPending } = useAppSelector(
(state) => state.profileEdit,
);
const [newBio, setNewBio] = useState(bio ?? '');
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
setNewBio(event.currentTarget.value);
},
[],
const maxLength = useAppSelector(
(state) =>
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 handleSave = useCallback(() => {
@@ -70,27 +57,18 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
onConfirm={handleSave}
onClose={onClose}
updating={isPending}
disabled={newBio.length > MAX_BIO_LENGTH}
disabled={!!maxLength && newBio.length > maxLength}
noFocusButton
>
<div className={classes.inputWrapper}>
<TextArea
value={newBio}
ref={textAreaRef}
onChange={handleChange}
className={classes.inputText}
aria-labelledby={titleId}
aria-describedby={counterId}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus
autoSize
/>
<EmojiPicker onPick={handlePickEmoji} />
</div>
<CharCounter
currentLength={newBio.length}
maxLength={MAX_BIO_LENGTH}
id={counterId}
<EmojiTextAreaField
label=''
value={newBio}
onChange={setNewBio}
aria-labelledby={titleId}
maxLength={maxLength}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus
autoSize
/>
</ConfirmationModal>
);

View File

@@ -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 '@/flavours/glitch/components/button';
import { EmojiTextInputField } from '@/flavours/glitch/components/form_fields';
import {
removeField,
selectFieldById,
updateField,
} from '@/flavours/glitch/reducers/slices/profile_edit';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/flavours/glitch/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 cant 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>
);
};

View File

@@ -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';

View File

@@ -1,20 +1,14 @@
import { useCallback, useId, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { useCallback, useId, useState } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { TextInput } from '@/flavours/glitch/components/form_fields';
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
import { EmojiTextInputField } from '@/flavours/glitch/components/form_fields';
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import classes from '../styles.module.scss';
import { CharCounter } from './char_counter';
import { EmojiPicker } from './emoji_picker';
const messages = defineMessages({
addTitle: {
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 }) => {
const intl = useIntl();
const titleId = useId();
const counterId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const { profile: { displayName } = {}, isPending } = useAppSelector(
(state) => state.profileEdit,
);
const [newName, setNewName] = useState(displayName ?? '');
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
setNewName(event.currentTarget.value);
},
[],
const maxLength = useAppSelector(
(state) =>
state.server.getIn([
'server',
'configuration',
'accounts',
'max_display_name_length',
]) as number | undefined,
);
const handlePickEmoji = useCallback((emoji: string) => {
setNewName((prev) => {
const position = inputRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
}, []);
const [newName, setNewName] = useState(displayName ?? '');
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
@@ -70,27 +58,18 @@ export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
onConfirm={handleSave}
onClose={onClose}
updating={isPending}
disabled={newName.length > MAX_NAME_LENGTH}
disabled={!!maxLength && newName.length > maxLength}
noCloseOnConfirm
noFocusButton
>
<div className={classes.inputWrapper}>
<TextInput
value={newName}
ref={inputRef}
onChange={handleChange}
className={classes.inputText}
aria-labelledby={titleId}
aria-describedby={counterId}
// 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}
<EmojiTextInputField
value={newName}
onChange={setNewName}
aria-labelledby={titleId}
maxLength={maxLength}
label=''
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus
/>
</ConfirmationModal>
);

View File

@@ -12,7 +12,8 @@ import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { DialogModal } from '../../ui/components/dialog_modal';
import { messages } from '../index';
import classes from '../styles.module.scss';
import classes from './styles.module.scss';
export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => {
const intl = useIntl();

View File

@@ -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);
}
}

View File

@@ -0,0 +1,85 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { CopyLinkField } from '@/flavours/glitch/components/form_fields/copy_link_field';
import { Icon } from '@/flavours/glitch/components/icon';
import { createAppSelector, useAppSelector } from '@/flavours/glitch/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. Heres 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>&lt;a&gt;</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 youve already added your website as a custom field, youll need to delete and re-add it to trigger verification.'
tagName='p'
/>
</li>
</ol>
</DialogModal>
);
};

View File

@@ -24,6 +24,38 @@
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
.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 {
@@ -195,14 +185,3 @@ textarea.inputText {
.sectionSubtitle {
color: var(--color-text-secondary);
}
// Counter component
.counter {
margin-top: 4px;
font-size: 13px;
}
.counterError {
color: var(--color-text-error);
}

View File

@@ -21,23 +21,26 @@ const messages = defineMessages({
},
});
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<
{
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
ConfirmationModalProps & BaseConfirmationModalProps
> = ({
title,
titleId,
@@ -50,6 +53,7 @@ export const ConfirmationModal: React.FC<
onSecondary,
extraContent,
children,
className,
updating,
disabled,
noCloseOnConfirm = false,
@@ -70,7 +74,7 @@ export const ConfirmationModal: React.FC<
return (
<ModalShell>
<ModalShellBody>
<ModalShellBody className={className}>
<h1 id={titleId}>{title}</h1>
{message && <p>{message}</p>}

View File

@@ -15,11 +15,10 @@ interface DialogModalProps {
title: ReactNode;
onClose: () => void;
description?: ReactNode;
formClassName?: string;
wrapperClassName?: string;
children?: ReactNode;
noCancelButton?: boolean;
onSave?: () => void;
saveLabel?: ReactNode;
buttons?: ReactNode;
}
export const DialogModal: FC<DialogModalProps> = ({
@@ -27,16 +26,13 @@ export const DialogModal: FC<DialogModalProps> = ({
title,
onClose,
description,
formClassName,
wrapperClassName,
children,
noCancelButton = false,
onSave,
saveLabel,
buttons,
}) => {
const intl = useIntl();
const showButtons = !noCancelButton || onSave;
return (
<div className={classNames('modal-root__modal dialog-modal', className)}>
<div className='dialog-modal__header'>
@@ -61,13 +57,16 @@ export const DialogModal: FC<DialogModalProps> = ({
</div>
)}
<div
className={classNames('dialog-modal__content__form', formClassName)}
className={classNames(
'dialog-modal__content__form',
wrapperClassName,
)}
>
{children}
</div>
</div>
{showButtons && (
{(buttons || !noCancelButton) && (
<div className='dialog-modal__content__actions'>
{!noCancelButton && (
<Button onClick={onClose} secondary>
@@ -77,16 +76,7 @@ export const DialogModal: FC<DialogModalProps> = ({
/>
</Button>
)}
{onSave && (
<Button onClick={onClose}>
{saveLabel ?? (
<FormattedMessage
id='confirmation_modal.cancel'
defaultMessage='Cancel'
/>
)}
</Button>
)}
{buttons}
</div>
)}
</div>

View File

@@ -102,11 +102,20 @@ export const MODAL_COMPONENTS = {
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
'ACCOUNT_NOTE': () => import('@/flavours/glitch/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
'ACCOUNT_FIELD_OVERFLOW': () => import('@/flavours/glitch/features/account_timeline/modals/field_modal').then(module => ({ default: module.AccountFieldModal })),
'ACCOUNT_EDIT_NAME': () => import('@/flavours/glitch/features/account_edit/components/name_modal').then(module => ({ default: module.NameModal })),
'ACCOUNT_EDIT_BIO': () => import('@/flavours/glitch/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })),
'ACCOUNT_EDIT_PROFILE_DISPLAY': () => import('@/flavours/glitch/features/account_edit/components/profile_display_modal').then(module => ({ default: module.ProfileDisplayModal })),
'ACCOUNT_EDIT_NAME': accountEditModal('NameModal'),
'ACCOUNT_EDIT_BIO': accountEditModal('BioModal'),
'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('@/flavours/glitch/features/account_edit/modals')} type */
function accountEditModal(type) {
return () => import('@/flavours/glitch/features/account_edit/modals').then(module => ({ default: module[type] }));
}
export default class ModalRoot extends PureComponent {
static propTypes = {

View File

@@ -12,6 +12,7 @@ import {
apiPostFeaturedTag,
} from '@/flavours/glitch/api/accounts';
import { apiGetSearch } from '@/flavours/glitch/api/search';
import type { ApiAccountFieldJSON } from '@/flavours/glitch/api_types/accounts';
import type {
ApiProfileJSON,
ApiProfileUpdateParams,
@@ -23,20 +24,25 @@ import type {
import type { AppDispatch } from '@/flavours/glitch/store';
import {
createAppAsyncThunk,
createAppSelector,
createDataLoadingThunk,
} from '@/flavours/glitch/store/typed_functions';
import { hashObjectArray } from '@/flavours/glitch/utils/hash';
import type { SnakeToCamelCase } from '@/flavours/glitch/utils/types';
type ProfileData = {
[Key in keyof Omit<
ApiProfileJSON,
'note' | 'featured_tags'
'note' | 'fields' | 'featured_tags'
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
} & {
bio: ApiProfileJSON['note'];
fields: FieldData[];
featuredTags: TagData[];
};
export type FieldData = ApiAccountFieldJSON & { id: string };
export type TagData = {
[Key in keyof Omit<
ApiFeaturedTagJSON,
@@ -186,7 +192,7 @@ const transformProfile = (result: ApiProfileJSON): ProfileData => ({
id: result.id,
displayName: result.display_name,
bio: result.note,
fields: result.fields,
fields: hashObjectArray(result.fields),
avatar: result.avatar,
avatarStatic: result.avatar_static,
avatarDescription: result.avatar_description,
@@ -218,6 +224,83 @@ export const patchProfile = createDataLoadingThunk(
{ 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(
`${profileEditSlice.name}/fetchFeaturedTags`,
apiGetCurrentFeaturedTags,

View 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');
});
});

View 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>;
});
}