diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 1067b0e041..b8b9bd385e 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -26,9 +26,10 @@ import { modes } from './modes'; import '../app/javascript/styles/application.scss'; import './styles.css'; -const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { - query: { as: 'json' }, -}); +// Disabling locales in Storybook as it's breaking with Vite 8. +// const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { +// query: { as: 'json' }, +// }); // Initialize MSW initialize({ @@ -39,17 +40,17 @@ const preview: Preview = { // Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], globalTypes: { - locale: { - description: 'Locale for the story', - toolbar: { - title: 'Locale', - icon: 'globe', - items: Object.keys(localeFiles).map((path) => - path.replace('/mastodon/locales/', '').replace('.json', ''), - ), - dynamicTitle: true, - }, - }, + // locale: { + // description: 'Locale for the story', + // toolbar: { + // title: 'Locale', + // icon: 'globe', + // items: Object.keys(localeFiles).map((path) => + // path.replace('/mastodon/locales/', '').replace('.json', ''), + // ), + // dynamicTitle: true, + // }, + // }, theme: { description: 'Theme for the story', toolbar: { diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cdb887424..56b6fbc92c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to this project will be documented in this file. +## [4.5.8] - 2026-03-24 + +### Security + +- Fix insufficient checks on quote authorizations ([GHSA-q4g8-82c5-9h33](https://github.com/mastodon/mastodon/security/advisories/GHSA-q4g8-82c5-9h33)) +- Fix open redirect in legacy path handler ([GHSA-xqw8-4j56-5hj6](https://github.com/mastodon/mastodon/security/advisories/GHSA-xqw8-4j56-5hj6)) +- Updated dependencies + +### Added + +- Add for searching already-known private GtS posts (#38057 by @ClearlyClaire) + +### Changed + +- Change media description length limit for remote media attachments from 1500 to 10000 characters (#37921 by @ClearlyClaire) +- Change HTTP signatures to skip the `Accept` header (#38132 by @ClearlyClaire) +- Change numeric AP endpoints to redirect to short account URLs when HTML is requested (#38056 by @ClearlyClaire) + +### Fixed + +- Fix some model definitions in `tootctl maintenance fix-duplicates` (#38214 by @ClearlyClaire) +- Fix overly strict checks for current username on account migration page (#38183 by @mjankowski) +- Fix OpenStack Swift Keystone token rate limiting (#38145 by @hugogameiro) +- Fix poll expiration notification being re-triggered on implicit updates (#38078 by @ClearlyClaire) +- Fix incorrect translation string in webauthn mailers (#38062 by @mjankowski) +- Fix “Unblock” and “Unmute” actions being disabled when blocked (#38075 by @ClearlyClaire) +- Fix username availability check being wrongly applied on race conditions (#37975 by @ClearlyClaire) +- Fix hover card unintentionally being shown in some cases (#38039 and #38112 by @diondiondion) +- Fix existing posts not being removed from lists when a list member is unfollowed (#38048 by @ClearlyClaire) + ## [4.5.7] - 2026-02-24 ### Security diff --git a/app/javascript/mastodon/components/column_header.tsx b/app/javascript/mastodon/components/column_header.tsx index 9f2f38e7d0..49c2bb1d4a 100644 --- a/app/javascript/mastodon/components/column_header.tsx +++ b/app/javascript/mastodon/components/column_header.tsx @@ -267,6 +267,15 @@ export const ColumnHeader: React.FC = ({ const hasTitle = (hasIcon || backButton) && title; const columnIndex = useColumnIndexContext(); + const titleContents = ( + <> + {!backButton && hasIcon && ( + + )} + {title} + + ); + const component = (

@@ -274,21 +283,25 @@ export const ColumnHeader: React.FC = ({ <> {backButton} - + {onClick && ( + + )} + {!onClick && ( + + {titleContents} + + )} )} diff --git a/app/javascript/mastodon/components/form_fields/range_input.module.scss b/app/javascript/mastodon/components/form_fields/range_input.module.scss index cbace07dcc..5aa46b52cc 100644 --- a/app/javascript/mastodon/components/form_fields/range_input.module.scss +++ b/app/javascript/mastodon/components/form_fields/range_input.module.scss @@ -13,6 +13,7 @@ margin: 6px 0; background-color: transparent; appearance: none; + display: block; &:focus { outline: none; diff --git a/app/javascript/mastodon/components/form_fields/range_input_field.tsx b/app/javascript/mastodon/components/form_fields/range_input_field.tsx index 8fb2620339..d619d6455a 100644 --- a/app/javascript/mastodon/components/form_fields/range_input_field.tsx +++ b/app/javascript/mastodon/components/form_fields/range_input_field.tsx @@ -14,7 +14,9 @@ export type RangeInputProps = Omit< markers?: { value: number; label: string }[] | number[]; }; -interface Props extends RangeInputProps, CommonFieldWrapperProps {} +interface Props extends RangeInputProps, CommonFieldWrapperProps { + inputPlacement?: 'inline-start' | 'inline-end'; // TODO: Move this to the common field wrapper props for other fields. +} /** * A simple form field for single-line text. @@ -25,7 +27,16 @@ interface Props extends RangeInputProps, CommonFieldWrapperProps {} export const RangeInputField = forwardRef( ( - { id, label, hint, status, required, wrapperClassName, ...otherProps }, + { + id, + label, + hint, + status, + required, + wrapperClassName, + inputPlacement, + ...otherProps + }, ref, ) => ( ( required={required} status={status} inputId={id} + inputPlacement={inputPlacement} className={wrapperClassName} > {(inputProps) => } diff --git a/app/javascript/mastodon/features/account_edit/components/column.tsx b/app/javascript/mastodon/features/account_edit/components/column.tsx index 5f0ad929a1..9fb83e444c 100644 --- a/app/javascript/mastodon/features/account_edit/components/column.tsx +++ b/app/javascript/mastodon/features/account_edit/components/column.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { FormattedMessage } from 'react-intl'; +import { Helmet } from 'react-helmet'; import { Link } from 'react-router-dom'; import { Column } from '@/mastodon/components/column'; @@ -36,22 +37,27 @@ export const AccountEditColumn: FC<{ const { multiColumn } = useColumnsContext(); return ( - - - - - } - /> + <> + + + + + } + /> - {children} - + {children} + + + {title} + + ); }; diff --git a/app/javascript/mastodon/features/account_edit/components/edit_button.tsx b/app/javascript/mastodon/features/account_edit/components/edit_button.tsx index f2fecf21d0..eaf6e291cd 100644 --- a/app/javascript/mastodon/features/account_edit/components/edit_button.tsx +++ b/app/javascript/mastodon/features/account_edit/components/edit_button.tsx @@ -1,8 +1,5 @@ import type { FC, MouseEventHandler } from 'react'; -import type { MessageDescriptor } from 'react-intl'; -import { defineMessages, useIntl } from 'react-intl'; - import classNames from 'classnames'; import { Button } from '@/mastodon/components/button'; @@ -12,43 +9,19 @@ import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import classes from '../styles.module.scss'; -const messages = defineMessages({ - add: { - id: 'account_edit.button.add', - defaultMessage: 'Add {item}', - }, - edit: { - id: 'account_edit.button.edit', - defaultMessage: 'Edit {item}', - }, - delete: { - id: 'account_edit.button.delete', - defaultMessage: 'Delete {item}', - }, -}); - export interface EditButtonProps { onClick: MouseEventHandler; - item: string | MessageDescriptor; - edit?: boolean; + label: string; icon?: boolean; disabled?: boolean; } export const EditButton: FC = ({ onClick, - item, - edit = false, - icon = edit, + label, + icon = false, disabled, }) => { - const intl = useIntl(); - - const itemText = typeof item === 'string' ? item : intl.formatMessage(item); - const label = intl.formatMessage(messages[edit ? 'edit' : 'add'], { - item: itemText, - }); - if (icon) { return ( @@ -83,18 +56,15 @@ export const EditIconButton: FC<{ export const DeleteIconButton: FC<{ onClick: MouseEventHandler; - item: string; + label: string; disabled?: boolean; -}> = ({ onClick, item, disabled }) => { - const intl = useIntl(); - return ( - - ); -}; +}> = ({ onClick, label, disabled }) => ( + +); diff --git a/app/javascript/mastodon/features/account_edit/components/field_actions.tsx b/app/javascript/mastodon/features/account_edit/components/field_actions.tsx index aed2fc3e0b..fecdcb8eff 100644 --- a/app/javascript/mastodon/features/account_edit/components/field_actions.tsx +++ b/app/javascript/mastodon/features/account_edit/components/field_actions.tsx @@ -1,15 +1,25 @@ import type { FC } from 'react'; import { useCallback } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + import { openModal } from '@/mastodon/actions/modal'; import { useAppDispatch } from '@/mastodon/store'; import { EditButton, DeleteIconButton } from './edit_button'; -export const AccountFieldActions: FC<{ item: string; id: string }> = ({ - item, - id, -}) => { +const messages = defineMessages({ + edit: { + id: 'account_edit.field_actions.edit', + defaultMessage: 'Edit field', + }, + delete: { + id: 'account_edit.field_actions.delete', + defaultMessage: 'Delete field', + }, +}); + +export const AccountFieldActions: FC<{ id: string }> = ({ id }) => { const dispatch = useAppDispatch(); const handleEdit = useCallback(() => { dispatch( @@ -28,10 +38,19 @@ export const AccountFieldActions: FC<{ item: string; id: string }> = ({ ); }, [dispatch, id]); + const intl = useIntl(); + return ( <> - - + + ); }; diff --git a/app/javascript/mastodon/features/account_edit/components/item_list.tsx b/app/javascript/mastodon/features/account_edit/components/item_list.tsx index eb6cf590f5..2b5dc9ab9f 100644 --- a/app/javascript/mastodon/features/account_edit/components/item_list.tsx +++ b/app/javascript/mastodon/features/account_edit/components/item_list.tsx @@ -1,5 +1,7 @@ import { useCallback } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + import classes from '../styles.module.scss'; import { DeleteIconButton, EditButton } from './edit_button'; @@ -50,6 +52,17 @@ type AccountEditItemButtonsProps = Pick< 'onEdit' | 'onDelete' | 'disabled' > & { item: Item }; +const messages = defineMessages({ + edit: { + id: 'account_edit.item_list.edit', + defaultMessage: 'Edit {name}', + }, + delete: { + id: 'account_edit.item_list.delete', + defaultMessage: 'Delete {name}', + }, +}); + const AccountEditItemButtons = ({ item, onDelete, @@ -63,6 +76,8 @@ const AccountEditItemButtons = ({ onDelete?.(item); }, [item, onDelete]); + const intl = useIntl(); + if (!onEdit && !onDelete) { return null; } @@ -71,15 +86,15 @@ const AccountEditItemButtons = ({
{onEdit && ( )} {onDelete && ( diff --git a/app/javascript/mastodon/features/account_edit/featured_tags.tsx b/app/javascript/mastodon/features/account_edit/featured_tags.tsx index 929fd554d5..5fc602208d 100644 --- a/app/javascript/mastodon/features/account_edit/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_edit/featured_tags.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Callout } from '@/mastodon/components/callout'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { Tag } from '@/mastodon/components/tags/tag'; import { useAccount } from '@/mastodon/hooks/useAccount'; @@ -28,17 +29,25 @@ import classes from './styles.module.scss'; const messages = defineMessages({ columnTitle: { id: 'account_edit_tags.column_title', - defaultMessage: 'Edit featured hashtags', + defaultMessage: 'Edit Tags', }, }); const selectTags = createAppSelector( - [(state) => state.profileEdit], - (profileEdit) => ({ + [ + (state) => state.profileEdit, + (state) => + state.server.getIn( + ['server', 'accounts', 'max_featured_tags'], + 10, + ) as number, + ], + (profileEdit, maxTags) => ({ tags: profileEdit.profile?.featuredTags ?? [], tagSuggestions: profileEdit.tagSuggestions ?? [], isLoading: !profileEdit.profile || !profileEdit.tagSuggestions, isPending: profileEdit.isPending, + maxTags, }), ); @@ -47,7 +56,7 @@ export const AccountEditFeaturedTags: FC = () => { const account = useAccount(accountId); const intl = useIntl(); - const { tags, tagSuggestions, isLoading, isPending } = + const { tags, tagSuggestions, isLoading, isPending, maxTags } = useAppSelector(selectTags); const dispatch = useAppDispatch(); @@ -67,6 +76,8 @@ export const AccountEditFeaturedTags: FC = () => { return ; } + const canAddMoreTags = tags.length < maxTags; + return ( { tagName='p' /> - + {canAddMoreTags && } - {tagSuggestions.length > 0 && ( + {tagSuggestions.length > 0 && canAddMoreTags && (
{
)} + {!canAddMoreTags && ( + + + + )} + {isLoading && } { buttons={ } > @@ -197,8 +221,10 @@ export const AccountEdit: FC = () => { buttons={ } > @@ -214,7 +240,7 @@ export const AccountEdit: FC = () => { description={messages.customFieldsPlaceholder} showDescription={!hasFields} buttons={ - <> +
= maxFieldCount} /> - +
} > {hasFields && ( @@ -240,10 +266,7 @@ export const AccountEdit: FC = () => {
- + ))} @@ -278,8 +301,8 @@ export const AccountEdit: FC = () => { buttons={ } > diff --git a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx index b5a095cf68..41991da7d0 100644 --- a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx @@ -1,10 +1,17 @@ -import { useCallback, useMemo, useState } from 'react'; +import { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useState, +} from 'react'; import type { FC } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import type { Map as ImmutableMap } from 'immutable'; +import { closeModal } from '@/mastodon/actions/modal'; import { Button } from '@/mastodon/components/button'; import { Callout } from '@/mastodon/components/callout'; import { EmojiTextInputField } from '@/mastodon/components/form_fields'; @@ -51,14 +58,19 @@ const messages = defineMessages({ id: 'account_edit.field_edit_modal.value_hint', defaultMessage: 'E.g. “https://example.me”', }, - limitHeader: { - id: 'account_edit.field_edit_modal.limit_header', - defaultMessage: 'Recommended character limit exceeded', - }, save: { id: 'account_edit.save', defaultMessage: 'Save', }, + discardMessage: { + id: 'account_edit.field_edit_modal.discard_message', + defaultMessage: + 'You have unsaved changes. Are you sure you want to discard them?', + }, + discardConfirm: { + id: 'account_edit.field_edit_modal.discard_confirm', + defaultMessage: 'Discard', + }, }); // We have two different values- the hard limit set by the server, @@ -83,19 +95,39 @@ const selectEmojiCodes = createAppSelector( (emojis) => emojis.map((emoji) => emoji.get('shortcode')).toArray(), ); -export const EditFieldModal: FC = ({ - onClose, - fieldKey, -}) => { +interface ConfirmationMessage { + message: string; + confirm: string; + props: { fieldKey?: string; lastLabel: string; lastValue: string }; +} + +interface ModalRef { + getCloseConfirmationMessage: () => null | ConfirmationMessage; +} + +export const EditFieldModal = forwardRef< + ModalRef, + DialogModalProps & { + fieldKey?: string; + lastLabel?: string; + lastValue?: string; + } +>(({ onClose, fieldKey, lastLabel, lastValue }, ref) => { const intl = useIntl(); const field = useAppSelector((state) => selectFieldById(state, fieldKey)); - const [newLabel, setNewLabel] = useState(field?.name ?? ''); - const [newValue, setNewValue] = useState(field?.value ?? ''); + const oldLabel = lastLabel ?? field?.name; + const oldValue = lastValue ?? field?.value; + const [newLabel, setNewLabel] = useState(oldLabel ?? ''); + const [newValue, setNewValue] = useState(oldValue ?? ''); + const isDirty = newLabel !== oldLabel || newValue !== oldValue; const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits); const isPending = useAppSelector((state) => state.profileEdit.isPending); const disabled = + !newLabel.trim() || + !newValue.trim() || + !isDirty || !nameLimit || !valueLimit || newLabel.length > nameLimit || @@ -122,11 +154,41 @@ export const EditFieldModal: FC = ({ } void dispatch( updateField({ id: fieldKey, name: newLabel, value: newValue }), - ).then(onClose); - }, [disabled, dispatch, fieldKey, isPending, newLabel, newValue, onClose]); + ).then(() => { + // Close without confirmation. + dispatch( + closeModal({ + modalType: 'ACCOUNT_EDIT_FIELD_EDIT', + ignoreFocus: false, + }), + ); + }); + }, [disabled, dispatch, fieldKey, isPending, newLabel, newValue]); + + useImperativeHandle( + ref, + () => ({ + getCloseConfirmationMessage: () => { + if (!newLabel || !newValue || !isDirty) { + return null; + } + return { + message: intl.formatMessage(messages.discardMessage), + confirm: intl.formatMessage(messages.discardConfirm), + props: { + fieldKey, + lastLabel: newLabel, + lastValue: newValue, + }, + }; + }, + }), + [fieldKey, intl, isDirty, newLabel, newValue], + ); return ( = ({ {(newLabel.length > RECOMMENDED_LIMIT || newValue.length > RECOMMENDED_LIMIT) && ( - + )} @@ -195,7 +254,8 @@ export const EditFieldModal: FC = ({ )} ); -}; +}); +EditFieldModal.displayName = 'EditFieldModal'; export const DeleteFieldModal: FC = ({ onClose, diff --git a/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx b/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx index fb9392185d..973c74ff83 100644 --- a/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx @@ -3,7 +3,6 @@ import { useCallback, useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { CharacterCounter } from '@/mastodon/components/character_counter'; import { Details } from '@/mastodon/components/details'; import { TextAreaField } from '@/mastodon/components/form_fields'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; @@ -69,6 +68,7 @@ export const ImageAltModal: FC< imageSrc={imageSrc} altText={altText} onChange={setAltText} + hideTip={location === 'header'} />
@@ -79,7 +79,8 @@ export const ImageAltTextField: FC<{ imageSrc: string; altText: string; onChange: (altText: string) => void; -}> = ({ imageSrc, altText, onChange }) => { + hideTip?: boolean; +}> = ({ imageSrc, altText, onChange, hideTip }) => { const altLimit = useAppSelector( (state) => state.server.getIn( @@ -99,49 +100,45 @@ export const ImageAltTextField: FC<{ <> -
- - } - hint={ - - } - onChange={handleChange} - value={altText} - /> - -
- -
} - className={classes.altHint} - > -
    {chunks}
, - li: (chunks) =>
  • {chunks}
  • , - }} - tagName='div' - /> -
    + hint={ + + } + onChange={handleChange} + value={altText} + maxLength={altLimit} + /> + + {!hideTip && ( +
    + } + className={classes.altHint} + > +
      {chunks}
    , + li: (chunks) =>
  • {chunks}
  • , + }} + tagName='div' + /> +
    + )} ); }; diff --git a/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx b/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx index ccf65cceed..23636083de 100644 --- a/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx @@ -1,14 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ChangeEventHandler, FC } from 'react'; -import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import type { Area } from 'react-easy-crop'; import Cropper from 'react-easy-crop'; import { setDragUploadEnabled } from '@/mastodon/actions/compose_typed'; import { Button } from '@/mastodon/components/button'; -import { RangeInput } from '@/mastodon/components/form_fields/range_input_field'; +import { RangeInputField } from '@/mastodon/components/form_fields/range_input_field'; import { selectImageInfo, uploadImage, @@ -24,16 +24,42 @@ import classes from './styles.module.scss'; import 'react-easy-crop/react-easy-crop.css'; +const messages = defineMessages({ + avatarAdd: { + id: 'account_edit.upload_modal.title_add.avatar', + defaultMessage: 'Add profile photo', + }, + headerAdd: { + id: 'account_edit.upload_modal.title_add.header', + defaultMessage: 'Add cover photo', + }, + avatarReplace: { + id: 'account_edit.upload_modal.title_replace.avatar', + defaultMessage: 'Replace profile photo', + }, + headerReplace: { + id: 'account_edit.upload_modal.title_replace.header', + defaultMessage: 'Replace cover photo', + }, + zoomLabel: { + id: 'account_edit.upload_modal.step_crop.zoom', + defaultMessage: 'Zoom', + }, +}); + export const ImageUploadModal: FC< DialogModalProps & { location: ImageLocation } > = ({ onClose, location }) => { const { src: oldSrc } = useAppSelector((state) => selectImageInfo(state, location), ); - const hasImage = !!oldSrc; - const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select'); + const intl = useIntl(); + const title = intl.formatMessage( + oldSrc ? messages[`${location}Replace`] : messages[`${location}Add`], + ); // State for individual steps. + const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select'); const [imageSrc, setImageSrc] = useState(null); const [imageBlob, setImageBlob] = useState(null); @@ -94,19 +120,7 @@ export const ImageUploadModal: FC< return ( - ) : ( - - ) - } + title={title} onClose={onClose} wrapperClassName={classes.uploadWrapper} noCancelButton @@ -124,6 +138,7 @@ export const ImageUploadModal: FC< )} {step === 'alt' && imageBlob && (
    -