Merge branch 'admin-announcements' into 'develop'

Dashboard: Allow to create announcements

See merge request soapbox-pub/soapbox!2276
This commit is contained in:
marcin mikołajczak 2023-02-14 23:05:07 +00:00
commit 2cfd402bec
14 changed files with 652 additions and 122 deletions

View file

@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Profile: add RSS link to user profiles.
- Posts: fix posts filtering.
- Chats: reset chat message field height after sending a message.
- Admin: allow to manage announcements.
### Changed
- Chats: improved display of media attachments.

View file

@ -1,13 +1,18 @@
import { defineMessages } from 'react-intl';
import { fetchRelationships } from 'soapbox/actions/accounts';
import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer';
import toast from 'soapbox/toast';
import { filterBadges, getTagDiff } from 'soapbox/utils/badges';
import { getFeatures } from 'soapbox/utils/features';
import api, { getLinks } from '../api';
import { openModal } from './modals';
import type { AxiosResponse } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
import type { APIEntity, Announcement } from 'soapbox/types/entities';
const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST';
const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS';
@ -77,16 +82,45 @@ const ADMIN_USERS_UNSUGGEST_REQUEST = 'ADMIN_USERS_UNSUGGEST_REQUEST';
const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS';
const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL';
const ADMIN_USER_INDEX_EXPAND_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL';
const ADMIN_USER_INDEX_EXPAND_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL';
const ADMIN_USER_INDEX_EXPAND_REQUEST = 'ADMIN_USER_INDEX_EXPAND_REQUEST';
const ADMIN_USER_INDEX_EXPAND_SUCCESS = 'ADMIN_USER_INDEX_EXPAND_SUCCESS';
const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL';
const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL';
const ADMIN_USER_INDEX_FETCH_REQUEST = 'ADMIN_USER_INDEX_FETCH_REQUEST';
const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS';
const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET';
const ADMIN_ANNOUNCEMENTS_FETCH_FAIL = 'ADMIN_ANNOUNCEMENTS_FETCH_FAILS';
const ADMIN_ANNOUNCEMENTS_FETCH_REQUEST = 'ADMIN_ANNOUNCEMENTS_FETCH_REQUEST';
const ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS = 'ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS';
const ADMIN_ANNOUNCEMENTS_EXPAND_FAIL = 'ADMIN_ANNOUNCEMENTS_EXPAND_FAILS';
const ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST = 'ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST';
const ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS = 'ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS';
const ADMIN_ANNOUNCEMENT_CHANGE_CONTENT = 'ADMIN_ANNOUNCEMENT_CHANGE_CONTENT';
const ADMIN_ANNOUNCEMENT_CHANGE_START_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_START_TIME';
const ADMIN_ANNOUNCEMENT_CHANGE_END_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_END_TIME';
const ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY = 'ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY';
const ADMIN_ANNOUNCEMENT_CREATE_REQUEST = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST';
const ADMIN_ANNOUNCEMENT_CREATE_SUCCESS = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST';
const ADMIN_ANNOUNCEMENT_CREATE_FAIL = 'ADMIN_ANNOUNCEMENT_CREATE_FAIL';
const ADMIN_ANNOUNCEMENT_DELETE_REQUEST = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST';
const ADMIN_ANNOUNCEMENT_DELETE_SUCCESS = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST';
const ADMIN_ANNOUNCEMENT_DELETE_FAIL = 'ADMIN_ANNOUNCEMENT_DELETE_FAIL';
const ADMIN_ANNOUNCEMENT_MODAL_INIT = 'ADMIN_ANNOUNCEMENT_MODAL_INIT';
const messages = defineMessages({
announcementCreateSuccess: { id: 'admin.edit_announcement.created', defaultMessage: 'Announcement created' },
announcementDeleteSuccess: { id: 'admin.edit_announcement.deleted', defaultMessage: 'Announcement deleted' },
announcementUpdateSuccess: { id: 'admin.edit_announcement.updated', defaultMessage: 'Announcement edited' },
});
const nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct);
const fetchConfig = () =>
@ -598,6 +632,93 @@ const expandUserIndex = () =>
});
};
const fetchAdminAnnouncements = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_REQUEST });
return api(getState)
.get('/api/pleroma/admin/announcements', { params: { limit: 50 } })
.then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, announcements: data });
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_FAIL, error });
});
};
const expandAdminAnnouncements = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const page = getState().admin_announcements.page;
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST });
return api(getState)
.get('/api/pleroma/admin/announcements', { params: { limit: 50, offset: page * 50 } })
.then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS, announcements: data });
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_FAIL, error });
});
};
const changeAnnouncementContent = (content: string) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_CONTENT,
value: content,
});
const changeAnnouncementStartTime = (time: Date | null) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_START_TIME,
value: time,
});
const changeAnnouncementEndTime = (time: Date | null) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_END_TIME,
value: time,
});
const changeAnnouncementAllDay = (allDay: boolean) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY,
value: allDay,
});
const handleCreateAnnouncement = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_REQUEST });
const { id, content, starts_at, ends_at, all_day } = getState().admin_announcements.form;
return api(getState)[id ? 'patch' : 'post'](
id ? `/api/pleroma/admin/announcements/${id}` : '/api/pleroma/admin/announcements',
{ content, starts_at, ends_at, all_day },
).then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, announcement: data });
toast.success(id ? messages.announcementUpdateSuccess : messages.announcementCreateSuccess);
dispatch(fetchAdminAnnouncements());
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_FAIL, error });
});
};
const deleteAnnouncement = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_REQUEST, id });
return api(getState).delete(`/api/pleroma/admin/announcements/${id}`).then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, id });
toast.success(messages.announcementDeleteSuccess);
dispatch(fetchAdminAnnouncements());
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_FAIL, id, error });
});
};
const initAnnouncementModal = (announcement?: Announcement) =>
(dispatch: AppDispatch) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_MODAL_INIT, announcement });
dispatch(openModal('EDIT_ANNOUNCEMENT'));
};
export {
ADMIN_CONFIG_FETCH_REQUEST,
ADMIN_CONFIG_FETCH_SUCCESS,
@ -657,6 +778,23 @@ export {
ADMIN_USER_INDEX_FETCH_REQUEST,
ADMIN_USER_INDEX_FETCH_SUCCESS,
ADMIN_USER_INDEX_QUERY_SET,
ADMIN_ANNOUNCEMENTS_FETCH_FAIL,
ADMIN_ANNOUNCEMENTS_FETCH_REQUEST,
ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS,
ADMIN_ANNOUNCEMENTS_EXPAND_FAIL,
ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST,
ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS,
ADMIN_ANNOUNCEMENT_CHANGE_CONTENT,
ADMIN_ANNOUNCEMENT_CHANGE_START_TIME,
ADMIN_ANNOUNCEMENT_CHANGE_END_TIME,
ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY,
ADMIN_ANNOUNCEMENT_CREATE_FAIL,
ADMIN_ANNOUNCEMENT_CREATE_REQUEST,
ADMIN_ANNOUNCEMENT_CREATE_SUCCESS,
ADMIN_ANNOUNCEMENT_DELETE_FAIL,
ADMIN_ANNOUNCEMENT_DELETE_REQUEST,
ADMIN_ANNOUNCEMENT_DELETE_SUCCESS,
ADMIN_ANNOUNCEMENT_MODAL_INIT,
fetchConfig,
updateConfig,
updateSoapboxConfig,
@ -686,4 +824,13 @@ export {
setUserIndexQuery,
fetchUserIndex,
expandUserIndex,
fetchAdminAnnouncements,
expandAdminAnnouncements,
changeAnnouncementContent,
changeAnnouncementStartTime,
changeAnnouncementEndTime,
changeAnnouncementAllDay,
handleCreateAnnouncement,
deleteAnnouncement,
initAnnouncementModal,
};

View file

@ -0,0 +1,128 @@
import React, { useEffect } from 'react';
import { FormattedDate, FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { deleteAnnouncement, fetchAdminAnnouncements, initAnnouncementModal } from 'soapbox/actions/admin';
import { openModal } from 'soapbox/actions/modals';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, Column, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { Announcement as AnnouncementEntity } from 'soapbox/types/entities';
const messages = defineMessages({
heading: { id: 'column.admin.announcements', defaultMessage: 'Announcements' },
deleteConfirm: { id: 'confirmations.admin.delete_announcement.confirm', defaultMessage: 'Delete' },
deleteHeading: { id: 'confirmations.admin.delete_announcement.heading', defaultMessage: 'Delete announcement' },
deleteMessage: { id: 'confirmations.admin.delete_announcement.message', defaultMessage: 'Are you sure you want to delete the announcement?' },
});
interface IAnnouncement {
announcement: AnnouncementEntity
}
const Announcement: React.FC<IAnnouncement> = ({ announcement }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleEditAnnouncement = (announcement: AnnouncementEntity) => () => {
dispatch(initAnnouncementModal(announcement));
};
const handleDeleteAnnouncement = (id: string) => () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.deleteHeading),
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteAnnouncement(id)),
}));
};
return (
<div key={announcement.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
<Stack space={2}>
<Text dangerouslySetInnerHTML={{ __html: announcement.contentHtml }} />
{(announcement.starts_at || announcement.ends_at || announcement.all_day) && (
<HStack space={2} wrap>
{announcement.starts_at && (
<Text size='sm'>
<Text tag='span' size='sm' weight='medium'>
<FormattedMessage id='admin.announcements.starts_at' defaultMessage='Starts at:' />
</Text>
{' '}
<FormattedDate value={announcement.starts_at} year='2-digit' month='short' day='2-digit' weekday='short' />
</Text>
)}
{announcement.ends_at && (
<Text size='sm'>
<Text tag='span' size='sm' weight='medium'>
<FormattedMessage id='admin.announcements.ends_at' defaultMessage='Ends at:' />
</Text>
{' '}
<FormattedDate value={announcement.ends_at} year='2-digit' month='short' day='2-digit' weekday='short' />
</Text>
)}
{announcement.all_day && (
<Text weight='medium' size='sm'>
<FormattedMessage id='admin.announcements.all_day' defaultMessage='All day' />
</Text>
)}
</HStack>
)}
<HStack justifyContent='end' space={2}>
<Button theme='primary' onClick={handleEditAnnouncement(announcement)}>
<FormattedMessage id='admin.announcements.edit' defaultMessage='Edit' />
</Button>
<Button theme='primary' onClick={handleDeleteAnnouncement(announcement.id)}>
<FormattedMessage id='admin.announcements.delete' defaultMessage='Delete' />
</Button>
</HStack>
</Stack>
</div>
);
};
const Announcements: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const announcements = useAppSelector((state) => state.admin_announcements.items);
const isLoading = useAppSelector((state) => state.admin_announcements.isLoading);
useEffect(() => {
dispatch(fetchAdminAnnouncements());
}, []);
const handleCreateAnnouncement = () => {
dispatch(initAnnouncementModal());
};
const emptyMessage = <FormattedMessage id='empty_column.admin.announcements' defaultMessage='There are no announcements yet.' />;
return (
<Column label={intl.formatMessage(messages.heading)}>
<Stack className='gap-4'>
<Button
className='sm:w-fit sm:self-end'
icon={require('@tabler/icons/plus.svg')}
onClick={handleCreateAnnouncement}
theme='secondary'
block
>
<FormattedMessage id='admin.announcements.action' defaultMessage='Create announcement' />
</Button>
<ScrollableList
scrollKey='announcements'
emptyMessage={emptyMessage}
itemClassName='py-3 first:pt-0 last:pb-0'
isLoading={isLoading}
showLoading={isLoading && !announcements.count()}
>
{announcements.map((announcement) => (
<Announcement key={announcement.id} announcement={announcement} />
))}
</ScrollableList>
</Stack>
</Column>
);
};
export default Announcements;

View file

@ -43,6 +43,7 @@ const Dashboard: React.FC = () => {
const navigateToSoapboxConfig = () => history.push('/soapbox/config');
const navigateToModerationLog = () => history.push('/soapbox/admin/log');
const navigateToAnnouncements = () => history.push('/soapbox/admin/announcements');
const v = parseVersion(instance.version);
@ -95,6 +96,13 @@ const Dashboard: React.FC = () => {
onClick={navigateToModerationLog}
label={<FormattedMessage id='column.admin.moderation_log' defaultMessage='Moderation Log' />}
/>
{features.announcements && (
<ListItem
onClick={navigateToAnnouncements}
label={<FormattedMessage id='column.admin.announcements' defaultMessage='Announcements' />}
/>
)}
</List>
{account.admin && (

View file

@ -67,4 +67,4 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
);
};
export default ChatTextarea;
export default ChatTextarea;

View file

@ -2,41 +2,42 @@ import React from 'react';
import Base from 'soapbox/components/modal-root';
import {
MediaModal,
VideoModal,
BoostModal,
ConfirmationModal,
MuteModal,
ReportModal,
EmbedModal,
CryptoDonateModal,
ListEditor,
ListAdder,
MissingDescriptionModal,
ActionsModal,
HotkeysModal,
ComposeModal,
ReplyMentionsModal,
UnauthorizedModal,
EditFederationModal,
ComponentModal,
ReactionsModal,
FavouritesModal,
ReblogsModal,
MentionsModal,
LandingPageModal,
BirthdaysModal,
AccountNoteModal,
CompareHistoryModal,
VerifySmsModal,
FamiliarFollowersModal,
ComposeEventModal,
JoinEventModal,
AccountModerationModal,
AccountNoteModal,
ActionsModal,
BirthdaysModal,
BoostModal,
CompareHistoryModal,
ComponentModal,
ComposeEventModal,
ComposeModal,
ConfirmationModal,
CryptoDonateModal,
EditAnnouncementModal,
EditFederationModal,
EmbedModal,
EventMapModal,
EventParticipantsModal,
PolicyModal,
FamiliarFollowersModal,
FavouritesModal,
HotkeysModal,
JoinEventModal,
LandingPageModal,
ListAdder,
ListEditor,
ManageGroupModal,
MediaModal,
MentionsModal,
MissingDescriptionModal,
MuteModal,
PolicyModal,
ReactionsModal,
ReblogsModal,
ReplyMentionsModal,
ReportModal,
UnauthorizedModal,
VerifySmsModal,
VideoModal,
} from 'soapbox/features/ui/util/async-components';
import BundleContainer from '../containers/bundle-container';
@ -45,42 +46,44 @@ import { BundleProps } from './bundle';
import BundleModalError from './bundle-modal-error';
import ModalLoading from './modal-loading';
/* eslint sort-keys: "error" */
const MODAL_COMPONENTS = {
'MEDIA': MediaModal,
'VIDEO': VideoModal,
'BOOST': BoostModal,
'CONFIRM': ConfirmationModal,
'MISSING_DESCRIPTION': MissingDescriptionModal,
'MUTE': MuteModal,
'REPORT': ReportModal,
'ACTIONS': ActionsModal,
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'LIST_ADDER': ListAdder,
'HOTKEYS': HotkeysModal,
'COMPOSE': ComposeModal,
'REPLY_MENTIONS': ReplyMentionsModal,
'UNAUTHORIZED': UnauthorizedModal,
'CRYPTO_DONATE': CryptoDonateModal,
'EDIT_FEDERATION': EditFederationModal,
'COMPONENT': ComponentModal,
'REBLOGS': ReblogsModal,
'FAVOURITES': FavouritesModal,
'REACTIONS': ReactionsModal,
'MENTIONS': MentionsModal,
'LANDING_PAGE': LandingPageModal,
'BIRTHDAYS': BirthdaysModal,
'ACCOUNT_NOTE': AccountNoteModal,
'COMPARE_HISTORY': CompareHistoryModal,
'VERIFY_SMS': VerifySmsModal,
'FAMILIAR_FOLLOWERS': FamiliarFollowersModal,
'COMPOSE_EVENT': ComposeEventModal,
'JOIN_EVENT': JoinEventModal,
'ACCOUNT_MODERATION': AccountModerationModal,
'ACCOUNT_NOTE': AccountNoteModal,
'ACTIONS': ActionsModal,
'BIRTHDAYS': BirthdaysModal,
'BOOST': BoostModal,
'COMPARE_HISTORY': CompareHistoryModal,
'COMPONENT': ComponentModal,
'COMPOSE': ComposeModal,
'COMPOSE_EVENT': ComposeEventModal,
'CONFIRM': ConfirmationModal,
'CRYPTO_DONATE': CryptoDonateModal,
'EDIT_ANNOUNCEMENT': EditAnnouncementModal,
'EDIT_FEDERATION': EditFederationModal,
'EMBED': EmbedModal,
'EVENT_MAP': EventMapModal,
'EVENT_PARTICIPANTS': EventParticipantsModal,
'POLICY': PolicyModal,
'FAMILIAR_FOLLOWERS': FamiliarFollowersModal,
'FAVOURITES': FavouritesModal,
'HOTKEYS': HotkeysModal,
'JOIN_EVENT': JoinEventModal,
'LANDING_PAGE': LandingPageModal,
'LIST_ADDER': ListAdder,
'LIST_EDITOR': ListEditor,
'MANAGE_GROUP': ManageGroupModal,
'MEDIA': MediaModal,
'MENTIONS': MentionsModal,
'MISSING_DESCRIPTION': MissingDescriptionModal,
'MUTE': MuteModal,
'POLICY': PolicyModal,
'REACTIONS': ReactionsModal,
'REBLOGS': ReblogsModal,
'REPLY_MENTIONS': ReplyMentionsModal,
'REPORT': ReportModal,
'UNAUTHORIZED': UnauthorizedModal,
'VERIFY_SMS': VerifySmsModal,
'VIDEO': VideoModal,
};
export type ModalType = keyof typeof MODAL_COMPONENTS | null;

View file

@ -0,0 +1,119 @@
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { changeAnnouncementAllDay, changeAnnouncementContent, changeAnnouncementEndTime, changeAnnouncementStartTime, handleCreateAnnouncement } from 'soapbox/actions/admin';
import { closeModal } from 'soapbox/actions/modals';
import { Form, FormGroup, HStack, Modal, Stack, Text, Textarea, Toggle } from 'soapbox/components/ui';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { DatePicker } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
save: { id: 'admin.edit_announcement.save', defaultMessage: 'Save' },
announcementContentPlaceholder: { id: 'admin.edit_announcement.fields.content_placeholder', defaultMessage: 'Announcement content' },
announcementStartTimePlaceholder: { id: 'admin.edit_announcement.fields.start_time_placeholder', defaultMessage: 'Announcement starts on…' },
announcementEndTimePlaceholder: { id: 'admin.edit_announcement.fields.end_time_placeholder', defaultMessage: 'Announcement ends on…' },
});
interface IEditAnnouncementModal {
onClose: (type?: string) => void,
}
const EditAnnouncementModal: React.FC<IEditAnnouncementModal> = ({ onClose }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const id = useAppSelector((state) => state.admin_announcements.form.id);
const content = useAppSelector((state) => state.admin_announcements.form.content);
const startTime = useAppSelector((state) => state.admin_announcements.form.starts_at);
const endTime = useAppSelector((state) => state.admin_announcements.form.ends_at);
const allDay = useAppSelector((state) => state.admin_announcements.form.all_day);
const onChangeContent: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) =>
dispatch(changeAnnouncementContent(target.value));
const onChangeStartTime = (date: Date | null) => dispatch(changeAnnouncementStartTime(date));
const onChangeEndTime = (date: Date | null) => dispatch(changeAnnouncementEndTime(date));
const onChangeAllDay: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => dispatch(changeAnnouncementAllDay(target.checked));
const onClickClose = () => {
onClose('EDIT_ANNOUNCEMENT');
};
const handleSubmit = () => dispatch(handleCreateAnnouncement()).then(() => dispatch(closeModal('EDIT_ANNOUNCEMENT')));
return (
<Modal
onClose={onClickClose}
title={id
? <FormattedMessage id='column.admin.edit_announcement' defaultMessage='Edit announcement' />
: <FormattedMessage id='column.admin.create_announcement' defaultMessage='Create announcement' />}
confirmationAction={handleSubmit}
confirmationText={intl.formatMessage(messages.save)}
>
<Form>
<FormGroup
labelText={<FormattedMessage id='admin.edit_announcement.fields.content_label' defaultMessage='Content' />}
>
<Textarea
autoComplete='off'
placeholder={intl.formatMessage(messages.announcementContentPlaceholder)}
value={content}
onChange={onChangeContent}
/>
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='admin.edit_announcement.fields.start_time_label' defaultMessage='Start date' />}
>
<BundleContainer fetchComponent={DatePicker}>
{Component => (<Component
showTimeSelect
dateFormat='MMMM d, yyyy h:mm aa'
timeIntervals={15}
wrapperClassName='react-datepicker-wrapper'
placeholderText={intl.formatMessage(messages.announcementStartTimePlaceholder)}
selected={startTime}
onChange={onChangeStartTime}
isClearable
/>)}
</BundleContainer>
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='admin.edit_announcement.fields.end_time_label' defaultMessage='End date' />}
>
<BundleContainer fetchComponent={DatePicker}>
{Component => (<Component
showTimeSelect
dateFormat='MMMM d, yyyy h:mm aa'
timeIntervals={15}
wrapperClassName='react-datepicker-wrapper'
placeholderText={intl.formatMessage(messages.announcementEndTimePlaceholder)}
selected={endTime}
onChange={onChangeEndTime}
isClearable
/>)}
</BundleContainer>
</FormGroup>
<HStack alignItems='center' space={2}>
<Toggle
icons={false}
checked={allDay}
onChange={onChangeAllDay}
/>
<Stack>
<Text tag='span' theme='muted'>
<FormattedMessage id='admin.edit_announcement.fields.all_day_label' defaultMessage='All-day event' />
</Text>
<Text size='xs' tag='span' theme='muted'>
<FormattedMessage id='admin.edit_announcement.fields.all_day_hint' defaultMessage='When checked, only the dates of the time range will be displayed' />
</Text>
</Stack>
</HStack>
</Form>
</Modal>
);
};
export default EditAnnouncementModal;

View file

@ -39,7 +39,7 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
const [currentStep, setCurrentStep] = useState<Steps>(id ? Steps.TWO : Steps.ONE);
const onClickClose = () => {
onClose('manage_group');
onClose('MANAGE_GROUP');
};
const handleSubmit = () => {

View file

@ -120,6 +120,7 @@ import {
ManageGroup,
GroupBlockedMembers,
GroupMembershipRequests,
Announcements,
} from './util/async-components';
import { WrappedRoute } from './util/react-router-helpers';
@ -311,6 +312,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/soapbox/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact />
<WrappedRoute path='/soapbox/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
<WrappedRoute path='/soapbox/admin/theme' staffOnly page={AdminPage} component={ThemeEditor} content={children} exact />
<WrappedRoute path='/soapbox/admin/announcements' staffOnly page={AdminPage} component={Announcements} content={children} exact />
<WrappedRoute path='/info' page={EmptyPage} component={ServerInfo} content={children} />
<WrappedRoute path='/developers/apps/create' developerOnly page={DefaultPage} component={CreateApp} content={children} />

View file

@ -577,3 +577,11 @@ export function GroupMediaPanel() {
export function NewEventPanel() {
return import(/* webpackChunkName: "features/events" */'../components/panels/new-event-panel');
}
export function Announcements() {
return import(/* webpackChunkName: "features/admin/announcements" */'../../admin/announcements');
}
export function EditAnnouncementModal() {
return import(/* webpackChunkName: "features/admin/announcements" */'../components/modals/edit-announcement-modal');
}

View file

@ -85,6 +85,12 @@
"account_search.placeholder": "Search for an account",
"actualStatus.edited": "Edited {date}",
"actualStatuses.quote_tombstone": "Post is unavailable.",
"admin.announcements.action": "Create announcement",
"admin.announcements.all_day": "All day",
"admin.announcements.delete": "Delete",
"admin.announcements.edit": "Edit",
"admin.announcements.ends_at": "Ends at:",
"admin.announcements.starts_at": "Starts at:",
"admin.awaiting_approval.approved_message": "{acct} was approved!",
"admin.awaiting_approval.empty_message": "There is nobody waiting for approval. When a new user signs up, you can review them here.",
"admin.awaiting_approval.rejected_message": "{acct} was rejected.",
@ -103,6 +109,18 @@
"admin.dashcounters.user_count_label": "total users",
"admin.dashwidgets.email_list_header": "Email list",
"admin.dashwidgets.software_header": "Software",
"admin.edit_announcement.created": "Announcement created",
"admin.edit_announcement.deleted": "Announcement deleted",
"admin.edit_announcement.fields.all_day_hint": "When checked, only the dates of the time range will be displayed",
"admin.edit_announcement.fields.all_day_label": "All-day event",
"admin.edit_announcement.fields.content_label": "Content",
"admin.edit_announcement.fields.content_placeholder": "Announcement content",
"admin.edit_announcement.fields.end_time_label": "End date",
"admin.edit_announcement.fields.end_time_placeholder": "Announcement ends on:",
"admin.edit_announcement.fields.start_time_label": "Start date",
"admin.edit_announcement.fields.start_time_placeholder": "Announcement starts on:",
"admin.edit_announcement.save": "Save",
"admin.edit_announcement.updated": "Announcement edited",
"admin.latest_accounts_panel.more": "Click to see {count, plural, one {# account} other {# accounts}}",
"admin.latest_accounts_panel.title": "Latest Accounts",
"admin.moderation_log.empty_message": "You have not performed any moderation actions yet. When you do, a history will be shown here.",
@ -265,8 +283,11 @@
"chats.main.blankslate_with_chats.subtitle": "Select from one of your open chats or create a new message.",
"chats.main.blankslate_with_chats.title": "Select a chat",
"chats.search_placeholder": "Start a chat with…",
"column.admin.announcements": "Announcements",
"column.admin.awaiting_approval": "Awaiting Approval",
"column.admin.create_announcement": "Create announcement",
"column.admin.dashboard": "Dashboard",
"column.admin.edit_announcement": "Edit announcement",
"column.admin.moderation_log": "Moderation Log",
"column.admin.reports": "Reports",
"column.admin.reports.menu.moderation_log": "Moderation Log",
@ -416,6 +437,9 @@
"confirmations.admin.deactivate_user.confirm": "Deactivate @{name}",
"confirmations.admin.deactivate_user.heading": "Deactivate @{acct}",
"confirmations.admin.deactivate_user.message": "You are about to deactivate @{acct}. Deactivating a user is a reversible action.",
"confirmations.admin.delete_announcement.confirm": "Delete",
"confirmations.admin.delete_announcement.heading": "Delete announcement",
"confirmations.admin.delete_announcement.message": "Are you sure you want to delete the announcement?",
"confirmations.admin.delete_local_user.checkbox": "I understand that I am about to delete a local user.",
"confirmations.admin.delete_status.confirm": "Delete post",
"confirmations.admin.delete_status.heading": "Delete post",
@ -613,6 +637,7 @@
"empty_column.account_favourited_statuses": "This user doesn't have any liked posts yet.",
"empty_column.account_timeline": "No posts here!",
"empty_column.account_unavailable": "Profile unavailable",
"empty_column.admin.announcements": "There are no announcements yet.",
"empty_column.aliases": "You haven't created any account alias yet.",
"empty_column.aliases.suggestions": "There are no account suggestions available for the provided term.",
"empty_column.blocks": "You haven't blocked any users yet.",

View file

@ -35,6 +35,8 @@ export const AnnouncementRecord = ImmutableRecord({
emojis: ImmutableList<Emoji>(),
updated_at: Date,
pleroma: ImmutableMap<string, any>(),
// Internal fields
contentHtml: '',
});

View file

@ -0,0 +1,85 @@
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import {
ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY,
ADMIN_ANNOUNCEMENT_CHANGE_CONTENT,
ADMIN_ANNOUNCEMENT_CHANGE_END_TIME,
ADMIN_ANNOUNCEMENT_CHANGE_START_TIME,
ADMIN_ANNOUNCEMENT_CREATE_FAIL,
ADMIN_ANNOUNCEMENT_CREATE_REQUEST,
ADMIN_ANNOUNCEMENT_CREATE_SUCCESS,
ADMIN_ANNOUNCEMENT_DELETE_SUCCESS,
ADMIN_ANNOUNCEMENT_MODAL_INIT,
ADMIN_ANNOUNCEMENTS_FETCH_FAIL,
ADMIN_ANNOUNCEMENTS_FETCH_REQUEST,
ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS,
} from 'soapbox/actions/admin';
import { normalizeAnnouncement } from 'soapbox/normalizers';
import type { AnyAction } from 'redux';
import type { Announcement, APIEntity } from 'soapbox/types/entities';
const AnnouncementFormRecord = ImmutableRecord({
id: null as string | null,
content: '',
starts_at: null as Date | null,
ends_at: null as Date | null,
all_day: false,
is_submitting: false,
});
const ReducerRecord = ImmutableRecord({
items: ImmutableList<Announcement>(),
isLoading: false,
page: -1,
form: AnnouncementFormRecord(),
});
export default function adminAnnouncementsReducer(state = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case ADMIN_ANNOUNCEMENTS_FETCH_REQUEST:
return state.set('isLoading', true);
case ADMIN_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 ADMIN_ANNOUNCEMENTS_FETCH_FAIL:
return state.set('isLoading', false);
case ADMIN_ANNOUNCEMENT_DELETE_SUCCESS:
return state.update('items', list => {
const idx = list.findIndex(x => x.id === action.id);
if (idx > -1) {
return list.delete(idx);
}
return list;
});
case ADMIN_ANNOUNCEMENT_CHANGE_CONTENT:
return state.setIn(['form', 'content'], action.value);
case ADMIN_ANNOUNCEMENT_CHANGE_START_TIME:
return state.setIn(['form', 'starts_at'], action.value);
case ADMIN_ANNOUNCEMENT_CHANGE_END_TIME:
return state.setIn(['form', 'ends_at'], action.value);
case ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY:
return state.setIn(['form', 'all_day'], action.value);
case ADMIN_ANNOUNCEMENT_CREATE_REQUEST:
return state.setIn(['form', 'is_submitting'], true);
case ADMIN_ANNOUNCEMENT_CREATE_SUCCESS:
case ADMIN_ANNOUNCEMENT_CREATE_FAIL:
return state.setIn(['form', 'is_submitting'], true);
case ADMIN_ANNOUNCEMENT_MODAL_INIT:
return state.set('form', action.announcement ? AnnouncementFormRecord({
id: action.announcement.id,
content: action.announcement.content,
starts_at: action.announcement.starts_at ? new Date(action.announcement.starts_at) : null,
ends_at: action.announcement.ends_at ? new Date(action.announcement.ends_at) : null,
all_day: action.announcement.all_day,
}) : AnnouncementFormRecord());
default:
return state;
}
}

View file

@ -9,6 +9,7 @@ import accounts from './accounts';
import accounts_counters from './accounts-counters';
import accounts_meta from './accounts-meta';
import admin from './admin';
import admin_announcements from './admin-announcements';
import admin_log from './admin-log';
import admin_user_index from './admin-user-index';
import aliases from './aliases';
@ -67,67 +68,68 @@ import user_lists from './user-lists';
import verification from './verification';
const reducers = {
dropdown_menu,
timelines,
meta,
modals,
user_lists,
domain_lists,
status_lists,
account_notes,
accounts,
accounts_counters,
statuses,
relationships,
settings,
push_notifications,
mutes,
reports,
contexts,
compose,
search,
notifications,
custom_emojis,
lists,
listEditor,
listAdder,
locations,
filters,
conversations,
suggestions,
polls,
trends,
sidebar,
patron,
soapbox,
instance,
me,
auth,
admin,
chats,
chat_messages,
chat_message_lists,
profile_hover_card,
status_hover_card,
backups,
admin_log,
security,
scheduled_statuses,
pending_statuses,
aliases,
accounts_meta,
trending_statuses,
verification,
onboarding,
rules,
history,
announcements,
compose_event,
admin,
admin_announcements,
admin_log,
admin_user_index,
groups,
group_relationships,
group_memberships,
aliases,
announcements,
auth,
backups,
chat_message_lists,
chat_messages,
chats,
compose,
compose_event,
contexts,
conversations,
custom_emojis,
domain_lists,
dropdown_menu,
filters,
group_editor,
group_memberships,
group_relationships,
groups,
history,
instance,
listAdder,
listEditor,
lists,
locations,
me,
meta,
modals,
mutes,
notifications,
onboarding,
patron,
pending_statuses,
polls,
profile_hover_card,
push_notifications,
relationships,
reports,
rules,
scheduled_statuses,
search,
security,
settings,
sidebar,
soapbox,
status_hover_card,
status_lists,
statuses,
suggestions,
timelines,
trending_statuses,
trends,
user_lists,
verification,
};
// Build a default state from all reducers: it has the key and `undefined`