From c0e242cb73c8e77983f1eec1df70d5421d6179fa Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 10 Jul 2025 08:56:40 +0200 Subject: [PATCH 01/26] Fix styling of external log-in button (#35320) --- app/helpers/application_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5a5ee05532..33d4bf6d02 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 From f65f6ad6f11e0377878fbae6e0b34816c465c6a1 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 11 Jul 2025 17:18:34 +0200 Subject: [PATCH 02/26] Make bio hashtags open the local page instead of the remote instance (#35349) --- .../mastodon/components/account_bio.tsx | 48 +++++++++++++++++-- .../components/account_header.tsx | 13 +++-- app/javascript/mastodon/hooks/useLinks.ts | 9 ++-- app/javascript/mastodon/models/account.ts | 7 ++- 4 files changed, 60 insertions(+), 17 deletions(-) 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/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/hooks/useLinks.ts b/app/javascript/mastodon/hooks/useLinks.ts index c99f3f4199..160fe18503 100644 --- a/app/javascript/mastodon/hooks/useLinks.ts +++ b/app/javascript/mastodon/hooks/useLinks.ts @@ -8,13 +8,14 @@ import { openURL } from 'mastodon/actions/search'; import { useAppDispatch } from 'mastodon/store'; const isMentionClick = (element: HTMLAnchorElement) => - element.classList.contains('mention'); + element.classList.contains('mention') && + !element.classList.contains('hashtag'); const isHashtagClick = (element: HTMLAnchorElement) => element.textContent?.[0] === '#' || element.previousSibling?.textContent?.endsWith('#'); -export const useLinks = () => { +export const useLinks = (skipHashtags?: boolean) => { const history = useHistory(); const dispatch = useAppDispatch(); @@ -61,12 +62,12 @@ export const useLinks = () => { if (isMentionClick(target)) { e.preventDefault(); void handleMentionClick(target); - } else if (isHashtagClick(target)) { + } else if (isHashtagClick(target) && !skipHashtags) { e.preventDefault(); handleHashtagClick(target); } }, - [handleMentionClick, handleHashtagClick], + [skipHashtags, handleMentionClick, handleHashtagClick], ); return handleClick; diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts index 2666059b40..75a5c09b9d 100644 --- a/app/javascript/mastodon/models/account.ts +++ b/app/javascript/mastodon/models/account.ts @@ -126,6 +126,9 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) { ? accountJSON.username : accountJSON.display_name; + const accountNote = + accountJSON.note && accountJSON.note !== '

' ? accountJSON.note : ''; + return AccountFactory({ ...accountJSON, moved: moved?.id, @@ -142,8 +145,8 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) { escapeTextContentForBrowser(displayName), emojiMap, ), - note_emojified: emojify(accountJSON.note, emojiMap), - note_plain: unescapeHTML(accountJSON.note), + note_emojified: emojify(accountNote, emojiMap), + note_plain: unescapeHTML(accountNote), url: accountJSON.url.startsWith('http://') || accountJSON.url.startsWith('https://') From ef6f5f9357d39c28ce726eebc403abf752f6313c Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 11 Jul 2025 18:35:06 +0200 Subject: [PATCH 03/26] Fix quote attributes missing from Mastodon's context (#35354) --- app/helpers/context_helper.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 33d7267905..c8e871a241 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -26,6 +26,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' => 'https://w3id.org/fep/044f#quoteAuthorization', + }, interaction_policies: { 'gts' => 'https://gotosocial.org/ns#', 'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, From a79dbf833488191278d27af00fbf0ba360e4f452 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 15 Jul 2025 09:52:34 +0200 Subject: [PATCH 04/26] fix: Improve `Dropdown` component accessibility (#35373) --- .../mastodon/components/dropdown_menu.tsx | 80 ++++++------------- .../mastodon/components/icon_button.tsx | 13 --- .../navigation_panel/components/more_link.tsx | 26 +++--- .../styles/mastodon/components.scss | 8 +- 4 files changed, 47 insertions(+), 80 deletions(-) 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/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