From 32841f801610e51a5606a2009ca85e6dd54049fc Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 27 Jan 2026 16:34:09 +0100 Subject: [PATCH] [Glitch] Profile redesign: Account notes Port 34f0482ad47e3cdadc9f2122530b581c039071b6 to glitch-soc Signed-off-by: Claire --- .../glitch/components/callout/index.tsx | 4 + .../components/callout/styles.module.css | 13 +- .../components/account_header.tsx | 5 +- .../account_timeline/components/menu.tsx | 33 ++++ .../account_timeline/components/note.tsx | 69 ++++++++ .../components/redesign.module.scss | 13 ++ .../account_timeline/modals/modals.module.css | 21 +++ .../account_timeline/modals/note_modal.tsx | 160 ++++++++++++++++++ .../confirmation_modal.tsx | 17 +- .../features/ui/components/modal_root.jsx | 1 + 10 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 app/javascript/flavours/glitch/features/account_timeline/components/note.tsx create mode 100644 app/javascript/flavours/glitch/features/account_timeline/modals/modals.module.css create mode 100644 app/javascript/flavours/glitch/features/account_timeline/modals/note_modal.tsx diff --git a/app/javascript/flavours/glitch/components/callout/index.tsx b/app/javascript/flavours/glitch/components/callout/index.tsx index e7ab410a9c..a9232ec3a7 100644 --- a/app/javascript/flavours/glitch/components/callout/index.tsx +++ b/app/javascript/flavours/glitch/components/callout/index.tsx @@ -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 = ({ onSecondary: secondaryAction, secondaryLabel, onClose, + extraContent, id, }) => { const intl = useIntl(); @@ -105,6 +107,8 @@ export const Callout: FC = ({ )} + {extraContent} + {onClose && (
- {me && account.id !== me && ( + {me && account.id !== me && isRedesignEnabled() ? ( + + ) : ( )} diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/menu.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/menu.tsx index fd774c4047..5f76a944fe 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/menu.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/components/menu.tsx @@ -34,6 +34,8 @@ import { import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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}' }, @@ -58,6 +60,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', @@ -190,7 +200,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, diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/note.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/note.tsx new file mode 100644 index 0000000000..5da393e712 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/components/note.tsx @@ -0,0 +1,69 @@ +import { useCallback, useEffect } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { fetchRelationships } from '@/flavours/glitch/actions/accounts'; +import { openModal } from '@/flavours/glitch/actions/modal'; +import { Callout } from '@/flavours/glitch/components/callout'; +import { IconButton } from '@/flavours/glitch/components/icon_button'; +import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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 ( + + } + > + {relationship.note} + + ); +}; diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/redesign.module.scss b/app/javascript/flavours/glitch/features/account_timeline/components/redesign.module.scss index 70d629e346..028e2b41dc 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/flavours/glitch/features/account_timeline/components/redesign.module.scss @@ -61,6 +61,19 @@ svg.badgeIcon { opacity: 1; } +.note { + margin-bottom: 16px; +} + +.noteEditButton { + color: inherit; + + svg { + width: 20px; + height: 20px; + } +} + .fieldList { margin-top: 16px; } diff --git a/app/javascript/flavours/glitch/features/account_timeline/modals/modals.module.css b/app/javascript/flavours/glitch/features/account_timeline/modals/modals.module.css new file mode 100644 index 0000000000..cee0bc498a --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/modals/modals.module.css @@ -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; +} diff --git a/app/javascript/flavours/glitch/features/account_timeline/modals/note_modal.tsx b/app/javascript/flavours/glitch/features/account_timeline/modals/note_modal.tsx new file mode 100644 index 0000000000..b595b19382 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/modals/note_modal.tsx @@ -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 '@/flavours/glitch/actions/account_notes'; +import { fetchRelationships } from '@/flavours/glitch/actions/accounts'; +import { Callout } from '@/flavours/glitch/components/callout'; +import { TextAreaField } from '@/flavours/glitch/components/form_fields'; +import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator'; +import type { Relationship } from '@/flavours/glitch/models/relationship'; +import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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 ; + } + + return ( + + ); +}; + +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 = 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 ( + + + + + + + } + onClose={handleCancel} + confirm={intl.formatMessage(messages.save)} + onConfirm={handleSave} + updating={state === 'saving'} + disabled={!isDirty} + closeWhenConfirm={false} + noFocusButton + /> + ); +}; 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 493363b7d8..8b340d248f 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 @@ -27,6 +27,9 @@ export const ConfirmationModal: React.FC< onConfirm: () => void; closeWhenConfirm?: boolean; extraContent?: React.ReactNode; + updating?: boolean; + disabled?: boolean; + noFocusButton?: boolean; } & BaseConfirmationModalProps > = ({ title, @@ -39,6 +42,9 @@ export const ConfirmationModal: React.FC< onSecondary, closeWhenConfirm = true, extraContent, + updating, + disabled, + noFocusButton = false, }) => { const handleClick = useCallback(() => { if (closeWhenConfirm) { @@ -82,16 +88,23 @@ export const ConfirmationModal: React.FC< onClick={handleSecondary} className='link-button' type='button' + disabled={disabled} > {secondary} )} - {/* eslint-disable-next-line jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */} - + {/* eslint-enable */}
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 3c9d640334..3dd8180719 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx @@ -93,6 +93,7 @@ export const MODAL_COMPONENTS = { 'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal, 'ANNUAL_REPORT': AnnualReportModal, 'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }), + 'ACCOUNT_NOTE': () => import('@/flavours/glitch/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })), 'ACCOUNT_FIELDS': () => import('flavours/glitch/features/account_timeline/components/fields_modal.tsx').then(module => ({ default: module.AccountFieldsModal })), };