2
0

Add new "quick boosting" setting (#36516)

This commit is contained in:
diondiondion
2025-10-17 15:02:47 +02:00
committed by GitHub
parent c96e28a41d
commit 6dad80eb8c
10 changed files with 120 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react'; 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'; import { useIntl } from 'react-intl';
@@ -8,6 +8,7 @@ import classNames from 'classnames';
import { quoteComposeById } from '@/mastodon/actions/compose_typed'; import { quoteComposeById } from '@/mastodon/actions/compose_typed';
import { toggleReblog } from '@/mastodon/actions/interactions'; import { toggleReblog } from '@/mastodon/actions/interactions';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import { quickBoosting } from '@/mastodon/initial_state';
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu'; import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
import type { Status } from '@/mastodon/models/status'; import type { Status } from '@/mastodon/models/status';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -24,6 +25,55 @@ import {
selectStatusState, selectStatusState,
} from './boost_button_utils'; } from './boost_button_utils';
const StandaloneBoostButton: FC<ReblogButtonProps> = ({ 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 (
<IconButton
disabled={disabled}
active={!!status.get('reblogged')}
title={intl.formatMessage(meta ?? title)}
icon='retweet'
iconComponent={iconComponent}
onClick={!disabled ? handleClick : undefined}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
/>
);
};
const renderMenuItem: RenderItemFn<ActionMenuItem> = ( const renderMenuItem: RenderItemFn<ActionMenuItem> = (
item, item,
index, index,
@@ -46,7 +96,7 @@ interface ReblogButtonProps {
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>; type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
export const BoostButton: FC<ReblogButtonProps> = ({ status, counters }) => { const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const statusState = useAppSelector((state) => const statusState = useAppSelector((state) =>
@@ -188,3 +238,9 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
</li> </li>
); );
}; };
// Switch between the standalone boost button or the
// "Boost or quote" menu based on the quickBoosting preference
export const BoostButton = quickBoosting
? StandaloneBoostButton
: BoostOrQuoteMenu;

View File

@@ -20,11 +20,12 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { Dropdown } from 'mastodon/components/dropdown_menu'; import { Dropdown } from 'mastodon/components/dropdown_menu';
import { me } from '../../initial_state'; import { me, quickBoosting } from '../../initial_state';
import { IconButton } from '../icon_button'; import { IconButton } from '../icon_button';
import { BoostButton } from '../status/boost_button'; import { BoostButton } from '../status/boost_button';
import { RemoveQuoteHint } from './remove_quote_hint'; import { RemoveQuoteHint } from './remove_quote_hint';
import { quoteItemState, selectStatusState } from '../status/boost_button_utils';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -68,6 +69,7 @@ const mapStateToProps = (state, { status }) => {
return ({ return ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null, quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
statusQuoteState: selectStatusState(state, status),
}); });
}; };
@@ -76,6 +78,7 @@ class StatusActionBar extends ImmutablePureComponent {
identity: identityContextPropShape, identity: identityContextPropShape,
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
relationship: ImmutablePropTypes.record, relationship: ImmutablePropTypes.record,
statusQuoteState: PropTypes.object,
quotedAccountId: PropTypes.string, quotedAccountId: PropTypes.string,
contextType: PropTypes.string, contextType: PropTypes.string,
onReply: PropTypes.func, onReply: PropTypes.func,
@@ -125,6 +128,10 @@ class StatusActionBar extends ImmutablePureComponent {
} }
}; };
handleQuoteClick = () => {
this.props.onQuote(this.props.status);
};
handleShareClick = () => { handleShareClick = () => {
navigator.share({ navigator.share({
url: this.props.status.get('url'), url: this.props.status.get('url'),
@@ -241,7 +248,7 @@ class StatusActionBar extends ImmutablePureComponent {
}; };
render () { 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 { signedIn, permissions } = this.props.identity;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@@ -265,6 +272,20 @@ class StatusActionBar extends ImmutablePureComponent {
if (publicStatus && 'share' in navigator) { if (publicStatus && 'share' in navigator) {
menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick }); 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) { if (publicStatus && !isRemote) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });

View File

@@ -18,8 +18,9 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
import { IconButton } from '../../../components/icon_button'; import { IconButton } from '../../../components/icon_button';
import { Dropdown } from 'mastodon/components/dropdown_menu'; 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 { BoostButton } from '@/mastodon/components/status/boost_button';
import { quoteItemState, selectStatusState } from '@/mastodon/components/status/boost_button_utils';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -60,6 +61,7 @@ const mapStateToProps = (state, { status }) => {
return ({ return ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null, quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
statusQuoteState: selectStatusState(state, status),
}); });
}; };
@@ -68,6 +70,7 @@ class ActionBar extends PureComponent {
identity: identityContextPropShape, identity: identityContextPropShape,
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
relationship: ImmutablePropTypes.record, relationship: ImmutablePropTypes.record,
statusQuoteState: PropTypes.object,
quotedAccountId: ImmutablePropTypes.string, quotedAccountId: ImmutablePropTypes.string,
onReply: PropTypes.func.isRequired, onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired,
@@ -116,6 +119,10 @@ class ActionBar extends PureComponent {
this.props.onRevokeQuote(this.props.status); this.props.onRevokeQuote(this.props.status);
}; };
handleQuoteClick = () => {
this.props.onQuote(this.props.status);
};
handleQuotePolicyChange = () => { handleQuotePolicyChange = () => {
this.props.onQuotePolicyChange(this.props.status); this.props.onQuotePolicyChange(this.props.status);
}; };
@@ -200,7 +207,7 @@ class ActionBar extends PureComponent {
}; };
render () { render () {
const { status, relationship, quotedAccountId, intl } = this.props; const { status, relationship, statusQuoteState, quotedAccountId, intl } = this.props;
const { signedIn, permissions } = this.props.identity; const { signedIn, permissions } = this.props.identity;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); 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 }); 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)) { if (publicStatus && (signedIn || !isRemote)) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
} }

View File

@@ -9,6 +9,7 @@ interface InitialStateMeta {
activity_api_enabled: boolean; activity_api_enabled: boolean;
admin: string; admin: string;
boost_modal?: boolean; boost_modal?: boolean;
quick_boosting?: boolean;
delete_modal?: boolean; delete_modal?: boolean;
missing_alt_text_modal?: boolean; missing_alt_text_modal?: boolean;
disable_swiping?: boolean; disable_swiping?: boolean;
@@ -89,6 +90,7 @@ function getMeta<K extends keyof InitialStateMeta>(
export const activityApiEnabled = getMeta('activity_api_enabled'); export const activityApiEnabled = getMeta('activity_api_enabled');
export const autoPlayGif = getMeta('auto_play_gif'); export const autoPlayGif = getMeta('auto_play_gif');
export const boostModal = getMeta('boost_modal'); export const boostModal = getMeta('boost_modal');
export const quickBoosting = getMeta('quick_boosting');
export const deleteModal = getMeta('delete_modal'); export const deleteModal = getMeta('delete_modal');
export const missingAltTextModal = getMeta('missing_alt_text_modal'); export const missingAltTextModal = getMeta('missing_alt_text_modal');
export const disableSwiping = getMeta('disable_swiping'); export const disableSwiping = getMeta('disable_swiping');

View File

@@ -224,6 +224,10 @@ code {
list-style: disc; list-style: disc;
margin-inline-start: 18px; margin-inline-start: 18px;
} }
.icon {
vertical-align: -3px;
}
} }
ul.hint { ul.hint {

View File

@@ -31,6 +31,10 @@ module User::HasSettings
settings['web.reblog_modal'] settings['web.reblog_modal']
end end
def setting_quick_boosting
settings['web.quick_boosting']
end
def setting_delete_modal def setting_delete_modal
settings['web.delete_modal'] settings['web.delete_modal']
end end

View File

@@ -30,6 +30,7 @@ class UserSettings
setting :disable_hover_cards, default: false setting :disable_hover_cards, default: false
setting :delete_modal, default: true setting :delete_modal, default: true
setting :reblog_modal, default: false setting :reblog_modal, default: false
setting :quick_boosting, default: false
setting :missing_alt_text_modal, default: true setting :missing_alt_text_modal, default: true
setting :reduce_motion, default: false setting :reduce_motion, default: false
setting :expand_content_warnings, default: false setting :expand_content_warnings, default: false

View File

@@ -18,6 +18,7 @@ class InitialStateSerializer < ActiveModel::Serializer
if object.current_account if object.current_account
store[:me] = object.current_account.id.to_s store[:me] = object.current_account.id.to_s
store[:boost_modal] = object_account_user.setting_boost_modal 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[:delete_modal] = object_account_user.setting_delete_modal
store[:missing_alt_text_modal] = object_account_user.settings['web.missing_alt_text_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 store[:auto_play_gif] = object_account_user.setting_auto_play_gif

View File

@@ -72,6 +72,8 @@
.fields-group .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', 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')) .flash-message.hidden-on-touch-devices= t('appearance.boosting_preferences_info_html', icon: material_symbol('repeat'))
%h4= t 'appearance.sensitive_content' %h4= t 'appearance.sensitive_content'

View File

@@ -65,6 +65,7 @@ en:
setting_display_media_hide_all: Always hide media setting_display_media_hide_all: Always hide media
setting_display_media_show_all: Always show 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_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_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_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 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_expand_spoilers: Always expand posts marked with content warnings
setting_hide_network: Hide your social graph setting_hide_network: Hide your social graph
setting_missing_alt_text_modal: Warn me before posting media without alt text 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_reduce_motion: Reduce motion in animations
setting_system_font_ui: Use system's default font setting_system_font_ui: Use system's default font
setting_system_scrollbars_ui: Use system's default scrollbar setting_system_scrollbars_ui: Use system's default scrollbar