diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index aa1c6de20e..aa23775085 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -81,6 +81,9 @@ export const COMPOSE_CHANGE_MEDIA_ORDER = 'COMPOSE_CHANGE_MEDIA_ORDER'; export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; +export const COMPOSE_CHANGE_IS_SCHEDULED = 'COMPOSE_CHANGE_IS_SCHEDULED'; +export const COMPOSE_CHANGE_SCHEDULE_TIME = 'COMPOSE_CHANGE_SCHEDULE_TIME'; + 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.' }, @@ -188,6 +191,7 @@ export function submitCompose() { const status = getState().getIn(['compose', 'text'], ''); const media = getState().getIn(['compose', 'media_attachments']); const statusId = getState().getIn(['compose', 'id'], null); + const is_scheduled = getState().getIn(['compose', 'is_scheduled']); if ((!status || !status.length) && media.size === 0) { return; @@ -228,6 +232,7 @@ export function submitCompose() { visibility: getState().getIn(['compose', 'privacy']), poll: getState().getIn(['compose', 'poll'], null), language: getState().getIn(['compose', 'language']), + scheduled_at: is_scheduled ? getState().getIn(['compose', 'scheduled_at']) : null, }, headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), @@ -237,9 +242,22 @@ export function submitCompose() { browserHistory.goBack(); } + + if ('scheduled_at' in response.data) { + dispatch(showAlert({ + message: messages.saved, + dismissAfter: 10000, + })); + dispatch(submitComposeSuccess({ ...response.data.params})); + return; + } + dispatch(insertIntoTagHistory(response.data.tags, status)); dispatch(submitComposeSuccess({ ...response.data })); + + + // To make the app more responsive, immediately push the status // into the columns const insertIfOnline = timelineId => { @@ -835,3 +853,16 @@ export const changeMediaOrder = (a, b) => ({ a, b, }); + +export function changeIsScheduled() { + return { + type: COMPOSE_CHANGE_IS_SCHEDULED, + }; +} + +export function changeScheduleTime(value) { + return { + type: COMPOSE_CHANGE_SCHEDULE_TIME, + value, + }; +} \ No newline at end of file diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index b5e8dabb7b..e1e0038eb9 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -29,6 +29,9 @@ import { PollForm } from "./poll_form"; import { ReplyIndicator } from './reply_indicator'; import { UploadForm } from './upload_form'; +import ScheduleButtonContainer from '../containers/schedule_button_container'; +import { ScheduleForm } from './schedule_form'; + const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; const messages = defineMessages({ @@ -69,6 +72,11 @@ class ComposeForm extends ImmutablePureComponent { singleColumn: PropTypes.bool, lang: PropTypes.string, maxChars: PropTypes.number, + + schedule_time: PropTypes.string, + schedule_timezone: PropTypes.string, + is_scheduled: PropTypes.bool.isRequired, + scheduled_at: PropTypes.string, }; static defaultProps = { @@ -295,6 +303,7 @@ class ComposeForm extends ImmutablePureComponent { + @@ -306,6 +315,7 @@ class ComposeForm extends ImmutablePureComponent { /> + diff --git a/app/javascript/mastodon/features/compose/components/schedule_form.jsx b/app/javascript/mastodon/features/compose/components/schedule_form.jsx new file mode 100644 index 0000000000..64dc2b0020 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/schedule_form.jsx @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import { useCallback } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { useDispatch, useSelector} from 'react-redux'; + +import { changeScheduleTime } from 'mastodon/actions/compose'; + +const messages = defineMessages({ + schedule_time: { id: 'compose_form.schedule_time', defaultMessage: 'This post is scheduled to be published at (UTC+8)' }, +}); + +export const ScheduleForm = () => { + const is_scheduled = useSelector(state => state.getIn(['compose', 'is_scheduled'])); + const schedule_time = useSelector(state => state.getIn(['compose', 'schedule_time'])); + const dispatch = useDispatch(); + const intl = useIntl(); + + const handleChange = useCallback(({ target: { value } }) => { + dispatch(changeScheduleTime(value)); + }, [dispatch]); + + if (!is_scheduled) { + return null; + } + + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index bda2edba60..dd7932cdbc 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -29,6 +29,10 @@ const mapStateToProps = state => ({ isInReply: state.getIn(['compose', 'in_reply_to']) !== null, lang: state.getIn(['compose', 'language']), maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500), + is_scheduled: state.getIn(['compose', 'is_scheduled']), + schedule_time: state.getIn(['compose', 'schedule_time']), + schedule_timezone: state.getIn(['compose', 'schedule_timezone']), + scheduled_at: state.getIn(['compose', 'scheduled_at']), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/javascript/mastodon/features/compose/containers/schedule_button_container.js b/app/javascript/mastodon/features/compose/containers/schedule_button_container.js new file mode 100644 index 0000000000..5d92f95670 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/schedule_button_container.js @@ -0,0 +1,30 @@ +import { injectIntl, defineMessages } from "react-intl"; + +import { connect } from 'react-redux'; + +import ScheduleIcon from '@/material-icons/400-20px/schedule.svg?react'; +import { IconButton } from "@/mastodon/components/icon_button"; + +import { changeIsScheduled } from '../../../actions/compose'; + +const messages = defineMessages({ + marked: { id: 'compose_form.schedule.marked', defaultMessage: 'This post will be published at the time chosen below'}, + unmarked: { id: 'compose_form.schedule.unmarked', defaultMessage: 'This post will be published at once'}, +}) + +const mapStateToProps = (state, { intl }) => ({ + iconComponent: ScheduleIcon, + title: intl.formatMessage(state.getIn(['compose', 'is_scheduled']) ? messages.marked : messages.unmarked), + active: state.getIn(['compose', 'is_scheduled']), + ariaControls: 'schedule-publish', + size: 18, + inverted: true, +}); + +const mapDispatchToProps = dispatch => ({ + onClick () { + dispatch(changeIsScheduled()); + }, +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(IconButton)); \ No newline at end of file diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index ec3851be40..27ff191d1d 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -166,6 +166,9 @@ "compose_form.publish_form": "New post", "compose_form.reply": "Reply", "compose_form.save_changes": "Update", + "compose_form.schedule.marked": "This post will be published at the time chosen below", + "compose_form.schedule.unmarked": "This post will be published at once", + "compose_form.schedule_time": "This post is scheduled to be published at (UTC+8)", "compose_form.spoiler.marked": "Remove content warning", "compose_form.spoiler.unmarked": "Add content warning", "compose_form.spoiler_placeholder": "Content warning (optional)", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 914eb30e91..4a7e083c6e 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -166,6 +166,9 @@ "compose_form.publish_form": "发嘟", "compose_form.reply": "回复", "compose_form.save_changes": "更改", + "compose_form.schedule.marked": "本文将在以下时间发布", + "compose_form.schedule.unmarked": "本文将立即发布", + "compose_form.schedule_time": "计划发文时间(北京时间)", "compose_form.spoiler.marked": "移除内容警告", "compose_form.spoiler.unmarked": "添加内容警告", "compose_form.spoiler_placeholder": "内容警告 (可选)", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index bfa2ec6a06..6b9a2c3ec4 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -50,6 +50,8 @@ import { COMPOSE_CHANGE_MEDIA_ORDER, COMPOSE_SET_STATUS, COMPOSE_FOCUS, + COMPOSE_CHANGE_IS_SCHEDULED, + COMPOSE_CHANGE_SCHEDULE_TIME } from '../actions/compose'; import { REDRAFT } from '../actions/statuses'; import { STORE_HYDRATE } from '../actions/store'; @@ -94,6 +96,11 @@ const initialState = ImmutableMap({ focusY: 0, dirty: false, }), + + schedule_time: null, + schedule_timezone: '+08:00', + is_scheduled: false, + scheduled_at: null, }); const initialPoll = ImmutableMap({ @@ -127,6 +134,9 @@ function clearAll(state) { map.update('media_attachments', list => list.clear()); map.set('poll', null); map.set('idempotencyKey', uuid()); + map.set('schedule_time', null); + map.set('is_scheduled', false); + map.set('scheduled_at', null); }); } @@ -560,6 +570,18 @@ export default function compose(state = initialState, action) { return list.splice(indexA, 1).splice(indexB, 0, moveItem); }); + case COMPOSE_CHANGE_IS_SCHEDULED: + return state.withMutations(map => { + map.set('is_scheduled', !state.get('is_scheduled')); + map.set('scheduled_at', state.get('schedule_time') + ':00.0' + state.get('schedule_timezone')); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_CHANGE_SCHEDULE_TIME: + return state.withMutations(map => { + map.set('schedule_time', action.value); + map.set('scheduled_at', action.value + ':00.0' + state.get('schedule_timezone')); + map.set('idempotencyKey', uuid()); + }); default: return state; } diff --git a/app/javascript/material-icons/400-20px/schedule.svg b/app/javascript/material-icons/400-20px/schedule.svg new file mode 100644 index 0000000000..350607c71e --- /dev/null +++ b/app/javascript/material-icons/400-20px/schedule.svg @@ -0,0 +1,6 @@ + + + + + +