mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-14 08:19:05 +00:00
Wrapstodon: Allow dismissing banner (#37202)
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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<PropsWithChildren<Props>> = ({
|
||||
id,
|
||||
children,
|
||||
}) => {
|
||||
export const DismissableBanner: FC<Props> = ({ id, children }) => {
|
||||
const intl = useIntl();
|
||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
||||
id,
|
||||
});
|
||||
const { wasDismissed, dismiss } = useDismissible(id);
|
||||
|
||||
if (wasDismissed) {
|
||||
return null;
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const intl = useIntl();
|
||||
|
||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
||||
id: DISMISSABLE_BANNER_ID,
|
||||
});
|
||||
const { wasDismissed, dismiss } = useDismissible(DISMISSIBLE_BANNER_ID);
|
||||
|
||||
const shouldShowHint = !wasDismissed && canShowHint;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} />
|
||||
);
|
||||
case TIMELINE_WRAPSTODON:
|
||||
return (
|
||||
<AnnualReportTimeline key={TIMELINE_WRAPSTODON} />
|
||||
)
|
||||
case TIMELINE_GAP:
|
||||
return (
|
||||
<LoadGap
|
||||
|
||||
@@ -1,38 +1,60 @@
|
||||
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 '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/AnnualReportAnnouncement',
|
||||
component: AnnualReportAnnouncement,
|
||||
args: {
|
||||
hasData: false,
|
||||
isLoading: false,
|
||||
year: '2025',
|
||||
onRequestBuild: fn(),
|
||||
onOpen: fn(),
|
||||
type Props = OmitValueType<
|
||||
// We can't use the name 'state' here because it's reserved for overriding Redux state.
|
||||
Omit<AnnualReportAnnouncementProps, 'state'> & {
|
||||
reportState: AnnualReportAnnouncementProps['state'];
|
||||
},
|
||||
} 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;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => <AnnualReportAnnouncement {...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,
|
||||
};
|
||||
|
||||
@@ -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<ApiAnnualReportState, 'ineligible'>;
|
||||
onRequestBuild: () => void;
|
||||
onOpen: () => void;
|
||||
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => {
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const AnnualReportAnnouncement: React.FC<
|
||||
AnnualReportAnnouncementProps
|
||||
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
|
||||
return (
|
||||
<div className={classNames('theme-dark', styles.wrapper)}>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.title'
|
||||
defaultMessage='Wrapstodon {year} has arrived'
|
||||
values={{ year }}
|
||||
tagName='h2'
|
||||
/>
|
||||
</h2>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.description'
|
||||
defaultMessage='Discover more about your engagement on Mastodon over the past year.'
|
||||
tagName='p'
|
||||
/>
|
||||
</p>
|
||||
{hasData ? (
|
||||
{state === 'available' ? (
|
||||
<Button onClick={onOpen}>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.action_view'
|
||||
@@ -36,13 +39,21 @@ export const AnnualReportAnnouncement: React.FC<{
|
||||
/>
|
||||
</Button>
|
||||
) : (
|
||||
<Button loading={isLoading} onClick={onRequestBuild}>
|
||||
<Button loading={state === 'generating'} onClick={onRequestBuild}>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.action_build'
|
||||
defaultMessage='Build my Wrapstodon'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{state === 'eligible' && (
|
||||
<Button onClick={onDismiss} plain className={styles.closeButton}>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.action_dismiss'
|
||||
defaultMessage='No thanks'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<AnnualReportAnnouncement
|
||||
year={year.toString()}
|
||||
hasData={state === 'available'}
|
||||
isLoading={state === 'generating'}
|
||||
state={state}
|
||||
onRequestBuild={handleBuildRequest}
|
||||
onOpen={handleOpen}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
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 (
|
||||
<div className='warning-banner'>
|
||||
<div className='warning-banner__message'>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.title'
|
||||
defaultMessage='Critical security update available!'
|
||||
tagName='h1'
|
||||
/>
|
||||
</h1>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.body'
|
||||
@@ -23,4 +30,5 @@ export const CriticalUpdateBanner = () => (
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = [
|
||||
<CriticalUpdateBanner key='critical-update-banner' />,
|
||||
<AnnualReportTimeline key='annual-report' />
|
||||
];
|
||||
|
||||
let announcementsButton;
|
||||
|
||||
@@ -145,10 +148,6 @@ class HomeTimeline extends PureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
if (criticalUpdatesPending) {
|
||||
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
|
||||
42
app/javascript/mastodon/hooks/useDismissible.ts
Normal file
42
app/javascript/mastodon/hooks/useDismissible.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -14,3 +14,9 @@
|
||||
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>> &
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user