[Glitch] Profile editing: Name and bio
Port ed4787c1b1de1d7491719be0f45b7a7f16af8c0d to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
eebed16f59
commit
3ac5b10dec
@ -6,7 +6,7 @@ import { EmojiHTML } from './emoji/html';
|
|||||||
import { useElementHandledLink } from './status/handled_link';
|
import { useElementHandledLink } from './status/handled_link';
|
||||||
|
|
||||||
interface AccountBioProps {
|
interface AccountBioProps {
|
||||||
className: string;
|
className?: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
showDropdown?: boolean;
|
showDropdown?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,6 +42,13 @@ export const WithError: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AutoSize: Story = {
|
||||||
|
args: {
|
||||||
|
autoSize: true,
|
||||||
|
defaultValue: 'This textarea will grow as you type more lines.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const Plain: Story = {
|
export const Plain: Story = {
|
||||||
render(args) {
|
render(args) {
|
||||||
return <TextArea {...args} />;
|
return <TextArea {...args} />;
|
||||||
|
|||||||
@ -3,12 +3,16 @@ import { forwardRef, useCallback } from 'react';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
|
||||||
|
import TextAreaAutosize from 'react-textarea-autosize';
|
||||||
|
|
||||||
import { FormFieldWrapper } from './form_field_wrapper';
|
import { FormFieldWrapper } from './form_field_wrapper';
|
||||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||||
import classes from './text_input.module.scss';
|
import classes from './text_input.module.scss';
|
||||||
|
|
||||||
interface Props
|
type TextAreaProps =
|
||||||
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}
|
| ({ autoSize?: false } & ComponentPropsWithoutRef<'textarea'>)
|
||||||
|
| ({ autoSize: true } & TextareaAutosizeProps);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple form field for multi-line text.
|
* A simple form field for multi-line text.
|
||||||
@ -17,45 +21,56 @@ interface Props
|
|||||||
* or optional (by explicitly setting `required={false}`)
|
* or optional (by explicitly setting `required={false}`)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>(
|
export const TextAreaField = forwardRef<
|
||||||
({ id, label, hint, required, hasError, ...otherProps }, ref) => (
|
HTMLTextAreaElement,
|
||||||
<FormFieldWrapper
|
TextAreaProps & CommonFieldWrapperProps
|
||||||
label={label}
|
>(({ id, label, hint, required, hasError, ...otherProps }, ref) => (
|
||||||
hint={hint}
|
<FormFieldWrapper
|
||||||
required={required}
|
label={label}
|
||||||
hasError={hasError}
|
hint={hint}
|
||||||
inputId={id}
|
required={required}
|
||||||
>
|
hasError={hasError}
|
||||||
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
inputId={id}
|
||||||
</FormFieldWrapper>
|
>
|
||||||
),
|
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
||||||
);
|
</FormFieldWrapper>
|
||||||
|
));
|
||||||
|
|
||||||
TextAreaField.displayName = 'TextAreaField';
|
TextAreaField.displayName = 'TextAreaField';
|
||||||
|
|
||||||
export const TextArea = forwardRef<
|
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||||
HTMLTextAreaElement,
|
({ className, onKeyDown, autoSize, ...otherProps }, ref) => {
|
||||||
ComponentPropsWithoutRef<'textarea'>
|
const handleSubmitHotkey = useCallback(
|
||||||
>(({ className, onKeyDown, ...otherProps }, ref) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
const handleSubmitHotkey = useCallback(
|
onKeyDown?.(e);
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
onKeyDown?.(e);
|
const targetForm = e.currentTarget.form;
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
targetForm?.requestSubmit();
|
||||||
const targetForm = e.currentTarget.form;
|
}
|
||||||
targetForm?.requestSubmit();
|
},
|
||||||
}
|
[onKeyDown],
|
||||||
},
|
);
|
||||||
[onKeyDown],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
if (autoSize) {
|
||||||
<textarea
|
return (
|
||||||
{...otherProps}
|
<TextAreaAutosize
|
||||||
onKeyDown={handleSubmitHotkey}
|
{...(otherProps as TextareaAutosizeProps)}
|
||||||
className={classNames(className, classes.input)}
|
onKeyDown={handleSubmitHotkey}
|
||||||
ref={ref}
|
className={classNames(className, classes.input)}
|
||||||
/>
|
ref={ref}
|
||||||
);
|
/>
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
{...otherProps}
|
||||||
|
onKeyDown={handleSubmitHotkey}
|
||||||
|
className={classNames(className, classes.input)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
TextArea.displayName = 'TextArea';
|
TextArea.displayName = 'TextArea';
|
||||||
|
|||||||
@ -0,0 +1,94 @@
|
|||||||
|
import { useCallback, useId, useRef, useState } from 'react';
|
||||||
|
import type { ChangeEventHandler, FC } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { TextArea } from '@/flavours/glitch/components/form_fields';
|
||||||
|
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||||
|
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
|
||||||
|
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
|
||||||
|
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
|
||||||
|
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||||
|
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||||
|
|
||||||
|
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',
|
||||||
|
defaultMessage: 'Add bio',
|
||||||
|
},
|
||||||
|
editTitle: {
|
||||||
|
id: 'account_edit.bio_modal.edit_title',
|
||||||
|
defaultMessage: 'Edit bio',
|
||||||
|
},
|
||||||
|
save: {
|
||||||
|
id: 'account_edit.save',
|
||||||
|
defaultMessage: 'Save',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 accountId = useCurrentAccountId();
|
||||||
|
const account = useAccount(accountId);
|
||||||
|
|
||||||
|
const [newBio, setNewBio] = useState(account?.note_plain ?? '');
|
||||||
|
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
setNewBio(event.currentTarget.value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handlePickEmoji = useCallback((emoji: string) => {
|
||||||
|
setNewBio((prev) => {
|
||||||
|
const position = textAreaRef.current?.selectionStart ?? prev.length;
|
||||||
|
return insertEmojiAtPosition(prev, emoji, position);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationModal
|
||||||
|
title={intl.formatMessage(
|
||||||
|
account.note_plain ? messages.editTitle : messages.addTitle,
|
||||||
|
)}
|
||||||
|
titleId={titleId}
|
||||||
|
confirm={intl.formatMessage(messages.save)}
|
||||||
|
onConfirm={onClose} // To be implemented
|
||||||
|
onClose={onClose}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</ConfirmationModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
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';
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
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,87 @@
|
|||||||
|
import { useCallback, useId, useRef, useState } from 'react';
|
||||||
|
import type { ChangeEventHandler, 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 type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
|
||||||
|
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
|
||||||
|
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||||
|
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||||
|
|
||||||
|
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',
|
||||||
|
defaultMessage: 'Add display name',
|
||||||
|
},
|
||||||
|
editTitle: {
|
||||||
|
id: 'account_edit.name_modal.edit_title',
|
||||||
|
defaultMessage: 'Edit display name',
|
||||||
|
},
|
||||||
|
save: {
|
||||||
|
id: 'account_edit.save',
|
||||||
|
defaultMessage: 'Save',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 accountId = useCurrentAccountId();
|
||||||
|
const account = useAccount(accountId);
|
||||||
|
|
||||||
|
const [newName, setNewName] = useState(account?.display_name ?? '');
|
||||||
|
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
setNewName(event.currentTarget.value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handlePickEmoji = useCallback((emoji: string) => {
|
||||||
|
setNewName((prev) => {
|
||||||
|
const position = inputRef.current?.selectionStart ?? prev.length;
|
||||||
|
return insertEmojiAtPosition(prev, emoji, position);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationModal
|
||||||
|
title={intl.formatMessage(messages.editTitle)}
|
||||||
|
titleId={titleId}
|
||||||
|
confirm={intl.formatMessage(messages.save)}
|
||||||
|
onConfirm={onClose} // To be implemented
|
||||||
|
onClose={onClose}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</ConfirmationModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from 'react-intl';
|
||||||
|
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||||
|
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||||
|
|
||||||
|
import classes from '../styles.module.scss';
|
||||||
|
|
||||||
|
const buttonMessage = defineMessage({
|
||||||
|
id: 'account_edit.section_edit_button',
|
||||||
|
defaultMessage: 'Edit',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AccountEditSectionProps {
|
||||||
|
title: MessageDescriptor;
|
||||||
|
description?: MessageDescriptor;
|
||||||
|
showDescription?: boolean;
|
||||||
|
onEdit?: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
extraButtons?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccountEditSection: FC<AccountEditSectionProps> = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
showDescription,
|
||||||
|
onEdit,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
extraButtons,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
return (
|
||||||
|
<section className={classNames(className, classes.section)}>
|
||||||
|
<header className={classes.sectionHeader}>
|
||||||
|
<h3 className={classes.sectionTitle}>
|
||||||
|
<FormattedMessage {...title} />
|
||||||
|
</h3>
|
||||||
|
{onEdit && (
|
||||||
|
<IconButton
|
||||||
|
icon='pencil'
|
||||||
|
iconComponent={EditIcon}
|
||||||
|
onClick={onEdit}
|
||||||
|
title={`${intl.formatMessage(buttonMessage)} ${intl.formatMessage(title)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{extraButtons}
|
||||||
|
</header>
|
||||||
|
{showDescription && (
|
||||||
|
<p className={classes.sectionSubtitle}>
|
||||||
|
<FormattedMessage {...description} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,23 +1,92 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import type { ModalType } from '@/flavours/glitch/actions/modal';
|
||||||
|
import { openModal } from '@/flavours/glitch/actions/modal';
|
||||||
|
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
||||||
|
import { Avatar } from '@/flavours/glitch/components/avatar';
|
||||||
import { Column } from '@/flavours/glitch/components/column';
|
import { Column } from '@/flavours/glitch/components/column';
|
||||||
import { ColumnHeader } from '@/flavours/glitch/components/column_header';
|
import { ColumnHeader } from '@/flavours/glitch/components/column_header';
|
||||||
|
import { DisplayNameSimple } from '@/flavours/glitch/components/display_name/simple';
|
||||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||||
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
|
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
|
||||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||||
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||||
|
import { autoPlayGif } from '@/flavours/glitch/initial_state';
|
||||||
|
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||||
|
|
||||||
|
import { AccountEditSection } from './components/section';
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
displayNameTitle: {
|
||||||
|
id: 'account_edit.display_name.title',
|
||||||
|
defaultMessage: 'Display name',
|
||||||
|
},
|
||||||
|
displayNamePlaceholder: {
|
||||||
|
id: 'account_edit.display_name.placeholder',
|
||||||
|
defaultMessage:
|
||||||
|
'Your display name is how your name appears on your profile and in timelines.',
|
||||||
|
},
|
||||||
|
bioTitle: {
|
||||||
|
id: 'account_edit.bio.title',
|
||||||
|
defaultMessage: 'Bio',
|
||||||
|
},
|
||||||
|
bioPlaceholder: {
|
||||||
|
id: 'account_edit.bio.placeholder',
|
||||||
|
defaultMessage: 'Add a short introduction to help others identify you.',
|
||||||
|
},
|
||||||
|
customFieldsTitle: {
|
||||||
|
id: 'account_edit.custom_fields.title',
|
||||||
|
defaultMessage: 'Custom fields',
|
||||||
|
},
|
||||||
|
customFieldsPlaceholder: {
|
||||||
|
id: 'account_edit.custom_fields.placeholder',
|
||||||
|
defaultMessage:
|
||||||
|
'Add your pronouns, external links, or anything else you’d like to share.',
|
||||||
|
},
|
||||||
|
featuredHashtagsTitle: {
|
||||||
|
id: 'account_edit.featured_hashtags.title',
|
||||||
|
defaultMessage: 'Featured hashtags',
|
||||||
|
},
|
||||||
|
featuredHashtagsPlaceholder: {
|
||||||
|
id: 'account_edit.featured_hashtags.placeholder',
|
||||||
|
defaultMessage:
|
||||||
|
'Help others identify, and have quick access to, your favorite topics.',
|
||||||
|
},
|
||||||
|
profileTabTitle: {
|
||||||
|
id: 'account_edit.profile_tab.title',
|
||||||
|
defaultMessage: 'Profile tab settings',
|
||||||
|
},
|
||||||
|
profileTabSubtitle: {
|
||||||
|
id: 'account_edit.profile_tab.subtitle',
|
||||||
|
defaultMessage: 'Customize the tabs on your profile and what they display.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
const accountId = useCurrentAccountId();
|
const accountId = useCurrentAccountId();
|
||||||
const account = useAccount(accountId);
|
const account = useAccount(accountId);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const handleOpenModal = useCallback(
|
||||||
|
(type: ModalType, props?: Record<string, unknown>) => {
|
||||||
|
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
const handleNameEdit = useCallback(() => {
|
||||||
|
handleOpenModal('ACCOUNT_EDIT_NAME');
|
||||||
|
}, [handleOpenModal]);
|
||||||
|
const handleBioEdit = useCallback(() => {
|
||||||
|
handleOpenModal('ACCOUNT_EDIT_BIO');
|
||||||
|
}, [handleOpenModal]);
|
||||||
|
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||||
}
|
}
|
||||||
@ -30,6 +99,8 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headerSrc = autoPlayGif ? account.header : account.header_static;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
<Column bindToDocument={!multiColumn} className={classes.column}>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
@ -37,7 +108,7 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||||||
id: 'account_edit.column_title',
|
id: 'account_edit.column_title',
|
||||||
defaultMessage: 'Edit Profile',
|
defaultMessage: 'Edit Profile',
|
||||||
})}
|
})}
|
||||||
className={classes.header}
|
className={classes.columnHeader}
|
||||||
showBackButton
|
showBackButton
|
||||||
extraButton={
|
extraButton={
|
||||||
<Link to={`/@${account.acct}`} className='button'>
|
<Link to={`/@${account.acct}`} className='button'>
|
||||||
@ -48,6 +119,48 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<header>
|
||||||
|
<div className={classes.profileImage}>
|
||||||
|
{headerSrc && <img src={headerSrc} alt='' />}
|
||||||
|
</div>
|
||||||
|
<Avatar account={account} size={80} className={classes.avatar} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<AccountEditSection
|
||||||
|
title={messages.displayNameTitle}
|
||||||
|
description={messages.displayNamePlaceholder}
|
||||||
|
showDescription={account.display_name.length === 0}
|
||||||
|
onEdit={handleNameEdit}
|
||||||
|
>
|
||||||
|
<DisplayNameSimple account={account} />
|
||||||
|
</AccountEditSection>
|
||||||
|
|
||||||
|
<AccountEditSection
|
||||||
|
title={messages.bioTitle}
|
||||||
|
description={messages.bioPlaceholder}
|
||||||
|
showDescription={!account.note_plain}
|
||||||
|
onEdit={handleBioEdit}
|
||||||
|
>
|
||||||
|
<AccountBio accountId={accountId} />
|
||||||
|
</AccountEditSection>
|
||||||
|
|
||||||
|
<AccountEditSection
|
||||||
|
title={messages.customFieldsTitle}
|
||||||
|
description={messages.customFieldsPlaceholder}
|
||||||
|
showDescription
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AccountEditSection
|
||||||
|
title={messages.featuredHashtagsTitle}
|
||||||
|
description={messages.featuredHashtagsPlaceholder}
|
||||||
|
showDescription
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AccountEditSection
|
||||||
|
title={messages.profileTabTitle}
|
||||||
|
description={messages.profileTabSubtitle}
|
||||||
|
showDescription
|
||||||
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
border-top-width: 0;
|
border-top-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.columnHeader {
|
||||||
:global(.column-header__buttons) {
|
:global(.column-header__buttons) {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-inline-end: 16px;
|
padding-inline-end: 16px;
|
||||||
@ -11,16 +11,100 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.profileImage {
|
||||||
display: flex;
|
height: 120px;
|
||||||
align-items: center;
|
background: var(--color-bg-secondary);
|
||||||
justify-content: space-between;
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
gap: 8px;
|
overflow: hidden;
|
||||||
padding: 24px 24px 12px;
|
|
||||||
|
|
||||||
> h1 {
|
@container (width >= 500px) {
|
||||||
flex-grow: 1;
|
height: 160px;
|
||||||
font-weight: 600;
|
}
|
||||||
font-size: 15px;
|
|
||||||
|
> img {
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
margin-top: -64px;
|
||||||
|
margin-left: 18px;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
flex-grow: 1;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionSubtitle {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counterError {
|
||||||
|
color: var(--color-text-error);
|
||||||
|
}
|
||||||
|
|||||||
@ -153,7 +153,7 @@ const InnerNodeModal: FC<{
|
|||||||
onConfirm={handleSave}
|
onConfirm={handleSave}
|
||||||
updating={state === 'saving'}
|
updating={state === 'saving'}
|
||||||
disabled={!isDirty}
|
disabled={!isDirty}
|
||||||
closeWhenConfirm={false}
|
noCloseOnConfirm
|
||||||
noFocusButton
|
noFocusButton
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -67,6 +67,24 @@ export function emojiToUnicodeHex(emoji: string): string {
|
|||||||
return codes.join('-');
|
return codes.join('-');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHARS_ALLOWED_AROUND_EMOJI =
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
/[>< …\u0009-\u000d\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
|
||||||
|
|
||||||
|
// TODO: Move to picker file when that's being built out.
|
||||||
|
export function insertEmojiAtPosition(
|
||||||
|
text: string,
|
||||||
|
emoji: string,
|
||||||
|
position = text.length,
|
||||||
|
): string {
|
||||||
|
const isShortcode = isCustomEmoji(emoji);
|
||||||
|
const needsSpace =
|
||||||
|
isShortcode &&
|
||||||
|
position > 0 &&
|
||||||
|
!CHARS_ALLOWED_AROUND_EMOJI.test(text[position - 1] ?? '');
|
||||||
|
return `${text.slice(0, position)}${needsSpace ? ' ' : ''}${emoji} ${text.slice(position)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function supportsRegExpSets() {
|
function supportsRegExpSets() {
|
||||||
return 'unicodeSets' in RegExp.prototype;
|
return 'unicodeSets' in RegExp.prototype;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,20 +19,23 @@ const messages = defineMessages({
|
|||||||
export const ConfirmationModal: React.FC<
|
export const ConfirmationModal: React.FC<
|
||||||
{
|
{
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
|
titleId?: string;
|
||||||
message?: React.ReactNode;
|
message?: React.ReactNode;
|
||||||
confirm: React.ReactNode;
|
confirm: React.ReactNode;
|
||||||
cancel?: React.ReactNode;
|
cancel?: React.ReactNode;
|
||||||
secondary?: React.ReactNode;
|
secondary?: React.ReactNode;
|
||||||
onSecondary?: () => void;
|
onSecondary?: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
closeWhenConfirm?: boolean;
|
noCloseOnConfirm?: boolean;
|
||||||
extraContent?: React.ReactNode;
|
extraContent?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
updating?: boolean;
|
updating?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
noFocusButton?: boolean;
|
noFocusButton?: boolean;
|
||||||
} & BaseConfirmationModalProps
|
} & BaseConfirmationModalProps
|
||||||
> = ({
|
> = ({
|
||||||
title,
|
title,
|
||||||
|
titleId,
|
||||||
message,
|
message,
|
||||||
confirm,
|
confirm,
|
||||||
cancel,
|
cancel,
|
||||||
@ -40,19 +43,20 @@ export const ConfirmationModal: React.FC<
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
secondary,
|
secondary,
|
||||||
onSecondary,
|
onSecondary,
|
||||||
closeWhenConfirm = true,
|
|
||||||
extraContent,
|
extraContent,
|
||||||
|
children,
|
||||||
updating,
|
updating,
|
||||||
disabled,
|
disabled,
|
||||||
|
noCloseOnConfirm = false,
|
||||||
noFocusButton = false,
|
noFocusButton = false,
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (closeWhenConfirm) {
|
if (!noCloseOnConfirm) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
onConfirm();
|
onConfirm();
|
||||||
}, [onClose, onConfirm, closeWhenConfirm]);
|
}, [onClose, onConfirm, noCloseOnConfirm]);
|
||||||
|
|
||||||
const handleSecondary = useCallback(() => {
|
const handleSecondary = useCallback(() => {
|
||||||
onClose();
|
onClose();
|
||||||
@ -63,10 +67,10 @@ export const ConfirmationModal: React.FC<
|
|||||||
<div className='modal-root__modal safety-action-modal'>
|
<div className='modal-root__modal safety-action-modal'>
|
||||||
<div className='safety-action-modal__top'>
|
<div className='safety-action-modal__top'>
|
||||||
<div className='safety-action-modal__confirmation'>
|
<div className='safety-action-modal__confirmation'>
|
||||||
<h1>{title}</h1>
|
<h1 id={titleId}>{title}</h1>
|
||||||
{message && <p>{message}</p>}
|
{message && <p>{message}</p>}
|
||||||
|
|
||||||
{extraContent}
|
{extraContent ?? children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
export type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||||
export { ConfirmationModal } from './confirmation_modal';
|
export { ConfirmationModal } from './confirmation_modal';
|
||||||
export { ConfirmDeleteStatusModal } from './delete_status';
|
export { ConfirmDeleteStatusModal } from './delete_status';
|
||||||
export { ConfirmDeleteListModal } from './delete_list';
|
export { ConfirmDeleteListModal } from './delete_list';
|
||||||
|
|||||||
@ -33,7 +33,7 @@ const messages = defineMessages({
|
|||||||
/**
|
/**
|
||||||
* [1] Since we only want this modal to have two buttons – "Don't ask again" and
|
* [1] Since we only want this modal to have two buttons – "Don't ask again" and
|
||||||
* "Got it" – , we have to use the `onClose` handler to handle the "Don't ask again"
|
* "Got it" – , we have to use the `onClose` handler to handle the "Don't ask again"
|
||||||
* functionality. Because of this, we need to set `closeWhenConfirm` to false and
|
* functionality. Because of this, we need to set `noCloseOnConfirm` to true and
|
||||||
* close the modal manually.
|
* close the modal manually.
|
||||||
* This prevents the modal from being dismissed permanently when just confirming.
|
* This prevents the modal from being dismissed permanently when just confirming.
|
||||||
*/
|
*/
|
||||||
@ -65,13 +65,13 @@ export const QuietPostQuoteInfoModal: React.FC<{ status: Status }> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
closeWhenConfirm={false} // [1]
|
|
||||||
title={intl.formatMessage(messages.title)}
|
title={intl.formatMessage(messages.title)}
|
||||||
message={intl.formatMessage(messages.message)}
|
message={intl.formatMessage(messages.message)}
|
||||||
confirm={intl.formatMessage(messages.got_it)}
|
confirm={intl.formatMessage(messages.got_it)}
|
||||||
cancel={intl.formatMessage(messages.dismiss)}
|
cancel={intl.formatMessage(messages.dismiss)}
|
||||||
onConfirm={confirm}
|
onConfirm={confirm}
|
||||||
onClose={dismiss}
|
onClose={dismiss}
|
||||||
|
noCloseOnConfirm
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -96,6 +96,8 @@ export const MODAL_COMPONENTS = {
|
|||||||
'ANNUAL_REPORT': AnnualReportModal,
|
'ANNUAL_REPORT': AnnualReportModal,
|
||||||
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
||||||
'ACCOUNT_NOTE': () => import('@/flavours/glitch/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
|
'ACCOUNT_NOTE': () => import('@/flavours/glitch/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
|
||||||
|
'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 })),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class ModalRoot extends PureComponent {
|
export default class ModalRoot extends PureComponent {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user