Profile redesign: Account notes (#37593)

This commit is contained in:
Echo 2026-01-27 16:34:09 +01:00 committed by GitHub
parent c0616bcab6
commit 34f0482ad4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 338 additions and 8 deletions

View File

@ -36,6 +36,7 @@ export interface CalloutProps {
secondaryLabel?: string;
onClose?: () => void;
id?: string;
extraContent?: ReactNode;
}
const variantClasses = {
@ -59,6 +60,7 @@ export const Callout: FC<CalloutProps> = ({
onSecondary: secondaryAction,
secondaryLabel,
onClose,
extraContent,
id,
}) => {
const intl = useIntl();
@ -105,6 +107,8 @@ export const Callout: FC<CalloutProps> = ({
)}
</div>
{extraContent}
{onClose && (
<IconButton
icon='close'

View File

@ -13,6 +13,7 @@
border-radius: 9999px;
width: 1rem;
height: 1rem;
margin-top: -2px;
}
.content {
@ -28,16 +29,12 @@
}
}
.icon + .content,
.wrapper:has(.close) .content {
margin-top: 2px;
}
.body {
flex-grow: 1;
h3 {
font-weight: 500;
margin-bottom: 5px;
}
}
@ -62,6 +59,12 @@
}
}
@media (prefers-reduced-motion: reduce) {
.action {
transition: none;
}
}
.close {
color: inherit;

View File

@ -24,6 +24,7 @@ import { AccountHeaderFields } from './fields';
import { AccountInfo } from './info';
import { MemorialNote } from './memorial_note';
import { MovedNote } from './moved_note';
import { AccountNote as AccountNoteRedesign } from './note';
import { AccountNumberFields } from './number_fields';
import redesignClasses from './redesign.module.scss';
import { AccountTabs } from './tabs';
@ -161,7 +162,9 @@ export const AccountHeader: React.FC<{
{!suspendedOrHidden && (
<div className='account__header__extra'>
<div className='account__header__bio'>
{me && account.id !== me && (
{me && account.id !== me && isRedesignEnabled() ? (
<AccountNoteRedesign accountId={accountId} />
) : (
<AccountNote accountId={accountId} />
)}

View File

@ -31,6 +31,8 @@ import {
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { isRedesignEnabled } from '../common';
const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
@ -55,6 +57,14 @@ const messages = defineMessages({
id: 'account.show_reblogs',
defaultMessage: 'Show boosts from @{name}',
},
addNote: {
id: 'account.add_note',
defaultMessage: 'Add a personal note',
},
editNote: {
id: 'account.edit_note',
defaultMessage: 'Edit personal note',
},
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
unendorse: {
id: 'account.unendorse',
@ -187,7 +197,30 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
});
arr.push(null);
}
}
if (isRedesignEnabled()) {
arr.push({
text: intl.formatMessage(
relationship?.note ? messages.editNote : messages.addNote,
),
action: () => {
dispatch(
openModal({
modalType: 'ACCOUNT_NOTE',
modalProps: {
accountId: account.id,
},
}),
);
},
});
if (!relationship?.following) {
arr.push(null);
}
}
if (relationship?.following) {
arr.push({
text: intl.formatMessage(
relationship.endorsed ? messages.unendorse : messages.endorse,

View File

@ -0,0 +1,69 @@
import { useCallback, useEffect } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { fetchRelationships } from '@/mastodon/actions/accounts';
import { openModal } from '@/mastodon/actions/modal';
import { Callout } from '@/mastodon/components/callout';
import { IconButton } from '@/mastodon/components/icon_button';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import EditIcon from '@/material-icons/400-24px/edit_square.svg?react';
import classes from './redesign.module.scss';
const messages = defineMessages({
title: {
id: 'account.note.title',
defaultMessage: 'Personal note (visible only to you)',
},
editButton: {
id: 'account.note.edit_button',
defaultMessage: 'Edit',
},
});
export const AccountNote: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!relationship) {
dispatch(fetchRelationships([accountId]));
}
}, [accountId, dispatch, relationship]);
const handleEdit = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_NOTE',
modalProps: { accountId },
}),
);
}, [accountId, dispatch]);
if (!relationship?.note) {
return null;
}
return (
<Callout
icon={false}
title={intl.formatMessage(messages.title)}
className={classes.note}
extraContent={
<IconButton
icon='edit'
iconComponent={EditIcon}
title={intl.formatMessage(messages.editButton)}
className={classes.noteEditButton}
onClick={handleEdit}
/>
}
>
{relationship.note}
</Callout>
);
};

View File

@ -61,6 +61,19 @@ svg.badgeIcon {
opacity: 1;
}
.note {
margin-bottom: 16px;
}
.noteEditButton {
color: inherit;
svg {
width: 20px;
height: 20px;
}
}
.fieldList {
margin-top: 16px;
}

View File

@ -0,0 +1,21 @@
.noteCallout {
margin-bottom: 16px;
}
.noteInput {
min-height: 70px;
width: 100%;
padding: 8px;
border-radius: 8px;
box-sizing: border-box;
background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary);
appearance: none;
resize: none;
margin-top: 4px;
}
.noteInput:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}

View File

@ -0,0 +1,160 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { submitAccountNote } from '@/mastodon/actions/account_notes';
import { fetchRelationships } from '@/mastodon/actions/accounts';
import { Callout } from '@/mastodon/components/callout';
import { TextAreaField } from '@/mastodon/components/form_fields';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import type { Relationship } from '@/mastodon/models/relationship';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
import classes from './modals.module.css';
const messages = defineMessages({
newTitle: {
id: 'account.node_modal.title',
defaultMessage: 'Add a personal note',
},
editTitle: {
id: 'account.node_modal.edit_title',
defaultMessage: 'Edit personal note',
},
save: {
id: 'account.node_modal.save',
defaultMessage: 'Save',
},
fieldLabel: {
id: 'account.node_modal.field_label',
defaultMessage: 'Personal Note',
},
errorUnknown: {
id: 'account.node_modal.error_unknown',
defaultMessage: 'Could not save the note',
},
});
export const AccountNoteModal: FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!relationship) {
dispatch(fetchRelationships([accountId]));
}
}, [accountId, dispatch, relationship]);
if (!relationship) {
return <LoadingIndicator />;
}
return (
<InnerNodeModal
relationship={relationship}
accountId={accountId}
onClose={onClose}
/>
);
};
const InnerNodeModal: FC<{
relationship: Relationship;
accountId: string;
onClose: () => void;
}> = ({ relationship, accountId, onClose }) => {
// Set up the state.
const initialContents = relationship.note;
const [note, setNote] = useState(initialContents);
const [errorText, setErrorText] = useState('');
const [state, setState] = useState<'idle' | 'saving' | 'error'>('idle');
const isDirty = note !== initialContents;
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(e) => {
if (state !== 'saving') {
setNote(e.target.value);
}
},
[state],
);
const intl = useIntl();
// Create an abort controller to cancel the request if the modal is closed.
const abortController = useRef(new AbortController());
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
if (state === 'saving' || !isDirty) {
return;
}
setState('saving');
dispatch(
submitAccountNote(
{ accountId, note },
{ signal: abortController.current.signal },
),
)
.then(() => {
setState('idle');
onClose();
})
.catch((err: unknown) => {
setState('error');
if (err instanceof Error) {
setErrorText(err.message);
} else {
setErrorText(intl.formatMessage(messages.errorUnknown));
}
});
}, [accountId, dispatch, intl, isDirty, note, onClose, state]);
const handleCancel = useCallback(() => {
abortController.current.abort();
onClose();
}, [onClose]);
return (
<ConfirmationModal
title={
initialContents
? intl.formatMessage(messages.editTitle)
: intl.formatMessage(messages.newTitle)
}
extraContent={
<>
<Callout className={classes.noteCallout}>
<FormattedMessage
id='account.node_modal.callout'
defaultMessage='Personal notes are visible only to you.'
/>
</Callout>
<TextAreaField
value={note}
onChange={handleChange}
label={intl.formatMessage(messages.fieldLabel)}
className={classes.noteInput}
hasError={state === 'error'}
hint={errorText}
// eslint-disable-next-line jsx-a11y/no-autofocus -- We want to focus here as it's a modal.
autoFocus
/>
</>
}
onClose={handleCancel}
confirm={intl.formatMessage(messages.save)}
onConfirm={handleSave}
updating={state === 'saving'}
disabled={!isDirty}
closeWhenConfirm={false}
noFocusButton
/>
);
};

View File

@ -19,6 +19,9 @@ export const ConfirmationModal: React.FC<
onConfirm: () => void;
closeWhenConfirm?: boolean;
extraContent?: React.ReactNode;
updating?: boolean;
disabled?: boolean;
noFocusButton?: boolean;
} & BaseConfirmationModalProps
> = ({
title,
@ -31,6 +34,9 @@ export const ConfirmationModal: React.FC<
onSecondary,
closeWhenConfirm = true,
extraContent,
updating,
disabled,
noFocusButton = false,
}) => {
const handleClick = useCallback(() => {
if (closeWhenConfirm) {
@ -74,16 +80,23 @@ export const ConfirmationModal: React.FC<
onClick={handleSecondary}
className='link-button'
type='button'
disabled={disabled}
>
{secondary}
</button>
</>
)}
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}
<Button onClick={handleClick} autoFocus>
{/* eslint-disable jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}
<Button
onClick={handleClick}
loading={updating}
disabled={disabled}
autoFocus={!noFocusButton}
>
{confirm}
</Button>
{/* eslint-enable */}
</div>
</div>
</div>

View File

@ -85,6 +85,7 @@ export const MODAL_COMPONENTS = {
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
'ANNUAL_REPORT': AnnualReportModal,
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
'ACCOUNT_FIELDS': () => import('mastodon/features/account_timeline/components/fields_modal.tsx').then(module => ({ default: module.AccountFieldsModal })),
};

View File

@ -14,6 +14,7 @@
"about.powered_by": "Decentralized social media powered by {mastodon}",
"about.rules": "Server rules",
"account.account_note_header": "Personal note",
"account.add_note": "Add a personal note",
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.badges.bot": "Automated",
"account.badges.group": "Group",
@ -27,6 +28,7 @@
"account.direct": "Privately mention @{name}",
"account.disable_notifications": "Stop notifying me when @{name} posts",
"account.domain_blocking": "Blocking domain",
"account.edit_note": "Edit personal note",
"account.edit_profile": "Edit profile",
"account.edit_profile_short": "Edit",
"account.enable_notifications": "Notify me when @{name} posts",
@ -72,6 +74,14 @@
"account.muting": "Muting",
"account.mutual": "You follow each other",
"account.no_bio": "No description provided.",
"account.node_modal.callout": "Personal notes are visible only to you.",
"account.node_modal.edit_title": "Edit personal note",
"account.node_modal.error_unknown": "Could not save the note",
"account.node_modal.field_label": "Personal Note",
"account.node_modal.save": "Save",
"account.node_modal.title": "Add a personal note",
"account.note.edit_button": "Edit",
"account.note.title": "Personal note (visible only to you)",
"account.open_original_page": "Open original page",
"account.posts": "Posts",
"account.posts_with_replies": "Posts and replies",