sforkowany z mirror/soapbox
Announcements
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>update-emoji-mart^2
rodzic
82e437cdda
commit
b3b6a7e4bc
|
@ -0,0 +1,187 @@
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
dispatch(fetchAnnouncementsRequest());
|
||||||
|
|
||||||
|
return api(getState).get('/api/v1/announcements').then(response => {
|
||||||
|
dispatch(fetchAnnouncementsSuccess(response.data));
|
||||||
|
}).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));
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
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 { connectStream } from '../stream';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteAnnouncement,
|
||||||
|
fetchAnnouncements,
|
||||||
|
updateAnnouncements,
|
||||||
|
updateReaction as updateAnnouncementsReaction,
|
||||||
|
} from './announcements';
|
||||||
import { updateConversations } from './conversations';
|
import { updateConversations } from './conversations';
|
||||||
import { fetchFilters } from './filters';
|
import { fetchFilters } from './filters';
|
||||||
import { updateNotificationsQueue, expandNotifications } from './notifications';
|
import { updateNotificationsQueue, expandNotifications } from './notifications';
|
||||||
|
@ -100,13 +106,24 @@ const connectTimelineStream = (
|
||||||
case 'pleroma:follow_relationships_update':
|
case 'pleroma:follow_relationships_update':
|
||||||
dispatch(updateFollowRelationships(JSON.parse(data.payload)));
|
dispatch(updateFollowRelationships(JSON.parse(data.payload)));
|
||||||
break;
|
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) =>
|
const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () => void) =>
|
||||||
dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({} as any, done))));
|
dispatch(expandHomeTimeline({}, () =>
|
||||||
|
dispatch(expandNotifications({}, () =>
|
||||||
|
dispatch(fetchAnnouncements(done))))));
|
||||||
|
|
||||||
const connectUserStream = () =>
|
const connectUserStream = () =>
|
||||||
connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
||||||
|
|
|
@ -7,7 +7,7 @@ import snackbar from 'soapbox/actions/snackbar';
|
||||||
import { Button, Form, FormGroup, Input, FormActions, Stack, Text } from 'soapbox/components/ui';
|
import { Button, Form, FormGroup, Input, FormActions, Stack, Text } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
mfa_setup_disable_button: { id: 'column.mfa_disable_button', defaultMessage: 'Disable' },
|
mfa_setup_disable_button: { id: 'column.mfa_disable_button', defaultMessage: 'Disable' },
|
||||||
disableFail: { id: 'security.disable.fail', defaultMessage: 'Incorrect password. Try again.' },
|
disableFail: { id: 'security.disable.fail', defaultMessage: 'Incorrect password. Try again.' },
|
||||||
mfaDisableSuccess: { id: 'mfa.disable.success_message', defaultMessage: 'MFA disabled' },
|
mfaDisableSuccess: { id: 'mfa.disable.success_message', defaultMessage: 'MFA disabled' },
|
||||||
|
|
|
@ -7,7 +7,7 @@ import snackbar from 'soapbox/actions/snackbar';
|
||||||
import { Button, FormActions, Spinner, Stack, Text } from 'soapbox/components/ui';
|
import { Button, FormActions, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
mfaCancelButton: { id: 'column.mfa_cancel', defaultMessage: 'Cancel' },
|
mfaCancelButton: { id: 'column.mfa_cancel', defaultMessage: 'Cancel' },
|
||||||
mfaSetupButton: { id: 'column.mfa_setup', defaultMessage: 'Proceed to Setup' },
|
mfaSetupButton: { id: 'column.mfa_setup', defaultMessage: 'Proceed to Setup' },
|
||||||
codesFail: { id: 'security.codes.fail', defaultMessage: 'Failed to fetch backup codes' },
|
codesFail: { id: 'security.codes.fail', defaultMessage: 'Failed to fetch backup codes' },
|
||||||
|
|
|
@ -0,0 +1,161 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import ReactSwipeableViews from 'react-swipeable-views';
|
||||||
|
|
||||||
|
import { Card, HStack, Widget } from 'soapbox/components/ui';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const AnnouncementContent = ({ announcement }: { announcement: AnnouncementEntity }) => {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** For regular links, just stop propogation */
|
||||||
|
const onLinkClick = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 and hashtags
|
||||||
|
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 {
|
||||||
|
link.setAttribute('title', link.href);
|
||||||
|
link.addEventListener('click', onLinkClick.bind(link), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='translate text-sm'
|
||||||
|
ref={node}
|
||||||
|
dangerouslySetInnerHTML={{ __html: announcement.content }}
|
||||||
|
// onMouseEnter={handleMouseEnter}
|
||||||
|
// onMouseLeave={handleMouseLeave}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Announcement = ({ announcement }: { announcement: AnnouncementEntity }) => {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{hasTimeRange && <span> · <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'} /></span>}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<AnnouncementContent announcement={announcement} />
|
||||||
|
|
||||||
|
{/* <ReactionsBar
|
||||||
|
reactions={announcement.get('reactions')}
|
||||||
|
announcementId={announcement.get('id')}
|
||||||
|
addReaction={this.props.addReaction}
|
||||||
|
removeReaction={this.props.removeReaction}
|
||||||
|
emojiMap={this.props.emojiMap}
|
||||||
|
/> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnnouncementsPanel = () => {
|
||||||
|
// const dispatch = useDispatch();
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
const announcements = useAppSelector((state) => state.announcements.items);
|
||||||
|
|
||||||
|
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='lg' variant='rounded'>
|
||||||
|
<ReactSwipeableViews animateHeight index={index} onChangeIndex={handleChangeIndex}>
|
||||||
|
{announcements.map((announcement) => (
|
||||||
|
<Announcement
|
||||||
|
key={announcement.id}
|
||||||
|
announcement={announcement}
|
||||||
|
// emojiMap={emojiMap}
|
||||||
|
// addReaction={addReaction}
|
||||||
|
// removeReaction={removeReaction}
|
||||||
|
// selected={index === idx}
|
||||||
|
// disabled={disableSwiping}
|
||||||
|
/>
|
||||||
|
)).reverse()}
|
||||||
|
</ReactSwipeableViews>
|
||||||
|
|
||||||
|
<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;
|
|
@ -9,6 +9,7 @@ import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchFollowRequests } from 'soapbox/actions/accounts';
|
import { fetchFollowRequests } from 'soapbox/actions/accounts';
|
||||||
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
|
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
|
||||||
|
import { fetchAnnouncements } from 'soapbox/actions/announcements';
|
||||||
import { fetchChats } from 'soapbox/actions/chats';
|
import { fetchChats } from 'soapbox/actions/chats';
|
||||||
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
|
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
|
||||||
import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis';
|
import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis';
|
||||||
|
@ -448,6 +449,8 @@ const UI: React.FC = ({ children }) => {
|
||||||
.then(() => dispatch(fetchMarker(['notifications'])))
|
.then(() => dispatch(fetchMarker(['notifications'])))
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
|
|
||||||
|
dispatch(fetchAnnouncements());
|
||||||
|
|
||||||
if (features.chats) {
|
if (features.chats) {
|
||||||
dispatch(fetchChats());
|
dispatch(fetchChats());
|
||||||
}
|
}
|
||||||
|
|
|
@ -521,3 +521,7 @@ export function VerifySmsModal() {
|
||||||
export function FamiliarFollowersModal() {
|
export function FamiliarFollowersModal() {
|
||||||
return import(/*webpackChunkName: "modals/familiar_followers_modal" */'../components/familiar_followers_modal');
|
return import(/*webpackChunkName: "modals/familiar_followers_modal" */'../components/familiar_followers_modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AnnouncementsPanel() {
|
||||||
|
return import(/* webpackChunkName: "features/announcements" */'../components/announcements-panel');
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* 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 { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||||
|
|
||||||
|
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,
|
||||||
|
mentions: ImmutableList<Mention>(),
|
||||||
|
tags: ImmutableList<ImmutableMap<string, any>>(),
|
||||||
|
emojis: ImmutableList<Emoji>(),
|
||||||
|
updated_at: Date,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeAnnouncement = (announcement: Record<string, any>) => {
|
||||||
|
return AnnouncementRecord(
|
||||||
|
ImmutableMap(fromJS(announcement)).withMutations(announcement => {
|
||||||
|
normalizeMentions(announcement);
|
||||||
|
normalizeReactions(announcement);
|
||||||
|
normalizeEmojis(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 { AccountRecord, FieldRecord, normalizeAccount } from './account';
|
||||||
export { AdminAccountRecord, normalizeAdminAccount } from './admin_account';
|
export { AdminAccountRecord, normalizeAdminAccount } from './admin_account';
|
||||||
export { AdminReportRecord, normalizeAdminReport } from './admin_report';
|
export { AdminReportRecord, normalizeAdminReport } from './admin_report';
|
||||||
|
export { AnnouncementRecord, normalizeAnnouncement } from './announcement';
|
||||||
|
export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement_reaction';
|
||||||
export { AttachmentRecord, normalizeAttachment } from './attachment';
|
export { AttachmentRecord, normalizeAttachment } from './attachment';
|
||||||
export { CardRecord, normalizeCard } from './card';
|
export { CardRecord, normalizeCard } from './card';
|
||||||
export { ChatRecord, normalizeChat } from './chat';
|
export { ChatRecord, normalizeChat } from './chat';
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
CryptoDonatePanel,
|
CryptoDonatePanel,
|
||||||
BirthdayPanel,
|
BirthdayPanel,
|
||||||
CtaBanner,
|
CtaBanner,
|
||||||
|
AnnouncementsPanel,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
import { useAppSelector, useOwnAccount, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
|
import { useAppSelector, useOwnAccount, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
@ -74,6 +75,11 @@ const HomePage: React.FC = ({ children }) => {
|
||||||
{Component => <Component />}
|
{Component => <Component />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
)}
|
)}
|
||||||
|
{me && features.announcements && (
|
||||||
|
<BundleContainer fetchComponent={AnnouncementsPanel}>
|
||||||
|
{Component => <Component key='announcements-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
{features.trends && (
|
{features.trends && (
|
||||||
<BundleContainer fetchComponent={TrendsPanel}>
|
<BundleContainer fetchComponent={TrendsPanel}>
|
||||||
{Component => <Component limit={3} />}
|
{Component => <Component limit={3} />}
|
||||||
|
|
|
@ -0,0 +1,112 @@
|
||||||
|
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 { normalizeAnnouncementReaction } from 'soapbox/normalizers';
|
||||||
|
import { normalizeAnnouncement } from 'soapbox/normalizers/announcement';
|
||||||
|
|
||||||
|
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 admin_log from './admin_log';
|
||||||
import alerts from './alerts';
|
import alerts from './alerts';
|
||||||
import aliases from './aliases';
|
import aliases from './aliases';
|
||||||
|
import announcements from './announcements';
|
||||||
import auth from './auth';
|
import auth from './auth';
|
||||||
import backups from './backups';
|
import backups from './backups';
|
||||||
import carousels from './carousels';
|
import carousels from './carousels';
|
||||||
|
@ -124,6 +125,7 @@ const reducers = {
|
||||||
rules,
|
rules,
|
||||||
history,
|
history,
|
||||||
carousels,
|
carousels,
|
||||||
|
announcements,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a default state from all reducers: it has the key and `undefined`
|
// Build a default state from all reducers: it has the key and `undefined`
|
||||||
|
|
|
@ -2,6 +2,8 @@ import {
|
||||||
AdminAccountRecord,
|
AdminAccountRecord,
|
||||||
AdminReportRecord,
|
AdminReportRecord,
|
||||||
AccountRecord,
|
AccountRecord,
|
||||||
|
AnnouncementRecord,
|
||||||
|
AnnouncementReactionRecord,
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
CardRecord,
|
CardRecord,
|
||||||
ChatRecord,
|
ChatRecord,
|
||||||
|
@ -26,6 +28,8 @@ import type { Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
type AdminAccount = ReturnType<typeof AdminAccountRecord>;
|
type AdminAccount = ReturnType<typeof AdminAccountRecord>;
|
||||||
type AdminReport = ReturnType<typeof AdminReportRecord>;
|
type AdminReport = ReturnType<typeof AdminReportRecord>;
|
||||||
|
type Announcement = ReturnType<typeof AnnouncementRecord>;
|
||||||
|
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
|
||||||
type Attachment = ReturnType<typeof AttachmentRecord>;
|
type Attachment = ReturnType<typeof AttachmentRecord>;
|
||||||
type Card = ReturnType<typeof CardRecord>;
|
type Card = ReturnType<typeof CardRecord>;
|
||||||
type Chat = ReturnType<typeof ChatRecord>;
|
type Chat = ReturnType<typeof ChatRecord>;
|
||||||
|
@ -64,6 +68,8 @@ export {
|
||||||
AdminAccount,
|
AdminAccount,
|
||||||
AdminReport,
|
AdminReport,
|
||||||
Account,
|
Account,
|
||||||
|
Announcement,
|
||||||
|
AnnouncementReaction,
|
||||||
Attachment,
|
Attachment,
|
||||||
Card,
|
Card,
|
||||||
Chat,
|
Chat,
|
||||||
|
|
|
@ -142,6 +142,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
accountWebsite: v.software === TRUTHSOCIAL,
|
accountWebsite: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
|
announcements: any([
|
||||||
|
v.software === MASTODON && gte(v.compatVersion, '3.1.0'),
|
||||||
|
v.software === PLEROMA && gte(v.version, '2.2.49'),
|
||||||
|
]),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set your birthday and view upcoming birthdays.
|
* Set your birthday and view upcoming birthdays.
|
||||||
* @see GET /api/v1/pleroma/birthdays
|
* @see GET /api/v1/pleroma/birthdays
|
||||||
|
|
Ładowanie…
Reference in New Issue