Merge branch 'announcements' into 'develop'

Announcements

See merge request soapbox-pub/soapbox-fe!1614
environments/review-develop-3zknud/deployments/560
marcin mikołajczak 2022-07-16 19:02:10 +00:00
commit c5b751a68d
26 zmienionych plików z 1166 dodań i 15 usunięć

Wyświetl plik

@ -0,0 +1,44 @@
[
{
"id": "1",
"content": "<p>Updated to Soapbox v3.</p>",
"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": "<p>Rolled back to Soapbox v2 for now.</p>",
"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
}
]
}
]

Wyświetl plik

@ -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<typeof mockStore>;
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<typeof mockStore>;
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);
});
});
});

Wyświetl plik

@ -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,
});

Wyświetl plik

@ -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);

Wyświetl plik

@ -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<IAnimatedNumber> = ({ value, obfuscate }) => {
const reduceMotion = useSettings().get('reduceMotion');
const [direction, setDirection] = useState(1);
const [displayedValue, setDisplayedValue] = useState<number>(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)}</> : <FormattedNumber value={displayedValue} />;
}
const styles = [{
key: `${displayedValue}`,
data: displayedValue,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
}];
return (
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
{items => (
<span className='inline-flex flex-col items-stretch relative overflow-hidden'>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
))}
</span>
)}
</TransitionMotion>
);
};
export default AnimatedNumber;

Wyświetl plik

@ -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<IAnnouncementContent> = ({ announcement }) => {
const history = useHistory();
const node = useRef<HTMLDivElement>(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 (
<div
className='translate text-sm'
ref={node}
dangerouslySetInnerHTML={{ __html: announcement.contentHtml }}
/>
);
};
export default AnnouncementContent;

Wyświetl plik

@ -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<string, ImmutableMap<string, string>>;
}
const Announcement: React.FC<IAnnouncement> = ({ 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 (
<Stack className='w-full' space={2}>
{hasTimeRange && (
<Text theme='muted'>
<FormattedDate
value={startsAt}
hour12={false}
year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
month='short'
day='2-digit'
hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'}
/>
{' '}
-
{' '}
<FormattedDate
value={endsAt}
hour12={false}
year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
month={skipEndDate ? undefined : 'short'}
day={skipEndDate ? undefined : '2-digit'}
hour={skipTime ? undefined : '2-digit'}
minute={skipTime ? undefined : '2-digit'}
/>
</Text>
)}
<AnnouncementContent announcement={announcement} />
{features.announcementsReactions && (
<ReactionsBar
reactions={announcement.reactions}
announcementId={announcement.id}
addReaction={addReaction}
removeReaction={removeReaction}
emojiMap={emojiMap}
/>
)}
</Stack>
);
};
export default Announcement;

Wyświetl plik

@ -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<ImmutableMap<string, string>>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap<string, ImmutableMap<string, string>>()));
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 (
<Widget title={<FormattedMessage id='announcements.title' defaultMessage='Announcements' />}>
<Card className='relative' size='md' variant='rounded'>
<ReactSwipeableViews animateHeight index={index} onChangeIndex={handleChangeIndex}>
{announcements.map((announcement) => (
<Announcement
key={announcement.id}
announcement={announcement}
emojiMap={emojiMap}
addReaction={addReaction}
removeReaction={removeReaction}
/>
)).reverse()}
</ReactSwipeableViews>
{announcements.size > 1 && (
<HStack space={2} alignItems='center' justifyContent='center' className='relative'>
{announcements.map((_, i) => (
<button
key={i}
tabIndex={0}
onClick={() => setIndex(i)}
className={classNames({
'w-2 h-2 rounded-full focus:ring-primary-600 focus:ring-2 focus:ring-offset-2': true,
'bg-gray-200 hover:bg-gray-300': i !== index,
'bg-primary-600': i === index,
})}
/>
))}
</HStack>
)}
</Card>
</Widget>
);
};
export default AnnouncementsPanel;

Wyświetl plik

@ -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<string, ImmutableMap<string, string>>;
hovered: boolean;
}
const Emoji: React.FC<IEmoji> = ({ 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 (
<img
draggable='false'
className='emojione block m-0'
alt={emoji}
title={title}
src={joinPublicPath(`/emoji/${filename}.svg`)}
/>
);
} else if (emojiMap.get(emoji as any)) {
const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
const shortCode = `:${emoji}:`;
return (
<img
draggable='false'
className='emojione block m-0'
alt={shortCode}
title={shortCode}
src={filename as string}
/>
);
} else {
return null;
}
};
export default Emoji;

Wyświetl plik

@ -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<string, ImmutableMap<string, string>>;
addReaction: (id: string, name: string) => void;
removeReaction: (id: string, name: string) => void;
style: React.CSSProperties;
}
const Reaction: React.FC<IReaction> = ({ 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 (
<button
className={classNames('flex shrink-0 items-center gap-1.5 bg-gray-100 dark:bg-primary-900 rounded-sm px-1.5 py-1 transition-colors', {
'bg-gray-200 dark:bg-primary-800': hovered,
'bg-primary-200 dark:bg-primary-500': reaction.me,
})}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
title={`:${shortCode}:`}
style={style}
>
<span className='block h-4 w-4'>
<Emoji hovered={hovered} emoji={reaction.name} emojiMap={emojiMap} />
</span>
<span className='block min-w-[9px] text-center text-xs font-medium text-primary-600 dark:text-white'>
<AnimatedNumber value={reaction.count} />
</span>
</button>
);
};
export default Reaction;

Wyświetl plik

@ -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<AnnouncementReaction>;
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
addReaction: (id: string, name: string) => void;
removeReaction: (id: string, name: string) => void;
}
const ReactionsBar: React.FC<IReactionsBar> = ({ 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 (
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
{items => (
<div className={classNames('flex flex-wrap items-center gap-1', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
announcementId={announcementId}
addReaction={addReaction}
removeReaction={removeReaction}
emojiMap={emojiMap}
/>
))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} button={<Icon className='h-4 w-4 text-gray-400 hover:text-gray-600 dark:hover:text-white' src={require('@tabler/icons/plus.svg')} />} />}
</div>
)}
</TransitionMotion>
);
};
export default ReactionsBar;

Wyświetl plik

@ -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 (
<div className='relative' onKeyDown={this.handleKeyDown}>
<IconButton
<div
ref={this.setTargetRef}
className={classNames({
'text-gray-400 hover:text-gray-600': true,
'pulse-loading': active && loading,
})}
alt='😀'
src={require('@tabler/icons/mood-happy.svg')}
title={title}
aria-label={title}
aria-expanded={active}
@ -373,7 +368,16 @@ class EmojiPickerDropdown extends React.PureComponent {
onClick={this.onToggle}
onKeyDown={this.onToggle}
tabIndex={0}
/>
>
{button || <IconButton
className={classNames({
'text-gray-400 hover:text-gray-600': true,
'pulse-loading': active && loading,
})}
alt='😀'
src={require('@tabler/icons/mood-happy.svg')}
/>}
</div>
<Overlay show={active} placement={placement} target={this.findTarget}>
<EmojiPickerMenu

Wyświetl plik

@ -67,7 +67,7 @@ const mapStateToProps = state => ({
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);
}
},
});

Wyświetl plik

@ -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());
}

Wyświetl plik

@ -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');
}

Wyświetl plik

@ -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<AnnouncementReaction>(),
statuses: ImmutableMap<string, string>(),
mentions: ImmutableList<Mention>(),
tags: ImmutableList<ImmutableMap<string, any>>(),
emojis: ImmutableList<Emoji>(),
updated_at: Date,
// Internal fields
contentHtml: '',
});
const normalizeMentions = (announcement: ImmutableMap<string, any>) => {
return announcement.update('mentions', ImmutableList(), mentions => {
return mentions.map(normalizeMention);
});
};
// Normalize reactions
const normalizeReactions = (announcement: ImmutableMap<string, any>) => {
return announcement.update('reactions', ImmutableList(), reactions => {
return reactions.map((reaction: ImmutableMap<string, any>) => normalizeAnnouncementReaction(reaction, announcement.get('id')));
});
};
// Normalize emojis
const normalizeEmojis = (announcement: ImmutableMap<string, any>) => {
return announcement.update('emojis', ImmutableList(), emojis => {
return emojis.map(normalizeEmoji);
});
};
const normalizeContent = (announcement: ImmutableMap<string, any>) => {
const emojiMap = makeEmojiMap(announcement.get('emojis'));
const contentHtml = emojify(announcement.get('content'), emojiMap);
return announcement.set('contentHtml', contentHtml);
};
const normalizeStatuses = (announcement: ImmutableMap<string, any>) => {
const statuses = announcement
.get('statuses', ImmutableList())
.reduce((acc: ImmutableMap<string, string>, curr: ImmutableMap<string, any>) => acc.set(curr.get('url'), `/@${curr.getIn(['account', 'acct'])}/${curr.get('id')}`), ImmutableMap());
return announcement.set('statuses', statuses);
};
export const normalizeAnnouncement = (announcement: Record<string, any>) => {
return AnnouncementRecord(
ImmutableMap(fromJS(announcement)).withMutations(announcement => {
normalizeMentions(announcement);
normalizeReactions(announcement);
normalizeEmojis(announcement);
normalizeContent(announcement);
normalizeStatuses(announcement);
}),
);
};

Wyświetl plik

@ -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<string, any>, announcementId?: string) => {
return AnnouncementReactionRecord(ImmutableMap(fromJS(announcementReaction)).withMutations(reaction => {
reaction.set('announcement_id', announcementId as any);
}));
};

Wyświetl plik

@ -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';

Wyświetl plik

@ -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 => <Component />}
</BundleContainer>
)}
{me && features.announcements && (
<BundleContainer fetchComponent={AnnouncementsPanel}>
{Component => <Component key='announcements-panel' />}
</BundleContainer>
)}
{features.trends && (
<BundleContainer fetchComponent={TrendsPanel}>
{Component => <Component limit={3} />}

Wyświetl plik

@ -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: '<p>Updated to Soapbox v3.0.0.</p>' } };
const result = reducer(state, action).items;
expect(result.size === 1);
expect(result.first()?.content === '<p>Updated to Soapbox v3.0.0.</p>');
});
});
});

Wyświetl plik

@ -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<Announcement>(),
isLoading: false,
show: false,
unread: ImmutableSet<string>(),
});
type State = ReturnType<typeof ReducerRecord>;
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<Announcement>) => 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<Announcement>((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;
}
}

Wyświetl plik

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

Wyświetl plik

@ -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<typeof AdminAccountRecord>;
type AdminReport = ReturnType<typeof AdminReportRecord>;
type Announcement = ReturnType<typeof AnnouncementRecord>;
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
type Attachment = ReturnType<typeof AttachmentRecord>;
type Card = ReturnType<typeof CardRecord>;
type Chat = ReturnType<typeof ChatRecord>;
@ -64,6 +68,8 @@ export {
AdminAccount,
AdminReport,
Account,
Announcement,
AnnouncementReaction,
Attachment,
Card,
Chat,

Wyświetl plik

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