diff --git a/app/javascript/flavours/glitch/components/form_fields/index.ts b/app/javascript/flavours/glitch/components/form_fields/index.ts
index b44ceb63f8..97fb90cf56 100644
--- a/app/javascript/flavours/glitch/components/form_fields/index.ts
+++ b/app/javascript/flavours/glitch/components/form_fields/index.ts
@@ -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';
diff --git a/app/javascript/flavours/glitch/features/account_edit/components/char_counter.tsx b/app/javascript/flavours/glitch/features/account_edit/components/char_counter.tsx
deleted file mode 100644
index a1e242b519..0000000000
--- a/app/javascript/flavours/glitch/features/account_edit/components/char_counter.tsx
+++ /dev/null
@@ -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) => (
- maxLength && classes.counterError,
- )}
- >
-
-
-));
-CharCounter.displayName = 'CharCounter';
diff --git a/app/javascript/flavours/glitch/features/account_edit/components/emoji_picker.tsx b/app/javascript/flavours/glitch/features/account_edit/components/emoji_picker.tsx
deleted file mode 100644
index e69b9e8b9d..0000000000
--- a/app/javascript/flavours/glitch/features/account_edit/components/emoji_picker.tsx
+++ /dev/null
@@ -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 ;
-};
diff --git a/app/javascript/flavours/glitch/features/account_edit/components/field_actions.tsx b/app/javascript/flavours/glitch/features/account_edit/components/field_actions.tsx
new file mode 100644
index 0000000000..4a1ee57d9c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_edit/components/field_actions.tsx
@@ -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 (
+ <>
+
+
+ >
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/account_edit/index.tsx b/app/javascript/flavours/glitch/features/account_edit/index.tsx
index ac5066a5da..8729d6fa35 100644
--- a/app/javascript/flavours/glitch/features/account_edit/index.tsx
+++ b/app/javascript/flavours/glitch/features/account_edit/index.tsx
@@ -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 = () => {
+ showDescription={!hasFields}
+ >
+
+ {profile.fields.map((field) => (
+ -
+
+
+
+
+
+
+ ))}
+
+
+ {!hasFields && (
+
+
+
+ )}
+
= ({ onClose }) => {
const intl = useIntl();
const titleId = useId();
- const counterId = useId();
- const textAreaRef = useRef(null);
const { profile: { bio } = {}, isPending } = useAppSelector(
(state) => state.profileEdit,
);
const [newBio, setNewBio] = useState(bio ?? '');
- const handleChange: ChangeEventHandler = 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 = ({ onClose }) => {
onConfirm={handleSave}
onClose={onClose}
updating={isPending}
- disabled={newBio.length > MAX_BIO_LENGTH}
+ disabled={!!maxLength && newBio.length > maxLength}
noFocusButton
>
-
-
-
-
-
);
diff --git a/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx b/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx
new file mode 100644
index 0000000000..5cd263821e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx
@@ -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
+ | undefined,
+ ],
+ (accounts) => ({
+ nameLimit: accounts?.get('profile_field_name_limit'),
+ valueLimit: accounts?.get('profile_field_value_limit'),
+ }),
+);
+
+export const EditFieldModal: FC = ({
+ 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 (
+
+
+
+
+
+ );
+};
+
+export const DeleteFieldModal: FC = ({
+ 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 (
+
+ }
+ buttons={
+
+ }
+ >
+
+
+ );
+};
+
+export const RearrangeFieldsModal: FC = ({ onClose }) => {
+ return (
+
+ Not implemented yet
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/account_edit/modals/index.ts b/app/javascript/flavours/glitch/features/account_edit/modals/index.ts
new file mode 100644
index 0000000000..4240544c51
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_edit/modals/index.ts
@@ -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';
diff --git a/app/javascript/flavours/glitch/features/account_edit/components/name_modal.tsx b/app/javascript/flavours/glitch/features/account_edit/modals/name_modal.tsx
similarity index 51%
rename from app/javascript/flavours/glitch/features/account_edit/components/name_modal.tsx
rename to app/javascript/flavours/glitch/features/account_edit/modals/name_modal.tsx
index b8428f38fe..45331dfc0c 100644
--- a/app/javascript/flavours/glitch/features/account_edit/components/name_modal.tsx
+++ b/app/javascript/flavours/glitch/features/account_edit/modals/name_modal.tsx
@@ -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 = ({ onClose }) => {
const intl = useIntl();
const titleId = useId();
- const counterId = useId();
- const inputRef = useRef(null);
const { profile: { displayName } = {}, isPending } = useAppSelector(
(state) => state.profileEdit,
);
- const [newName, setNewName] = useState(displayName ?? '');
- const handleChange: ChangeEventHandler = 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 = ({ onClose }) => {
onConfirm={handleSave}
onClose={onClose}
updating={isPending}
- disabled={newName.length > MAX_NAME_LENGTH}
+ disabled={!!maxLength && newName.length > maxLength}
noCloseOnConfirm
noFocusButton
>
-
-
-
-
-
);
diff --git a/app/javascript/flavours/glitch/features/account_edit/components/profile_display_modal.tsx b/app/javascript/flavours/glitch/features/account_edit/modals/profile_display_modal.tsx
similarity index 98%
rename from app/javascript/flavours/glitch/features/account_edit/components/profile_display_modal.tsx
rename to app/javascript/flavours/glitch/features/account_edit/modals/profile_display_modal.tsx
index 3b866d2ba3..7fe525b2cc 100644
--- a/app/javascript/flavours/glitch/features/account_edit/components/profile_display_modal.tsx
+++ b/app/javascript/flavours/glitch/features/account_edit/modals/profile_display_modal.tsx
@@ -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 = ({ onClose }) => {
const intl = useIntl();
diff --git a/app/javascript/flavours/glitch/features/account_edit/modals/styles.module.scss b/app/javascript/flavours/glitch/features/account_edit/modals/styles.module.scss
new file mode 100644
index 0000000000..4c00f85cfe
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_edit/modals/styles.module.scss
@@ -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);
+ }
+}
diff --git a/app/javascript/flavours/glitch/features/account_edit/modals/verified_modal.tsx b/app/javascript/flavours/glitch/features/account_edit/modals/verified_modal.tsx
new file mode 100644
index 0000000000..d12d9a16bc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_edit/modals/verified_modal.tsx
@@ -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 = ({ onClose }) => {
+ const accountUrl = useAppSelector(selectAccountUrl);
+
+ return (
+
+ }
+ noCancelButton
+ >
+
+
+
+ -
+
+ }
+ value={`Mastodon`}
+ />
+
+
+
+
+
+ <a> }}
+ />
+
+
+ -
+
+
+
+
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/account_edit/styles.module.scss b/app/javascript/flavours/glitch/features/account_edit/styles.module.scss
index 29daddbe3f..9408e80942 100644
--- a/app/javascript/flavours/glitch/features/account_edit/styles.module.scss
+++ b/app/javascript/flavours/glitch/features/account_edit/styles.module.scss
@@ -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);
-}
diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx
index 815f6fd09b..04af80ec13 100644
--- a/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx
+++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx
@@ -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 (
-
+
{title}
{message && {message}
}
diff --git a/app/javascript/flavours/glitch/features/ui/components/dialog_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/dialog_modal.tsx
index 462fe369cc..98e783a145 100644
--- a/app/javascript/flavours/glitch/features/ui/components/dialog_modal.tsx
+++ b/app/javascript/flavours/glitch/features/ui/components/dialog_modal.tsx
@@ -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 = ({
@@ -27,16 +26,13 @@ export const DialogModal: FC = ({
title,
onClose,
description,
- formClassName,
+ wrapperClassName,
children,
noCancelButton = false,
- onSave,
- saveLabel,
+ buttons,
}) => {
const intl = useIntl();
- const showButtons = !noCancelButton || onSave;
-
return (
@@ -61,13 +57,16 @@ export const DialogModal: FC = ({
)}
{children}
- {showButtons && (
+ {(buttons || !noCancelButton) && (
{!noCancelButton && (
)}
- {onSave && (
-
- )}
+ {buttons}
)}
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
index d2f94749f9..106be8be4f 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
@@ -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 = {
diff --git a/app/javascript/flavours/glitch/reducers/slices/profile_edit.ts b/app/javascript/flavours/glitch/reducers/slices/profile_edit.ts
index 65db68647f..ae4a8d2537 100644
--- a/app/javascript/flavours/glitch/reducers/slices/profile_edit.ts
+++ b/app/javascript/flavours/glitch/reducers/slices/profile_edit.ts
@@ -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]: 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[] = [];
+ 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,
diff --git a/app/javascript/flavours/glitch/utils/hash.test.ts b/app/javascript/flavours/glitch/utils/hash.test.ts
new file mode 100644
index 0000000000..c82361d96a
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/hash.test.ts
@@ -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');
+ });
+});
diff --git a/app/javascript/flavours/glitch/utils/hash.ts b/app/javascript/flavours/glitch/utils/hash.ts
new file mode 100644
index 0000000000..d7f59b56c5
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/hash.ts
@@ -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)[] {
+ const keySet = new Set();
+
+ 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;
+ });
+}