kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'announcements' into 'develop'
Announcements See merge request soapbox-pub/soapbox-fe!1614environments/review-develop-3zknud/deployments/560
commit
c5b751a68d
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
}));
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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} />}
|
||||
|
|
|
@ -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>');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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`
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
Ładowanie…
Reference in New Issue