From e7c30cd072eab00de84cf61a4da2769bac88dc88 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 4 Sep 2025 15:01:12 +0200 Subject: [PATCH] Add first-time user education hint about quote removal on Quote notifications (#35986) --- .../mastodon/components/alt_text_badge.tsx | 8 +- .../components/dismissable_banner.tsx | 50 +++++++---- .../index.jsx} | 45 ++++++---- .../status_action_bar/remove_quote_hint.tsx | 90 +++++++++++++++++++ app/javascript/mastodon/locales/en.json | 3 + app/javascript/mastodon/reducers/settings.js | 1 + .../styles/mastodon/components.scss | 27 ++++-- 7 files changed, 179 insertions(+), 45 deletions(-) rename app/javascript/mastodon/components/{status_action_bar.jsx => status_action_bar/index.jsx} (93%) create mode 100644 app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx diff --git a/app/javascript/mastodon/components/alt_text_badge.tsx b/app/javascript/mastodon/components/alt_text_badge.tsx index 07369795aca..c7fb0cd81b1 100644 --- a/app/javascript/mastodon/components/alt_text_badge.tsx +++ b/app/javascript/mastodon/components/alt_text_badge.tsx @@ -13,9 +13,9 @@ import { useSelectableClick } from 'mastodon/hooks/useSelectableClick'; const offset = [0, 4] as OffsetValue; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; -export const AltTextBadge: React.FC<{ - description: string; -}> = ({ description }) => { +export const AltTextBadge: React.FC<{ description: string }> = ({ + description, +}) => { const accessibilityId = useId(); const anchorRef = useRef(null); const [open, setOpen] = useState(false); @@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{ {({ props }) => (
> = ({ - id, - children, -}) => { - const dismissed = useAppSelector((state) => +export function useDismissableBannerState({ id }: Props) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const dismissed: boolean = useAppSelector((state) => + /* eslint-disable-next-line */ state.settings.getIn(['dismissed_banners', id], false), ); + + const [isVisible, setIsVisible] = useState( + !bannerSettings.get(id) && !dismissed, + ); + const dispatch = useAppDispatch(); - const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed); - const intl = useIntl(); - - const handleDismiss = useCallback(() => { - setVisible(false); + const dismiss = useCallback(() => { + setIsVisible(false); bannerSettings.set(id, true); dispatch(changeSetting(['dismissed_banners', id], true)); }, [id, dispatch]); useEffect(() => { - if (!visible && !dismissed) { + // Store legacy localStorage setting on server + if (!isVisible && !dismissed) { dispatch(changeSetting(['dismissed_banners', id], true)); } - }, [id, dispatch, visible, dismissed]); + }, [id, dispatch, isVisible, dismissed]); - if (!visible) { + return { + isVisible, + dismiss, + }; +} + +export const DismissableBanner: React.FC> = ({ + id, + children, +}) => { + const intl = useIntl(); + const { isVisible, dismiss } = useDismissableBannerState({ + id, + }); + + if (!isVisible) { return null; } @@ -58,7 +70,7 @@ export const DismissableBanner: React.FC> = ({ icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.dismiss)} - onClick={handleDismiss} + onClick={dismiss} />
diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar/index.jsx similarity index 93% rename from app/javascript/mastodon/components/status_action_bar.jsx rename to app/javascript/mastodon/components/status_action_bar/index.jsx index 143407193b3..09692406108 100644 --- a/app/javascript/mastodon/components/status_action_bar.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 } from '../../initial_state'; -import { IconButton } from './icon_button'; -import { isFeatureEnabled } from '../utils/environment'; -import { ReblogButton } from './status/reblog_button'; +import { IconButton } from '../icon_button'; +import { isFeatureEnabled } from '../../utils/environment'; +import { ReblogButton } from '../status/reblog_button'; +import { RemoveQuoteHint } from './remove_quote_hint'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -77,6 +78,7 @@ class StatusActionBar extends ImmutablePureComponent { status: ImmutablePropTypes.map.isRequired, relationship: ImmutablePropTypes.record, quotedAccountId: PropTypes.string, + contextType: PropTypes.string, onReply: PropTypes.func, onFavourite: PropTypes.func, onDelete: PropTypes.func, @@ -240,7 +242,7 @@ class StatusActionBar extends ImmutablePureComponent { }; 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 publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -249,6 +251,7 @@ class StatusActionBar extends ImmutablePureComponent { const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']); + const isQuotingMe = quotedAccountId === me; 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(null); - if (quotedAccountId === me) { + if (isQuotingMe) { 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 favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite); const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); + + const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications'; return (
@@ -375,17 +380,23 @@ class StatusActionBar extends ImmutablePureComponent {
-
- -
+ + {(dismissQuoteHint) => ( + { + dismissQuoteHint(); + return true; + }} + /> + )} +
); } diff --git a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx new file mode 100644 index 00000000000..6046dad035d --- /dev/null +++ b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx @@ -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(null); + const intl = useIntl(); + + const { isVisible, dismiss } = useDismissableBannerState({ + id: DISMISSABLE_BANNER_ID, + }); + + return ( +
+ {children(dismiss)} + {isVisible && canShowHint && ( + + {({ props, placement }) => ( +
+

+ +

+ + ), + }} + > + {(text) =>

{text}

} +
+ + {(text) => ( + + )} + +
+ )} +
+ )} +
+ ); +}; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index f2f23e60fa2..6bff9ed5949 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -768,6 +768,9 @@ "relative_time.minutes": "{number}m", "relative_time.seconds": "{number}s", "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.cancel": "Cancel", "reply_indicator.poll": "Poll", diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index cea8949f23c..43cf4e53423 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -117,6 +117,7 @@ const initialState = ImmutableMap({ 'explore/links': false, 'explore/statuses': false, 'explore/tags': false, + 'notifications/remove_quote_hint': false, }), }); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 80f8e5cb02a..1b5867e8b98 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -471,8 +471,8 @@ } } -body > [data-popper-placement] { - z-index: 3; +[data-popper-placement] { + z-index: 9999; } .invisible { @@ -7127,7 +7127,8 @@ a.status-card { cursor: default; } -.media-gallery__alt__popover { +.info-tooltip { + color: $white; background: color.change($black, $alpha: 0.65); backdrop-filter: $backdrop-blur-filter; border-radius: 4px; @@ -7139,20 +7140,36 @@ a.status-card { max-height: 30em; 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 { font-size: 15px; line-height: 20px; font-weight: 500; - color: $white; margin-bottom: 8px; } p { font-size: 15px; line-height: 20px; - color: color.change($white, $alpha: 0.85); + opacity: 0.85; white-space: pre-line; } + + .button { + margin-block-start: 8px; + } } .attachment-list {