Add first-time user education hint about quote removal on Quote notifications (#35986)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Crowdin / Upload translations / upload-translations (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
CSS Linting / lint (push) Has been cancelled

This commit is contained in:
diondiondion 2025-09-04 15:01:12 +02:00 committed by GitHub
parent 42be0ca0eb
commit e7c30cd072
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 179 additions and 45 deletions

View File

@ -13,9 +13,9 @@ import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
const offset = [0, 4] as OffsetValue; const offset = [0, 4] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions; const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
export const AltTextBadge: React.FC<{ export const AltTextBadge: React.FC<{ description: string }> = ({
description: string; description,
}> = ({ description }) => { }) => {
const accessibilityId = useId(); const accessibilityId = useId();
const anchorRef = useRef<HTMLButtonElement>(null); const anchorRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{
{({ props }) => ( {({ props }) => (
<div {...props} className='hover-card-controller'> <div {...props} className='hover-card-controller'>
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions <div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
className='media-gallery__alt__popover dropdown-animation' className='info-tooltip dropdown-animation'
role='region' role='region'
id={accessibilityId} id={accessibilityId}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}

View File

@ -1,8 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-call,
@typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access
-- the settings store is not yet typed */
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import { useCallback, useState, useEffect } from 'react'; import { useCallback, useState, useEffect } from 'react';
@ -23,31 +18,48 @@ interface Props {
id: string; id: string;
} }
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({ export function useDismissableBannerState({ id }: Props) {
id, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
children, const dismissed: boolean = useAppSelector((state) =>
}) => { /* eslint-disable-next-line */
const dismissed = useAppSelector((state) =>
state.settings.getIn(['dismissed_banners', id], false), state.settings.getIn(['dismissed_banners', id], false),
); );
const [isVisible, setIsVisible] = useState(
!bannerSettings.get(id) && !dismissed,
);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed); const dismiss = useCallback(() => {
const intl = useIntl(); setIsVisible(false);
const handleDismiss = useCallback(() => {
setVisible(false);
bannerSettings.set(id, true); bannerSettings.set(id, true);
dispatch(changeSetting(['dismissed_banners', id], true)); dispatch(changeSetting(['dismissed_banners', id], true));
}, [id, dispatch]); }, [id, dispatch]);
useEffect(() => { useEffect(() => {
if (!visible && !dismissed) { // Store legacy localStorage setting on server
if (!isVisible && !dismissed) {
dispatch(changeSetting(['dismissed_banners', id], true)); dispatch(changeSetting(['dismissed_banners', id], true));
} }
}, [id, dispatch, visible, dismissed]); }, [id, dispatch, isVisible, dismissed]);
if (!visible) { return {
isVisible,
dismiss,
};
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const intl = useIntl();
const { isVisible, dismiss } = useDismissableBannerState({
id,
});
if (!isVisible) {
return null; return null;
} }
@ -58,7 +70,7 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
icon='times' icon='times'
iconComponent={CloseIcon} iconComponent={CloseIcon}
title={intl.formatMessage(messages.dismiss)} title={intl.formatMessage(messages.dismiss)}
onClick={handleDismiss} onClick={dismiss}
/> />
</div> </div>

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 } from '../../initial_state';
import { IconButton } from './icon_button'; import { IconButton } from '../icon_button';
import { isFeatureEnabled } from '../utils/environment'; import { isFeatureEnabled } from '../../utils/environment';
import { ReblogButton } from './status/reblog_button'; import { ReblogButton } from '../status/reblog_button';
import { RemoveQuoteHint } from './remove_quote_hint';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -77,6 +78,7 @@ class StatusActionBar extends ImmutablePureComponent {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
relationship: ImmutablePropTypes.record, relationship: ImmutablePropTypes.record,
quotedAccountId: PropTypes.string, quotedAccountId: PropTypes.string,
contextType: PropTypes.string,
onReply: PropTypes.func, onReply: PropTypes.func,
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
@ -240,7 +242,7 @@ class StatusActionBar extends ImmutablePureComponent {
}; };
render () { render () {
const { status, relationship, quotedAccountId, intl, withDismiss, withCounters, scrollKey } = this.props; const { status, relationship, 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'));
@ -249,6 +251,7 @@ class StatusActionBar extends ImmutablePureComponent {
const account = status.get('account'); const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me; const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']); const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
const isQuotingMe = quotedAccountId === me;
let menu = []; let menu = [];
@ -293,7 +296,7 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
menu.push(null); menu.push(null);
if (quotedAccountId === me) { if (isQuotingMe) {
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true }); menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
} }
@ -360,6 +363,8 @@ class StatusActionBar extends ImmutablePureComponent {
const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark); const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite); const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications';
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
@ -375,17 +380,23 @@ class StatusActionBar extends ImmutablePureComponent {
<div className='status__action-bar__button-wrapper'> <div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /> <IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
</div> </div>
<div className='status__action-bar__button-wrapper'> <RemoveQuoteHint className='status__action-bar__button-wrapper' canShowHint={shouldShowQuoteRemovalHint}>
<Dropdown {(dismissQuoteHint) => (
scrollKey={scrollKey} <Dropdown
status={status} scrollKey={scrollKey}
items={menu} status={status}
icon='ellipsis-h' items={menu}
iconComponent={MoreHorizIcon} icon='ellipsis-h'
direction='right' iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)} direction='right'
/> title={intl.formatMessage(messages.more)}
</div> onOpen={() => {
dismissQuoteHint();
return true;
}}
/>
)}
</RemoveQuoteHint>
</div> </div>
); );
} }

View File

@ -0,0 +1,90 @@
import { useRef } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { Button } from '../button';
import { useDismissableBannerState } from '../dismissable_banner';
import { Icon } from '../icon';
const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint';
export const RemoveQuoteHint: React.FC<{
canShowHint: boolean;
className?: string;
children: (dismiss: () => void) => React.ReactNode;
}> = ({ canShowHint, className, children }) => {
const anchorRef = useRef<HTMLDivElement>(null);
const intl = useIntl();
const { isVisible, dismiss } = useDismissableBannerState({
id: DISMISSABLE_BANNER_ID,
});
return (
<div className={className} ref={anchorRef}>
{children(dismiss)}
{isVisible && canShowHint && (
<Overlay
show
flip
offset={[12, 10]}
placement='bottom-end'
target={anchorRef.current}
container={anchorRef.current}
>
{({ props, placement }) => (
<div
{...props}
className={classNames(
'info-tooltip info-tooltip--solid dropdown-animation',
placement,
)}
>
<h4>
<FormattedMessage
id='remove_quote_hint.title'
defaultMessage='Want to remove your quoted post?'
/>
</h4>
<FormattedMessage
id='remove_quote_hint.message'
defaultMessage='You can do so from the {icon} options menu.'
values={{
icon: (
<Icon
id='ellipsis-h'
icon={MoreHorizIcon}
aria-label={intl.formatMessage({
id: 'status.more',
defaultMessage: 'More',
})}
style={{ verticalAlign: 'middle' }}
/>
),
}}
>
{(text) => <p>{text}</p>}
</FormattedMessage>
<FormattedMessage
id='remove_quote_hint.button_label'
defaultMessage='Got it'
>
{(text) => (
<Button plain compact onClick={dismiss}>
{text}
</Button>
)}
</FormattedMessage>
</div>
)}
</Overlay>
)}
</div>
);
};

View File

@ -768,6 +768,9 @@
"relative_time.minutes": "{number}m", "relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s", "relative_time.seconds": "{number}s",
"relative_time.today": "today", "relative_time.today": "today",
"remove_quote_hint.button_label": "Got it",
"remove_quote_hint.message": "You can do so from the {icon} options menu.",
"remove_quote_hint.title": "Want to remove your quoted post?",
"reply_indicator.attachments": "{count, plural, one {# attachment} other {# attachments}}", "reply_indicator.attachments": "{count, plural, one {# attachment} other {# attachments}}",
"reply_indicator.cancel": "Cancel", "reply_indicator.cancel": "Cancel",
"reply_indicator.poll": "Poll", "reply_indicator.poll": "Poll",

View File

@ -117,6 +117,7 @@ const initialState = ImmutableMap({
'explore/links': false, 'explore/links': false,
'explore/statuses': false, 'explore/statuses': false,
'explore/tags': false, 'explore/tags': false,
'notifications/remove_quote_hint': false,
}), }),
}); });

View File

@ -471,8 +471,8 @@
} }
} }
body > [data-popper-placement] { [data-popper-placement] {
z-index: 3; z-index: 9999;
} }
.invisible { .invisible {
@ -7127,7 +7127,8 @@ a.status-card {
cursor: default; cursor: default;
} }
.media-gallery__alt__popover { .info-tooltip {
color: $white;
background: color.change($black, $alpha: 0.65); background: color.change($black, $alpha: 0.65);
backdrop-filter: $backdrop-blur-filter; backdrop-filter: $backdrop-blur-filter;
border-radius: 4px; border-radius: 4px;
@ -7139,20 +7140,36 @@ a.status-card {
max-height: 30em; max-height: 30em;
overflow-y: auto; overflow-y: auto;
&--solid {
color: var(--nested-card-text);
background:
/* This is a bit of a silly hack for layering two background colours
* since --nested-card-background is too transparent for a tooltip */
linear-gradient(
var(--nested-card-background),
var(--nested-card-background)
),
linear-gradient(var(--background-color), var(--background-color));
border: var(--nested-card-border);
}
h4 { h4 {
font-size: 15px; font-size: 15px;
line-height: 20px; line-height: 20px;
font-weight: 500; font-weight: 500;
color: $white;
margin-bottom: 8px; margin-bottom: 8px;
} }
p { p {
font-size: 15px; font-size: 15px;
line-height: 20px; line-height: 20px;
color: color.change($white, $alpha: 0.85); opacity: 0.85;
white-space: pre-line; white-space: pre-line;
} }
.button {
margin-block-start: 8px;
}
} }
.attachment-list { .attachment-list {