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"