diff --git a/app/soapbox/__fixtures__/announcements.json b/app/soapbox/__fixtures__/announcements.json
new file mode 100644
index 000000000..20e1960d0
--- /dev/null
+++ b/app/soapbox/__fixtures__/announcements.json
@@ -0,0 +1,44 @@
+[
+ {
+ "id": "1",
+ "content": "
Updated to Soapbox v3.
",
+ "starts_at": null,
+ "ends_at": null,
+ "all_day": false,
+ "published_at": "2022-06-15T18:47:14.190Z",
+ "updated_at": "2022-06-15T18:47:18.339Z",
+ "read": true,
+ "mentions": [],
+ "statuses": [],
+ "tags": [],
+ "emojis": [],
+ "reactions": [
+ {
+ "name": "📈",
+ "count": 476,
+ "me": true
+ }
+ ]
+ },
+ {
+ "id": "2",
+ "content": "Rolled back to Soapbox v2 for now.
",
+ "starts_at": null,
+ "ends_at": null,
+ "all_day": false,
+ "published_at": "2022-07-13T11:11:50.628Z",
+ "updated_at": "2022-07-13T11:11:50.628Z",
+ "read": true,
+ "mentions": [],
+ "statuses": [],
+ "tags": [],
+ "emojis": [],
+ "reactions": [
+ {
+ "name": "📉",
+ "count": 420,
+ "me": false
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/app/soapbox/actions/__tests__/announcements.test.ts b/app/soapbox/actions/__tests__/announcements.test.ts
new file mode 100644
index 000000000..978311585
--- /dev/null
+++ b/app/soapbox/actions/__tests__/announcements.test.ts
@@ -0,0 +1,113 @@
+import { List as ImmutableList } from 'immutable';
+
+import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'soapbox/actions/announcements';
+import { __stub } from 'soapbox/api';
+import { mockStore, rootState } from 'soapbox/jest/test-helpers';
+import { normalizeAnnouncement, normalizeInstance } from 'soapbox/normalizers';
+
+import type { APIEntity } from 'soapbox/types/entities';
+
+const announcements = require('soapbox/__fixtures__/announcements.json');
+
+describe('fetchAnnouncements()', () => {
+ describe('with a successful API request', () => {
+ it('should fetch announcements from the API', async() => {
+ const state = rootState
+ .set('instance', normalizeInstance({ version: '3.5.3' }));
+ const store = mockStore(state);
+
+ __stub((mock) => {
+ mock.onGet('/api/v1/announcements').reply(200, announcements);
+ });
+
+ const expectedActions = [
+ { type: 'ANNOUNCEMENTS_FETCH_REQUEST', skipLoading: true },
+ { type: 'ANNOUNCEMENTS_FETCH_SUCCESS', announcements, skipLoading: true },
+ { type: 'POLLS_IMPORT', polls: [] },
+ { type: 'ACCOUNTS_IMPORT', accounts: [] },
+ { type: 'STATUSES_IMPORT', statuses: [], expandSpoilers: false },
+ ];
+ await store.dispatch(fetchAnnouncements());
+ const actions = store.getActions();
+
+ expect(actions).toEqual(expectedActions);
+ });
+ });
+});
+
+describe('dismissAnnouncement', () => {
+ describe('with a successful API request', () => {
+ it('should mark announcement as dismissed', async() => {
+ const store = mockStore(rootState);
+
+ __stub((mock) => {
+ mock.onPost('/api/v1/announcements/1/dismiss').reply(200);
+ });
+
+ const expectedActions = [
+ { type: 'ANNOUNCEMENTS_DISMISS_REQUEST', id: '1' },
+ { type: 'ANNOUNCEMENTS_DISMISS_SUCCESS', id: '1' },
+ ];
+ await store.dispatch(dismissAnnouncement('1'));
+ const actions = store.getActions();
+
+ expect(actions).toEqual(expectedActions);
+ });
+ });
+});
+
+describe('addReaction', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ const state = rootState
+ .setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement))))
+ .setIn(['announcements', 'isLoading'], false);
+ store = mockStore(state);
+ });
+
+ describe('with a successful API request', () => {
+ it('should add reaction to a post', async() => {
+ __stub((mock) => {
+ mock.onPut('/api/v1/announcements/2/reactions/📉').reply(200);
+ });
+
+ const expectedActions = [
+ { type: 'ANNOUNCEMENTS_REACTION_ADD_REQUEST', id: '2', name: '📉', skipLoading: true },
+ { type: 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS', id: '2', name: '📉', skipLoading: true },
+ ];
+ await store.dispatch(addReaction('2', '📉'));
+ const actions = store.getActions();
+
+ expect(actions).toEqual(expectedActions);
+ });
+ });
+});
+
+describe('removeReaction', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ const state = rootState
+ .setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement))))
+ .setIn(['announcements', 'isLoading'], false);
+ store = mockStore(state);
+ });
+
+ describe('with a successful API request', () => {
+ it('should remove reaction from a post', async() => {
+ __stub((mock) => {
+ mock.onDelete('/api/v1/announcements/2/reactions/📉').reply(200);
+ });
+
+ const expectedActions = [
+ { type: 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST', id: '2', name: '📉', skipLoading: true },
+ { type: 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS', id: '2', name: '📉', skipLoading: true },
+ ];
+ await store.dispatch(removeReaction('2', '📉'));
+ const actions = store.getActions();
+
+ expect(actions).toEqual(expectedActions);
+ });
+ });
+});
diff --git a/app/soapbox/actions/announcements.ts b/app/soapbox/actions/announcements.ts
new file mode 100644
index 000000000..410de3cd9
--- /dev/null
+++ b/app/soapbox/actions/announcements.ts
@@ -0,0 +1,197 @@
+import api from 'soapbox/api';
+import { getFeatures } from 'soapbox/utils/features';
+
+import { importFetchedStatuses } from './importer';
+
+import type { AxiosError } from 'axios';
+import type { AppDispatch, RootState } from 'soapbox/store';
+import type { APIEntity } from 'soapbox/types/entities';
+
+export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
+export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
+export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
+export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE';
+export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE';
+
+export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST';
+export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS';
+export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
+export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
+export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST';
+export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS';
+export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';
+
+export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW';
+
+const noOp = () => {};
+
+export const fetchAnnouncements = (done = noOp) =>
+ (dispatch: AppDispatch, getState: () => RootState) => {
+ const { instance } = getState();
+ const features = getFeatures(instance);
+
+ if (!features.announcements) return null;
+
+ dispatch(fetchAnnouncementsRequest());
+
+ return api(getState).get('/api/v1/announcements').then(response => {
+ dispatch(fetchAnnouncementsSuccess(response.data));
+ dispatch(importFetchedStatuses(response.data.map(({ statuses }: APIEntity) => statuses)));
+ }).catch(error => {
+ dispatch(fetchAnnouncementsFail(error));
+ }).finally(() => {
+ done();
+ });
+ };
+
+export const fetchAnnouncementsRequest = () => ({
+ type: ANNOUNCEMENTS_FETCH_REQUEST,
+ skipLoading: true,
+});
+
+export const fetchAnnouncementsSuccess = (announcements: APIEntity) => ({
+ type: ANNOUNCEMENTS_FETCH_SUCCESS,
+ announcements,
+ skipLoading: true,
+});
+
+export const fetchAnnouncementsFail = (error: AxiosError) => ({
+ type: ANNOUNCEMENTS_FETCH_FAIL,
+ error,
+ skipLoading: true,
+ skipAlert: true,
+});
+
+export const updateAnnouncements = (announcement: APIEntity) => ({
+ type: ANNOUNCEMENTS_UPDATE,
+ announcement: announcement,
+});
+
+export const dismissAnnouncement = (announcementId: string) =>
+ (dispatch: AppDispatch, getState: () => RootState) => {
+ dispatch(dismissAnnouncementRequest(announcementId));
+
+ return api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => {
+ dispatch(dismissAnnouncementSuccess(announcementId));
+ }).catch(error => {
+ dispatch(dismissAnnouncementFail(announcementId, error));
+ });
+ };
+
+export const dismissAnnouncementRequest = (announcementId: string) => ({
+ type: ANNOUNCEMENTS_DISMISS_REQUEST,
+ id: announcementId,
+});
+
+export const dismissAnnouncementSuccess = (announcementId: string) => ({
+ type: ANNOUNCEMENTS_DISMISS_SUCCESS,
+ id: announcementId,
+});
+
+export const dismissAnnouncementFail = (announcementId: string, error: AxiosError) => ({
+ type: ANNOUNCEMENTS_DISMISS_FAIL,
+ id: announcementId,
+ error,
+});
+
+export const addReaction = (announcementId: string, name: string) =>
+ (dispatch: AppDispatch, getState: () => RootState) => {
+ const announcement = getState().announcements.items.find(x => x.get('id') === announcementId);
+
+ let alreadyAdded = false;
+
+ if (announcement) {
+ const reaction = announcement.reactions.find(x => x.name === name);
+
+ if (reaction && reaction.me) {
+ alreadyAdded = true;
+ }
+ }
+
+ if (!alreadyAdded) {
+ dispatch(addReactionRequest(announcementId, name, alreadyAdded));
+ }
+
+ return api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
+ dispatch(addReactionSuccess(announcementId, name, alreadyAdded));
+ }).catch(err => {
+ if (!alreadyAdded) {
+ dispatch(addReactionFail(announcementId, name, err));
+ }
+ });
+ };
+
+export const addReactionRequest = (announcementId: string, name: string, alreadyAdded?: boolean) => ({
+ type: ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+ id: announcementId,
+ name,
+ skipLoading: true,
+});
+
+export const addReactionSuccess = (announcementId: string, name: string, alreadyAdded?: boolean) => ({
+ type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS,
+ id: announcementId,
+ name,
+ skipLoading: true,
+});
+
+export const addReactionFail = (announcementId: string, name: string, error: AxiosError) => ({
+ type: ANNOUNCEMENTS_REACTION_ADD_FAIL,
+ id: announcementId,
+ name,
+ error,
+ skipLoading: true,
+});
+
+export const removeReaction = (announcementId: string, name: string) =>
+ (dispatch: AppDispatch, getState: () => RootState) => {
+ dispatch(removeReactionRequest(announcementId, name));
+
+ return api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
+ dispatch(removeReactionSuccess(announcementId, name));
+ }).catch(err => {
+ dispatch(removeReactionFail(announcementId, name, err));
+ });
+ };
+
+export const removeReactionRequest = (announcementId: string, name: string) => ({
+ type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+ id: announcementId,
+ name,
+ skipLoading: true,
+});
+
+export const removeReactionSuccess = (announcementId: string, name: string) => ({
+ type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS,
+ id: announcementId,
+ name,
+ skipLoading: true,
+});
+
+export const removeReactionFail = (announcementId: string, name: string, error: AxiosError) => ({
+ type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+ id: announcementId,
+ name,
+ error,
+ skipLoading: true,
+});
+
+export const updateReaction = (reaction: APIEntity) => ({
+ type: ANNOUNCEMENTS_REACTION_UPDATE,
+ reaction,
+});
+
+export const toggleShowAnnouncements = () => ({
+ type: ANNOUNCEMENTS_TOGGLE_SHOW,
+});
+
+export const deleteAnnouncement = (id: string) => ({
+ type: ANNOUNCEMENTS_DELETE,
+ id,
+});
diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts
index c47667197..c77daa6ac 100644
--- a/app/soapbox/actions/streaming.ts
+++ b/app/soapbox/actions/streaming.ts
@@ -3,6 +3,12 @@ import messages from 'soapbox/locales/messages';
import { connectStream } from '../stream';
+import {
+ deleteAnnouncement,
+ fetchAnnouncements,
+ updateAnnouncements,
+ updateReaction as updateAnnouncementsReaction,
+} from './announcements';
import { updateConversations } from './conversations';
import { fetchFilters } from './filters';
import { updateNotificationsQueue, expandNotifications } from './notifications';
@@ -100,13 +106,24 @@ const connectTimelineStream = (
case 'pleroma:follow_relationships_update':
dispatch(updateFollowRelationships(JSON.parse(data.payload)));
break;
+ case 'announcement':
+ dispatch(updateAnnouncements(JSON.parse(data.payload)));
+ break;
+ case 'announcement.reaction':
+ dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
+ break;
+ case 'announcement.delete':
+ dispatch(deleteAnnouncement(data.payload));
+ break;
}
},
};
});
const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () => void) =>
- dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({} as any, done))));
+ dispatch(expandHomeTimeline({}, () =>
+ dispatch(expandNotifications({}, () =>
+ dispatch(fetchAnnouncements(done))))));
const connectUserStream = () =>
connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
diff --git a/app/soapbox/components/animated-number.tsx b/app/soapbox/components/animated-number.tsx
new file mode 100644
index 000000000..0f6908fde
--- /dev/null
+++ b/app/soapbox/components/animated-number.tsx
@@ -0,0 +1,63 @@
+import React, { useEffect, useState } from 'react';
+import { FormattedNumber } from 'react-intl';
+import { TransitionMotion, spring } from 'react-motion';
+
+import { useSettings } from 'soapbox/hooks';
+
+const obfuscatedCount = (count: number) => {
+ if (count < 0) {
+ return 0;
+ } else if (count <= 1) {
+ return count;
+ } else {
+ return '1+';
+ }
+};
+
+interface IAnimatedNumber {
+ value: number;
+ obfuscate?: boolean;
+}
+
+const AnimatedNumber: React.FC = ({ value, obfuscate }) => {
+ const reduceMotion = useSettings().get('reduceMotion');
+
+ const [direction, setDirection] = useState(1);
+ const [displayedValue, setDisplayedValue] = useState(value);
+
+ useEffect(() => {
+ if (displayedValue !== undefined) {
+ if (value > displayedValue) setDirection(1);
+ else if (value < displayedValue) setDirection(-1);
+ }
+ setDisplayedValue(value);
+ }, [value]);
+
+ const willEnter = () => ({ y: -1 * direction });
+
+ const willLeave = () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) });
+
+ if (reduceMotion) {
+ return obfuscate ? <>{obfuscatedCount(displayedValue)}> : ;
+ }
+
+ const styles = [{
+ key: `${displayedValue}`,
+ data: displayedValue,
+ style: { y: spring(0, { damping: 35, stiffness: 400 }) },
+ }];
+
+ return (
+
+ {items => (
+
+ {items.map(({ key, data, style }) => (
+ 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : }
+ ))}
+
+ )}
+
+ );
+};
+
+export default AnimatedNumber;
\ No newline at end of file
diff --git a/app/soapbox/components/announcements/announcement-content.tsx b/app/soapbox/components/announcements/announcement-content.tsx
new file mode 100644
index 000000000..f4265d1fd
--- /dev/null
+++ b/app/soapbox/components/announcements/announcement-content.tsx
@@ -0,0 +1,86 @@
+import React, { useEffect, useRef } from 'react';
+import { useHistory } from 'react-router-dom';
+
+import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities';
+
+interface IAnnouncementContent {
+ announcement: AnnouncementEntity;
+}
+
+const AnnouncementContent: React.FC = ({ announcement }) => {
+ const history = useHistory();
+
+ const node = useRef(null);
+
+ useEffect(() => {
+ updateLinks();
+ });
+
+ const onMentionClick = (mention: MentionEntity, e: MouseEvent) => {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ e.stopPropagation();
+ history.push(`/@${mention.acct}`);
+ }
+ };
+
+ const onHashtagClick = (hashtag: string, e: MouseEvent) => {
+ hashtag = hashtag.replace(/^#/, '').toLowerCase();
+
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ e.stopPropagation();
+ history.push(`/tags/${hashtag}`);
+ }
+ };
+
+ const onStatusClick = (status: string, e: MouseEvent) => {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ history.push(status);
+ }
+ };
+
+ const updateLinks = () => {
+ if (!node.current) return;
+
+ const links = node.current.querySelectorAll('a');
+
+ links.forEach(link => {
+ // Skip already processed
+ if (link.classList.contains('status-link')) return;
+
+ // Add attributes
+ link.classList.add('status-link');
+ link.setAttribute('rel', 'nofollow noopener');
+ link.setAttribute('target', '_blank');
+
+ const mention = announcement.mentions.find(mention => link.href === `${mention.url}`);
+
+ // Add event listeners on mentions, hashtags and statuses
+ if (mention) {
+ link.addEventListener('click', onMentionClick.bind(link, mention), false);
+ link.setAttribute('title', mention.acct);
+ } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) {
+ link.addEventListener('click', onHashtagClick.bind(link, link.text), false);
+ } else {
+ const status = announcement.statuses.get(link.href);
+ if (status) {
+ link.addEventListener('click', onStatusClick.bind(this, status), false);
+ }
+ link.setAttribute('title', link.href);
+ link.classList.add('unhandled-link');
+ }
+ });
+ };
+
+ return (
+
+ );
+};
+
+export default AnnouncementContent;
diff --git a/app/soapbox/components/announcements/announcement.tsx b/app/soapbox/components/announcements/announcement.tsx
new file mode 100644
index 000000000..f6344f7b5
--- /dev/null
+++ b/app/soapbox/components/announcements/announcement.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { FormattedDate } from 'react-intl';
+
+import { Stack, Text } from 'soapbox/components/ui';
+import { useFeatures } from 'soapbox/hooks';
+
+import AnnouncementContent from './announcement-content';
+import ReactionsBar from './reactions-bar';
+
+import type { Map as ImmutableMap } from 'immutable';
+import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities';
+
+interface IAnnouncement {
+ announcement: AnnouncementEntity;
+ addReaction: (id: string, name: string) => void;
+ removeReaction: (id: string, name: string) => void;
+ emojiMap: ImmutableMap>;
+}
+
+const Announcement: React.FC = ({ announcement, addReaction, removeReaction, emojiMap }) => {
+ const features = useFeatures();
+
+ const startsAt = announcement.starts_at && new Date(announcement.starts_at);
+ const endsAt = announcement.ends_at && new Date(announcement.ends_at);
+ const now = new Date();
+ const hasTimeRange = startsAt && endsAt;
+ const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
+ const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
+ const skipTime = announcement.all_day;
+
+ return (
+
+ {hasTimeRange && (
+
+
+ {' '}
+ -
+ {' '}
+
+
+ )}
+
+
+
+ {features.announcementsReactions && (
+
+ )}
+
+ );
+};
+
+export default Announcement;
diff --git a/app/soapbox/components/announcements/announcements-panel.tsx b/app/soapbox/components/announcements/announcements-panel.tsx
new file mode 100644
index 000000000..200615dab
--- /dev/null
+++ b/app/soapbox/components/announcements/announcements-panel.tsx
@@ -0,0 +1,69 @@
+import classNames from 'classnames';
+import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
+import React, { useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+import ReactSwipeableViews from 'react-swipeable-views';
+import { createSelector } from 'reselect';
+
+import { addReaction as addReactionAction, removeReaction as removeReactionAction } from 'soapbox/actions/announcements';
+import { Card, HStack, Widget } from 'soapbox/components/ui';
+import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
+
+import Announcement from './announcement';
+
+import type { RootState } from 'soapbox/store';
+
+const customEmojiMap = createSelector([(state: RootState) => state.custom_emojis], items => (items as ImmutableList>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap>()));
+
+const AnnouncementsPanel = () => {
+ const dispatch = useAppDispatch();
+ const emojiMap = useAppSelector(state => customEmojiMap(state));
+ const [index, setIndex] = useState(0);
+
+ const announcements = useAppSelector((state) => state.announcements.items);
+
+ const addReaction = (id: string, name: string) => dispatch(addReactionAction(id, name));
+ const removeReaction = (id: string, name: string) => dispatch(removeReactionAction(id, name));
+
+ if (announcements.size === 0) return null;
+
+ const handleChangeIndex = (index: number) => {
+ setIndex(index % announcements.size);
+ };
+
+ return (
+ }>
+
+
+ {announcements.map((announcement) => (
+
+ )).reverse()}
+
+ {announcements.size > 1 && (
+
+ {announcements.map((_, i) => (
+
+ )}
+
+
+ );
+};
+
+export default AnnouncementsPanel;
diff --git a/app/soapbox/components/announcements/emoji.tsx b/app/soapbox/components/announcements/emoji.tsx
new file mode 100644
index 000000000..6b8debcbf
--- /dev/null
+++ b/app/soapbox/components/announcements/emoji.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+
+import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light';
+import { useSettings } from 'soapbox/hooks';
+import { joinPublicPath } from 'soapbox/utils/static';
+
+import type { Map as ImmutableMap } from 'immutable';
+
+interface IEmoji {
+ emoji: string;
+ emojiMap: ImmutableMap>;
+ hovered: boolean;
+}
+
+const Emoji: React.FC = ({ emoji, emojiMap, hovered }) => {
+ const autoPlayGif = useSettings().get('autoPlayGif');
+
+ // @ts-ignore
+ if (unicodeMapping[emoji]) {
+ // @ts-ignore
+ const { filename, shortCode } = unicodeMapping[emoji];
+ const title = shortCode ? `:${shortCode}:` : '';
+
+ return (
+
+ );
+ } else if (emojiMap.get(emoji as any)) {
+ const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+ const shortCode = `:${emoji}:`;
+
+ return (
+
+ );
+ } else {
+ return null;
+ }
+};
+
+export default Emoji;
diff --git a/app/soapbox/components/announcements/reaction.tsx b/app/soapbox/components/announcements/reaction.tsx
new file mode 100644
index 000000000..1e415c667
--- /dev/null
+++ b/app/soapbox/components/announcements/reaction.tsx
@@ -0,0 +1,66 @@
+import classNames from 'classnames';
+import React, { useState } from 'react';
+
+import AnimatedNumber from 'soapbox/components/animated-number';
+import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light';
+
+import Emoji from './emoji';
+
+import type { Map as ImmutableMap } from 'immutable';
+import type { AnnouncementReaction } from 'soapbox/types/entities';
+
+interface IReaction {
+ announcementId: string;
+ reaction: AnnouncementReaction;
+ emojiMap: ImmutableMap>;
+ addReaction: (id: string, name: string) => void;
+ removeReaction: (id: string, name: string) => void;
+ style: React.CSSProperties;
+}
+
+const Reaction: React.FC = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => {
+ const [hovered, setHovered] = useState(false);
+
+ const handleClick = () => {
+ if (reaction.me) {
+ removeReaction(announcementId, reaction.name);
+ } else {
+ addReaction(announcementId, reaction.name);
+ }
+ };
+
+ const handleMouseEnter = () => setHovered(true);
+
+ const handleMouseLeave = () => setHovered(false);
+
+ let shortCode = reaction.name;
+
+ // @ts-ignore
+ if (unicodeMapping[shortCode]) {
+ // @ts-ignore
+ shortCode = unicodeMapping[shortCode].shortCode;
+ }
+
+ return (
+
+ );
+};
+
+export default Reaction;
diff --git a/app/soapbox/components/announcements/reactions-bar.tsx b/app/soapbox/components/announcements/reactions-bar.tsx
new file mode 100644
index 000000000..66b5f3f83
--- /dev/null
+++ b/app/soapbox/components/announcements/reactions-bar.tsx
@@ -0,0 +1,65 @@
+import classNames from 'classnames';
+import React from 'react';
+import { TransitionMotion, spring } from 'react-motion';
+
+import { Icon } from 'soapbox/components/ui';
+import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container';
+import { useSettings } from 'soapbox/hooks';
+
+import Reaction from './reaction';
+
+import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
+import type { Emoji } from 'soapbox/components/autosuggest_emoji';
+import type { AnnouncementReaction } from 'soapbox/types/entities';
+
+interface IReactionsBar {
+ announcementId: string;
+ reactions: ImmutableList;
+ emojiMap: ImmutableMap>;
+ addReaction: (id: string, name: string) => void;
+ removeReaction: (id: string, name: string) => void;
+}
+
+const ReactionsBar: React.FC = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => {
+ const reduceMotion = useSettings().get('reduceMotion');
+
+ const handleEmojiPick = (data: Emoji) => {
+ addReaction(announcementId, data.native.replace(/:/g, ''));
+ };
+
+ const willEnter = () => ({ scale: reduceMotion ? 1 : 0 });
+
+ const willLeave = () => ({ scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) });
+
+ const visibleReactions = reactions.filter(x => x.count > 0);
+
+ const styles = visibleReactions.map(reaction => ({
+ key: reaction.name,
+ data: reaction,
+ style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
+ })).toArray();
+
+ return (
+
+ {items => (
+
+ {items.map(({ key, data, style }) => (
+
+ ))}
+
+ {visibleReactions.size < 8 && } />}
+
+ )}
+
+ );
+};
+
+export default ReactionsBar;
diff --git a/app/soapbox/features/compose/components/emoji_picker_dropdown.js b/app/soapbox/features/compose/components/emoji_picker_dropdown.js
index 675d87de5..8ff75566f 100644
--- a/app/soapbox/features/compose/components/emoji_picker_dropdown.js
+++ b/app/soapbox/features/compose/components/emoji_picker_dropdown.js
@@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent {
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
+ button: PropTypes.node,
};
state = {
@@ -352,20 +353,14 @@ class EmojiPickerDropdown extends React.PureComponent {
}
render() {
- const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
+ const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
return (
-
+ >
+ {button || }
+
({
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
});
-const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+const mapDispatchToProps = (dispatch, props) => ({
onSkinTone: skinTone => {
dispatch(changeSetting(['skinTone'], skinTone));
},
@@ -75,8 +75,8 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
onPickEmoji: emoji => {
dispatch(useEmoji(emoji)); // eslint-disable-line react-hooks/rules-of-hooks
- if (onPickEmoji) {
- onPickEmoji(emoji);
+ if (props.onPickEmoji) {
+ props.onPickEmoji(emoji);
}
},
});
diff --git a/app/soapbox/features/security/mfa/disable_otp_form.tsx b/app/soapbox/features/security/mfa/disable_otp_form.tsx
index ba0e5fbbf..63a551f31 100644
--- a/app/soapbox/features/security/mfa/disable_otp_form.tsx
+++ b/app/soapbox/features/security/mfa/disable_otp_form.tsx
@@ -7,7 +7,7 @@ import snackbar from 'soapbox/actions/snackbar';
import { Button, Form, FormGroup, Input, FormActions, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
-const messages = defineMessages({
+const messages = defineMessages({
mfa_setup_disable_button: { id: 'column.mfa_disable_button', defaultMessage: 'Disable' },
disableFail: { id: 'security.disable.fail', defaultMessage: 'Incorrect password. Try again.' },
mfaDisableSuccess: { id: 'mfa.disable.success_message', defaultMessage: 'MFA disabled' },
diff --git a/app/soapbox/features/security/mfa/enable_otp_form.tsx b/app/soapbox/features/security/mfa/enable_otp_form.tsx
index a5608bf18..98dae6519 100644
--- a/app/soapbox/features/security/mfa/enable_otp_form.tsx
+++ b/app/soapbox/features/security/mfa/enable_otp_form.tsx
@@ -7,7 +7,7 @@ import snackbar from 'soapbox/actions/snackbar';
import { Button, FormActions, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
-const messages = defineMessages({
+const messages = defineMessages({
mfaCancelButton: { id: 'column.mfa_cancel', defaultMessage: 'Cancel' },
mfaSetupButton: { id: 'column.mfa_setup', defaultMessage: 'Proceed to Setup' },
codesFail: { id: 'security.codes.fail', defaultMessage: 'Failed to fetch backup codes' },
diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx
index 420f08035..b9fdab67d 100644
--- a/app/soapbox/features/ui/index.tsx
+++ b/app/soapbox/features/ui/index.tsx
@@ -9,6 +9,7 @@ import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
import { fetchFollowRequests } from 'soapbox/actions/accounts';
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
+import { fetchAnnouncements } from 'soapbox/actions/announcements';
import { fetchChats } from 'soapbox/actions/chats';
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis';
@@ -451,6 +452,8 @@ const UI: React.FC = ({ children }) => {
.then(() => dispatch(fetchMarker(['notifications'])))
.catch(console.error);
+ dispatch(fetchAnnouncements());
+
if (features.chats) {
dispatch(fetchChats());
}
diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts
index b4ae937bb..930fc81da 100644
--- a/app/soapbox/features/ui/util/async-components.ts
+++ b/app/soapbox/features/ui/util/async-components.ts
@@ -521,3 +521,7 @@ export function VerifySmsModal() {
export function FamiliarFollowersModal() {
return import(/*webpackChunkName: "modals/familiar_followers_modal" */'../components/familiar_followers_modal');
}
+
+export function AnnouncementsPanel() {
+ return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
+}
diff --git a/app/soapbox/normalizers/announcement.ts b/app/soapbox/normalizers/announcement.ts
new file mode 100644
index 000000000..0db9f3e00
--- /dev/null
+++ b/app/soapbox/normalizers/announcement.ts
@@ -0,0 +1,87 @@
+/**
+ * Announcement normalizer:
+ * Converts API announcements into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/announcement/}
+ */
+import {
+ Map as ImmutableMap,
+ List as ImmutableList,
+ Record as ImmutableRecord,
+ fromJS,
+} from 'immutable';
+
+import emojify from 'soapbox/features/emoji/emoji';
+import { normalizeEmoji } from 'soapbox/normalizers/emoji';
+import { makeEmojiMap } from 'soapbox/utils/normalizers';
+
+import { normalizeAnnouncementReaction } from './announcement_reaction';
+import { normalizeMention } from './mention';
+
+import type { AnnouncementReaction, Emoji, Mention } from 'soapbox/types/entities';
+
+// https://docs.joinmastodon.org/entities/announcement/
+export const AnnouncementRecord = ImmutableRecord({
+ id: '',
+ content: '',
+ starts_at: null as Date | null,
+ ends_at: null as Date | null,
+ all_day: false,
+ read: false,
+ published_at: Date,
+ reactions: ImmutableList(),
+ statuses: ImmutableMap(),
+ mentions: ImmutableList(),
+ tags: ImmutableList>(),
+ emojis: ImmutableList(),
+ updated_at: Date,
+
+ // Internal fields
+ contentHtml: '',
+});
+
+const normalizeMentions = (announcement: ImmutableMap) => {
+ return announcement.update('mentions', ImmutableList(), mentions => {
+ return mentions.map(normalizeMention);
+ });
+};
+
+// Normalize reactions
+const normalizeReactions = (announcement: ImmutableMap) => {
+ return announcement.update('reactions', ImmutableList(), reactions => {
+ return reactions.map((reaction: ImmutableMap) => normalizeAnnouncementReaction(reaction, announcement.get('id')));
+ });
+};
+
+// Normalize emojis
+const normalizeEmojis = (announcement: ImmutableMap) => {
+ return announcement.update('emojis', ImmutableList(), emojis => {
+ return emojis.map(normalizeEmoji);
+ });
+};
+
+const normalizeContent = (announcement: ImmutableMap) => {
+ const emojiMap = makeEmojiMap(announcement.get('emojis'));
+ const contentHtml = emojify(announcement.get('content'), emojiMap);
+
+ return announcement.set('contentHtml', contentHtml);
+};
+
+const normalizeStatuses = (announcement: ImmutableMap) => {
+ const statuses = announcement
+ .get('statuses', ImmutableList())
+ .reduce((acc: ImmutableMap, curr: ImmutableMap) => acc.set(curr.get('url'), `/@${curr.getIn(['account', 'acct'])}/${curr.get('id')}`), ImmutableMap());
+
+ return announcement.set('statuses', statuses);
+};
+
+export const normalizeAnnouncement = (announcement: Record) => {
+ return AnnouncementRecord(
+ ImmutableMap(fromJS(announcement)).withMutations(announcement => {
+ normalizeMentions(announcement);
+ normalizeReactions(announcement);
+ normalizeEmojis(announcement);
+ normalizeContent(announcement);
+ normalizeStatuses(announcement);
+ }),
+ );
+};
diff --git a/app/soapbox/normalizers/announcement_reaction.ts b/app/soapbox/normalizers/announcement_reaction.ts
new file mode 100644
index 000000000..56827d28c
--- /dev/null
+++ b/app/soapbox/normalizers/announcement_reaction.ts
@@ -0,0 +1,22 @@
+/**
+ * Announcement reaction normalizer:
+ * Converts API announcement emoji reactions into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/announcementreaction/}
+ */
+import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
+
+// https://docs.joinmastodon.org/entities/announcement/
+export const AnnouncementReactionRecord = ImmutableRecord({
+ name: '',
+ count: 0,
+ me: false,
+ url: null as string | null,
+ static_url: null as string | null,
+ announcement_id: '',
+});
+
+export const normalizeAnnouncementReaction = (announcementReaction: Record, announcementId?: string) => {
+ return AnnouncementReactionRecord(ImmutableMap(fromJS(announcementReaction)).withMutations(reaction => {
+ reaction.set('announcement_id', announcementId as any);
+ }));
+};
diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts
index b1b41e0d6..d25cbd014 100644
--- a/app/soapbox/normalizers/index.ts
+++ b/app/soapbox/normalizers/index.ts
@@ -1,6 +1,8 @@
export { AccountRecord, FieldRecord, normalizeAccount } from './account';
export { AdminAccountRecord, normalizeAdminAccount } from './admin_account';
export { AdminReportRecord, normalizeAdminReport } from './admin_report';
+export { AnnouncementRecord, normalizeAnnouncement } from './announcement';
+export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement_reaction';
export { AttachmentRecord, normalizeAttachment } from './attachment';
export { CardRecord, normalizeCard } from './card';
export { ChatRecord, normalizeChat } from './chat';
diff --git a/app/soapbox/pages/home_page.tsx b/app/soapbox/pages/home_page.tsx
index 149fa90f4..e161bbe53 100644
--- a/app/soapbox/pages/home_page.tsx
+++ b/app/soapbox/pages/home_page.tsx
@@ -12,6 +12,7 @@ import {
CryptoDonatePanel,
BirthdayPanel,
CtaBanner,
+ AnnouncementsPanel,
} from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useOwnAccount, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
@@ -74,6 +75,11 @@ const HomePage: React.FC = ({ children }) => {
{Component => }
)}
+ {me && features.announcements && (
+
+ {Component => }
+
+ )}
{features.trends && (
{Component => }
diff --git a/app/soapbox/reducers/__tests__/announcements.test.ts b/app/soapbox/reducers/__tests__/announcements.test.ts
new file mode 100644
index 000000000..2051f75d8
--- /dev/null
+++ b/app/soapbox/reducers/__tests__/announcements.test.ts
@@ -0,0 +1,42 @@
+import { List as ImmutableList, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
+
+import {
+ ANNOUNCEMENTS_FETCH_SUCCESS,
+ ANNOUNCEMENTS_UPDATE,
+} from 'soapbox/actions/announcements';
+
+import reducer from '../announcements';
+
+const announcements = require('soapbox/__fixtures__/announcements.json');
+
+describe('accounts reducer', () => {
+ it('should return the initial state', () => {
+ expect(reducer(undefined, {} as any)).toMatchObject({
+ items: ImmutableList(),
+ isLoading: false,
+ show: false,
+ unread: ImmutableSet(),
+ });
+ });
+
+ describe('ANNOUNCEMENTS_FETCH_SUCCESS', () => {
+ it('parses announcements as Records', () => {
+ const action = { type: ANNOUNCEMENTS_FETCH_SUCCESS, announcements };
+ const result = reducer(undefined, action).items;
+
+ expect(result.every((announcement) => ImmutableRecord.isRecord(announcement))).toBe(true);
+ });
+ });
+
+ describe('ANNOUNCEMENTS_UPDATE', () => {
+ it('updates announcements', () => {
+ const state = reducer(undefined, { type: ANNOUNCEMENTS_FETCH_SUCCESS, announcements: [announcements[0]] });
+
+ const action = { type: ANNOUNCEMENTS_UPDATE, announcement: { ...announcements[0], content: 'Updated to Soapbox v3.0.0.
' } };
+ const result = reducer(state, action).items;
+
+ expect(result.size === 1);
+ expect(result.first()?.content === 'Updated to Soapbox v3.0.0.
');
+ });
+ });
+});
diff --git a/app/soapbox/reducers/announcements.ts b/app/soapbox/reducers/announcements.ts
new file mode 100644
index 000000000..e6be16d2a
--- /dev/null
+++ b/app/soapbox/reducers/announcements.ts
@@ -0,0 +1,110 @@
+import { List as ImmutableList, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
+
+import {
+ ANNOUNCEMENTS_FETCH_REQUEST,
+ ANNOUNCEMENTS_FETCH_SUCCESS,
+ ANNOUNCEMENTS_FETCH_FAIL,
+ ANNOUNCEMENTS_UPDATE,
+ ANNOUNCEMENTS_REACTION_UPDATE,
+ ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+ ANNOUNCEMENTS_REACTION_ADD_FAIL,
+ ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+ ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+ ANNOUNCEMENTS_TOGGLE_SHOW,
+ ANNOUNCEMENTS_DELETE,
+ ANNOUNCEMENTS_DISMISS_SUCCESS,
+} from 'soapbox/actions/announcements';
+import { normalizeAnnouncement, normalizeAnnouncementReaction } from 'soapbox/normalizers';
+
+import type { AnyAction } from 'redux';
+import type{ Announcement, AnnouncementReaction, APIEntity } from 'soapbox/types/entities';
+
+const ReducerRecord = ImmutableRecord({
+ items: ImmutableList(),
+ isLoading: false,
+ show: false,
+ unread: ImmutableSet(),
+});
+
+type State = ReturnType;
+
+const updateReaction = (state: State, id: string, name: string, updater: (a: AnnouncementReaction) => AnnouncementReaction) => state.update('items', list => list.map(announcement => {
+ if (announcement.id === id) {
+ return announcement.update('reactions', reactions => {
+ const idx = reactions.findIndex(reaction => reaction.name === name);
+
+ if (idx > -1) {
+ return reactions.update(idx, reaction => updater(reaction!));
+ }
+
+ return reactions.push(updater(normalizeAnnouncementReaction({ name, count: 0 })));
+ });
+ }
+
+ return announcement;
+}));
+
+const updateReactionCount = (state: State, reaction: AnnouncementReaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count));
+
+const addReaction = (state: State, id: string, name: string) => updateReaction(state, id, name, (x: AnnouncementReaction) => x.set('me', true).update('count', y => y + 1));
+
+const removeReaction = (state: State, id: string, name: string) => updateReaction(state, id, name, (x: AnnouncementReaction) => x.set('me', false).update('count', y => y - 1));
+
+const sortAnnouncements = (list: ImmutableList) => list.sortBy(x => x.starts_at || x.published_at);
+
+const updateAnnouncement = (state: State, announcement: Announcement) => {
+ const idx = state.items.findIndex(x => x.id === announcement.id);
+
+ if (idx > -1) {
+ // Deep merge is used because announcements from the streaming API do not contain
+ // personalized data about which reactions have been selected by the given user,
+ // and that is information we want to preserve
+ return state.update('items', list => sortAnnouncements(list.update(idx, x => x!.mergeDeep(announcement))));
+ }
+
+ return state.update('items', list => sortAnnouncements(list.unshift(announcement)));
+};
+
+export default function announcementsReducer(state = ReducerRecord(), action: AnyAction) {
+ switch (action.type) {
+ case ANNOUNCEMENTS_TOGGLE_SHOW:
+ return state.withMutations(map => {
+ map.set('show', !map.show);
+ });
+ case ANNOUNCEMENTS_FETCH_REQUEST:
+ return state.set('isLoading', true);
+ case ANNOUNCEMENTS_FETCH_SUCCESS:
+ return state.withMutations(map => {
+ const items = ImmutableList((action.announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement)));
+
+ map.set('items', items);
+ map.set('isLoading', false);
+ });
+ case ANNOUNCEMENTS_FETCH_FAIL:
+ return state.set('isLoading', false);
+ case ANNOUNCEMENTS_UPDATE:
+ return updateAnnouncement(state, normalizeAnnouncement(action.announcement));
+ case ANNOUNCEMENTS_REACTION_UPDATE:
+ return updateReactionCount(state, action.reaction);
+ case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
+ case ANNOUNCEMENTS_REACTION_REMOVE_FAIL:
+ return addReaction(state, action.id, action.name);
+ case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
+ case ANNOUNCEMENTS_REACTION_ADD_FAIL:
+ return removeReaction(state, action.id, action.name);
+ case ANNOUNCEMENTS_DISMISS_SUCCESS:
+ return updateAnnouncement(state, normalizeAnnouncement({ id: action.id, read: true }));
+ case ANNOUNCEMENTS_DELETE:
+ return state.update('items', list => {
+ const idx = list.findIndex(x => x.id === action.id);
+
+ if (idx > -1) {
+ return list.delete(idx);
+ }
+
+ return list;
+ });
+ default:
+ return state;
+ }
+}
\ No newline at end of file
diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts
index 72b33f785..fda62e79e 100644
--- a/app/soapbox/reducers/index.ts
+++ b/app/soapbox/reducers/index.ts
@@ -12,6 +12,7 @@ import admin from './admin';
import admin_log from './admin_log';
import alerts from './alerts';
import aliases from './aliases';
+import announcements from './announcements';
import auth from './auth';
import backups from './backups';
import carousels from './carousels';
@@ -124,6 +125,7 @@ const reducers = {
rules,
history,
carousels,
+ announcements,
};
// Build a default state from all reducers: it has the key and `undefined`
diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts
index 37572ae24..4a8a80858 100644
--- a/app/soapbox/types/entities.ts
+++ b/app/soapbox/types/entities.ts
@@ -2,6 +2,8 @@ import {
AdminAccountRecord,
AdminReportRecord,
AccountRecord,
+ AnnouncementRecord,
+ AnnouncementReactionRecord,
AttachmentRecord,
CardRecord,
ChatRecord,
@@ -26,6 +28,8 @@ import type { Record as ImmutableRecord } from 'immutable';
type AdminAccount = ReturnType;
type AdminReport = ReturnType;
+type Announcement = ReturnType;
+type AnnouncementReaction = ReturnType;
type Attachment = ReturnType;
type Card = ReturnType;
type Chat = ReturnType;
@@ -64,6 +68,8 @@ export {
AdminAccount,
AdminReport,
Account,
+ Announcement,
+ AnnouncementReaction,
Attachment,
Card,
Chat,
diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts
index 2c3ff62ee..921b780f0 100644
--- a/app/soapbox/utils/features.ts
+++ b/app/soapbox/utils/features.ts
@@ -142,6 +142,25 @@ const getInstanceFeatures = (instance: Instance) => {
*/
accountWebsite: v.software === TRUTHSOCIAL,
+ /**
+ * Can display announcements set by admins.
+ * @see GET /api/v1/announcements
+ * @see POST /api/v1/announcements/:id/dismiss
+ * @see {@link https://docs.joinmastodon.org/methods/announcements/}
+ */
+ announcements: any([
+ v.software === MASTODON && gte(v.compatVersion, '3.1.0'),
+ v.software === PLEROMA && gte(v.version, '2.2.49'),
+ ]),
+
+ /**
+ * Can emoji react to announcements set by admins.
+ * @see PUT /api/v1/announcements/:id/reactions/:name
+ * @see DELETE /api/v1/announcements/:id/reactions/:name
+ * @see {@link https://docs.joinmastodon.org/methods/announcements/}
+ */
+ announcementsReactions: v.software === MASTODON && gte(v.compatVersion, '3.1.0'),
+
/**
* Set your birthday and view upcoming birthdays.
* @see GET /api/v1/pleroma/birthdays