From 10f232ca08f9fab4d91c6fd29f4344f4d9f6a08f Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 12 Dec 2025 10:40:45 +0100 Subject: [PATCH] Wrapstodon: Allow dismissing banner (#37202) --- app/javascript/mastodon/actions/timelines.js | 3 - .../components/dismissable_banner.tsx | 50 ++-------------- .../status_action_bar/remove_quote_hint.tsx | 8 +-- .../mastodon/components/status_list.jsx | 6 -- .../announcement/announcement.stories.tsx | 58 +++++++++++++------ .../annual_report/announcement/index.tsx | 49 ++++++++++------ .../announcement/styles.module.scss | 8 +++ .../features/annual_report/timeline.tsx | 14 +++-- .../components/critical_update_banner.tsx | 44 ++++++++------ .../mastodon/features/home_timeline/index.jsx | 11 ++-- .../mastodon/hooks/useDismissible.ts | 42 ++++++++++++++ app/javascript/mastodon/locales/en.json | 1 + .../mastodon/reducers/slices/annual_report.ts | 33 +---------- app/javascript/mastodon/utils/types.ts | 6 ++ 14 files changed, 179 insertions(+), 154 deletions(-) create mode 100644 app/javascript/mastodon/hooks/useDismissible.ts diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 056e7d7b23..d97885bad1 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,6 +1,5 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; -import { reinsertAnnualReport, TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report'; import api, { getLinks } from 'mastodon/api'; import { compareId } from 'mastodon/compare_id'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; @@ -32,7 +31,6 @@ export const TIMELINE_GAP = null; export const TIMELINE_NON_STATUS_MARKERS = [ TIMELINE_GAP, TIMELINE_SUGGESTIONS, - TIMELINE_WRAPSTODON, ]; export const loadPending = timeline => ({ @@ -132,7 +130,6 @@ export function expandTimeline(timelineId, path, params = {}) { if (timelineId === 'home') { dispatch(submitMarkers()); - dispatch(reinsertAnnualReport()) } } catch(error) { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); diff --git a/app/javascript/mastodon/components/dismissable_banner.tsx b/app/javascript/mastodon/components/dismissable_banner.tsx index a874f4792e..39ae11422a 100644 --- a/app/javascript/mastodon/components/dismissable_banner.tsx +++ b/app/javascript/mastodon/components/dismissable_banner.tsx @@ -1,12 +1,10 @@ -import type { PropsWithChildren } from 'react'; -import { useCallback, useState, useEffect } from 'react'; +import type { FC, ReactNode } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import { changeSetting } from 'mastodon/actions/settings'; -import { bannerSettings } from 'mastodon/settings'; -import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import { useDismissible } from '../hooks/useDismissible'; import { IconButton } from './icon_button'; @@ -16,48 +14,12 @@ const messages = defineMessages({ interface Props { id: string; + children: ReactNode; } -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 dismiss = useCallback(() => { - setIsVisible(false); - bannerSettings.set(id, true); - dispatch(changeSetting(['dismissed_banners', id], true)); - }, [id, dispatch]); - - useEffect(() => { - // Store legacy localStorage setting on server - if (!isVisible && !dismissed) { - dispatch(changeSetting(['dismissed_banners', id], true)); - } - }, [id, dispatch, isVisible, dismissed]); - - return { - wasDismissed: !isVisible, - dismiss, - }; -} - -export const DismissableBanner: React.FC> = ({ - id, - children, -}) => { +export const DismissableBanner: FC = ({ id, children }) => { const intl = useIntl(); - const { wasDismissed, dismiss } = useDismissableBannerState({ - id, - }); + const { wasDismissed, dismiss } = useDismissible(id); if (wasDismissed) { return null; 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 index 1c5cfeddcc..69795945a0 100644 --- a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx +++ b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx @@ -6,13 +6,13 @@ import classNames from 'classnames'; import Overlay from 'react-overlays/Overlay'; +import { useDismissible } from '@/mastodon/hooks/useDismissible'; 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'; +const DISMISSIBLE_BANNER_ID = 'notifications/remove_quote_hint'; /** * We don't want to show this hint in the UI more than once, @@ -29,9 +29,7 @@ export const RemoveQuoteHint: React.FC<{ const anchorRef = useRef(null); const intl = useIntl(); - const { wasDismissed, dismiss } = useDismissableBannerState({ - id: DISMISSABLE_BANNER_ID, - }); + const { wasDismissed, dismiss } = useDismissible(DISMISSIBLE_BANNER_ID); const shouldShowHint = !wasDismissed && canShowHint; diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index 78e6fbcf5f..049905dc08 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -8,8 +8,6 @@ import { debounce } from 'lodash'; import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines'; import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator'; import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions'; -import { AnnualReportTimeline } from 'mastodon/features/annual_report/timeline'; -import { TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report'; import { StatusQuoteManager } from '../components/status_quoted'; @@ -67,10 +65,6 @@ export default class StatusList extends ImmutablePureComponent { return ( ); - case TIMELINE_WRAPSTODON: - return ( - - ) case TIMELINE_GAP: return ( & { + reportState: AnnualReportAnnouncementProps['state']; }, -} satisfies Meta; + AnyFunction // Remove any functions, as they can't meaningfully be controlled in Storybook. +>; + +const meta = { + title: 'Components/AnnualReport/Announcement', + args: { + reportState: 'eligible', + year: '2025', + }, + argTypes: { + reportState: { + control: { + type: 'select', + }, + options: ['eligible', 'generating', 'available'], + }, + }, + render({ reportState, ...args }: Props) { + return ( + + ); + }, +} satisfies Meta; export default meta; type Story = StoryObj; -export const Default: Story = { - render: (args) => , -}; +export const Default: Story = {}; export const Loading: Story = { args: { - isLoading: true, + reportState: 'generating', }, - render: Default.render, }; export const WithData: Story = { args: { - hasData: true, + reportState: 'available', }, - render: Default.render, }; diff --git a/app/javascript/mastodon/features/annual_report/announcement/index.tsx b/app/javascript/mastodon/features/annual_report/announcement/index.tsx index 67e1d7b3e5..ee73d35352 100644 --- a/app/javascript/mastodon/features/annual_report/announcement/index.tsx +++ b/app/javascript/mastodon/features/annual_report/announcement/index.tsx @@ -2,33 +2,36 @@ import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import type { ApiAnnualReportState } from '@/mastodon/api/annual_report'; import { Button } from '@/mastodon/components/button'; import styles from './styles.module.scss'; -export const AnnualReportAnnouncement: React.FC<{ +export interface AnnualReportAnnouncementProps { year: string; - hasData: boolean; - isLoading: boolean; + state: Exclude; onRequestBuild: () => void; onOpen: () => void; -}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => { + onDismiss: () => void; +} + +export const AnnualReportAnnouncement: React.FC< + AnnualReportAnnouncementProps +> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => { return (
-

- -

-

- -

- {hasData ? ( + + + {state === 'available' ? ( ) : ( - )} + {state === 'eligible' && ( + + )}
); }; diff --git a/app/javascript/mastodon/features/annual_report/announcement/styles.module.scss b/app/javascript/mastodon/features/annual_report/announcement/styles.module.scss index 1c3d033b2b..b96ca2e679 100644 --- a/app/javascript/mastodon/features/annual_report/announcement/styles.module.scss +++ b/app/javascript/mastodon/features/annual_report/announcement/styles.module.scss @@ -15,6 +15,7 @@ radial-gradient(at 16% 95%, #1e948299 0, transparent 50%) var(--color-bg-primary); border-bottom: 1px solid var(--color-border-primary); + position: relative; h2 { font-size: 20px; @@ -26,4 +27,11 @@ p { margin-bottom: 20px; } + + .closeButton { + position: absolute; + bottom: 8px; + right: 8px; + margin-inline: 0; + } } diff --git a/app/javascript/mastodon/features/annual_report/timeline.tsx b/app/javascript/mastodon/features/annual_report/timeline.tsx index ee46d20403..4280c2a98a 100644 --- a/app/javascript/mastodon/features/annual_report/timeline.tsx +++ b/app/javascript/mastodon/features/annual_report/timeline.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import type { FC } from 'react'; import { openModal } from '@/mastodon/actions/modal'; +import { useDismissible } from '@/mastodon/hooks/useDismissible'; import { generateReport, selectWrapstodonYear, @@ -19,21 +20,26 @@ export const AnnualReportTimeline: FC = () => { void dispatch(generateReport()); }, [dispatch]); + const { wasDismissed, dismiss } = useDismissible( + `annual_report_announcement_${year}`, + ); + const handleOpen = useCallback(() => { dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} })); - }, [dispatch]); + dismiss(); + }, [dismiss, dispatch]); - if (!year || !state || state === 'ineligible') { + if (!year || wasDismissed || !state || state === 'ineligible') { return null; } return ( ); }; diff --git a/app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx b/app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx index d0dd2b6acd..b57231132f 100644 --- a/app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx +++ b/app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx @@ -1,26 +1,34 @@ +import type { FC } from 'react'; + import { FormattedMessage } from 'react-intl'; -export const CriticalUpdateBanner = () => ( -
-
-

+import { criticalUpdatesPending } from '@/mastodon/initial_state'; + +export const CriticalUpdateBanner: FC = () => { + if (!criticalUpdatesPending) { + return null; + } + return ( +
+
-

-

- {' '} - +

- -

+ id='home.pending_critical_update.body' + defaultMessage='Please update your Mastodon server as soon as possible!' + />{' '} + + + +

+
- -); + ); +}; diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx index 8c5555fd49..893e2c08ca 100644 --- a/app/javascript/mastodon/features/home_timeline/index.jsx +++ b/app/javascript/mastodon/features/home_timeline/index.jsx @@ -15,7 +15,6 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { criticalUpdatesPending } from 'mastodon/initial_state'; import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; @@ -27,6 +26,7 @@ import StatusListContainer from '../ui/containers/status_list_container'; import { ColumnSettings } from './components/column_settings'; import { CriticalUpdateBanner } from './components/critical_update_banner'; import { Announcements } from './components/announcements'; +import { AnnualReportTimeline } from '../annual_report/timeline'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, @@ -127,7 +127,10 @@ class HomeTimeline extends PureComponent { const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props; const pinned = !!columnId; const { signedIn } = this.props.identity; - const banners = []; + const banners = [ + , + + ]; let announcementsButton; @@ -145,10 +148,6 @@ class HomeTimeline extends PureComponent { ); } - if (criticalUpdatesPending) { - banners.push(); - } - return ( + !!( + state.settings as ImmutableMap< + 'dismissed_banners', + ImmutableMap + > + ).getIn(['dismissed_banners', id], false), + ); + + const wasDismissed = !!bannerSettings.get(id) || dismissed; + + const dispatch = useAppDispatch(); + + const dismiss = useCallback(() => { + bannerSettings.set(id, true); + dispatch(changeSetting(['dismissed_banners', id], true)); + }, [id, dispatch]); + + useEffect(() => { + // Store legacy localStorage setting on server + if (wasDismissed && !dismissed) { + dispatch(changeSetting(['dismissed_banners', id], true)); + } + }, [id, dispatch, wasDismissed, dismissed]); + + return { + wasDismissed, + dismiss, + }; +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 0d46b446a7..e91af0c904 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -114,6 +114,7 @@ "alt_text_modal.done": "Done", "announcement.announcement": "Announcement", "annual_report.announcement.action_build": "Build my Wrapstodon", + "annual_report.announcement.action_dismiss": "No thanks", "annual_report.announcement.action_view": "View my Wrapstodon", "annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.", "annual_report.announcement.title": "Wrapstodon {year} has arrived", diff --git a/app/javascript/mastodon/reducers/slices/annual_report.ts b/app/javascript/mastodon/reducers/slices/annual_report.ts index 3ad18f8ec1..e242fdbf9a 100644 --- a/app/javascript/mastodon/reducers/slices/annual_report.ts +++ b/app/javascript/mastodon/reducers/slices/annual_report.ts @@ -5,8 +5,6 @@ import { importFetchedAccounts, importFetchedStatuses, } from '@/mastodon/actions/importer'; -import { insertIntoTimeline } from '@/mastodon/actions/timelines'; -import { timelineDelete } from '@/mastodon/actions/timelines_typed'; import type { ApiAnnualReportState } from '@/mastodon/api/annual_report'; import { apiGetAnnualReport, @@ -21,8 +19,6 @@ import { createDataLoadingThunk, } from '../../store/typed_functions'; -export const TIMELINE_WRAPSTODON = 'inline-wrapstodon'; - interface AnnualReportState { state?: ApiAnnualReportState; report?: AnnualReport; @@ -64,37 +60,12 @@ export const selectWrapstodonYear = createAppSelector( // This kicks everything off, and is called after fetching the server info. export const checkAnnualReport = createAppThunk( `${annualReportSlice.name}/checkAnnualReport`, - async (_arg: unknown, { dispatch, getState }) => { + (_arg: unknown, { dispatch, getState }) => { const year = selectWrapstodonYear(getState()); if (!year) { return; } - const state = await dispatch(fetchReportState()); - if ( - state.meta.requestStatus === 'fulfilled' && - state.payload !== 'ineligible' - ) { - dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1)); - } - }, -); - -export const reinsertAnnualReport = createAppThunk( - `${annualReportSlice.name}/reinsertAnnualReport`, - (_arg: unknown, { dispatch, getState }) => { - dispatch( - timelineDelete({ - statusId: TIMELINE_WRAPSTODON, - accountId: '', - references: [], - reblogOf: null, - }), - ); - const { state } = getState().annualReport; - if (!state || state === 'ineligible') { - return; - } - dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1)); + void dispatch(fetchReportState()); }, ); diff --git a/app/javascript/mastodon/utils/types.ts b/app/javascript/mastodon/utils/types.ts index 24b9ee180f..eb45881ee4 100644 --- a/app/javascript/mastodon/utils/types.ts +++ b/app/javascript/mastodon/utils/types.ts @@ -14,3 +14,9 @@ export type SomeRequired = T & Required>; export type SomeOptional = Pick> & Partial>; + +export type OmitValueType = { + [K in keyof T as T[K] extends V ? never : K]: T[K]; +}; + +export type AnyFunction = (...args: never) => unknown;