diff --git a/app/javascript/mastodon/actions/notices.ts b/app/javascript/mastodon/actions/notices.ts new file mode 100644 index 0000000000..5490b6583b --- /dev/null +++ b/app/javascript/mastodon/actions/notices.ts @@ -0,0 +1,36 @@ +import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; + +import api from '../api'; + +export interface ApiNoticeActionJSON { + label: string; + url: string; +} + +export interface ApiNoticeJSON { + id: string; + title: string; + message: string; + icon?: string; + actions: ApiNoticeActionJSON[]; +} + +export const fetchNotices = createAppAsyncThunk( + 'notices/fetch', + async (_, { getState }) => { + const response = await api(getState).get( + '/api/v1/notices', + ); + + return { notices: response.data }; + }, +); + +export const dismissNotice = createAppAsyncThunk( + 'notices/dismiss', + async (args: { id: string }, { getState }) => { + await api(getState).delete(`/api/v1/notices/${args.id}`); + + return {}; + }, +); diff --git a/app/javascript/mastodon/features/home_timeline/components/notice.tsx b/app/javascript/mastodon/features/home_timeline/components/notice.tsx new file mode 100644 index 0000000000..7af76adc96 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/notice.tsx @@ -0,0 +1,61 @@ +import { useCallback } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import background from 'mastodon/../images/friends-cropped.png'; +import type { ApiNoticeJSON } from 'mastodon/actions/notices'; +import { dismissNotice } from 'mastodon/actions/notices'; +import { IconButton } from 'mastodon/components/icon_button'; +import { useAppDispatch } from 'mastodon/store'; + +const messages = defineMessages({ + dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' }, +}); + +interface Props { + notice: ApiNoticeJSON; +} + +export const Notice: React.FC = ({ notice }) => { + const intl = useIntl(); + + const dispatch = useAppDispatch(); + + const handleDismiss = useCallback(() => { + void dispatch(dismissNotice({ id: notice.id })); + }, [dispatch, notice.id]); + + return ( +
+
+ + +

{notice.title}

+ +

{notice.message}

+ +
+
+ {notice.actions.map((action, i) => ( + + {action.label} + + ))} +
+
+
+ +
+ +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx index 8ff0377946..c0909843fe 100644 --- a/app/javascript/mastodon/features/home_timeline/index.jsx +++ b/app/javascript/mastodon/features/home_timeline/index.jsx @@ -25,6 +25,7 @@ import StatusListContainer from '../ui/containers/status_list_container'; import { ColumnSettings } from './components/column_settings'; import { CriticalUpdateBanner } from './components/critical_update_banner'; import { ExplorePrompt } from './components/explore_prompt'; +import { Notice } from './components/notice'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, @@ -66,6 +67,7 @@ const mapStateToProps = state => ({ unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')), showAnnouncements: state.getIn(['announcements', 'show']), tooSlow: homeTooSlow(state), + notices: state.get('notices'), }); class HomeTimeline extends PureComponent { @@ -85,6 +87,7 @@ class HomeTimeline extends PureComponent { unreadAnnouncements: PropTypes.number, showAnnouncements: PropTypes.bool, tooSlow: PropTypes.bool, + notices: PropTypes.arrayOf(PropTypes.object), }; handlePin = () => { @@ -154,7 +157,7 @@ class HomeTimeline extends PureComponent { }; render () { - const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; + const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements, notices } = this.props; const pinned = !!columnId; const { signedIn } = this.context.identity; const banners = []; @@ -179,7 +182,9 @@ class HomeTimeline extends PureComponent { banners.push(); } - if (tooSlow) { + if (notices.length) { + banners.push(); + } else if (tooSlow) { banners.push(); } diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 55ccde72f5..6373d9f082 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -13,6 +13,7 @@ import { HotKeys } from 'react-hotkeys'; import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; +import { fetchNotices } from 'mastodon/actions/notices'; import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; import PictureInPicture from 'mastodon/features/picture_in_picture'; import { layoutFromWindow } from 'mastodon/is_mobile'; @@ -401,6 +402,7 @@ class UI extends PureComponent { } if (signedIn) { + this.props.dispatch(fetchNotices()); this.props.dispatch(fetchMarkers()); this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index c61c862cfe..046275fefc 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -28,6 +28,7 @@ import media_attachments from './media_attachments'; import meta from './meta'; import { modalReducer } from './modal'; import mutes from './mutes'; +import { noticesReducer } from './notices'; import notifications from './notifications'; import picture_in_picture from './picture_in_picture'; import polls from './polls'; @@ -86,6 +87,7 @@ const reducers = { history, tags, followed_tags, + notices: noticesReducer, }; // We want the root state to be an ImmutableRecord, which is an object with a defined list of keys, diff --git a/app/javascript/mastodon/reducers/notices.ts b/app/javascript/mastodon/reducers/notices.ts new file mode 100644 index 0000000000..bd06ee1d1a --- /dev/null +++ b/app/javascript/mastodon/reducers/notices.ts @@ -0,0 +1,15 @@ +import { createReducer } from '@reduxjs/toolkit'; + +import type { ApiNoticeJSON } from '../actions/notices'; +import { fetchNotices, dismissNotice } from '../actions/notices'; + +const initialState: ApiNoticeJSON[] = []; + +export const noticesReducer = createReducer(initialState, (builder) => { + builder + .addCase( + fetchNotices.fulfilled, + (_state, { payload: { notices } }) => notices, + ) + .addCase(dismissNotice.fulfilled, () => []); +});