From 3a209e2fea381a4df24a9cd454b30384088a7b69 Mon Sep 17 00:00:00 2001
From: NEETzsche <8018262-NEETzsche@users.noreply.gitlab.com>
Date: Fri, 18 Jun 2021 16:04:31 +0000
Subject: [PATCH] Schedule posts
---
app/soapbox/actions/compose.js | 27 +++++-
.../compose/components/compose_form.js | 5 +
.../compose/components/schedule_button.js | 50 ++++++++++
.../compose/components/schedule_form.js | 96 +++++++++++++++++++
.../containers/compose_form_container.js | 1 +
.../containers/schedule_button_container.js | 23 +++++
.../containers/schedule_form_container.js | 16 ++++
app/soapbox/locales/en.json | 3 +
app/soapbox/reducers/compose.js | 14 +++
app/styles/components/compose-form.scss | 13 ++-
app/styles/ui.scss | 4 +
package.json | 1 +
yarn.lock | 40 ++++++++
13 files changed, 290 insertions(+), 3 deletions(-)
create mode 100644 app/soapbox/features/compose/components/schedule_button.js
create mode 100644 app/soapbox/features/compose/components/schedule_form.js
create mode 100644 app/soapbox/features/compose/containers/schedule_button_container.js
create mode 100644 app/soapbox/features/compose/containers/schedule_form_container.js
diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js
index 54519c112..bd91daaae 100644
--- a/app/soapbox/actions/compose.js
+++ b/app/soapbox/actions/compose.js
@@ -63,6 +63,10 @@ export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE';
export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE';
export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
+export const COMPOSE_SCHEDULE_ADD = 'COMPOSE_SCHEDULE_ADD';
+export const COMPOSE_SCHEDULE_SET = 'COMPOSE_SCHEDULE_SET';
+export const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE';
+
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
@@ -133,7 +137,7 @@ export function directCompose(account, routerHistory) {
export function handleComposeSubmit(dispatch, getState, response, status) {
if (!dispatch || !getState) return;
- dispatch(insertIntoTagHistory(response.data.tags, status));
+ dispatch(insertIntoTagHistory(response.data.tags || [], status));
dispatch(submitComposeSuccess({ ...response.data }));
// To make the app more responsive, immediately push the status into the columns
@@ -179,7 +183,7 @@ export function submitCompose(routerHistory, group) {
visibility: getState().getIn(['compose', 'privacy']),
content_type: getState().getIn(['compose', 'content_type']),
poll: getState().getIn(['compose', 'poll'], null),
- group_id: group ? group.get('id') : null,
+ scheduled_at: getState().getIn(['compose', 'schedule'], null),
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@@ -533,6 +537,25 @@ export function removePoll() {
};
};
+export function addSchedule() {
+ return {
+ type: COMPOSE_SCHEDULE_ADD,
+ };
+};
+
+export function setSchedule(date) {
+ return {
+ type: COMPOSE_SCHEDULE_SET,
+ date: date,
+ };
+};
+
+export function removeSchedule() {
+ return {
+ type: COMPOSE_SCHEDULE_REMOVE,
+ };
+};
+
export function addPollOption(title) {
return {
type: COMPOSE_POLL_OPTION_ADD,
diff --git a/app/soapbox/features/compose/components/compose_form.js b/app/soapbox/features/compose/components/compose_form.js
index 899b6ae12..c08b7aa24 100644
--- a/app/soapbox/features/compose/components/compose_form.js
+++ b/app/soapbox/features/compose/components/compose_form.js
@@ -12,6 +12,8 @@ import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl } from 'react-intl';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import MarkdownButtonContainer from '../containers/markdown_button_container';
+import ScheduleFormContainer from '../containers/schedule_form_container';
+import ScheduleButtonContainer from '../containers/schedule_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollFormContainer from '../containers/poll_form_container';
@@ -71,6 +73,7 @@ class ComposeForm extends ImmutablePureComponent {
group: ImmutablePropTypes.map,
isModalOpen: PropTypes.bool,
clickableAreaRef: PropTypes.object,
+ scheduledAt: PropTypes.instanceOf(Date),
};
static defaultProps = {
@@ -310,6 +313,7 @@ class ComposeForm extends ImmutablePureComponent {
}
@@ -321,6 +325,7 @@ class ComposeForm extends ImmutablePureComponent {
+
diff --git a/app/soapbox/features/compose/components/schedule_button.js b/app/soapbox/features/compose/components/schedule_button.js
new file mode 100644
index 000000000..31218fcaf
--- /dev/null
+++ b/app/soapbox/features/compose/components/schedule_button.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import IconButton from '../../../components/icon_button';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ add_schedule: { id: 'schedule_button.add_schedule', defaultMessage: 'Schedule post for later' },
+ remove_schedule: { id: 'schedule_button.remove_schedule', defaultMessage: 'Post immediately' },
+});
+
+const iconStyle = {
+ height: null,
+ lineHeight: '27px',
+};
+
+export default
+@injectIntl
+class ScheduleButton extends React.PureComponent {
+
+ static propTypes = {
+ disabled: PropTypes.bool,
+ active: PropTypes.bool,
+ onClick: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleClick = () => {
+ this.props.onClick();
+ }
+
+ render() {
+ const { intl, active, disabled } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/soapbox/features/compose/components/schedule_form.js b/app/soapbox/features/compose/components/schedule_form.js
new file mode 100644
index 000000000..13b1d4f38
--- /dev/null
+++ b/app/soapbox/features/compose/components/schedule_form.js
@@ -0,0 +1,96 @@
+'use strict';
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import DatePicker from 'react-datepicker';
+import 'react-datepicker/dist/react-datepicker.css';
+
+const messages = defineMessages({
+ schedule: { id: 'schedule.post_time', defaultMessage: 'Post Date/Time' },
+});
+
+class ScheduleForm extends React.Component {
+
+ static propTypes = {
+ schedule: PropTypes.instanceOf(Date),
+ intl: PropTypes.object.isRequired,
+ onSchedule: PropTypes.func.isRequired,
+ active: PropTypes.bool,
+ };
+
+ setSchedule(date)
+ {
+ this.setState({ schedule: date });
+ this.props.onSchedule(date);
+ }
+
+ openDatePicker(datePicker)
+ {
+ if (!datePicker)
+ {
+ return;
+ }
+
+ datePicker.setOpen(true);
+ }
+
+ componentDidMount()
+ {
+ this.setState({ schedule: this.props.schedule });
+ }
+
+ constructor(props)
+ {
+ super(props);
+
+ this.setSchedule = this.setSchedule.bind(this);
+ }
+
+ isCurrentOrFutureDate(date)
+ {
+ return date && new Date().setHours(0, 0, 0, 0) <= date.setHours(0, 0, 0, 0);
+ }
+
+ isFiveMinutesFromNow(time)
+ {
+ const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); // now, plus five minutes (Pleroma won't schedule posts )
+ const selectedDate = new Date(time);
+
+ return fiveMinutesFromNow.getTime() < selectedDate.getTime();
+ };
+
+ render() {
+ if (!this.props.active || !this.state)
+ {
+ return null;
+ }
+
+ const { schedule } = this.state;
+
+ return (
+
+ );
+ }
+
+}
+
+const mapStateToProps = (state, ownProps) => ({
+ schedule: state.getIn(['compose', 'schedule']),
+});
+
+export default injectIntl(connect(mapStateToProps)(ScheduleForm));
diff --git a/app/soapbox/features/compose/containers/compose_form_container.js b/app/soapbox/features/compose/containers/compose_form_container.js
index 0a7cefc96..ba0f43c69 100644
--- a/app/soapbox/features/compose/containers/compose_form_container.js
+++ b/app/soapbox/features/compose/containers/compose_form_container.js
@@ -26,6 +26,7 @@ const mapStateToProps = state => ({
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
isModalOpen: state.get('modal').modalType === 'COMPOSE',
maxTootChars: state.getIn(['instance', 'max_toot_chars']),
+ schedule: state.getIn(['instance', 'schedule']),
});
const mapDispatchToProps = (dispatch) => ({
diff --git a/app/soapbox/features/compose/containers/schedule_button_container.js b/app/soapbox/features/compose/containers/schedule_button_container.js
new file mode 100644
index 000000000..472155284
--- /dev/null
+++ b/app/soapbox/features/compose/containers/schedule_button_container.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import ScheduleButton from '../components/schedule_button';
+import { addSchedule, removeSchedule } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+ active: state.getIn(['compose', 'schedule']) ? true : false,
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onClick() {
+ dispatch((_, getState) => {
+ if (getState().getIn(['compose', 'schedule'])) {
+ dispatch(removeSchedule());
+ } else {
+ dispatch(addSchedule());
+ }
+ });
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ScheduleButton);
diff --git a/app/soapbox/features/compose/containers/schedule_form_container.js b/app/soapbox/features/compose/containers/schedule_form_container.js
new file mode 100644
index 000000000..da4887300
--- /dev/null
+++ b/app/soapbox/features/compose/containers/schedule_form_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import ScheduleForm from '../components/schedule_form';
+import { setSchedule } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+ schedule: state.getIn(['compose', 'schedule']),
+ active: state.getIn(['compose', 'schedule']) ? true : false,
+});
+
+const mapDispatchToProps = dispatch => ({
+ onSchedule(date) {
+ dispatch(setSchedule(date));
+ },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ScheduleForm);
diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json
index 797ec9da7..c840e714a 100644
--- a/app/soapbox/locales/en.json
+++ b/app/soapbox/locales/en.json
@@ -189,6 +189,9 @@
"compose_form.spoiler.marked": "Text is hidden behind warning",
"compose_form.spoiler.unmarked": "Text is not hidden",
"compose_form.spoiler_placeholder": "Write your warning here",
+ "schedule_button.add_schedule": "Schedule post for later",
+ "schedule_button.remove_schedule": "Post immediately",
+ "schedule.post_time": "Post Date/Time",
"confirmation_modal.cancel": "Cancel",
"confirmations.admin.deactivate_user.confirm": "Deactivate @{name}",
"confirmations.admin.deactivate_user.message": "You are about to deactivate @{acct}. Deactivating a user is a reversible action.",
diff --git a/app/soapbox/reducers/compose.js b/app/soapbox/reducers/compose.js
index 43113f635..fa11f62f5 100644
--- a/app/soapbox/reducers/compose.js
+++ b/app/soapbox/reducers/compose.js
@@ -32,6 +32,9 @@ import {
COMPOSE_RESET,
COMPOSE_POLL_ADD,
COMPOSE_POLL_REMOVE,
+ COMPOSE_SCHEDULE_ADD,
+ COMPOSE_SCHEDULE_SET,
+ COMPOSE_SCHEDULE_REMOVE,
COMPOSE_POLL_OPTION_ADD,
COMPOSE_POLL_OPTION_CHANGE,
COMPOSE_POLL_OPTION_REMOVE,
@@ -81,6 +84,10 @@ const initialPoll = ImmutableMap({
multiple: false,
});
+const initialSchedule = new Date();
+initialSchedule.setDate(initialSchedule.getDate() - 1);
+
+
function statusToTextMentions(state, status, account) {
const author = status.getIn(['account', 'acct']);
const mentions = status.get('mentions', []).map(m => m.get('acct'));
@@ -107,6 +114,7 @@ function clearAll(state) {
map.set('media_attachments', ImmutableList());
map.set('poll', null);
map.set('idempotencyKey', uuid());
+ map.set('schedule', null);
});
};
@@ -398,6 +406,12 @@ export default function compose(state = initialState, action) {
return state.set('poll', initialPoll);
case COMPOSE_POLL_REMOVE:
return state.set('poll', null);
+ case COMPOSE_SCHEDULE_ADD:
+ return state.set('schedule', initialSchedule);
+ case COMPOSE_SCHEDULE_SET:
+ return state.set('schedule', action.date);
+ case COMPOSE_SCHEDULE_REMOVE:
+ return state.set('schedule', null);
case COMPOSE_POLL_OPTION_ADD:
return state.updateIn(['poll', 'options'], options => options.push(action.title));
case COMPOSE_POLL_OPTION_CHANGE:
diff --git a/app/styles/components/compose-form.scss b/app/styles/components/compose-form.scss
index af736a19a..3a9f97b75 100644
--- a/app/styles/components/compose-form.scss
+++ b/app/styles/components/compose-form.scss
@@ -90,7 +90,8 @@
}
.autosuggest-textarea__textarea,
- .spoiler-input__input {
+ .spoiler-input__input,
+ .react-datepicker__input-container input {
display: block;
box-sizing: border-box;
width: 100%;
@@ -376,6 +377,16 @@
}
} // end .compose-form
+.react-datepicker-wrapper {
+ margin-left: 10px;
+ background: var(--background-color);
+ border: 2px solid var(--brand-color);
+}
+
+.react-datepicker-popper {
+ background: var(--background-color) !important;
+}
+
.upload-area {
align-items: center;
background: rgba($base-overlay-background, 0.8);
diff --git a/app/styles/ui.scss b/app/styles/ui.scss
index 3f6637b49..df6700752 100644
--- a/app/styles/ui.scss
+++ b/app/styles/ui.scss
@@ -129,6 +129,10 @@
}
}
+.react-datepicker-popper {
+ z-index: 9999 !important;
+}
+
.ellipsis::after { content: "…"; }
.timeline-compose-block {
diff --git a/package.json b/package.json
index 707c474be..b1ddba8d0 100644
--- a/package.json
+++ b/package.json
@@ -100,6 +100,7 @@
"qrcode.react": "^1.0.0",
"react": "^16.13.1",
"react-color": "^2.18.1",
+ "react-datepicker": "^4.1.1",
"react-dom": "^16.13.1",
"react-helmet": "^6.0.0",
"react-hotkeys": "^1.1.4",
diff --git a/yarn.lock b/yarn.lock
index b4cce27af..f993be66e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1690,6 +1690,11 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.4.4.tgz#11d5db19bd178936ec89cd84519c4de439574398"
integrity sha512-1oO6+dN5kdIA3sKPZhRGJTfGVP4SWV6KqlMOwry4J3HfyD68sl/3KmG7DeYUzvN+RbhXDnv/D8vNNB8168tAMg==
+"@popperjs/core@^2.9.2":
+ version "2.9.2"
+ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.2.tgz#adea7b6953cbb34651766b0548468e743c6a2353"
+ integrity sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==
+
"@sinonjs/commons@^1.7.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.0.tgz#c8d68821a854c555bba172f3b06959a0039b236d"
@@ -3415,6 +3420,11 @@ classnames@^2.2.5:
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
+classnames@^2.2.6:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
+ integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
+
clean-css@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
@@ -4135,6 +4145,11 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
+date-fns@^2.0.1:
+ version "2.22.1"
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.22.1.tgz#1e5af959831ebb1d82992bf67b765052d8f0efc4"
+ integrity sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg==
+
date-now@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
@@ -9983,6 +9998,18 @@ react-color@^2.18.1:
reactcss "^1.2.0"
tinycolor2 "^1.4.1"
+react-datepicker@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.1.1.tgz#5ecef49c672b2250fca26327c988464e6ba52b62"
+ integrity sha512-vtZIA7MbUrffRw1CHiyOGtmTO/tTdZGr5BYaiRucHMTb6rCqA8TkaQhzX6tTwMwP8vV38Khv4UWohrJbiX1rMw==
+ dependencies:
+ "@popperjs/core" "^2.9.2"
+ classnames "^2.2.6"
+ date-fns "^2.0.1"
+ prop-types "^15.7.2"
+ react-onclickoutside "^6.10.0"
+ react-popper "^2.2.5"
+
react-dom@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f"
@@ -10102,6 +10129,11 @@ react-notification@^6.8.4:
dependencies:
prop-types "^15.6.2"
+react-onclickoutside@^6.10.0:
+ version "6.11.2"
+ resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.11.2.tgz#790e2100b9a3589eefca1404ecbf0476b81b7928"
+ integrity sha512-640486eSwU/t5iD6yeTlefma8dI3bxPXD93hM9JGKyYITAd0P1JFkkcDeyHZRqNpY/fv1YW0Fad9BXr44OY8wQ==
+
react-overlays@^0.9.0:
version "0.9.2"
resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.9.2.tgz#51ab1c62ded5af4d279bd3b943999531bbd648da"
@@ -10122,6 +10154,14 @@ react-popper@^2.2.3:
react-fast-compare "^3.0.1"
warning "^4.0.2"
+react-popper@^2.2.5:
+ version "2.2.5"
+ resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.5.tgz#1214ef3cec86330a171671a4fbcbeeb65ee58e96"
+ integrity sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==
+ dependencies:
+ react-fast-compare "^3.0.1"
+ warning "^4.0.2"
+
react-redux-loading-bar@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/react-redux-loading-bar/-/react-redux-loading-bar-5.0.0.tgz#fffbc2b893c556b7b4c577743427507ee6dbc1f3"