diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js
index 6c3057436..8c0f1d3c4 100644
--- a/app/soapbox/actions/compose.js
+++ b/app/soapbox/actions/compose.js
@@ -14,6 +14,7 @@ import { getSettings } from './settings';
import { getFeatures } from 'soapbox/utils/features';
import { uploadMedia } from './media';
import { isLoggedIn } from 'soapbox/utils/auth';
+import { createStatus } from './statuses';
let cancelFetchComposeSuggestionsAccounts;
@@ -134,11 +135,11 @@ export function directCompose(account, routerHistory) {
};
};
-export function handleComposeSubmit(dispatch, getState, response, status) {
+export function handleComposeSubmit(dispatch, getState, data, status) {
if (!dispatch || !getState) return;
- dispatch(insertIntoTagHistory(response.data.tags || [], status));
- dispatch(submitComposeSuccess({ ...response.data }));
+ dispatch(insertIntoTagHistory(data.tags || [], status));
+ dispatch(submitComposeSuccess({ ...data }));
// To make the app more responsive, immediately push the status into the columns
const insertIfOnline = timelineId => {
@@ -148,64 +149,71 @@ export function handleComposeSubmit(dispatch, getState, response, status) {
let dequeueArgs = {};
if (timelineId === 'community') dequeueArgs.onlyMedia = getSettings(getState()).getIn(['community', 'other', 'onlyMedia']);
dispatch(dequeueTimeline(timelineId, null, dequeueArgs));
- dispatch(updateTimeline(timelineId, response.data.id));
+ dispatch(updateTimeline(timelineId, data.id));
}
};
- if (response.data.visibility !== 'direct') {
+ if (data.visibility !== 'direct') {
insertIfOnline('home');
- } else if (response.data.visibility === 'public') {
+ } else if (data.visibility === 'public') {
insertIfOnline('community');
insertIfOnline('public');
}
}
-export function submitCompose(routerHistory, group) {
+const needsDescriptions = state => {
+ const media = state.getIn(['compose', 'media_attachments']);
+ const missingDescriptionModal = getSettings(state).get('missingDescriptionModal');
+
+ const hasMissing = media.filter(item => !item.get('description')).size > 0;
+
+ return missingDescriptionModal && hasMissing;
+};
+
+export function submitCompose(routerHistory, force = false) {
return function(dispatch, getState) {
if (!isLoggedIn(getState)) return;
+ const state = getState();
- function onModalSubmitCompose() {
- dispatch(submitComposeRequest());
- dispatch(closeModal());
-
- api(getState).post('/api/v1/statuses', {
- status,
- in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
- media_ids: media.map(item => item.get('id')),
- sensitive: getState().getIn(['compose', 'sensitive']),
- spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
- visibility: getState().getIn(['compose', 'privacy']),
- content_type: getState().getIn(['compose', 'content_type']),
- poll: getState().getIn(['compose', 'poll'], null),
- scheduled_at: getState().getIn(['compose', 'schedule'], null),
- }, {
- headers: {
- 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
- },
- }).then(function(response) {
- if (response.data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) {
- routerHistory.push('/messages');
- }
- handleComposeSubmit(dispatch, getState, response, status);
- }).catch(function(error) {
- dispatch(submitComposeFail(error));
- });
- }
-
- const status = getState().getIn(['compose', 'text'], '');
- const media = getState().getIn(['compose', 'media_attachments']);
+ const status = state.getIn(['compose', 'text'], '');
+ const media = state.getIn(['compose', 'media_attachments']);
if ((!status || !status.length) && media.size === 0) {
return;
}
- const missingDescriptionModal = getSettings(getState()).get('missingDescriptionModal');
-
- if (missingDescriptionModal && media.filter(item => !item.get('description')).size) {
+ if (!force && needsDescriptions(state)) {
dispatch(openModal('MISSING_DESCRIPTION', {
- onContinue: () => onModalSubmitCompose(),
+ onContinue: () => dispatch(submitCompose(routerHistory, true)),
}));
- } else onModalSubmitCompose();
+ return;
+ }
+
+ dispatch(submitComposeRequest());
+ dispatch(closeModal());
+
+ const idempotencyKey = state.getIn(['compose', 'idempotencyKey']);
+
+ const params = {
+ status,
+ in_reply_to_id: state.getIn(['compose', 'in_reply_to'], null),
+ media_ids: media.map(item => item.get('id')),
+ sensitive: state.getIn(['compose', 'sensitive']),
+ spoiler_text: state.getIn(['compose', 'spoiler_text'], ''),
+ visibility: state.getIn(['compose', 'privacy']),
+ content_type: state.getIn(['compose', 'content_type']),
+ poll: state.getIn(['compose', 'poll'], null),
+ scheduled_at: state.getIn(['compose', 'schedule'], null),
+ };
+
+ dispatch(createStatus(params, idempotencyKey)).then(function(data) {
+ if (data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) {
+ routerHistory.push('/messages');
+ }
+ handleComposeSubmit(dispatch, getState, data, status);
+ }).catch(function(error) {
+ dispatch(submitComposeFail(error));
+ });
};
};
diff --git a/app/soapbox/actions/scheduled_statuses.js b/app/soapbox/actions/scheduled_statuses.js
new file mode 100644
index 000000000..5d5b493f1
--- /dev/null
+++ b/app/soapbox/actions/scheduled_statuses.js
@@ -0,0 +1,102 @@
+import api, { getLinks } from '../api';
+
+export const SCHEDULED_STATUSES_FETCH_REQUEST = 'SCHEDULED_STATUSES_FETCH_REQUEST';
+export const SCHEDULED_STATUSES_FETCH_SUCCESS = 'SCHEDULED_STATUSES_FETCH_SUCCESS';
+export const SCHEDULED_STATUSES_FETCH_FAIL = 'SCHEDULED_STATUSES_FETCH_FAIL';
+
+export const SCHEDULED_STATUSES_EXPAND_REQUEST = 'SCHEDULED_STATUSES_EXPAND_REQUEST';
+export const SCHEDULED_STATUSES_EXPAND_SUCCESS = 'SCHEDULED_STATUSES_EXPAND_SUCCESS';
+export const SCHEDULED_STATUSES_EXPAND_FAIL = 'SCHEDULED_STATUSES_EXPAND_FAIL';
+
+export const SCHEDULED_STATUS_CANCEL_REQUEST = 'SCHEDULED_STATUS_CANCEL_REQUEST';
+export const SCHEDULED_STATUS_CANCEL_SUCCESS = 'SCHEDULED_STATUS_CANCEL_SUCCESS';
+export const SCHEDULED_STATUS_CANCEL_FAIL = 'SCHEDULED_STATUS_CANCEL_FAIL';
+
+export function fetchScheduledStatuses() {
+ return (dispatch, getState) => {
+ if (getState().getIn(['status_lists', 'scheduled_statuses', 'isLoading'])) {
+ return;
+ }
+
+ dispatch(fetchScheduledStatusesRequest());
+
+ api(getState).get('/api/v1/scheduled_statuses').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchScheduledStatusesSuccess(response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(fetchScheduledStatusesFail(error));
+ });
+ };
+};
+
+export function cancelScheduledStatus(id) {
+ return (dispatch, getState) => {
+ dispatch({ type: SCHEDULED_STATUS_CANCEL_REQUEST, id });
+ api(getState).delete(`/api/v1/scheduled_statuses/${id}`).then(({ data }) => {
+ dispatch({ type: SCHEDULED_STATUS_CANCEL_SUCCESS, id, data });
+ }).catch(error => {
+ dispatch({ type: SCHEDULED_STATUS_CANCEL_FAIL, id, error });
+ });
+ };
+}
+
+export function fetchScheduledStatusesRequest() {
+ return {
+ type: SCHEDULED_STATUSES_FETCH_REQUEST,
+ };
+};
+
+export function fetchScheduledStatusesSuccess(statuses, next) {
+ return {
+ type: SCHEDULED_STATUSES_FETCH_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function fetchScheduledStatusesFail(error) {
+ return {
+ type: SCHEDULED_STATUSES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandScheduledStatuses() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['status_lists', 'scheduled_statuses', 'next'], null);
+
+ if (url === null || getState().getIn(['status_lists', 'scheduled_statuses', 'isLoading'])) {
+ return;
+ }
+
+ dispatch(expandScheduledStatusesRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandScheduledStatusesSuccess(response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(expandScheduledStatusesFail(error));
+ });
+ };
+};
+
+export function expandScheduledStatusesRequest() {
+ return {
+ type: SCHEDULED_STATUSES_EXPAND_REQUEST,
+ };
+};
+
+export function expandScheduledStatusesSuccess(statuses, next) {
+ return {
+ type: SCHEDULED_STATUSES_EXPAND_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function expandScheduledStatusesFail(error) {
+ return {
+ type: SCHEDULED_STATUSES_EXPAND_FAIL,
+ error,
+ };
+};
diff --git a/app/soapbox/actions/statuses.js b/app/soapbox/actions/statuses.js
index f06aefa39..a9123cd64 100644
--- a/app/soapbox/actions/statuses.js
+++ b/app/soapbox/actions/statuses.js
@@ -6,6 +6,10 @@ import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus
import { openModal } from './modal';
import { isLoggedIn } from 'soapbox/utils/auth';
+export const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST';
+export const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS';
+export const STATUS_CREATE_FAIL = 'STATUS_CREATE_FAIL';
+
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
@@ -39,6 +43,22 @@ export function fetchStatusRequest(id, skipLoading) {
};
};
+export function createStatus(params, idempotencyKey) {
+ return (dispatch, getState) => {
+ dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey });
+
+ return api(getState).post('/api/v1/statuses', params, {
+ headers: { 'Idempotency-Key': idempotencyKey },
+ }).then(({ data: status }) => {
+ dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey });
+ return status;
+ }).catch(error => {
+ dispatch({ type: STATUS_CREATE_FAIL, error, params, idempotencyKey });
+ throw error;
+ });
+ };
+};
+
function getFromDB(dispatch, getState, accountIndex, index, id) {
return new Promise((resolve, reject) => {
const request = index.get(id);
diff --git a/app/soapbox/features/compose/components/compose_form.js b/app/soapbox/features/compose/components/compose_form.js
index c08b7aa24..2c48a921f 100644
--- a/app/soapbox/features/compose/components/compose_form.js
+++ b/app/soapbox/features/compose/components/compose_form.js
@@ -4,12 +4,13 @@ import Button from '../../../components/button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import classNames from 'classnames';
+import { Link } from 'react-router-dom';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import AutosuggestInput from '../../../components/autosuggest_input';
import PollButtonContainer from '../containers/poll_button_container';
import UploadButtonContainer from '../containers/upload_button_container';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import MarkdownButtonContainer from '../containers/markdown_button_container';
import ScheduleFormContainer from '../containers/schedule_form_container';
@@ -25,6 +26,7 @@ import { length } from 'stringz';
import { countableText } from '../util/counter';
import Icon from 'soapbox/components/icon';
import { get } from 'lodash';
+import Warning from '../components/warning';
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';
@@ -245,7 +247,7 @@ class ComposeForm extends ImmutablePureComponent {
}
render() {
- const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen, maxTootChars } = this.props;
+ const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen, maxTootChars, scheduledStatusCount } = this.props;
const condensed = shouldCondense && !this.state.composeFocused && this.isEmpty() && !this.props.isUploading;
const disabled = this.props.isSubmitting;
const text = [this.props.spoilerText, countableText(this.props.text)].join('');
@@ -267,6 +269,25 @@ class ComposeForm extends ImmutablePureComponent {
return (
+ {scheduledStatusCount > 0 && (
+
+
+
+ ) }}
+ />)
+ }
+ />
+ )}
+
{ !shouldCondense && }
diff --git a/app/soapbox/features/compose/containers/compose_form_container.js b/app/soapbox/features/compose/containers/compose_form_container.js
index ba0f43c69..dad55a041 100644
--- a/app/soapbox/features/compose/containers/compose_form_container.js
+++ b/app/soapbox/features/compose/containers/compose_form_container.js
@@ -27,6 +27,7 @@ const mapStateToProps = state => ({
isModalOpen: state.get('modal').modalType === 'COMPOSE',
maxTootChars: state.getIn(['instance', 'max_toot_chars']),
schedule: state.getIn(['instance', 'schedule']),
+ scheduledStatusCount: state.get('scheduled_statuses').size,
});
const mapDispatchToProps = (dispatch) => ({
diff --git a/app/soapbox/features/scheduled_statuses/builder.js b/app/soapbox/features/scheduled_statuses/builder.js
new file mode 100644
index 000000000..fd0772fe3
--- /dev/null
+++ b/app/soapbox/features/scheduled_statuses/builder.js
@@ -0,0 +1,45 @@
+import { fromJS } from 'immutable';
+import { normalizeStatus } from 'soapbox/actions/importer/normalizer';
+import { makeGetAccount } from 'soapbox/selectors';
+
+export const buildStatus = (state, scheduledStatus) => {
+ const getAccount = makeGetAccount();
+
+ const me = state.get('me');
+ const params = scheduledStatus.get('params');
+ const account = getAccount(state, me);
+
+ const status = normalizeStatus({
+ account,
+ application: null,
+ bookmarked: false,
+ card: null,
+ content: params.get('text'),
+ created_at: params.get('scheduled_at'),
+ emojis: [],
+ favourited: false,
+ favourites_count: 0,
+ id: scheduledStatus.get('id'),
+ in_reply_to_account_id: null,
+ in_reply_to_id: params.get('in_reply_to_id'),
+ language: null,
+ media_attachments: scheduledStatus.get('media_attachments'),
+ mentions: [],
+ muted: false,
+ pinned: false,
+ poll: params.get('poll'),
+ reblog: null,
+ reblogged: false,
+ reblogs_count: 0,
+ replies_count: 0,
+ sensitive: params.get('sensitive'),
+ spoiler_text: '',
+ tags: [],
+ text: null,
+ uri: `/scheduled_statuses/${scheduledStatus.get('id')}`,
+ url: `/scheduled_statuses/${scheduledStatus.get('id')}`,
+ visibility: params.get('visibility'),
+ });
+
+ return fromJS(status).set('account', account);
+};
diff --git a/app/soapbox/features/scheduled_statuses/components/scheduled_status.js b/app/soapbox/features/scheduled_statuses/components/scheduled_status.js
new file mode 100644
index 000000000..c0fd0ffd8
--- /dev/null
+++ b/app/soapbox/features/scheduled_statuses/components/scheduled_status.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import StatusContent from 'soapbox/components/status_content';
+import { buildStatus } from '../builder';
+import classNames from 'classnames';
+import RelativeTimestamp from 'soapbox/components/relative_timestamp';
+import { Link, NavLink } from 'react-router-dom';
+import { getDomain } from 'soapbox/utils/accounts';
+import Avatar from 'soapbox/components/avatar';
+import DisplayName from 'soapbox/components/display_name';
+import AttachmentList from 'soapbox/components/attachment_list';
+import PollContainer from 'soapbox/containers/poll_container';
+import ScheduledStatusActionBar from './scheduled_status_action_bar';
+
+const mapStateToProps = (state, props) => {
+ const scheduledStatus = state.getIn(['scheduled_statuses', props.statusId]);
+ return {
+ status: buildStatus(state, scheduledStatus),
+ };
+};
+
+export default @connect(mapStateToProps)
+class ScheduledStatus extends ImmutablePureComponent {
+
+ render() {
+ const { status, showThread, account, ...other } = this.props;
+ if (!status.get('account')) return null;
+
+ const statusUrl = `/scheduled_statuses/${status.get('id')}`;
+ const favicon = status.getIn(['account', 'pleroma', 'favicon']);
+ const domain = getDomain(status.get('account'));
+
+ return (
+
+
+
+
+
+
+
+
+
+ {favicon &&
+
+
+

+
+
}
+
+
+
+
+
+
+
+
+ {status.get('poll') &&
}
+
+ {showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
+
+ )}
+
+
+
+
+
+ );
+ }
+
+};
diff --git a/app/soapbox/features/scheduled_statuses/components/scheduled_status_action_bar.js b/app/soapbox/features/scheduled_statuses/components/scheduled_status_action_bar.js
new file mode 100644
index 000000000..509f01520
--- /dev/null
+++ b/app/soapbox/features/scheduled_statuses/components/scheduled_status_action_bar.js
@@ -0,0 +1,58 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
+import IconButton from 'soapbox/components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { cancelScheduledStatus } from 'soapbox/actions/scheduled_statuses';
+
+const messages = defineMessages({
+ cancel: { id: 'schedled_status.cancel', defaultMessage: 'Cancel' },
+});
+
+const mapStateToProps = state => {
+ const me = state.get('me');
+ return {
+ me,
+ };
+};
+
+export default @connect(mapStateToProps, null, null, { forwardRef: true })
+@injectIntl
+class ScheduledStatusActionBar extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ me: SoapboxPropTypes.me,
+ };
+
+ handleCancelClick = e => {
+ const { status, dispatch } = this.props;
+ dispatch(cancelScheduledStatus(status.get('id')));
+ }
+
+ render() {
+ const { intl } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/soapbox/features/scheduled_statuses/index.js b/app/soapbox/features/scheduled_statuses/index.js
new file mode 100644
index 000000000..0d293e7e0
--- /dev/null
+++ b/app/soapbox/features/scheduled_statuses/index.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from '../ui/components/column';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from 'soapbox/components/scrollable_list';
+import { fetchScheduledStatuses, expandScheduledStatuses } from '../../actions/scheduled_statuses';
+import ScheduledStatus from './components/scheduled_status';
+import { debounce } from 'lodash';
+
+const messages = defineMessages({
+ heading: { id: 'column.scheduled_statuses', defaultMessage: 'Scheduled Statuses' },
+});
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'scheduled_statuses', 'items']),
+ isLoading: state.getIn(['status_lists', 'scheduled_statuses', 'isLoading'], true),
+ hasMore: !!state.getIn(['status_lists', 'scheduled_statuses', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class ScheduledStatuses extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ intl: PropTypes.object.isRequired,
+ hasMore: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ };
+
+ componentDidMount() {
+ const { dispatch } = this.props;
+ dispatch(fetchScheduledStatuses());
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandScheduledStatuses());
+ }, 300, { leading: true })
+
+
+ render() {
+ const { intl, statusIds, hasMore, isLoading } = this.props;
+ const emptyMessage = ;
+
+ return (
+
+
+ {statusIds.map(id => )}
+
+
+ );
+ }
+
+}
diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js
index 3dffbf238..d972cb4e4 100644
--- a/app/soapbox/features/ui/index.js
+++ b/app/soapbox/features/ui/index.js
@@ -21,6 +21,7 @@ import { fetchChats } from 'soapbox/actions/chats';
import { clearHeight } from '../../actions/height_cache';
import { openModal } from '../../actions/modal';
import { fetchFollowRequests } from '../../actions/accounts';
+import { fetchScheduledStatuses } from '../../actions/scheduled_statuses';
import { WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area';
import TabsBar from './components/tabs_bar';
@@ -100,6 +101,7 @@ import {
Reports,
ModerationLog,
CryptoDonate,
+ ScheduledStatuses,
} from './util/async-components';
// Dummy import, to make sure that ends up in the application bundle.
@@ -300,6 +302,7 @@ class SwitchingColumnsArea extends React.PureComponent {
+
@@ -501,6 +504,8 @@ class UI extends React.PureComponent {
if (account.get('locked')) {
setTimeout(() => this.props.dispatch(fetchFollowRequests()), 700);
}
+
+ setTimeout(() => this.props.dispatch(fetchScheduledStatuses()), 900);
}
this.connectStreaming();
}
diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js
index dc06d1677..c3c5dc9d7 100644
--- a/app/soapbox/features/ui/util/async-components.js
+++ b/app/soapbox/features/ui/util/async-components.js
@@ -233,3 +233,7 @@ export function ModerationLog() {
export function CryptoDonate() {
return import(/* webpackChunkName: "features/crypto_donate" */'../../crypto_donate');
}
+
+export function ScheduledStatuses() {
+ return import(/* webpackChunkName: "features/scheduled_statuses" */'../../scheduled_statuses');
+}
diff --git a/app/soapbox/reducers/__tests__/status_lists-test.js b/app/soapbox/reducers/__tests__/status_lists-test.js
index 27f6ffde1..f24bc4d39 100644
--- a/app/soapbox/reducers/__tests__/status_lists-test.js
+++ b/app/soapbox/reducers/__tests__/status_lists-test.js
@@ -19,6 +19,11 @@ describe('status_lists reducer', () => {
loaded: false,
items: ImmutableList(),
}),
+ scheduled_statuses: ImmutableMap({
+ next: null,
+ loaded: false,
+ items: ImmutableList(),
+ }),
}));
});
});
diff --git a/app/soapbox/reducers/index.js b/app/soapbox/reducers/index.js
index 48ddd77b8..751a04afb 100644
--- a/app/soapbox/reducers/index.js
+++ b/app/soapbox/reducers/index.js
@@ -52,6 +52,7 @@ import profile_hover_card from './profile_hover_card';
import backups from './backups';
import admin_log from './admin_log';
import security from './security';
+import scheduled_statuses from './scheduled_statuses';
const appReducer = combineReducers({
dropdown_menu,
@@ -105,6 +106,7 @@ const appReducer = combineReducers({
backups,
admin_log,
security,
+ scheduled_statuses,
});
// Clear the state (mostly) when the user logs out
diff --git a/app/soapbox/reducers/scheduled_statuses.js b/app/soapbox/reducers/scheduled_statuses.js
new file mode 100644
index 000000000..2e304a8eb
--- /dev/null
+++ b/app/soapbox/reducers/scheduled_statuses.js
@@ -0,0 +1,36 @@
+import { STATUS_CREATE_SUCCESS } from 'soapbox/actions/statuses';
+import {
+ SCHEDULED_STATUSES_FETCH_SUCCESS,
+ SCHEDULED_STATUS_CANCEL_REQUEST,
+ SCHEDULED_STATUS_CANCEL_SUCCESS,
+} from 'soapbox/actions/scheduled_statuses';
+import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const importStatus = (state, status) => {
+ if (!status.scheduled_at) return state;
+ return state.set(status.id, fromJS(status));
+};
+
+const importStatuses = (state, statuses) =>
+ state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status)));
+
+const deleteStatus = (state, id) => state.delete(id);
+
+const initialState = ImmutableMap();
+
+export default function statuses(state = initialState, action) {
+ switch(action.type) {
+ case STATUS_IMPORT:
+ case STATUS_CREATE_SUCCESS:
+ return importStatus(state, action.status);
+ case STATUSES_IMPORT:
+ case SCHEDULED_STATUSES_FETCH_SUCCESS:
+ return importStatuses(state, action.statuses);
+ case SCHEDULED_STATUS_CANCEL_REQUEST:
+ case SCHEDULED_STATUS_CANCEL_SUCCESS:
+ return deleteStatus(state, action.id);
+ default:
+ return state;
+ }
+};
diff --git a/app/soapbox/reducers/status_lists.js b/app/soapbox/reducers/status_lists.js
index 9f8f28dee..a9e5079b2 100644
--- a/app/soapbox/reducers/status_lists.js
+++ b/app/soapbox/reducers/status_lists.js
@@ -26,6 +26,16 @@ import {
PIN_SUCCESS,
UNPIN_SUCCESS,
} from '../actions/interactions';
+import {
+ SCHEDULED_STATUSES_FETCH_REQUEST,
+ SCHEDULED_STATUSES_FETCH_SUCCESS,
+ SCHEDULED_STATUSES_FETCH_FAIL,
+ SCHEDULED_STATUSES_EXPAND_REQUEST,
+ SCHEDULED_STATUSES_EXPAND_SUCCESS,
+ SCHEDULED_STATUSES_EXPAND_FAIL,
+ SCHEDULED_STATUS_CANCEL_REQUEST,
+ SCHEDULED_STATUS_CANCEL_SUCCESS,
+} from '../actions/scheduled_statuses';
const initialState = ImmutableMap({
favourites: ImmutableMap({
@@ -43,6 +53,11 @@ const initialState = ImmutableMap({
loaded: false,
items: ImmutableList(),
}),
+ scheduled_statuses: ImmutableMap({
+ next: null,
+ loaded: false,
+ items: ImmutableList(),
+ }),
});
const normalizeList = (state, listType, statuses, next) => {
@@ -68,9 +83,9 @@ const prependOneToList = (state, listType, status) => {
}));
};
-const removeOneFromList = (state, listType, status) => {
+const removeOneFromList = (state, listType, statusId) => {
return state.update(listType, listMap => listMap.withMutations(map => {
- map.set('items', map.get('items').filter(item => item !== status.get('id')));
+ map.set('items', map.get('items').filter(item => item !== statusId));
}));
};
@@ -110,6 +125,19 @@ export default function statusLists(state = initialState, action) {
return prependOneToList(state, 'pins', action.status);
case UNPIN_SUCCESS:
return removeOneFromList(state, 'pins', action.status);
+ case SCHEDULED_STATUSES_FETCH_REQUEST:
+ case SCHEDULED_STATUSES_EXPAND_REQUEST:
+ return state.setIn(['scheduled_statuses', 'isLoading'], true);
+ case SCHEDULED_STATUSES_FETCH_FAIL:
+ case SCHEDULED_STATUSES_EXPAND_FAIL:
+ return state.setIn(['scheduled_statuses', 'isLoading'], false);
+ case SCHEDULED_STATUSES_FETCH_SUCCESS:
+ return normalizeList(state, 'scheduled_statuses', action.statuses, action.next);
+ case SCHEDULED_STATUSES_EXPAND_SUCCESS:
+ return appendToList(state, 'scheduled_statuses', action.statuses, action.next);
+ case SCHEDULED_STATUS_CANCEL_REQUEST:
+ case SCHEDULED_STATUS_CANCEL_SUCCESS:
+ return removeOneFromList(state, 'scheduled_statuses', action.id || action.status.get('id'));
default:
return state;
}
diff --git a/app/styles/application.scss b/app/styles/application.scss
index 457a4faf4..a8cf79ba9 100644
--- a/app/styles/application.scss
+++ b/app/styles/application.scss
@@ -82,6 +82,7 @@
@import 'components/admin';
@import 'components/backups';
@import 'components/crypto-donate';
+@import 'components/datepicker';
// Holiday
@import 'holiday/halloween';
diff --git a/app/styles/components/compose-form.scss b/app/styles/components/compose-form.scss
index 3a9f97b75..e6a14d07e 100644
--- a/app/styles/components/compose-form.scss
+++ b/app/styles/components/compose-form.scss
@@ -377,16 +377,6 @@
}
} // 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/components/datepicker.scss b/app/styles/components/datepicker.scss
new file mode 100644
index 000000000..b06a313a9
--- /dev/null
+++ b/app/styles/components/datepicker.scss
@@ -0,0 +1,123 @@
+.ui .react-datepicker {
+ box-shadow: 0 0 6px 0 rgb(0 0 0 / 30%);
+ font-size: 12px;
+ border: 0;
+ border-radius: 10px;
+ background-color: var(--foreground-color);
+ color: var(--primary-text-color);
+
+ &-wrapper {
+ margin-left: 10px;
+ background: var(--foreground-color);
+ border: 2px solid var(--brand-color);
+ }
+
+ &__current-month,
+ &-time__header,
+ &-year-header {
+ font-size: 14px;
+ color: var(--primary-text-color);
+ }
+
+ &__day--keyboard-selected,
+ &__month-text--keyboard-selected,
+ &__quarter-text--keyboard-selected,
+ &__year-text--keyboard-selected {
+ background-color: var(--brand-color);
+ color: white !important;
+ }
+
+ &__day,
+ &__day-name,
+ &__time-name {
+ width: 22px;
+ line-height: 21px;
+ color: var(--primary-text-color);
+ }
+
+ &__day,
+ &__month-text,
+ &__quarter-text,
+ &__year-text {
+ transition: 0.2s;
+
+ &:hover {
+ background-color: var(--background-color);
+ color: var(--primary-text-color) !important;
+ }
+
+ &--disabled {
+ color: hsla(var(--primary-text-color_hsl), 0.4);
+ }
+
+ &--selected {
+ background-color: var(--brand-color);
+ color: white;
+ }
+ }
+
+ &__day-names {
+ padding-top: 8px;
+ }
+
+ &__time {
+ background-color: var(--foreground-color);
+ }
+
+ &__header {
+ background-color: var(--background-color);
+ border: 0;
+ border-top-left-radius: 10px;
+ padding: 8px 0 14px;
+ }
+
+ &__triangle::before,
+ &__triangle::after {
+ border-bottom-color: var(--background-color) !important;
+ }
+
+ &__navigation-icon::before,
+ &__year-read-view--down-arrow,
+ &__month-read-view--down-arrow,
+ &__month-year-read-view--down-arrow {
+ border-color: hsla(var(--primary-text-color_hsl), 0.4);
+ transition: 0.2s;
+ }
+
+ &__navigation-icon:hover::before {
+ border-color: var(--primary-text-color--faint);
+ }
+
+ &__time-list-item {
+ display: flex !important;
+ align-items: center !important;
+ transition: 0.2s !important;
+
+ &:hover {
+ background-color: var(--background-color) !important;
+ color: var(--primary-text-color) !important;
+ }
+
+ &--disabled {
+ color: hsla(var(--primary-text-color_hsl), 0.4) !important;
+ }
+
+ &--selected {
+ background-color: var(--brand-color) !important;
+ color: white;
+ }
+ }
+
+ &__header:not(.react-datepicker__header--has-time-select) {
+ border-top-left-radius: 0;
+ border-top-right-radius: 10px;
+ }
+
+ &__month {
+ margin: 8px 14px 16px;
+ }
+
+ &__time-container {
+ border-color: var(--background-color);
+ }
+}
diff --git a/app/styles/components/detailed-status.scss b/app/styles/components/detailed-status.scss
index 5bf63db28..4cbaad0ed 100644
--- a/app/styles/components/detailed-status.scss
+++ b/app/styles/components/detailed-status.scss
@@ -73,7 +73,10 @@
.detailed-status__button {
padding: 10px 0;
+}
+.status__button,
+.detailed-status__button {
.icon-button {
display: inline-flex;
align-items: center;