From f953d40289e026057ed1a564b6bf547ad067aeda Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Mon, 2 Mar 2026 14:38:03 +0100 Subject: [PATCH 1/7] Add API to revoke collection item (#38027) --- .../v1_alpha/collection_items_controller.rb | 10 +++++- app/policies/collection_item_policy.rb | 13 ++++++++ config/routes/api.rb | 6 +++- spec/policies/collection_item_policy_spec.rb | 23 ++++++++++++++ .../api/v1_alpha/collection_items_spec.rb | 31 +++++++++++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 app/policies/collection_item_policy.rb create mode 100644 spec/policies/collection_item_policy_spec.rb diff --git a/app/controllers/api/v1_alpha/collection_items_controller.rb b/app/controllers/api/v1_alpha/collection_items_controller.rb index 5c78de14e9..2c46cc4f9f 100644 --- a/app/controllers/api/v1_alpha/collection_items_controller.rb +++ b/app/controllers/api/v1_alpha/collection_items_controller.rb @@ -11,7 +11,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController before_action :set_collection before_action :set_account, only: [:create] - before_action :set_collection_item, only: [:destroy] + before_action :set_collection_item, only: [:destroy, :revoke] after_action :verify_authorized @@ -32,6 +32,14 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController head 200 end + def revoke + authorize @collection_item, :revoke? + + RevokeCollectionItemService.new.call(@collection_item) + + head 200 + end + private def set_collection diff --git a/app/policies/collection_item_policy.rb b/app/policies/collection_item_policy.rb new file mode 100644 index 0000000000..73552a3b8a --- /dev/null +++ b/app/policies/collection_item_policy.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CollectionItemPolicy < ApplicationPolicy + def revoke? + featured_account.present? && current_account == featured_account + end + + private + + def featured_account + record.account + end +end diff --git a/config/routes/api.rb b/config/routes/api.rb index 5322f15c8f..285b032d01 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -13,7 +13,11 @@ namespace :api, format: false do resources :async_refreshes, only: :show resources :collections, only: [:show, :create, :update, :destroy] do - resources :items, only: [:create, :destroy], controller: 'collection_items' + resources :items, only: [:create, :destroy], controller: 'collection_items' do + member do + post :revoke + end + end end end diff --git a/spec/policies/collection_item_policy_spec.rb b/spec/policies/collection_item_policy_spec.rb new file mode 100644 index 0000000000..d12384209e --- /dev/null +++ b/spec/policies/collection_item_policy_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CollectionItemPolicy do + subject { described_class } + + let(:account) { Fabricate(:account) } + + permissions :revoke? do + context 'when collection item features the revoking account' do + let(:collection_item) { Fabricate.build(:collection_item, account:) } + + it { is_expected.to permit(account, collection_item) } + end + + context 'when collection item does not feature the revoking account' do + let(:collection_item) { Fabricate.build(:collection_item) } + + it { is_expected.to_not permit(account, collection_item) } + end + end +end diff --git a/spec/requests/api/v1_alpha/collection_items_spec.rb b/spec/requests/api/v1_alpha/collection_items_spec.rb index 6d33e6a711..e7ee854e67 100644 --- a/spec/requests/api/v1_alpha/collection_items_spec.rb +++ b/spec/requests/api/v1_alpha/collection_items_spec.rb @@ -102,4 +102,35 @@ RSpec.describe 'Api::V1Alpha::CollectionItems', feature: :collections do end end end + + describe 'POST /api/v1_alpha/collections/:collection_id/items/:id/revoke' do + subject do + post "/api/v1_alpha/collections/#{collection.id}/items/#{item.id}/revoke", headers: headers + end + + let(:collection) { Fabricate(:collection) } + let(:item) { Fabricate(:collection_item, collection:, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'read' + + context 'when user is in item' do + it 'revokes the collection item and returns http success' do + subject + + expect(item.reload).to be_revoked + + expect(response).to have_http_status(200) + end + end + + context 'when user is not in the item' do + let(:item) { Fabricate(:collection_item, collection:) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + end end From 87004ddb96c766a3dce889bad098c16f80bb3d0a Mon Sep 17 00:00:00 2001 From: Antoine Cellerier Date: Mon, 2 Mar 2026 14:49:28 +0100 Subject: [PATCH 2/7] Add g+e keyboard shortcut for /explore (trending) (#38014) --- .../mastodon/components/hotkeys/hotkeys.stories.tsx | 6 ++++++ app/javascript/mastodon/components/hotkeys/index.tsx | 1 + .../mastodon/features/keyboard_shortcuts/index.jsx | 4 ++++ app/javascript/mastodon/features/ui/index.jsx | 5 +++++ app/javascript/mastodon/locales/en.json | 1 + 5 files changed, 17 insertions(+) diff --git a/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx b/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx index 9baef18668..43002013a3 100644 --- a/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx +++ b/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx @@ -50,6 +50,9 @@ const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => { await userEvent.keyboard('gh'); await confirmHotkey('goToHome'); + await userEvent.keyboard('ge'); + await confirmHotkey('goToExplore'); + await userEvent.keyboard('gn'); await confirmHotkey('goToNotifications'); @@ -106,6 +109,9 @@ export const Default = { goToHome: () => { setMatchedHotkey('goToHome'); }, + goToExplore: () => { + setMatchedHotkey('goToExplore'); + }, goToNotifications: () => { setMatchedHotkey('goToNotifications'); }, diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx index c62fc0c20a..751ec01fe5 100644 --- a/app/javascript/mastodon/components/hotkeys/index.tsx +++ b/app/javascript/mastodon/components/hotkeys/index.tsx @@ -118,6 +118,7 @@ const hotkeyMatcherMap = { openMedia: just('e'), onTranslate: just('t'), goToHome: sequence('g', 'h'), + goToExplore: sequence('g', 'e'), goToNotifications: sequence('g', 'n'), goToLocal: sequence('g', 'l'), goToFederated: sequence('g', 't'), diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx index 8a6ebe6def..d2b041ec3f 100644 --- a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx +++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx @@ -134,6 +134,10 @@ class KeyboardShortcuts extends ImmutablePureComponent { g+h + + g+e + + g+n diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 17822929a7..d40891ef62 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -534,6 +534,10 @@ class UI extends PureComponent { this.props.history.push('/home'); }; + handleHotkeyGoToExplore = () => { + this.props.history.push('/explore'); + }; + handleHotkeyGoToNotifications = () => { this.props.history.push('/notifications'); }; @@ -595,6 +599,7 @@ class UI extends PureComponent { moveToTop: this.handleMoveToTop, back: this.handleHotkeyBack, goToHome: this.handleHotkeyGoToHome, + goToExplore: this.handleHotkeyGoToExplore, goToNotifications: this.handleHotkeyGoToNotifications, goToLocal: this.handleHotkeyGoToLocal, goToFederated: this.handleHotkeyGoToFederated, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 03b7a5d2c4..e7a346a38f 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -682,6 +682,7 @@ "keyboard_shortcuts.direct": "Open private mentions column", "keyboard_shortcuts.down": "Move down in the list", "keyboard_shortcuts.enter": "Open post", + "keyboard_shortcuts.explore": "Open trending timeline", "keyboard_shortcuts.favourite": "Favorite post", "keyboard_shortcuts.favourites": "Open favorites list", "keyboard_shortcuts.federated": "Open federated timeline", From 816e63d2a553059af4dc88509a327bfc02b05be8 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Mon, 2 Mar 2026 15:37:33 +0100 Subject: [PATCH 3/7] Add "skip to content", "skip to navigation" links (#38006) --- .../components/column_back_button.tsx | 11 ++- .../mastodon/components/column_header.tsx | 17 ++-- .../features/navigation_panel/index.tsx | 7 +- .../features/ui/components/columns_area.jsx | 23 +++-- .../ui/components/skip_links/index.tsx | 84 +++++++++++++++++++ .../skip_links/skip_links.module.scss | 64 ++++++++++++++ app/javascript/mastodon/features/ui/index.jsx | 19 ++++- .../mastodon/features/ui/util/focusUtils.ts | 70 ++++++++++++---- app/javascript/mastodon/locales/en.json | 3 + .../styles/mastodon/components.scss | 26 +++--- 10 files changed, 281 insertions(+), 43 deletions(-) create mode 100644 app/javascript/mastodon/features/ui/components/skip_links/index.tsx create mode 100644 app/javascript/mastodon/features/ui/components/skip_links/skip_links.module.scss diff --git a/app/javascript/mastodon/components/column_back_button.tsx b/app/javascript/mastodon/components/column_back_button.tsx index 8012ba7df6..bb6939e24c 100644 --- a/app/javascript/mastodon/components/column_back_button.tsx +++ b/app/javascript/mastodon/components/column_back_button.tsx @@ -4,8 +4,11 @@ import { FormattedMessage } from 'react-intl'; import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; import { Icon } from 'mastodon/components/icon'; +import { getColumnSkipLinkId } from 'mastodon/features/ui/components/skip_links'; import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; +import { useColumnIndexContext } from '../features/ui/components/columns_area'; + import { useAppHistory } from './router'; type OnClickCallback = () => void; @@ -28,9 +31,15 @@ export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({ onClick, }) => { const handleClick = useHandleClick(onClick); + const columnIndex = useColumnIndexContext(); const component = ( - @@ -221,7 +226,7 @@ export const ColumnHeader: React.FC = ({ !pinned && ((multiColumn && history.location.state?.fromMastodon) || showBackButton) ) { - backButton = ; + backButton = ; } const collapsedContent = [extraContent]; @@ -260,6 +265,7 @@ export const ColumnHeader: React.FC = ({ const hasIcon = icon && iconComponent; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const hasTitle = (hasIcon || backButton) && title; + const columnIndex = useColumnIndexContext(); const component = (
@@ -272,6 +278,7 @@ export const ColumnHeader: React.FC = ({ onClick={handleTitleClick} className='column-header__title' type='button' + id={getColumnSkipLinkId(columnIndex)} > {!backButton && hasIcon && ( = ({ return (
- +
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx index 753f7e9ac3..5609a52b31 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.jsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { Children, cloneElement, useCallback } from 'react'; +import { Children, cloneElement, createContext, useContext, useCallback } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -53,6 +53,13 @@ const TabsBarPortal = () => { return
; }; +// Simple context to allow column children to know which column they're in +export const ColumnIndexContext = createContext(1); +/** + * @returns {number} + */ +export const useColumnIndexContext = () => useContext(ColumnIndexContext); + export default class ColumnsArea extends ImmutablePureComponent { static propTypes = { columns: ImmutablePropTypes.list.isRequired, @@ -140,18 +147,22 @@ export default class ColumnsArea extends ImmutablePureComponent { return (
- {columns.map(column => { + {columns.map((column, index) => { const params = column.get('params', null) === null ? null : column.get('params').toJS(); const other = params && params.other ? params.other : {}; return ( - - {SpecificComponent => } - + + + {SpecificComponent => } + + ); })} - {Children.map(children, child => cloneElement(child, { multiColumn: true }))} + + {Children.map(children, child => cloneElement(child, { multiColumn: true }))} +
); } diff --git a/app/javascript/mastodon/features/ui/components/skip_links/index.tsx b/app/javascript/mastodon/features/ui/components/skip_links/index.tsx new file mode 100644 index 0000000000..7ecff95287 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/skip_links/index.tsx @@ -0,0 +1,84 @@ +import { useCallback, useId } from 'react'; + +import { useIntl } from 'react-intl'; + +import { useAppSelector } from 'mastodon/store'; + +import classes from './skip_links.module.scss'; + +export const getNavigationSkipLinkId = () => 'skip-link-target-nav'; +export const getColumnSkipLinkId = (index: number) => + `skip-link-target-content-${index}`; + +export const SkipLinks: React.FC<{ + multiColumn: boolean; + onFocusGettingStartedColumn: () => void; +}> = ({ multiColumn, onFocusGettingStartedColumn }) => { + const intl = useIntl(); + const columnCount = useAppSelector((state) => { + const settings = state.settings as Immutable.Collection; + return (settings.get('columns') as Immutable.Map).size; + }); + + const focusMultiColumnNavbar = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + onFocusGettingStartedColumn(); + }, + [onFocusGettingStartedColumn], + ); + + return ( +
    +
  • + + {intl.formatMessage({ + id: 'skip_links.skip_to_content', + defaultMessage: 'Skip to main content', + })} + +
  • +
  • + + {intl.formatMessage({ + id: 'skip_links.skip_to_navigation', + defaultMessage: 'Skip to main navigation', + })} + +
  • +
+ ); +}; + +const SkipLink: React.FC<{ + children: string; + target: string; + onRouterLinkClick?: React.MouseEventHandler; + hotkey: string; +}> = ({ children, hotkey, target, onRouterLinkClick }) => { + const intl = useIntl(); + const id = useId(); + return ( + <> + + {children} + + + {intl.formatMessage( + { + id: 'skip_links.hotkey', + defaultMessage: 'Hotkey {hotkey}', + }, + { + hotkey, + span: (text) => {text}, + }, + )} + + + ); +}; diff --git a/app/javascript/mastodon/features/ui/components/skip_links/skip_links.module.scss b/app/javascript/mastodon/features/ui/components/skip_links/skip_links.module.scss new file mode 100644 index 0000000000..1d4dc1c3f5 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/skip_links/skip_links.module.scss @@ -0,0 +1,64 @@ +.list { + position: fixed; + z-index: 100; + margin: 10px; + padding: 10px 16px; + border-radius: 10px; + font-size: 15px; + color: var(--color-text-primary); + background: var(--color-bg-primary); + box-shadow: var(--dropdown-shadow); + + /* Hide visually when not focused */ + &:not(:focus-within) { + width: 1px; + height: 1px; + margin: 0; + padding: 0; + clip-path: inset(50%); + overflow: hidden; + } +} + +.listItem { + display: flex; + align-items: center; + gap: 10px; + padding-inline-end: 4px; + border-radius: 4px; + + &:not(:first-child) { + margin-top: 2px; + } + + &:focus-within { + outline: var(--outline-focus-default); + background: var(--color-bg-brand-softer); + } + + :any-link { + display: block; + padding: 8px; + color: inherit; + text-decoration-color: var(--color-text-secondary); + text-underline-offset: 0.2em; + + &:focus, + &:focus-visible { + outline: none; + } + } +} + +.hotkeyHint { + display: inline-block; + box-sizing: border-box; + min-width: 2.5ch; + margin-inline-start: auto; + padding: 3px 5px; + font-family: 'Courier New', Courier, monospace; + text-align: center; + background: var(--color-bg-primary); + border: 1px solid var(--color-border-primary); + border-radius: 4px; +} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index d40891ef62..b46f61745e 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -92,6 +92,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; // Without this it ends up in ~8 very commonly used bundles. import '../../components/status'; import { areCollectionsEnabled } from '../collections/utils'; +import { getNavigationSkipLinkId, SkipLinks } from './components/skip_links'; const messages = defineMessages({ beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' }, @@ -253,9 +254,9 @@ class SwitchingColumnsArea extends PureComponent { {areCollectionsEnabled() && [ - , - , - + , + , + ] } @@ -556,6 +557,14 @@ class UI extends PureComponent { handleHotkeyGoToStart = () => { this.props.history.push('/getting-started'); + // Set focus to the navigation after a timeout + // to allow for it to be displayed first + setTimeout(() => { + const navbarSkipTarget = document.querySelector( + `#${getNavigationSkipLinkId()}`, + ); + navbarSkipTarget?.focus(); + }, 0); }; handleHotkeyGoToFavourites = () => { @@ -617,6 +626,10 @@ class UI extends PureComponent { return (
+ ( + `#${getColumnSkipLinkId(index - 1)}, #${getNavigationSkipLinkId()}`, + ) + ?.focus(); + } + } else { + const idSelector = + index === 2 + ? `#${getNavigationSkipLinkId()}` + : `#${getColumnSkipLinkId(1)}`; + + document.querySelector(idSelector)?.focus(); + } +} + /** * Move focus to the column of the passed index (1-based). - * Focus is placed on the topmost visible item + * Focus is placed on the topmost visible item, or the column title */ export function focusColumn(index = 1) { // Skip the leftmost drawer in multi-column mode @@ -35,11 +60,21 @@ export function focusColumn(index = 1) { `.column:nth-child(${index + indexOffset})`, ); - if (!column) return; + function fallback() { + focusColumnTitle(index + indexOffset, isMultiColumnLayout); + } + + if (!column) { + fallback(); + return; + } const container = column.querySelector('.scrollable'); - if (!container) return; + if (!container) { + fallback(); + return; + } const focusableItems = Array.from( container.querySelectorAll( @@ -50,20 +85,23 @@ export function focusColumn(index = 1) { // Find first item visible in the viewport const itemToFocus = findFirstVisibleWithRect(focusableItems); - if (itemToFocus) { - const viewportWidth = - window.innerWidth || document.documentElement.clientWidth; - const { item, rect } = itemToFocus; - - if ( - container.scrollTop > item.offsetTop || - rect.right > viewportWidth || - rect.left < 0 - ) { - itemToFocus.item.scrollIntoView(true); - } - itemToFocus.item.focus(); + if (!itemToFocus) { + fallback(); + return; } + + const viewportWidth = + window.innerWidth || document.documentElement.clientWidth; + const { item, rect } = itemToFocus; + + if ( + container.scrollTop > item.offsetTop || + rect.right > viewportWidth || + rect.left < 0 + ) { + itemToFocus.item.scrollIntoView(true); + } + itemToFocus.item.focus(); } /** diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index e7a346a38f..e5d62e9436 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -1072,6 +1072,9 @@ "sign_in_banner.mastodon_is": "Mastodon is the best way to keep up with what's happening.", "sign_in_banner.sign_in": "Login", "sign_in_banner.sso_redirect": "Login or Register", + "skip_links.hotkey": "Hotkey {hotkey}", + "skip_links.skip_to_content": "Skip to main content", + "skip_links.skip_to_navigation": "Skip to main navigation", "status.admin_account": "Open moderation interface for @{name}", "status.admin_domain": "Open moderation interface for {domain}", "status.admin_status": "Open this post in the moderation interface", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 1e29fcb2b3..7697d6c273 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3903,6 +3903,11 @@ a.account__display-name { &:hover { text-decoration: underline; } + + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: -2px; + } } .column-header__back-button { @@ -4036,15 +4041,17 @@ a.account__display-name { } .column-link { + box-sizing: border-box; display: flex; align-items: center; gap: 8px; width: 100%; - padding: 12px; + padding: 10px; + padding-inline-start: 14px; + overflow: hidden; font-size: 16px; font-weight: 400; text-decoration: none; - overflow: hidden; white-space: nowrap; color: color-mix( in oklab, @@ -4052,9 +4059,8 @@ a.account__display-name { var(--color-text-secondary) ); background: transparent; - border: 0; - border-left: 4px solid transparent; - box-sizing: border-box; + border: 2px solid transparent; + border-radius: 4px; &:hover, &:active, @@ -4066,17 +4072,15 @@ a.account__display-name { color: var(--color-text-brand); } - &:focus { - outline: 0; - } - &:focus-visible { + outline: none; border-color: var(--color-text-brand); - border-radius: 0; + background: var(--color-bg-brand-softer); } &--logo { - padding: 10px; + padding: 8px; + padding-inline-start: 12px; } } From e7cec161fdb468df56197f4471554c2530dc282b Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Mon, 2 Mar 2026 09:43:57 -0500 Subject: [PATCH 4/7] Reduce haml-lint `LineLength` configuration to 240 (#37287) --- .haml-lint.yml | 2 +- app/helpers/registration_helper.rb | 8 ++++++++ app/helpers/settings_helper.rb | 10 ++++++++++ app/views/admin/announcements/previews/show.html.haml | 11 +++++++++-- .../admin/terms_of_service/previews/show.html.haml | 11 +++++++++-- app/views/auth/registrations/new.html.haml | 2 +- .../settings/preferences/appearance/show.html.haml | 10 ++++++++-- app/views/settings/verifications/show.html.haml | 7 +++++-- .../user_mailer/confirmation_instructions.html.haml | 4 +++- 9 files changed, 54 insertions(+), 11 deletions(-) diff --git a/.haml-lint.yml b/.haml-lint.yml index 74d243a3ad..4048895806 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -10,6 +10,6 @@ linters: MiddleDot: enabled: true LineLength: - max: 300 + max: 240 # Override default value of 80 inherited from rubocop ViewLength: max: 200 # Override default value of 100 inherited from rubocop diff --git a/app/helpers/registration_helper.rb b/app/helpers/registration_helper.rb index 002d167c05..fd3979f5af 100644 --- a/app/helpers/registration_helper.rb +++ b/app/helpers/registration_helper.rb @@ -18,4 +18,12 @@ module RegistrationHelper def ip_blocked?(remote_ip) IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists? end + + def terms_agreement_label + if TermsOfService.live.exists? + t('auth.user_agreement_html', privacy_policy_path: privacy_policy_path, terms_of_service_path: terms_of_service_path) + else + t('auth.user_privacy_agreement_html', privacy_policy_path: privacy_policy_path) + end + end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index fd631ce92e..44113f3d47 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -23,6 +23,16 @@ module SettingsHelper ) end + def author_attribution_name(account) + return if account.nil? + + link_to(root_url, class: 'story__details__shared__author-link') do + safe_join( + [image_tag(account.avatar.url, class: 'account__avatar', size: 16, alt: ''), tag.bdi(display_name(account))] + ) + end + end + def session_device_icon(session) device = session.detection.device diff --git a/app/views/admin/announcements/previews/show.html.haml b/app/views/admin/announcements/previews/show.html.haml index 54d5d45ed6..917d663638 100644 --- a/app/views/admin/announcements/previews/show.html.haml +++ b/app/views/admin/announcements/previews/show.html.haml @@ -18,5 +18,12 @@ %hr.spacer/ .content__heading__actions - = link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), admin_announcement_test_path(@announcement), method: :post, class: 'button button-secondary' - = link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)), admin_announcement_distribution_path(@announcement), method: :post, class: 'button', data: { confirm: t('admin.reports.are_you_sure') } + = link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), + admin_announcement_test_path(@announcement), + class: 'button button-secondary', + method: :post + = link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)), + admin_announcement_distribution_path(@announcement), + class: 'button', + data: { confirm: t('admin.reports.are_you_sure') }, + method: :post diff --git a/app/views/admin/terms_of_service/previews/show.html.haml b/app/views/admin/terms_of_service/previews/show.html.haml index 48c94cb052..907ac32f45 100644 --- a/app/views/admin/terms_of_service/previews/show.html.haml +++ b/app/views/admin/terms_of_service/previews/show.html.haml @@ -16,5 +16,12 @@ %hr.spacer/ .content__heading__actions - = link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), admin_terms_of_service_test_path(@terms_of_service), method: :post, class: 'button button-secondary' - = link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)), admin_terms_of_service_distribution_path(@terms_of_service), method: :post, class: 'button', data: { confirm: t('admin.reports.are_you_sure') } + = link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), + admin_terms_of_service_test_path(@terms_of_service), + class: 'button button-secondary', + method: :post + = link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)), + admin_terms_of_service_distribution_path(@terms_of_service), + class: 'button', + data: { confirm: t('admin.reports.are_you_sure') }, + method: :post diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index eef4b485f6..0dc1d894ec 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -79,7 +79,7 @@ .fields-group = f.input :agreement, as: :boolean, - label: TermsOfService.live.exists? ? t('auth.user_agreement_html', privacy_policy_path: privacy_policy_path, terms_of_service_path: terms_of_service_path) : t('auth.user_privacy_agreement_html', privacy_policy_path: privacy_policy_path), + label: terms_agreement_label, required: false, wrapper: :with_label diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index f5dba2606f..57c3b4713e 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -93,9 +93,15 @@ %h4= t 'appearance.boosting_preferences' .fields-group - = ff.input :'web.reblog_modal', wrapper: :with_label, hint: I18n.t('simple_form.hints.defaults.setting_boost_modal'), label: I18n.t('simple_form.labels.defaults.setting_boost_modal') + = ff.input :'web.reblog_modal', + hint: I18n.t('simple_form.hints.defaults.setting_boost_modal'), + label: I18n.t('simple_form.labels.defaults.setting_boost_modal'), + wrapper: :with_label .fields-group - = ff.input :'web.quick_boosting', wrapper: :with_label, hint: t('simple_form.hints.defaults.setting_quick_boosting_html', boost_icon: material_symbol('repeat'), options_icon: material_symbol('more_horiz')), label: I18n.t('simple_form.labels.defaults.setting_quick_boosting') + = ff.input :'web.quick_boosting', + hint: t('simple_form.hints.defaults.setting_quick_boosting_html', boost_icon: material_symbol('repeat'), options_icon: material_symbol('more_horiz')), + label: I18n.t('simple_form.labels.defaults.setting_quick_boosting'), + wrapper: :with_label .flash-message.hidden-on-touch-devices= t('appearance.boosting_preferences_info_html', icon: material_symbol('repeat')) %h4= t 'appearance.sensitive_content' diff --git a/app/views/settings/verifications/show.html.haml b/app/views/settings/verifications/show.html.haml index ac8778a7ec..b348b7bc03 100644 --- a/app/views/settings/verifications/show.html.haml +++ b/app/views/settings/verifications/show.html.haml @@ -51,7 +51,7 @@ %strong.status-card__title= t('author_attribution.example_title') .more-from-author = logo_as_symbol(:icon) - = t('author_attribution.more_from_html', name: link_to(root_url, class: 'story__details__shared__author-link') { image_tag(@account.avatar.url, class: 'account__avatar', width: 16, height: 16, alt: '') + tag.bdi(display_name(@account)) }) + = t('author_attribution.more_from_html', name: author_attribution_name(@account)) %h4= t('verification.here_is_how') @@ -65,7 +65,10 @@ %p.lead= t('author_attribution.then_instructions') .fields-group - = f.input :attribution_domains, as: :text, wrapper: :with_block_label, input_html: { value: @account.attribution_domains.join("\n"), placeholder: "example1.com\nexample2.com\nexample3.com", rows: 4, autocapitalize: 'none', autocorrect: 'off' } + = f.input :attribution_domains, + as: :text, + input_html: { value: @account.attribution_domains.join("\n"), placeholder: "example1.com\nexample2.com\nexample3.com", rows: 4, autocapitalize: 'none', autocorrect: 'off' }, + wrapper: :with_block_label .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/user_mailer/confirmation_instructions.html.haml b/app/views/user_mailer/confirmation_instructions.html.haml index d3e3e8f930..d5d5e0dc1e 100644 --- a/app/views/user_mailer/confirmation_instructions.html.haml +++ b/app/views/user_mailer/confirmation_instructions.html.haml @@ -10,7 +10,9 @@ %td.email-inner-card-td.email-prose %p= t @resource.approved? ? 'devise.mailer.confirmation_instructions.explanation' : 'devise.mailer.confirmation_instructions.explanation_when_pending', host: site_hostname - if @resource.created_by_application - = render 'application/mailer/button', text: t('devise.mailer.confirmation_instructions.action_with_app', app: @resource.created_by_application.name), url: confirmation_url(@resource, confirmation_token: @token, redirect_to_app: 'true') + = render 'application/mailer/button', + text: t('devise.mailer.confirmation_instructions.action_with_app', app: @resource.created_by_application.name), + url: confirmation_url(@resource, confirmation_token: @token, redirect_to_app: 'true') - else = render 'application/mailer/button', text: t('devise.mailer.confirmation_instructions.action'), url: confirmation_url(@resource, confirmation_token: @token) %p= t 'devise.mailer.confirmation_instructions.extra_html', terms_path: about_more_url, policy_path: privacy_policy_url From ceaadc791e83ec9f484da4b4789602e2604ab3e4 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Mon, 2 Mar 2026 16:13:56 +0100 Subject: [PATCH 5/7] Change cursor to make clear `summary` is clickable (#38029) --- app/javascript/styles/mastodon/admin.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index dd95f4e49b..0213700429 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -385,6 +385,7 @@ $content-width: 840px; color: var(--color-text-secondary); padding-top: 24px; margin-bottom: 8px; + cursor: pointer; } @media screen and (max-width: $no-columns-breakpoint) { From 03b2f77ad243b3b0d419f5e292669e5c40483061 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Mon, 2 Mar 2026 17:19:13 +0100 Subject: [PATCH 6/7] Collection share modal cleanup (#38030) --- .../mastodon/components/modal_shell/index.tsx | 19 +++++++------------ .../account_timeline/modals/field_modal.tsx | 14 +++++++++----- .../collections/detail/share_modal.tsx | 14 +++++++++----- .../confirmation_modal.tsx | 14 +++++++++----- .../features/ui/components/modal_root.jsx | 3 +-- .../features/ui/util/async-components.js | 6 ------ .../styles/mastodon/components.scss | 4 ++++ 7 files changed, 39 insertions(+), 35 deletions(-) diff --git a/app/javascript/mastodon/components/modal_shell/index.tsx b/app/javascript/mastodon/components/modal_shell/index.tsx index 8b6fdcc6ad..8b06087532 100644 --- a/app/javascript/mastodon/components/modal_shell/index.tsx +++ b/app/javascript/mastodon/components/modal_shell/index.tsx @@ -1,16 +1,14 @@ import classNames from 'classnames'; -interface SimpleComponentProps { +interface ModalShellProps { className?: string; children?: React.ReactNode; } -interface ModalShellComponent extends React.FC { - Body: React.FC; - Actions: React.FC; -} - -export const ModalShell: ModalShellComponent = ({ children, className }) => { +export const ModalShell: React.FC = ({ + children, + className, +}) => { return (
{ ); }; -const ModalShellBody: ModalShellComponent['Body'] = ({ +export const ModalShellBody: React.FC = ({ children, className, }) => { @@ -39,7 +37,7 @@ const ModalShellBody: ModalShellComponent['Body'] = ({ ); }; -const ModalShellActions: ModalShellComponent['Actions'] = ({ +export const ModalShellActions: React.FC = ({ children, className, }) => { @@ -51,6 +49,3 @@ const ModalShellActions: ModalShellComponent['Actions'] = ({
); }; - -ModalShell.Body = ModalShellBody; -ModalShell.Actions = ModalShellActions; diff --git a/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx b/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx index f7251f7b41..c778e08fa2 100644 --- a/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx +++ b/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx @@ -4,7 +4,11 @@ import { FormattedMessage } from 'react-intl'; import { Button } from '@/mastodon/components/button'; import { EmojiHTML } from '@/mastodon/components/emoji/html'; -import { ModalShell } from '@/mastodon/components/modal_shell'; +import { + ModalShell, + ModalShellActions, + ModalShellBody, +} from '@/mastodon/components/modal_shell'; import type { AccountField } from '../common'; import { useFieldHtml } from '../hooks/useFieldHtml'; @@ -19,7 +23,7 @@ export const AccountFieldModal: FC<{ const handleValueElement = useFieldHtml(field.valueHasEmojis); return ( - + - - + + - + ); }; diff --git a/app/javascript/mastodon/features/collections/detail/share_modal.tsx b/app/javascript/mastodon/features/collections/detail/share_modal.tsx index 3bff066ee6..137794d95b 100644 --- a/app/javascript/mastodon/features/collections/detail/share_modal.tsx +++ b/app/javascript/mastodon/features/collections/detail/share_modal.tsx @@ -13,7 +13,11 @@ import { AvatarGroup } from 'mastodon/components/avatar_group'; import { Button } from 'mastodon/components/button'; import { CopyLinkField } from 'mastodon/components/form_fields'; import { IconButton } from 'mastodon/components/icon_button'; -import { ModalShell } from 'mastodon/components/modal_shell'; +import { + ModalShell, + ModalShellActions, + ModalShellBody, +} from 'mastodon/components/modal_shell'; import { useAppDispatch } from 'mastodon/store'; import { AuthorNote } from '.'; @@ -64,7 +68,7 @@ export const CollectionShareModal: React.FC<{ return ( - +

{isNew ? ( - + - +
- + ); }; diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx index 385ec6a794..5fbf7fff66 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -3,7 +3,11 @@ import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; import { Button } from 'mastodon/components/button'; -import { ModalShell } from 'mastodon/components/modal_shell'; +import { + ModalShell, + ModalShellActions, + ModalShellBody, +} from 'mastodon/components/modal_shell'; export interface BaseConfirmationModalProps { onClose: () => void; @@ -58,14 +62,14 @@ export const ConfirmationModal: React.FC< return ( - +

{title}

{message &&

{message}

} {extraContent ?? children} -
+ - +