diff --git a/CHANGELOG.md b/CHANGELOG.md index 19be8ea68e..97a7234382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project will be documented in this file. +## [4.4.2] - 2025-07-23 + +### Security + +- Update dependencies + +### Fixed + +- Fix menu not clickable in Firefox (#35390 and #35414 by @diondiondion) +- Add `lang` attribute to current composer language in alt text modal (#35412 by @diondiondion) +- Fix quote posts styling on notifications page (#35411 by @diondiondion) +- Improve a11y of custom select menus in notifications settings (#35403 by @diondiondion) +- Fix selected item in poll select menus is unreadable in Firefox (#35402 by @diondiondion) +- Update age limit wording (#35387 by @diondiondion) +- Fix support for quote verification in implicit status updates (#35384 by @ClearlyClaire) +- Improve `Dropdown` component accessibility (#35373 by @diondiondion) +- Fix processing some incoming quotes failing because of missing JSON-LD context (#35354 and #35380 by @ClearlyClaire) +- Make bio hashtags open the local page instead of the remote instance (#35349 by @ChaosExAnima) +- Fix styling of external log-in button (#35320 by @ClearlyClaire) + ## [4.4.1] - 2025-07-09 ### Fixed diff --git a/Gemfile.lock b/Gemfile.lock index 299507cac9..de7b7cfd0c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -458,7 +458,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.8) + nokogiri (1.18.9) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.11) @@ -869,7 +869,7 @@ GEM terrapin (1.1.0) climate_control test-prof (1.4.4) - thor (1.3.2) + thor (1.4.0) tilt (2.6.0) timeout (0.4.3) tpm-key_attestation (0.14.1) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 90f8fd000c..cb349f4d51 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -66,7 +66,7 @@ module ApplicationHelper def provider_sign_in_link(provider) label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize) - link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post + link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post end def locale_direction diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 22d1964cae..9972b507cd 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -27,6 +27,12 @@ module ContextHelper suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' }, + quotes: { + 'quote' => 'https://w3id.org/fep/044f#quote', + 'quoteUri' => 'http://fedibird.com/ns#quoteUri', + '_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote', + 'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' }, + }, interaction_policies: { 'gts' => 'https://gotosocial.org/ns#', 'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index 301ffcbb24..e0127f2092 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -1,12 +1,30 @@ +import { useCallback } from 'react'; + import { useLinks } from 'mastodon/hooks/useLinks'; -export const AccountBio: React.FC<{ +interface AccountBioProps { note: string; className: string; -}> = ({ note, className }) => { - const handleClick = useLinks(); + dropdownAccountId?: string; +} - if (note.length === 0 || note === '

') { +export const AccountBio: React.FC = ({ + note, + className, + dropdownAccountId, +}) => { + const handleClick = useLinks(!!dropdownAccountId); + const handleNodeChange = useCallback( + (node: HTMLDivElement | null) => { + if (!dropdownAccountId || !node || node.childNodes.length === 0) { + return; + } + addDropdownToHashtags(node, dropdownAccountId); + }, + [dropdownAccountId], + ); + + if (note.length === 0) { return null; } @@ -15,6 +33,28 @@ export const AccountBio: React.FC<{ className={`${className} translate`} dangerouslySetInnerHTML={{ __html: note }} onClickCapture={handleClick} + ref={handleNodeChange} /> ); }; + +function addDropdownToHashtags(node: HTMLElement | null, accountId: string) { + if (!node) { + return; + } + for (const childNode of node.childNodes) { + if (!(childNode instanceof HTMLElement)) { + continue; + } + if ( + childNode instanceof HTMLAnchorElement && + (childNode.classList.contains('hashtag') || + childNode.innerText.startsWith('#')) && + !childNode.dataset.menuHashtag + ) { + childNode.dataset.menuHashtag = accountId; + } else if (childNode.childNodes.length > 0) { + addDropdownToHashtags(childNode, accountId); + } + } +} diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index 23d77f0dda..d9c87e93a7 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -5,6 +5,7 @@ import { useCallback, cloneElement, Children, + useId, } from 'react'; import classNames from 'classnames'; @@ -16,6 +17,7 @@ import Overlay from 'react-overlays/Overlay'; import type { OffsetValue, UsePopperOptions, + Placement, } from 'react-overlays/esm/usePopper'; import { fetchRelationships } from 'mastodon/actions/accounts'; @@ -295,6 +297,11 @@ interface DropdownProps { title?: string; disabled?: boolean; scrollable?: boolean; + placement?: Placement; + /** + * Prevent the `ScrollableList` with this scrollKey + * from being scrolled while the dropdown is open + */ scrollKey?: string; status?: ImmutableMap; forceDropdown?: boolean; @@ -316,6 +323,7 @@ export const Dropdown = ({ title = 'Menu', disabled, scrollable, + placement = 'bottom', status, forceDropdown = false, renderItem, @@ -331,16 +339,15 @@ export const Dropdown = ({ ); const [currentId] = useState(id++); const open = currentId === openDropdownId; - const activeElement = useRef(null); - const targetRef = useRef(null); + const buttonRef = useRef(null); + const menuId = useId(); const prefetchAccountId = status ? status.getIn(['account', 'id']) : undefined; const handleClose = useCallback(() => { - if (activeElement.current) { - activeElement.current.focus({ preventScroll: true }); - activeElement.current = null; + if (buttonRef.current) { + buttonRef.current.focus({ preventScroll: true }); } dispatch( @@ -375,7 +382,7 @@ export const Dropdown = ({ [handleClose, onItemClick, items], ); - const handleClick = useCallback( + const toggleDropdown = useCallback( (e: React.MouseEvent | React.KeyboardEvent) => { const { type } = e; @@ -423,38 +430,6 @@ export const Dropdown = ({ ], ); - const handleMouseDown = useCallback(() => { - if (!open && document.activeElement instanceof HTMLElement) { - activeElement.current = document.activeElement; - } - }, [open]); - - const handleButtonKeyDown = useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case ' ': - case 'Enter': - handleMouseDown(); - break; - } - }, - [handleMouseDown], - ); - - const handleKeyPress = useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case ' ': - case 'Enter': - handleClick(e); - e.stopPropagation(); - e.preventDefault(); - break; - } - }, - [handleClick], - ); - useEffect(() => { return () => { if (currentId === openDropdownId) { @@ -465,14 +440,16 @@ export const Dropdown = ({ let button: React.ReactElement; + const buttonProps = { + disabled, + onClick: toggleDropdown, + 'aria-expanded': open, + 'aria-controls': menuId, + ref: buttonRef, + }; + if (children) { - button = cloneElement(Children.only(children), { - onClick: handleClick, - onMouseDown: handleMouseDown, - onKeyDown: handleButtonKeyDown, - onKeyPress: handleKeyPress, - ref: targetRef, - }); + button = cloneElement(Children.only(children), buttonProps); } else if (icon && iconComponent) { button = ( ({ iconComponent={iconComponent} title={title} active={open} - disabled={disabled} - onClick={handleClick} - onMouseDown={handleMouseDown} - onKeyDown={handleButtonKeyDown} - onKeyPress={handleKeyPress} - ref={targetRef} + {...buttonProps} /> ); } else { @@ -499,13 +471,13 @@ export const Dropdown = ({ {({ props, arrowProps, placement }) => ( -
+
; onMouseDown?: React.MouseEventHandler; onKeyDown?: React.KeyboardEventHandler; - onKeyPress?: React.KeyboardEventHandler; active?: boolean; expanded?: boolean; style?: React.CSSProperties; @@ -45,7 +44,6 @@ export const IconButton = forwardRef( activeStyle, onClick, onKeyDown, - onKeyPress, onMouseDown, active = false, disabled = false, @@ -85,16 +83,6 @@ export const IconButton = forwardRef( [disabled, onClick], ); - const handleKeyPress: React.KeyboardEventHandler = - useCallback( - (e) => { - if (!disabled) { - onKeyPress?.(e); - } - }, - [disabled, onKeyPress], - ); - const handleMouseDown: React.MouseEventHandler = useCallback( (e) => { @@ -161,7 +149,6 @@ export const IconButton = forwardRef( onClick={handleClick} onMouseDown={handleMouseDown} onKeyDown={handleKeyDown} - onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated style={buttonStyle} tabIndex={tabIndex} disabled={disabled} diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 30433151c6..b9f83bebaa 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { Helmet } from 'react-helmet'; import { NavLink } from 'react-router-dom'; +import { AccountBio } from '@/mastodon/components/account_bio'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; @@ -773,7 +774,6 @@ export const AccountHeader: React.FC<{ ); } - const content = { __html: account.note_emojified }; const displayNameHtml = { __html: account.display_name_html }; const fields = account.fields; const isLocal = !account.acct.includes('@'); @@ -897,12 +897,11 @@ export const AccountHeader: React.FC<{ )} - {account.note.length > 0 && account.note !== '

' && ( -
- )} +
diff --git a/app/javascript/mastodon/features/alt_text_modal/index.tsx b/app/javascript/mastodon/features/alt_text_modal/index.tsx index 8a91e14e31..f285c35929 100644 --- a/app/javascript/mastodon/features/alt_text_modal/index.tsx +++ b/app/javascript/mastodon/features/alt_text_modal/index.tsx @@ -261,7 +261,9 @@ export const AltTextModal = forwardRef>( ); const lang = useAppSelector( (state) => - (state.compose as ImmutableMap).get('lang') as string, + (state.compose as ImmutableMap).get( + 'language', + ) as string, ); const focusX = (media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0; diff --git a/app/javascript/mastodon/features/navigation_panel/components/more_link.tsx b/app/javascript/mastodon/features/navigation_panel/components/more_link.tsx index a26935eacf..a3477ec4e5 100644 --- a/app/javascript/mastodon/features/navigation_panel/components/more_link.tsx +++ b/app/javascript/mastodon/features/navigation_panel/components/more_link.tsx @@ -50,16 +50,22 @@ export const MoreLink: React.FC = () => { const menu = useMemo(() => { const arr: MenuItem[] = [ - { text: intl.formatMessage(messages.filters), href: '/filters' }, - { text: intl.formatMessage(messages.mutes), to: '/mutes' }, - { text: intl.formatMessage(messages.blocks), to: '/blocks' }, { - text: intl.formatMessage(messages.domainBlocks), - to: '/domain_blocks', + href: '/filters', + text: intl.formatMessage(messages.filters), + }, + { + to: '/mutes', + text: intl.formatMessage(messages.mutes), + }, + { + to: '/blocks', + text: intl.formatMessage(messages.blocks), + }, + { + to: '/domain_blocks', + text: intl.formatMessage(messages.domainBlocks), }, - ]; - - arr.push( null, { href: '/settings/privacy', @@ -77,7 +83,7 @@ export const MoreLink: React.FC = () => { href: '/settings/export', text: intl.formatMessage(messages.importExport), }, - ); + ]; if (canManageReports(permissions)) { arr.push(null, { @@ -106,7 +112,7 @@ export const MoreLink: React.FC = () => { }, [intl, dispatch, permissions]); return ( - +
); diff --git a/app/javascript/mastodon/features/notifications/components/select_with_label.tsx b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx index 413267c0f8..b25f8e66be 100644 --- a/app/javascript/mastodon/features/notifications/components/select_with_label.tsx +++ b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren } from 'react'; -import { useCallback, useState, useRef } from 'react'; +import { useCallback, useState, useRef, useId } from 'react'; import classNames from 'classnames'; @@ -16,6 +16,8 @@ interface DropdownProps { options: SelectItem[]; disabled?: boolean; onChange: (value: string) => void; + 'aria-labelledby': string; + 'aria-describedby'?: string; placement?: Placement; } @@ -24,51 +26,33 @@ const Dropdown: React.FC = ({ options, disabled, onChange, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, placement: initialPlacement = 'bottom-end', }) => { - const activeElementRef = useRef(null); - const containerRef = useRef(null); + const containerRef = useRef(null); + const buttonRef = useRef(null); const [isOpen, setOpen] = useState(false); const [placement, setPlacement] = useState(initialPlacement); - - const handleToggle = useCallback(() => { - if ( - isOpen && - activeElementRef.current && - activeElementRef.current instanceof HTMLElement - ) { - activeElementRef.current.focus({ preventScroll: true }); - } - - setOpen(!isOpen); - }, [isOpen, setOpen]); - - const handleMouseDown = useCallback(() => { - if (!isOpen) activeElementRef.current = document.activeElement; - }, [isOpen]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case ' ': - case 'Enter': - if (!isOpen) activeElementRef.current = document.activeElement; - break; - } - }, - [isOpen], - ); + const uniqueId = useId(); + const menuId = `${uniqueId}-menu`; + const buttonLabelId = `${uniqueId}-button`; const handleClose = useCallback(() => { - if ( - isOpen && - activeElementRef.current && - activeElementRef.current instanceof HTMLElement - ) - activeElementRef.current.focus({ preventScroll: true }); + if (isOpen && buttonRef.current) { + buttonRef.current.focus({ preventScroll: true }); + } setOpen(false); }, [isOpen]); + const handleToggle = useCallback(() => { + if (isOpen) { + handleClose(); + } else { + setOpen(true); + } + }, [isOpen, handleClose]); + const handleOverlayEnter = useCallback( (state: Partial) => { if (state.placement) setPlacement(state.placement); @@ -82,13 +66,18 @@ const Dropdown: React.FC = ({
@@ -101,7 +90,7 @@ const Dropdown: React.FC = ({ popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }} > {({ props, placement }) => ( -
+
@@ -123,6 +112,8 @@ const Dropdown: React.FC = ({ interface Props { value: string; options: SelectItem[]; + label: string | React.ReactElement; + hint: string | React.ReactElement; disabled?: boolean; onChange: (value: string) => void; } @@ -130,13 +121,26 @@ interface Props { export const SelectWithLabel: React.FC> = ({ value, options, + label, + hint, disabled, - children, onChange, }) => { + const uniqueId = useId(); + const labelId = `${uniqueId}-label`; + const descId = `${uniqueId}-desc`; + return ( + // This label is only used for its click-forwarding behaviour, + // accessible names are assigned manually + // eslint-disable-next-line jsx-a11y/label-has-associated-control