From 6dad80eb8c9030af59c57f0369b078efee3e3a82 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 17 Oct 2025 15:02:47 +0200 Subject: [PATCH] Add new "quick boosting" setting (#36516) --- .../components/status/boost_button.tsx | 60 ++++++++++++++++++- .../components/status_action_bar/index.jsx | 25 +++++++- .../features/status/components/action_bar.jsx | 25 +++++++- app/javascript/mastodon/initial_state.ts | 2 + app/javascript/styles/mastodon/forms.scss | 4 ++ app/models/concerns/user/has_settings.rb | 4 ++ app/models/user_settings.rb | 1 + app/serializers/initial_state_serializer.rb | 1 + .../preferences/appearance/show.html.haml | 2 + config/locales/simple_form.en.yml | 2 + 10 files changed, 120 insertions(+), 6 deletions(-) diff --git a/app/javascript/mastodon/components/status/boost_button.tsx b/app/javascript/mastodon/components/status/boost_button.tsx index 337eca507..cdbe24228 100644 --- a/app/javascript/mastodon/components/status/boost_button.tsx +++ b/app/javascript/mastodon/components/status/boost_button.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import type { FC, KeyboardEvent, MouseEvent } from 'react'; +import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react'; import { useIntl } from 'react-intl'; @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { quoteComposeById } from '@/mastodon/actions/compose_typed'; import { toggleReblog } from '@/mastodon/actions/interactions'; import { openModal } from '@/mastodon/actions/modal'; +import { quickBoosting } from '@/mastodon/initial_state'; import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu'; import type { Status } from '@/mastodon/models/status'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; @@ -24,6 +25,55 @@ import { selectStatusState, } from './boost_button_utils'; +const StandaloneBoostButton: FC = ({ status, counters }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const statusState = useAppSelector((state) => + selectStatusState(state, status), + ); + const { title, meta, iconComponent, disabled } = useMemo( + () => boostItemState(statusState), + [statusState], + ); + + const handleClick: MouseEventHandler = useCallback( + (event) => { + if (statusState.isLoggedIn) { + dispatch(toggleReblog(status.get('id') as string, event.shiftKey)); + } else { + dispatch( + openModal({ + modalType: 'INTERACTION', + modalProps: { + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + }), + ); + } + }, + [dispatch, status, statusState.isLoggedIn], + ); + + return ( + + ); +}; + const renderMenuItem: RenderItemFn = ( item, index, @@ -46,7 +96,7 @@ interface ReblogButtonProps { type ActionMenuItemWithIcon = SomeRequired; -export const BoostButton: FC = ({ status, counters }) => { +const BoostOrQuoteMenu: FC = ({ status, counters }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const statusState = useAppSelector((state) => @@ -188,3 +238,9 @@ const ReblogMenuItem: FC = ({ ); }; + +// Switch between the standalone boost button or the +// "Boost or quote" menu based on the quickBoosting preference +export const BoostButton = quickBoosting + ? StandaloneBoostButton + : BoostOrQuoteMenu; diff --git a/app/javascript/mastodon/components/status_action_bar/index.jsx b/app/javascript/mastodon/components/status_action_bar/index.jsx index 3e82912ab..9366da354 100644 --- a/app/javascript/mastodon/components/status_action_bar/index.jsx +++ b/app/javascript/mastodon/components/status_action_bar/index.jsx @@ -20,11 +20,12 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/ import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { Dropdown } from 'mastodon/components/dropdown_menu'; -import { me } from '../../initial_state'; +import { me, quickBoosting } from '../../initial_state'; import { IconButton } from '../icon_button'; import { BoostButton } from '../status/boost_button'; import { RemoveQuoteHint } from './remove_quote_hint'; +import { quoteItemState, selectStatusState } from '../status/boost_button_utils'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -68,6 +69,7 @@ const mapStateToProps = (state, { status }) => { return ({ relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null, + statusQuoteState: selectStatusState(state, status), }); }; @@ -76,6 +78,7 @@ class StatusActionBar extends ImmutablePureComponent { identity: identityContextPropShape, status: ImmutablePropTypes.map.isRequired, relationship: ImmutablePropTypes.record, + statusQuoteState: PropTypes.object, quotedAccountId: PropTypes.string, contextType: PropTypes.string, onReply: PropTypes.func, @@ -125,6 +128,10 @@ class StatusActionBar extends ImmutablePureComponent { } }; + handleQuoteClick = () => { + this.props.onQuote(this.props.status); + }; + handleShareClick = () => { navigator.share({ url: this.props.status.get('url'), @@ -241,7 +248,7 @@ class StatusActionBar extends ImmutablePureComponent { }; render () { - const { status, relationship, quotedAccountId, contextType, intl, withDismiss, withCounters, scrollKey } = this.props; + const { status, relationship, statusQuoteState, quotedAccountId, contextType, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn, permissions } = this.props.identity; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -265,6 +272,20 @@ class StatusActionBar extends ImmutablePureComponent { if (publicStatus && 'share' in navigator) { menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick }); } + + if (quickBoosting && signedIn) { + const quoteItem = quoteItemState(statusQuoteState); + menu.push(null); + menu.push({ + text: intl.formatMessage(quoteItem.title), + description: quoteItem.meta + ? intl.formatMessage(quoteItem.meta) + : undefined, + disabled: quoteItem.disabled, + action: this.handleQuoteClick, + }); + menu.push(null); + } if (publicStatus && !isRemote) { menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index 6156cf191..88d77a01b 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -18,8 +18,9 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/ import { IconButton } from '../../../components/icon_button'; import { Dropdown } from 'mastodon/components/dropdown_menu'; -import { me } from '../../../initial_state'; +import { me, quickBoosting } from '../../../initial_state'; import { BoostButton } from '@/mastodon/components/status/boost_button'; +import { quoteItemState, selectStatusState } from '@/mastodon/components/status/boost_button_utils'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -60,6 +61,7 @@ const mapStateToProps = (state, { status }) => { return ({ relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null, + statusQuoteState: selectStatusState(state, status), }); }; @@ -68,6 +70,7 @@ class ActionBar extends PureComponent { identity: identityContextPropShape, status: ImmutablePropTypes.map.isRequired, relationship: ImmutablePropTypes.record, + statusQuoteState: PropTypes.object, quotedAccountId: ImmutablePropTypes.string, onReply: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired, @@ -116,6 +119,10 @@ class ActionBar extends PureComponent { this.props.onRevokeQuote(this.props.status); }; + handleQuoteClick = () => { + this.props.onQuote(this.props.status); + }; + handleQuotePolicyChange = () => { this.props.onQuotePolicyChange(this.props.status); }; @@ -200,7 +207,7 @@ class ActionBar extends PureComponent { }; render () { - const { status, relationship, quotedAccountId, intl } = this.props; + const { status, relationship, statusQuoteState, quotedAccountId, intl } = this.props; const { signedIn, permissions } = this.props.identity; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -222,6 +229,20 @@ class ActionBar extends PureComponent { menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare }); } + if (quickBoosting && signedIn) { + const quoteItem = quoteItemState(statusQuoteState); + menu.push(null); + menu.push({ + text: intl.formatMessage(quoteItem.title), + description: quoteItem.meta + ? intl.formatMessage(quoteItem.meta) + : undefined, + disabled: quoteItem.disabled, + action: this.handleQuoteClick, + }); + menu.push(null); + } + if (publicStatus && (signedIn || !isRemote)) { menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); } diff --git a/app/javascript/mastodon/initial_state.ts b/app/javascript/mastodon/initial_state.ts index f28d81a10..324c093b4 100644 --- a/app/javascript/mastodon/initial_state.ts +++ b/app/javascript/mastodon/initial_state.ts @@ -9,6 +9,7 @@ interface InitialStateMeta { activity_api_enabled: boolean; admin: string; boost_modal?: boolean; + quick_boosting?: boolean; delete_modal?: boolean; missing_alt_text_modal?: boolean; disable_swiping?: boolean; @@ -89,6 +90,7 @@ function getMeta( export const activityApiEnabled = getMeta('activity_api_enabled'); export const autoPlayGif = getMeta('auto_play_gif'); export const boostModal = getMeta('boost_modal'); +export const quickBoosting = getMeta('quick_boosting'); export const deleteModal = getMeta('delete_modal'); export const missingAltTextModal = getMeta('missing_alt_text_modal'); export const disableSwiping = getMeta('disable_swiping'); diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 57f3d1694..e0ccd0a27 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -224,6 +224,10 @@ code { list-style: disc; margin-inline-start: 18px; } + + .icon { + vertical-align: -3px; + } } ul.hint { diff --git a/app/models/concerns/user/has_settings.rb b/app/models/concerns/user/has_settings.rb index 04ad524c5..d917732d8 100644 --- a/app/models/concerns/user/has_settings.rb +++ b/app/models/concerns/user/has_settings.rb @@ -31,6 +31,10 @@ module User::HasSettings settings['web.reblog_modal'] end + def setting_quick_boosting + settings['web.quick_boosting'] + end + def setting_delete_modal settings['web.delete_modal'] end diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb index 5558ffe04..d6cc23e73 100644 --- a/app/models/user_settings.rb +++ b/app/models/user_settings.rb @@ -30,6 +30,7 @@ class UserSettings setting :disable_hover_cards, default: false setting :delete_modal, default: true setting :reblog_modal, default: false + setting :quick_boosting, default: false setting :missing_alt_text_modal, default: true setting :reduce_motion, default: false setting :expand_content_warnings, default: false diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 93c06b767..79fcfcf79 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -18,6 +18,7 @@ class InitialStateSerializer < ActiveModel::Serializer if object.current_account store[:me] = object.current_account.id.to_s store[:boost_modal] = object_account_user.setting_boost_modal + store[:quick_boosting] = object_account_user.setting_quick_boosting store[:delete_modal] = object_account_user.setting_delete_modal store[:missing_alt_text_modal] = object_account_user.settings['web.missing_alt_text_modal'] store[:auto_play_gif] = object_account_user.setting_auto_play_gif diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index cca587526..e1ee4ac0b 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -72,6 +72,8 @@ .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') + .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') .flash-message.hidden-on-touch-devices= t('appearance.boosting_preferences_info_html', icon: material_symbol('repeat')) %h4= t 'appearance.sensitive_content' diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 1bad98c0b..7855fbb13 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -65,6 +65,7 @@ en: setting_display_media_hide_all: Always hide media setting_display_media_show_all: Always show media setting_emoji_style: How to display emojis. "Auto" will try using native emoji, but falls back to Twemoji for legacy browsers. + setting_quick_boosting_html: When enabled, clicking on the %{boost_icon} Boost icon will immediately boost instead of opening the boost/quote dropdown menu. Relocates the quoting action to the %{options_icon} (Options) menu. setting_system_scrollbars_ui: Applies only to desktop browsers based on Safari and Chrome setting_use_blurhash: Gradients are based on the colors of the hidden visuals but obfuscate any details setting_use_pending_items: Hide timeline updates behind a click instead of automatically scrolling the feed @@ -252,6 +253,7 @@ en: setting_expand_spoilers: Always expand posts marked with content warnings setting_hide_network: Hide your social graph setting_missing_alt_text_modal: Warn me before posting media without alt text + setting_quick_boosting: Enable quick boosting setting_reduce_motion: Reduce motion in animations setting_system_font_ui: Use system's default font setting_system_scrollbars_ui: Use system's default scrollbar