mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
[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:
@@ -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';
|
||||
|
||||
@@ -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 '@/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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 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: {
|
||||
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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 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 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>
|
||||
);
|
||||
@@ -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();
|
||||
@@ -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 '@/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. 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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
48
app/javascript/flavours/glitch/utils/hash.test.ts
Normal file
48
app/javascript/flavours/glitch/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/flavours/glitch/utils/hash.ts
Normal file
39
app/javascript/flavours/glitch/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>;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user