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 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