diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e975479e..cfbc450d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [4.5.6] - 2026-02-03 + +### Security + +- Fix ActivityPub collection caching logic for pinned posts and featured tags not checking blocked accounts ([GHSA-ccpr-m53r-mfwr](https://github.com/mastodon/mastodon/security/advisories/GHSA-ccpr-m53r-mfwr)) + +### Changed + +- Shorten caching of quote posts pending approval (#37570 and #37592 by @ClearlyClaire) + +### Fixed + +- Fix relationship cache not being cleared when handling account migrations (#37664 by @ClearlyClaire) +- Fix quote cancel button not appearing after edit then delete-and-redraft (#37066 by @PGrayCS) +- Fix followers with profile subscription (bell icon) being notified of post edits (#37646 by @ClearlyClaire) +- Fix error when encountering invalid tag in updated object (#37635 by @ClearlyClaire) +- Fix cross-server conversation tracking (#37559 by @ClearlyClaire) +- Fix recycled connections not being immediately closed (#37335 and #37674 by @ClearlyClaire and @shleeable) + ## [4.5.5] - 2026-01-20 ### Security diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index 553a43dad2..6647d09997 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -6,17 +6,31 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :check_authorization before_action :set_items before_action :set_size before_action :set_type def show expires_in 3.minutes, public: public_fetch_mode? - render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + + if @unauthorized + render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + else + render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end end private + def check_authorization + # Because in public fetch mode we cache the response, there would be no + # benefit from performing the check below, since a blocked account or domain + # would likely be served the cache from the reverse proxy anyway + + @unauthorized = authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + end + def set_items case params[:id] when 'featured' @@ -59,11 +73,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController end def for_signed_account - # Because in public fetch mode we cache the response, there would be no - # benefit from performing the check below, since a blocked account or domain - # would likely be served the cache from the reverse proxy anyway - - if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + if @unauthorized [] else yield diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index b4157a502e..5960c3dc2a 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -153,7 +153,8 @@ export function fetchAccountFail(id, error) { */ export function followAccount(id, options = { reblogs: true }) { return (dispatch, getState) => { - const alreadyFollowing = getState().getIn(['relationships', id, 'following']); + const relationship = getState().getIn(['relationships', id]); + const alreadyFollowing = relationship?.following || relationship?.requested; const locked = getState().getIn(['accounts', id, 'locked'], false); dispatch(followAccountRequest({ id, locked })); diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index fc20ff53fa..6ed138c301 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -71,10 +71,15 @@ export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({ return null; } - const { text, description, icon } = item; + const { text, description, icon, iconId } = item; return ( <> - {icon && } + {icon && ( + + )} {text} {Boolean(description) && ( diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index 97aaecd1aa..97c1fbcae4 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -5,6 +5,7 @@ import { useIntl, defineMessages } from 'react-intl'; import classNames from 'classnames'; import { useIdentity } from '@/mastodon/identity_context'; +import { isClientFeatureEnabled } from '@/mastodon/utils/environment'; import { fetchRelationships, followAccount, @@ -94,7 +95,17 @@ export const FollowButton: React.FC<{ if (accountId === me) { return; - } else if (relationship.muting) { + } else if (relationship.blocking) { + dispatch( + openModal({ + modalType: 'CONFIRM_UNBLOCK', + modalProps: { account }, + }), + ); + } else if ( + relationship.muting && + !isClientFeatureEnabled('profile_redesign') + ) { dispatch(unmuteAccount(accountId)); } else if (account && relationship.following) { dispatch( @@ -107,13 +118,6 @@ export const FollowButton: React.FC<{ modalProps: { account }, }), ); - } else if (relationship.blocking) { - dispatch( - openModal({ - modalType: 'CONFIRM_UNBLOCK', - modalProps: { account }, - }), - ); } else { dispatch(followAccount(accountId)); } @@ -136,7 +140,10 @@ export const FollowButton: React.FC<{ label = intl.formatMessage(messages.editProfile); } else if (!relationship) { label = ; - } else if (relationship.muting) { + } else if ( + relationship.muting && + !isClientFeatureEnabled('profile_redesign') + ) { label = intl.formatMessage(messages.unmute); } else if (relationship.following) { label = intl.formatMessage(messages.unfollow); @@ -173,7 +180,7 @@ export const FollowButton: React.FC<{ (!(relationship?.following || relationship?.requested) && (account?.suspended || !!account?.moved)) } - secondary={following} + secondary={following || relationship?.blocking} compact={compact} className={classNames(className, { 'button--destructive': following })} > diff --git a/app/javascript/mastodon/components/form_fields/form_stack.module.scss b/app/javascript/mastodon/components/form_fields/form_stack.module.scss new file mode 100644 index 0000000000..083e36c320 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/form_stack.module.scss @@ -0,0 +1,7 @@ +.stack { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 25px; + padding: 16px; +} diff --git a/app/javascript/mastodon/components/form_fields/form_stack.tsx b/app/javascript/mastodon/components/form_fields/form_stack.tsx new file mode 100644 index 0000000000..707545898e --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/form_stack.tsx @@ -0,0 +1,23 @@ +import classNames from 'classnames'; + +import { polymorphicForwardRef } from '@/types/polymorphic'; + +import classes from './form_stack.module.scss'; + +/** + * A simple wrapper for providing consistent spacing to a group of form fields. + */ + +export const FormStack = polymorphicForwardRef<'div'>( + ({ as: Element = 'div', children, className, ...otherProps }, ref) => ( + + {children} + + ), +); + +FormStack.displayName = 'FormStack'; diff --git a/app/javascript/mastodon/components/form_fields/index.ts b/app/javascript/mastodon/components/form_fields/index.ts index 8dd693d51e..f87626cb65 100644 --- a/app/javascript/mastodon/components/form_fields/index.ts +++ b/app/javascript/mastodon/components/form_fields/index.ts @@ -1,5 +1,6 @@ -export { TextInputField } from './text_input_field'; -export { TextAreaField } from './text_area_field'; +export { FormStack } from './form_stack'; +export { TextInputField, TextInput } from './text_input_field'; +export { TextAreaField, TextArea } from './text_area_field'; export { CheckboxField, Checkbox } from './checkbox_field'; export { ToggleField, Toggle } from './toggle_field'; -export { SelectField } from './select_field'; +export { SelectField, Select } from './select_field'; diff --git a/app/javascript/mastodon/components/form_fields/select.module.scss b/app/javascript/mastodon/components/form_fields/select.module.scss new file mode 100644 index 0000000000..e68e248fec --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/select.module.scss @@ -0,0 +1,66 @@ +.wrapper { + position: relative; + width: 100%; + + /* Dropdown indicator icon */ + &::after { + --icon-size: 11px; + + content: ''; + position: absolute; + top: 0; + bottom: 0; + inset-inline-end: 9px; + width: var(--icon-size); + background-color: var(--color-text-tertiary); + pointer-events: none; + mask-image: url("data:image/svg+xml;utf8,"); + mask-position: right center; + mask-size: var(--icon-size); + mask-repeat: no-repeat; + } + + &:has(.select:focus-visible)::after { + background-color: var(--color-text-secondary); + } + + &:has(.select:disabled)::after { + background-color: var(--color-text-disabled); + } +} + +.select { + appearance: none; + box-sizing: border-box; + display: block; + width: 100%; + height: 41px; + padding-inline-start: 10px; + padding-inline-end: 30px; + font-family: inherit; + font-size: 14px; + color: var(--color-text-primary); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border-primary); + border-radius: 4px; + outline: 0; + + @media screen and (width <= 600px) { + font-size: 16px; + } + + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: -1px; + } + + &:disabled { + color: var(--color-text-disabled); + border-color: transparent; + cursor: not-allowed; + } + + [data-has-error='true'] & { + border-color: var(--color-text-error); + } +} diff --git a/app/javascript/mastodon/components/form_fields/select_field.stories.tsx b/app/javascript/mastodon/components/form_fields/select_field.stories.tsx index 762436fe28..469238dd44 100644 --- a/app/javascript/mastodon/components/form_fields/select_field.stories.tsx +++ b/app/javascript/mastodon/components/form_fields/select_field.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { SelectField } from './select_field'; +import { SelectField, Select } from './select_field'; const meta = { title: 'Components/Form Fields/SelectField', @@ -8,24 +8,19 @@ const meta = { args: { label: 'Fruit preference', hint: 'Select your favourite fruit or not. Up to you.', - }, - render(args) { - // Component styles require a wrapper class at the moment - return ( -
- - - - - - - - - - - -
- ); + children: ( + <> + + + + + + + + + + + ), }, } satisfies Meta; @@ -59,3 +54,16 @@ export const WithError: Story = { hasError: true, }, }; + +export const Plain: Story = { + render(args) { + return - {children} - - + )} ), ); SelectField.displayName = 'SelectField'; + +export const Select = forwardRef< + HTMLSelectElement, + ComponentPropsWithoutRef<'select'> +>(({ className, size, ...otherProps }, ref) => ( +
+