Wrapstodon: Allow dismissing banner (#37202)

This commit is contained in:
Echo
2025-12-12 10:40:45 +01:00
committed by GitHub
parent dfbf908870
commit 10f232ca08
14 changed files with 179 additions and 154 deletions

View File

@@ -1,6 +1,5 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; 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 api, { getLinks } from 'mastodon/api';
import { compareId } from 'mastodon/compare_id'; import { compareId } from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
@@ -32,7 +31,6 @@ export const TIMELINE_GAP = null;
export const TIMELINE_NON_STATUS_MARKERS = [ export const TIMELINE_NON_STATUS_MARKERS = [
TIMELINE_GAP, TIMELINE_GAP,
TIMELINE_SUGGESTIONS, TIMELINE_SUGGESTIONS,
TIMELINE_WRAPSTODON,
]; ];
export const loadPending = timeline => ({ export const loadPending = timeline => ({
@@ -132,7 +130,6 @@ export function expandTimeline(timelineId, path, params = {}) {
if (timelineId === 'home') { if (timelineId === 'home') {
dispatch(submitMarkers()); dispatch(submitMarkers());
dispatch(reinsertAnnualReport())
} }
} catch(error) { } catch(error) {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); dispatch(expandTimelineFail(timelineId, error, isLoadingMore));

View File

@@ -1,12 +1,10 @@
import type { PropsWithChildren } from 'react'; import type { FC, ReactNode } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { changeSetting } from 'mastodon/actions/settings';
import { bannerSettings } from 'mastodon/settings'; import { useDismissible } from '../hooks/useDismissible';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
@@ -16,48 +14,12 @@ const messages = defineMessages({
interface Props { interface Props {
id: string; id: string;
children: ReactNode;
} }
export function useDismissableBannerState({ id }: Props) { export const DismissableBanner: FC<Props> = ({ id, children }) => {
// 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<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const intl = useIntl(); const intl = useIntl();
const { wasDismissed, dismiss } = useDismissableBannerState({ const { wasDismissed, dismiss } = useDismissible(id);
id,
});
if (wasDismissed) { if (wasDismissed) {
return null; return null;

View File

@@ -6,13 +6,13 @@ import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import { useDismissible } from '@/mastodon/hooks/useDismissible';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { Button } from '../button'; import { Button } from '../button';
import { useDismissableBannerState } from '../dismissable_banner';
import { Icon } from '../icon'; 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, * 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<HTMLDivElement>(null); const anchorRef = useRef<HTMLDivElement>(null);
const intl = useIntl(); const intl = useIntl();
const { wasDismissed, dismiss } = useDismissableBannerState({ const { wasDismissed, dismiss } = useDismissible(DISMISSIBLE_BANNER_ID);
id: DISMISSABLE_BANNER_ID,
});
const shouldShowHint = !wasDismissed && canShowHint; const shouldShowHint = !wasDismissed && canShowHint;

View File

@@ -8,8 +8,6 @@ import { debounce } from 'lodash';
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines'; import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator'; import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions'; 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'; import { StatusQuoteManager } from '../components/status_quoted';
@@ -67,10 +65,6 @@ export default class StatusList extends ImmutablePureComponent {
return ( return (
<InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} /> <InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} />
); );
case TIMELINE_WRAPSTODON:
return (
<AnnualReportTimeline key={TIMELINE_WRAPSTODON} />
)
case TIMELINE_GAP: case TIMELINE_GAP:
return ( return (
<LoadGap <LoadGap

View File

@@ -1,38 +1,60 @@
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test'; import { action } from 'storybook/actions';
import type { AnyFunction, OmitValueType } from '@/mastodon/utils/types';
import type { AnnualReportAnnouncementProps } from '.';
import { AnnualReportAnnouncement } from '.'; import { AnnualReportAnnouncement } from '.';
const meta = { type Props = OmitValueType<
title: 'Components/AnnualReportAnnouncement', // We can't use the name 'state' here because it's reserved for overriding Redux state.
component: AnnualReportAnnouncement, Omit<AnnualReportAnnouncementProps, 'state'> & {
args: { reportState: AnnualReportAnnouncementProps['state'];
hasData: false,
isLoading: false,
year: '2025',
onRequestBuild: fn(),
onOpen: fn(),
}, },
} satisfies Meta<typeof AnnualReportAnnouncement>; 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 (
<AnnualReportAnnouncement
state={reportState}
{...args}
onDismiss={action('dismissed announcement')}
onOpen={action('opened report modal')}
onRequestBuild={action('requested build')}
/>
);
},
} satisfies Meta<Props>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {};
render: (args) => <AnnualReportAnnouncement {...args} />,
};
export const Loading: Story = { export const Loading: Story = {
args: { args: {
isLoading: true, reportState: 'generating',
}, },
render: Default.render,
}; };
export const WithData: Story = { export const WithData: Story = {
args: { args: {
hasData: true, reportState: 'available',
}, },
render: Default.render,
}; };

View File

@@ -2,33 +2,36 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
import { Button } from '@/mastodon/components/button'; import { Button } from '@/mastodon/components/button';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
export const AnnualReportAnnouncement: React.FC<{ export interface AnnualReportAnnouncementProps {
year: string; year: string;
hasData: boolean; state: Exclude<ApiAnnualReportState, 'ineligible'>;
isLoading: boolean;
onRequestBuild: () => void; onRequestBuild: () => void;
onOpen: () => void; onOpen: () => void;
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => { onDismiss: () => void;
}
export const AnnualReportAnnouncement: React.FC<
AnnualReportAnnouncementProps
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
return ( return (
<div className={classNames('theme-dark', styles.wrapper)}> <div className={classNames('theme-dark', styles.wrapper)}>
<h2>
<FormattedMessage <FormattedMessage
id='annual_report.announcement.title' id='annual_report.announcement.title'
defaultMessage='Wrapstodon {year} has arrived' defaultMessage='Wrapstodon {year} has arrived'
values={{ year }} values={{ year }}
tagName='h2'
/> />
</h2>
<p>
<FormattedMessage <FormattedMessage
id='annual_report.announcement.description' id='annual_report.announcement.description'
defaultMessage='Discover more about your engagement on Mastodon over the past year.' defaultMessage='Discover more about your engagement on Mastodon over the past year.'
tagName='p'
/> />
</p> {state === 'available' ? (
{hasData ? (
<Button onClick={onOpen}> <Button onClick={onOpen}>
<FormattedMessage <FormattedMessage
id='annual_report.announcement.action_view' id='annual_report.announcement.action_view'
@@ -36,13 +39,21 @@ export const AnnualReportAnnouncement: React.FC<{
/> />
</Button> </Button>
) : ( ) : (
<Button loading={isLoading} onClick={onRequestBuild}> <Button loading={state === 'generating'} onClick={onRequestBuild}>
<FormattedMessage <FormattedMessage
id='annual_report.announcement.action_build' id='annual_report.announcement.action_build'
defaultMessage='Build my Wrapstodon' defaultMessage='Build my Wrapstodon'
/> />
</Button> </Button>
)} )}
{state === 'eligible' && (
<Button onClick={onDismiss} plain className={styles.closeButton}>
<FormattedMessage
id='annual_report.announcement.action_dismiss'
defaultMessage='No thanks'
/>
</Button>
)}
</div> </div>
); );
}; };

View File

@@ -15,6 +15,7 @@
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%) radial-gradient(at 16% 95%, #1e948299 0, transparent 50%)
var(--color-bg-primary); var(--color-bg-primary);
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
position: relative;
h2 { h2 {
font-size: 20px; font-size: 20px;
@@ -26,4 +27,11 @@
p { p {
margin-bottom: 20px; margin-bottom: 20px;
} }
.closeButton {
position: absolute;
bottom: 8px;
right: 8px;
margin-inline: 0;
}
} }

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import { useDismissible } from '@/mastodon/hooks/useDismissible';
import { import {
generateReport, generateReport,
selectWrapstodonYear, selectWrapstodonYear,
@@ -19,21 +20,26 @@ export const AnnualReportTimeline: FC = () => {
void dispatch(generateReport()); void dispatch(generateReport());
}, [dispatch]); }, [dispatch]);
const { wasDismissed, dismiss } = useDismissible(
`annual_report_announcement_${year}`,
);
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} })); dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} }));
}, [dispatch]); dismiss();
}, [dismiss, dispatch]);
if (!year || !state || state === 'ineligible') { if (!year || wasDismissed || !state || state === 'ineligible') {
return null; return null;
} }
return ( return (
<AnnualReportAnnouncement <AnnualReportAnnouncement
year={year.toString()} year={year.toString()}
hasData={state === 'available'} state={state}
isLoading={state === 'generating'}
onRequestBuild={handleBuildRequest} onRequestBuild={handleBuildRequest}
onOpen={handleOpen} onOpen={handleOpen}
onDismiss={dismiss}
/> />
); );
}; };

View File

@@ -1,14 +1,21 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
export const CriticalUpdateBanner = () => ( import { criticalUpdatesPending } from '@/mastodon/initial_state';
export const CriticalUpdateBanner: FC = () => {
if (!criticalUpdatesPending) {
return null;
}
return (
<div className='warning-banner'> <div className='warning-banner'>
<div className='warning-banner__message'> <div className='warning-banner__message'>
<h1>
<FormattedMessage <FormattedMessage
id='home.pending_critical_update.title' id='home.pending_critical_update.title'
defaultMessage='Critical security update available!' defaultMessage='Critical security update available!'
tagName='h1'
/> />
</h1>
<p> <p>
<FormattedMessage <FormattedMessage
id='home.pending_critical_update.body' id='home.pending_critical_update.body'
@@ -24,3 +31,4 @@ export const CriticalUpdateBanner = () => (
</div> </div>
</div> </div>
); );
};

View File

@@ -15,7 +15,6 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { criticalUpdatesPending } from 'mastodon/initial_state';
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; 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 { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner'; import { CriticalUpdateBanner } from './components/critical_update_banner';
import { Announcements } from './components/announcements'; import { Announcements } from './components/announcements';
import { AnnualReportTimeline } from '../annual_report/timeline';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' }, 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 { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const { signedIn } = this.props.identity; const { signedIn } = this.props.identity;
const banners = []; const banners = [
<CriticalUpdateBanner key='critical-update-banner' />,
<AnnualReportTimeline key='annual-report' />
];
let announcementsButton; let announcementsButton;
@@ -145,10 +148,6 @@ class HomeTimeline extends PureComponent {
); );
} }
if (criticalUpdatesPending) {
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
}
return ( return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader

View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect } from 'react';
import type { Map as ImmutableMap } from 'immutable';
import { changeSetting } from '@/mastodon/actions/settings';
import { bannerSettings } from '@/mastodon/settings';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
export function useDismissible(id: string) {
// We use "dismissed_banners" as that was what this was previously called,
// but we can use this to track any dismissible state.
const dismissed = useAppSelector(
(state) =>
!!(
state.settings as ImmutableMap<
'dismissed_banners',
ImmutableMap<string, boolean>
>
).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,
};
}

View File

@@ -114,6 +114,7 @@
"alt_text_modal.done": "Done", "alt_text_modal.done": "Done",
"announcement.announcement": "Announcement", "announcement.announcement": "Announcement",
"annual_report.announcement.action_build": "Build my Wrapstodon", "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.action_view": "View my Wrapstodon",
"annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.", "annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.",
"annual_report.announcement.title": "Wrapstodon {year} has arrived", "annual_report.announcement.title": "Wrapstodon {year} has arrived",

View File

@@ -5,8 +5,6 @@ import {
importFetchedAccounts, importFetchedAccounts,
importFetchedStatuses, importFetchedStatuses,
} from '@/mastodon/actions/importer'; } 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 type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
import { import {
apiGetAnnualReport, apiGetAnnualReport,
@@ -21,8 +19,6 @@ import {
createDataLoadingThunk, createDataLoadingThunk,
} from '../../store/typed_functions'; } from '../../store/typed_functions';
export const TIMELINE_WRAPSTODON = 'inline-wrapstodon';
interface AnnualReportState { interface AnnualReportState {
state?: ApiAnnualReportState; state?: ApiAnnualReportState;
report?: AnnualReport; report?: AnnualReport;
@@ -64,37 +60,12 @@ export const selectWrapstodonYear = createAppSelector(
// This kicks everything off, and is called after fetching the server info. // This kicks everything off, and is called after fetching the server info.
export const checkAnnualReport = createAppThunk( export const checkAnnualReport = createAppThunk(
`${annualReportSlice.name}/checkAnnualReport`, `${annualReportSlice.name}/checkAnnualReport`,
async (_arg: unknown, { dispatch, getState }) => { (_arg: unknown, { dispatch, getState }) => {
const year = selectWrapstodonYear(getState()); const year = selectWrapstodonYear(getState());
if (!year) { if (!year) {
return; return;
} }
const state = await dispatch(fetchReportState()); void 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));
}, },
); );

View File

@@ -14,3 +14,9 @@
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>; export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> & export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Partial<Pick<T, K>>; Partial<Pick<T, K>>;
export type OmitValueType<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K];
};
export type AnyFunction = (...args: never) => unknown;