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;