Add scheduled statuses column

This commit is contained in:
noellabo 2023-01-09 01:07:46 +09:00
parent 5e92944842
commit 304af2abcb
56 changed files with 1053 additions and 123 deletions

View file

@ -90,6 +90,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_post_reference_modal,
:setting_add_reference_modal,
:setting_unselect_reference_modal,
:setting_delete_scheduled_status_modal,
:setting_enable_empty_column,
:setting_content_font_size,
:setting_info_font_size,

View file

@ -12,9 +12,10 @@ import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { openModal } from './modal';
import { defineMessages } from 'react-intl';
import { addYears, addMonths, addDays, addHours, addMinutes, addSeconds, millisecondsToSeconds, set, parseISO, formatISO, format } from 'date-fns';
import { addYears, addMonths, addDays, addHours, addMinutes, addSeconds, millisecondsToSeconds, set, formatISO, format } from 'date-fns';
import { Set as ImmutableSet } from 'immutable';
import { postReferenceModal, enableFederatedTimeline } from '../initial_state';
import { deleteScheduledStatus } from './scheduled_statuses';
let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
@ -35,6 +36,10 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
export const COMPOSE_SCHEDULED_EDIT_CANCEL = 'COMPOSE_SCHEDULED_EDIT_CANCEL';
export const SCHEDULED_STATUS_SUBMIT_SUCCESS = 'SCHEDULED_STATUS_SUBMIT_SUCCESS';
export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST';
export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS';
export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL';
@ -182,6 +187,12 @@ export function directCompose(account, routerHistory) {
};
};
export function cancelScheduledStatusCompose() {
return {
type: COMPOSE_SCHEDULED_EDIT_CANCEL,
};
};
const parseSimpleDurationFormat = (value, origin = new Date()) => {
if (!value || typeof value !== 'string') {
return null;
@ -205,7 +216,7 @@ export const getDateTimeFromText = (value, origin = new Date()) => {
}
if (value.length >= 7) {
const isoDateTime = parseISO(value);
const isoDateTime = new Date(value);
if (isoDateTime.toString() === 'Invalid Date') {
return null;
@ -255,6 +266,7 @@ export function submitCompose(routerHistory) {
const { in: expires_in = null, at: expires_at = null } = getDateTimeFromText(getState().getIn(['compose', 'expires']), scheduled_at ?? new Date());
const expires_action = getState().getIn(['compose', 'expires_action']);
const statusReferenceIds = getState().getIn(['compose', 'references']);
const scheduled_status_id = getState().getIn(['compose', 'scheduled_status_id']);
if ((!status || !status.length) && media.size === 0) {
return;
@ -285,7 +297,11 @@ export function submitCompose(routerHistory) {
},
}).then(function (response) {
if (response.data.scheduled_at !== null && response.data.scheduled_at !== undefined) {
dispatch(submitComposeSuccess({ ...response.data }));
dispatch(submitScheduledStatusSuccess({ ...response.data }));
if (scheduled_status_id) {
dispatch(deleteScheduledStatus(scheduled_status_id));
}
routerHistory.push('/scheduled_statuses');
return;
} else if (routerHistory && routerHistory.location.pathname === '/statuses/new' && window.history.state) {
routerHistory.goBack();
@ -348,6 +364,13 @@ export function submitComposeFail(error) {
};
};
export function submitScheduledStatusSuccess(status) {
return {
type: SCHEDULED_STATUS_SUBMIT_SUCCESS,
scheduled_status: status,
};
};
export function uploadCompose(files) {
return function (dispatch, getState) {
const uploadLimit = 4;
@ -898,7 +921,7 @@ export function removeReference(id) {
};
};
export function resetReference() {
export function resetReference() {
return {
type: COMPOSE_REFERENCE_RESET,
};

View file

@ -66,7 +66,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
let filtered = false;
if (['mention', 'status', 'status_reference'].includes(notification.type)) {
if (['mention', 'status', 'scheduled_status', 'status_reference'].includes(notification.type)) {
const dropRegex = filters[0];
const regex = filters[1];
const searchIndex = searchTextFromRawStatus(notification.status);

View file

@ -0,0 +1,147 @@
import api, { getLinks } from '../api';
import { Map as ImmutableMap } from 'immutable';
import { ensureComposeIsVisible } from './compose';
import { fetchStatuses, redraft } from './statuses';
import { uniqCompact } from '../utils/uniq';
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_DELETE_REQUEST = 'SCHEDULED_STATUS_DELETE_REQUEST';
export const SCHEDULED_STATUS_DELETE_SUCCESS = 'SCHEDULED_STATUS_DELETE_SUCCESS';
export const SCHEDULED_STATUS_DELETE_FAIL = 'SCHEDULED_STATUS_DELETE_FAIL';
export function fetchScheduledStatuses() {
return (dispatch, getState) => {
if (getState().getIn(['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 fetchScheduledStatusesRequest() {
return {
type: SCHEDULED_STATUSES_FETCH_REQUEST,
};
};
export function fetchScheduledStatusesSuccess(scheduled_statuses, next) {
return {
type: SCHEDULED_STATUSES_FETCH_SUCCESS,
scheduled_statuses,
next,
};
};
export function fetchScheduledStatusesFail(error) {
return {
type: SCHEDULED_STATUSES_FETCH_FAIL,
error,
};
};
export function expandScheduledStatuses() {
return (dispatch, getState) => {
const url = getState().getIn(['scheduled_statuses', 'next'], null);
if (url === null || getState().getIn(['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(scheduled_statuses, next) {
return {
type: SCHEDULED_STATUSES_EXPAND_SUCCESS,
scheduled_statuses,
next,
};
};
export function expandScheduledStatusesFail(error) {
return {
type: SCHEDULED_STATUSES_EXPAND_FAIL,
error,
};
};
export function deleteScheduledStatus(id) {
return (dispatch, getState) => {
dispatch(deleteScheduledStatusRequest(id));
api(getState).delete(`/api/v1/scheduled_statuses/${id}`).then(() => {
dispatch(deleteScheduledStatusSuccess(id));
}).catch(error => {
dispatch(deleteScheduledStatusFail(id, error));
});
};
};
export function deleteScheduledStatusRequest(id) {
return {
type: SCHEDULED_STATUS_DELETE_REQUEST,
id: id,
};
};
export function deleteScheduledStatusSuccess(id) {
return {
type: SCHEDULED_STATUS_DELETE_SUCCESS,
id: id,
};
};
export function deleteScheduledStatusFail(id, error) {
return {
type: SCHEDULED_STATUS_DELETE_FAIL,
id: id,
error: error,
};
};
export function redraftScheduledStatus(scheduled_status, routerHistory) {
return (dispatch, getState) => {
const status = ImmutableMap({
...scheduled_status.get('params').toObject(),
...scheduled_status.delete('params').toObject(),
scheduled_status_id: scheduled_status.get('id'),
quote: scheduled_status.getIn(['params', 'quote_id']) ? ImmutableMap({ id: scheduled_status.getIn(['params', 'quote_id']), url: '' }) : null,
});
dispatch(fetchStatuses(uniqCompact([status.get('in_reply_to_id'), status.getIn(['quote', 'id']), ...status.get('status_reference_ids').toArray()])));
const replyStatus = getState().getIn(['statuses', status.get('in_reply_to_id')], null)?.update('account', account => getState().getIn(['accounts', account])) || null;
dispatch(redraft(getState, status, replyStatus, status.get('text')));
ensureComposeIsVisible(getState, routerHistory);
};
};

View file

@ -112,7 +112,7 @@ export function fetchStatuses(ids) {
dispatch(fetchAccountsFromStatuses(statuses));
dispatch(fetchStatusesSuccess());
}).catch(error => {
dispatch(fetchStatusesFail(id, error));
dispatch(fetchStatusesFail(newStatusIds, error));
});
};
};
@ -123,10 +123,10 @@ export function fetchStatusesSuccess() {
};
};
export function fetchStatusesFail(id, error) {
export function fetchStatusesFail(ids, error) {
return {
type: STATUSES_FETCH_FAIL,
id,
ids,
error,
skipAlert: true,
};
@ -144,12 +144,8 @@ export function redraft(getState, status, replyStatus, raw_text) {
export function deleteStatus(id, routerHistory, withRedraft = false) {
return (dispatch, getState) => {
let status = getState().getIn(['statuses', id]);
const replyStatus = status.get('in_reply_to_id') ? getState().getIn(['statuses', status.get('in_reply_to_id')]) : null;
if (status.get('poll')) {
status = status.set('poll', getState().getIn(['polls', status.get('poll')]));
}
const status = getState().getIn(['statuses', id]).update('poll', poll => poll ? getState().getIn(['polls', poll]) : null);
const replyStatus = status.get('in_reply_to_id') ? getState().getIn(['statuses', status.get('in_reply_to_id')]).update('account', account => getState().getIn(['accounts', account])) : null;
dispatch(deleteStatusRequest(id));

View file

@ -22,6 +22,7 @@ import {
} from './announcements';
import { fetchFilters } from './filters';
import { getLocale } from '../locales';
import { deleteScheduledStatusSuccess } from './scheduled_statuses';
const { messages } = getLocale();
@ -85,6 +86,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
case 'expire':
dispatch(expireFromTimelines(data.payload));
break;
case 'scheduled_status':
dispatch(deleteScheduledStatusSuccess(data.payload));
break;
case 'notification':
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
break;

View file

@ -711,9 +711,9 @@ class Status extends ImmutablePureComponent {
);
}
const expires_at = status.get('expires_at')
const expires_date = expires_at && new Date(expires_at)
const expired = expires_date && expires_date.getTime() < intl.now()
const expires_at = status.get('expires_at');
const expires_date = expires_at && new Date(expires_at);
const expired = expires_date && expires_date.getTime() < intl.now();
const ancestorCount = showThread && show_reply_tree_button && status.get('in_reply_to_id', 0) > 0 ? 1 : 0;
const descendantCount = showThread && show_reply_tree_button ? status.get('replies_count', 0) : 0;
@ -732,30 +732,34 @@ class Status extends ImmutablePureComponent {
threadMarkTitle = intl.formatMessage(messages.mark_descendant);
}
const threadMark = threadCount > 0 ? <span className={classNames('status__thread_mark', {
'status__thread_mark-ancenstor': (ancestorCount + referenceCount) > 0,
'status__thread_mark-descendant': descendantCount > 0,
})} title={threadMarkTitle}>+</span> : null;
const threadMark = threadCount > 0 ? (<span
className={classNames('status__thread_mark', {
'status__thread_mark-ancenstor': (ancestorCount + referenceCount) > 0,
'status__thread_mark-descendant': descendantCount > 0,
})} title={threadMarkTitle}
>+</span>) : null;
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, {
'status__wrapper-reply': !!status.get('in_reply_to_id'),
unread,
focusable: !this.props.muted,
'status__wrapper-with-expiration': expires_date,
'status__wrapper-expired': expired,
'status__wrapper-referenced': referenced,
'status__wrapper-context-referenced': contextReferenced,
'status__wrapper-reference': referenceCount > 0,
})} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
<div
className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, {
'status__wrapper-reply': !!status.get('in_reply_to_id'),
unread,
focusable: !this.props.muted,
'status__wrapper-with-expiration': expires_date,
'status__wrapper-expired': expired,
'status__wrapper-referenced': referenced,
'status__wrapper-context-referenced': contextReferenced,
'status__wrapper-reference': referenceCount > 0,
})} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}
>
{prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, 'status-with-expiration': expires_date, 'status-expired': expired, referenced, 'context-referenced': contextReferenced })} data-id={status.get('id')}>
<AccountActionBar account={status.get('account')} {...other} />
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'>
{status.get('expires_at') && <span className='status__expiration-time'><time dateTime={expires_at} title={intl.formatDate(expires_date, dateFormatOptions)}><i className="fa fa-clock-o" aria-hidden="true"></i></time></span>}
{status.get('expires_at') && <span className='status__expiration-time'><time dateTime={expires_at} title={intl.formatDate(expires_date, dateFormatOptions)}><i className='fa fa-clock-o' aria-hidden='true' /></time></span>}
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
{threadMark}
{disableRelativeTime ? <AbsoluteTimestamp timestamp={status.get('created_at')} /> : <RelativeTimestamp timestamp={status.get('created_at')} /> }
@ -776,7 +780,7 @@ class Status extends ImmutablePureComponent {
{quote}
{media}
{enableReaction && (contextType == 'thread' || !compactReaction) && <EmojiReactionsBar
{enableReaction && (contextType === 'thread' || !compactReaction) && <EmojiReactionsBar
status={status}
addEmojiReaction={this.props.addEmojiReaction}
removeEmojiReaction={this.props.removeEmojiReaction}

View file

@ -84,8 +84,8 @@ class StatusItem extends ImmutablePureComponent {
}
return (
<div className={classNames('mini-status__wrapper', `mini-status__wrapper-${status.get('visibility')}`, { 'mini-status__wrapper-reply': !!status.get('in_reply_to_id') })} tabIndex={0} ref={this.handleRef}>
<div className={classNames('mini-status', `mini-status-${status.get('visibility')}`, { 'mini-status-reply': !!status.get('in_reply_to_id') })} onClick={this.handleClick} data-id={status.get('id')}>
<div className={classNames('mini-status__wrapper', `mini-status__wrapper-${status.get('visibility')}`, { 'mini-status__wrapper-reply': !!status.get('in_reply_to_id') })} ref={this.handleRef}>
<div className={classNames('mini-status', `mini-status-${status.get('visibility')}`, { 'mini-status-reply': !!status.get('in_reply_to_id') })} onClick={this.handleClick} role='button' tabIndex={0} data-id={status.get('id')}>
<div className='mini-status__account'>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} data-group={status.getIn(['account', 'group'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='mini-status__avatar'>
@ -95,7 +95,7 @@ class StatusItem extends ImmutablePureComponent {
</div>
<div className='mini-status__content'>
<div className='mini-status__content__text translate' dangerouslySetInnerHTML={{__html: status.get('shortHtml')}} />
<div className='mini-status__content__text translate' dangerouslySetInnerHTML={{ __html: status.get('shortHtml') }} />
<Bundle fetchComponent={ThumbnailGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component

View file

@ -102,7 +102,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
if (state.getIn(['compose', 'text']).trim().length !== 0 && state.getIn(['compose', 'dirty'])) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
@ -134,7 +134,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
if (state.getIn(['compose', 'text']).trim().length !== 0 && state.getIn(['compose', 'dirty'])) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.quoteMessage),
confirm: intl.formatMessage(messages.quoteConfirm),

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { Fragment } from 'react';
import CharacterCounter from './character_counter';
import Button from '../../../components/button';
import ImmutablePropTypes from 'react-immutable-proptypes';
@ -36,6 +36,9 @@ const messages = defineMessages({
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
update: { id: 'compose_form.update_scheduled_status', defaultMessage: 'Update scheduled post' },
delete: { id: 'compose_form.delete_scheduled_status', defaultMessage: 'Delete scheduled post' },
and: { id: 'compose_form.and', defaultMessage: ' + ' },
});
export default @injectIntl
@ -61,6 +64,8 @@ class ComposeForm extends ImmutablePureComponent {
isCircleUnselected: PropTypes.bool,
prohibitedVisibilities: ImmutablePropTypes.set,
prohibitedWords: ImmutablePropTypes.set,
isScheduled: PropTypes.bool,
isScheduledStatusEditting: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired,
@ -217,6 +222,14 @@ class ComposeForm extends ImmutablePureComponent {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
if (this.props.isScheduledStatusEditting) {
if (this.props.isScheduled ) {
publishText = <span className='compose-form__update'>{intl.formatMessage(messages.update)}</span>;
} else {
publishText = <Fragment><span className='compose-form__delete'>{intl.formatMessage(messages.delete)}</span>{intl.formatMessage(messages.and)}{publishText}</Fragment>;
}
}
return (
<div className='compose-form'>
<WarningContainer />

View file

@ -38,7 +38,7 @@ const mapStateToProps = (state, { value, origin, minDate, maxDate }) => {
dateValue: dateValue,
stringValue: stringValue,
invalid: invalid,
}
};
};
export default @connect(mapStateToProps)

View file

@ -21,6 +21,7 @@ class QuoteIndicator extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
isScheduledStatusEditting: PropTypes.bool,
onCancel: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -37,7 +38,7 @@ class QuoteIndicator extends ImmutablePureComponent {
}
render () {
const { status, intl } = this.props;
const { status, isScheduledStatusEditting, intl } = this.props;
if (!status) {
return null;
@ -48,7 +49,7 @@ class QuoteIndicator extends ImmutablePureComponent {
return (
<div className='quote-indicator'>
<div className='quote-indicator__header'>
<div className='quote-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
{!isScheduledStatusEditting && <div className='quote-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>}
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='quote-indicator__display-name'>
<div className='quote-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>

View file

@ -21,6 +21,7 @@ class ReplyIndicator extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
isScheduledStatusEditting: PropTypes.bool,
onCancel: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -42,7 +43,7 @@ class ReplyIndicator extends ImmutablePureComponent {
}
render () {
const { status, intl } = this.props;
const { status, isScheduledStatusEditting, intl } = this.props;
if (!status) {
return null;
@ -53,7 +54,7 @@ class ReplyIndicator extends ImmutablePureComponent {
return (
<div className='reply-indicator'>
<div className='reply-indicator__header'>
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} inverted /></div>
{!isScheduledStatusEditting && <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} inverted /></div>}
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>

View file

@ -29,6 +29,8 @@ const mapStateToProps = state => ({
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
prohibitedVisibilities: state.getIn(['compose', 'prohibited_visibilities']),
prohibitedWords: state.getIn(['compose', 'prohibited_words']),
isScheduled: !!state.getIn(['compose', 'scheduled']),
isScheduledStatusEditting: !!state.getIn(['compose', 'scheduled_status_id']),
});
const mapDispatchToProps = (dispatch, { intl }) => ({

View file

@ -8,6 +8,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = state => ({
status: getStatus(state, { id: state.getIn(['compose', 'quote_from']) }),
isScheduledStatusEditting: !!state.getIn(['compose', 'scheduled_status_id']),
});
return mapStateToProps;

View file

@ -8,6 +8,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = state => ({
status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }),
isScheduledStatusEditting: !!state.getIn(['compose', 'scheduled_status_id']),
});
return mapStateToProps;

View file

@ -1,9 +1,12 @@
import React from 'react';
import React, { Fragment } from 'react';
import { connect } from 'react-redux';
import Warning from '../components/warning';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { me } from '../../../initial_state';
import { cancelScheduledStatusCompose } from '../../../actions/compose';
import Icon from 'mastodon/components/icon';
import IconButton from 'mastodon/components/icon_button';
const buildHashtagRE = () => {
try {
@ -37,9 +40,38 @@ const mapStateToProps = state => ({
limitedMessageWarning: state.getIn(['compose', 'privacy']) === 'limited',
mutualMessageWarning: state.getIn(['compose', 'privacy']) === 'mutual',
personalMessageWarning: state.getIn(['compose', 'privacy']) === 'personal',
isScheduledStatusEditting: !!state.getIn(['compose', 'scheduled_status_id']),
});
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, limitedMessageWarning, mutualMessageWarning, personalMessageWarning }) => {
const mapDispatchToProps = dispatch => ({
onCancel () {
dispatch(cancelScheduledStatusCompose());
},
});
const ScheduledStatusWarningWrapper = ({ isScheduledStatusEditting, onCancel }) => {
if (!isScheduledStatusEditting) {
return null;
}
return (
<div className='scheduled-status-warning-indicator'>
<div className='scheduled-status-warning-indicator__cancel'><IconButton title='Cancel' icon='times' onClick={onCancel} inverted /></div>
<div className='scheduled-status-warning-indicator__content translate'>
<Icon id='clock-o' fixedWidth /><FormattedMessage id='compose_form.scheduled_status_warning' defaultMessage='Scheduled post editing in progress.' />
</div>
</div>
);
};
ScheduledStatusWarningWrapper.propTypes = {
isScheduledStatusEditting: PropTypes.bool,
onCancel: PropTypes.func.isRequired,
};
const PrivacyWarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, limitedMessageWarning, mutualMessageWarning, personalMessageWarning }) => {
if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
}
@ -73,7 +105,7 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
return null;
};
WarningWrapper.propTypes = {
PrivacyWarningWrapper.propTypes = {
needsLockWarning: PropTypes.bool,
hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool,
@ -82,4 +114,24 @@ WarningWrapper.propTypes = {
personalMessageWarning: PropTypes.bool,
};
export default connect(mapStateToProps)(WarningWrapper);
const WarningWrapper = (props) => {
return (
<Fragment>
<ScheduledStatusWarningWrapper {...props} />
<PrivacyWarningWrapper {...props} />
</Fragment>
);
};
WarningWrapper.propTypes = {
needsLockWarning: PropTypes.bool,
hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool,
limitedMessageWarning: PropTypes.bool,
mutualMessageWarning: PropTypes.bool,
personalMessageWarning: PropTypes.bool,
isScheduledStatusEditting: PropTypes.bool,
onCancel: PropTypes.func.isRequired,
};
export default connect(mapStateToProps, mapDispatchToProps)(WarningWrapper);

View file

@ -37,7 +37,7 @@ const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
if (state.getIn(['compose', 'text']).trim().length !== 0 && state.getIn(['compose', 'dirty'])) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),

View file

@ -11,6 +11,7 @@ import { me, profile_directory, showTrends, enableLimitedTimeline, enablePersona
import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { fetchFavouriteDomains } from 'mastodon/actions/favourite_domains';
import { fetchFavouriteTags } from 'mastodon/actions/favourite_tags';
import { fetchScheduledStatuses } from 'mastodon/actions/scheduled_statuses';
import { List as ImmutableList } from 'immutable';
import NavigationContainer from '../compose/containers/navigation_container';
import Icon from 'mastodon/components/icon';
@ -31,6 +32,7 @@ const messages = defineMessages({
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
scheduled_statuses: { id: 'navigation_bar.scheduled_statuses', defaultMessage: 'Scheduled Posts' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
emoji_reactions: { id: 'navigation_bar.emoji_reactions', defaultMessage: 'Emoji reactions' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
@ -60,6 +62,7 @@ const mapStateToProps = state => ({
myAccount: state.getIn(['accounts', me]),
columns: state.getIn(['settings', 'columns']),
unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
scheduledStatusesCount: state.getIn(['scheduled_statuses', 'items'], ImmutableList()).size,
lists: getOrderedLists(state),
favourite_domains: getOrderedDomains(state),
favourite_tags: getOrderedTags(state),
@ -70,6 +73,7 @@ const mapDispatchToProps = dispatch => ({
fetchFollowRequests: () => dispatch(fetchFollowRequests()),
fetchFavouriteDomains: () => dispatch(fetchFavouriteDomains()),
fetchFavouriteTags: () => dispatch(fetchFavouriteTags()),
fetchScheduledStatuses: () => dispatch(fetchScheduledStatuses()),
});
const badgeDisplay = (number, limit) => {
@ -101,7 +105,9 @@ class GettingStarted extends ImmutablePureComponent {
fetchFollowRequests: PropTypes.func.isRequired,
fetchFavouriteDomains: PropTypes.func.isRequired,
fetchFavouriteTags: PropTypes.func.isRequired,
fetchScheduledStatuses: PropTypes.func.isRequired,
unreadFollowRequests: PropTypes.number,
scheduledStatusesCount: PropTypes.number,
unreadNotifications: PropTypes.number,
lists: ImmutablePropTypes.list,
favourite_domains: ImmutablePropTypes.list,
@ -109,7 +115,7 @@ class GettingStarted extends ImmutablePureComponent {
};
componentDidMount () {
const { fetchFollowRequests, fetchFavouriteDomains, fetchFavouriteTags, multiColumn } = this.props;
const { fetchFollowRequests, fetchFavouriteDomains, fetchFavouriteTags, fetchScheduledStatuses, multiColumn } = this.props;
if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
this.context.router.history.replace('/timelines/home');
@ -119,10 +125,11 @@ class GettingStarted extends ImmutablePureComponent {
fetchFollowRequests();
fetchFavouriteDomains();
fetchFavouriteTags();
fetchScheduledStatuses();
}
render () {
const { intl, myAccount, columns, multiColumn, unreadFollowRequests, lists, favourite_domains, favourite_tags, columnWidth } = this.props;
const { intl, myAccount, columns, multiColumn, unreadFollowRequests, scheduledStatusesCount, lists, favourite_domains, favourite_tags, columnWidth } = this.props;
const navItems = [];
let height = (multiColumn) ? 0 : 60;
@ -245,14 +252,24 @@ class GettingStarted extends ImmutablePureComponent {
height += 48*6;
if (myAccount.get('locked') || unreadFollowRequests > 0) {
navItems.push(<ColumnLink key='follow_requests' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
height += 48;
}
if (scheduledStatusesCount > 0) {
navItems.push(<ColumnLink key='scheduled_statuses' icon='clock-o' text={intl.formatMessage(messages.scheduled_statuses)} to='/scheduled_statuses' />);
height += 48;
}
if (lists && !lists.isEmpty()) {
navItems.push(<ColumnSubheading key='header-lists' text={intl.formatMessage(messages.lists_subheading)} />);
height += 34;
lists.map(list => {
navItems.push(<ColumnLink key={`list:${list.get('id')}`} icon='list-ul' text={list.get('title')} to={`/timelines/list/${list.get('id')}`} />);
height += 48
})
height += 48;
});
}
if (favourite_domains && !favourite_domains.isEmpty()) {
@ -261,8 +278,8 @@ class GettingStarted extends ImmutablePureComponent {
favourite_domains.map(favourite_domain => {
navItems.push(<ColumnLink key={`favourite_domain:${favourite_domain.get('id')}`} icon='users' text={favourite_domain.get('name')} to={`/timelines/public/domain/${favourite_domain.get('name')}`} />);
height += 48
})
height += 48;
});
}
if (favourite_tags && !favourite_tags.isEmpty()) {
@ -271,13 +288,8 @@ class GettingStarted extends ImmutablePureComponent {
favourite_tags.map(favourite_tag => {
navItems.push(<ColumnLink key={`favourite_tag:${favourite_tag.get('id')}`} icon='hashtag' text={favourite_tag.get('name')} to={`/timelines/tag/${favourite_tag.get('name')}`} />);
height += 48
})
}
if (myAccount.get('locked') || unreadFollowRequests > 0) {
navItems.push(<ColumnLink key='follow_requests' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
height += 48;
height += 48;
});
}
if (!multiColumn) {

View file

@ -178,6 +178,16 @@ export default class ColumnSettings extends React.PureComponent {
</div>
</div>
}
<div role='group' aria-labelledby='notifications-scheduled-status'>
<span id='notifications-scheduled-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.scheduled_statuses' defaultMessage='Scheduled posts:' /></span>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'scheduled_status']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'scheduled_status']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'scheduled_status']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'scheduled_status']} onChange={onChange} label={soundStr} />
</div>
</div>
</div>
);
}

View file

@ -13,6 +13,7 @@ const tooltips = defineMessages({
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
reactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Reactions' },
reference: { id: 'notifications.filter.status_references', defaultMessage: 'Status references' },
scheduled_statuses: { id: 'notifications.filter.scheduled_statuses', defaultMessage: 'Scheduled statuses' },
});
export default @injectIntl
@ -98,6 +99,13 @@ class FilterBar extends React.PureComponent {
>
<Icon id='home' fixedWidth />
</button>
<button
className={selectedFilter === 'scheduled_status' ? 'active' : ''}
onClick={this.onClick('scheduled_status')}
title={intl.formatMessage(tooltips.scheduled_statuses)}
>
<Icon id='clock-o' fixedWidth />
</button>
{enableReaction &&
<button
className={selectedFilter === 'emoji_reaction' ? 'active' : ''}

View file

@ -22,6 +22,7 @@ const messages = defineMessages({
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
emoji_reaction: { id: 'notification.emoji_reaction', defaultMessage: '{name} reactioned your post' },
status_reference: { id: 'notification.status_reference', defaultMessage: '{name} referenced your post' },
scheduled_status: { id: 'notification.scheduled_status', defaultMessage: 'Your scheduled post has been posted' },
});
const notificationForScreenReader = (intl, message, timestamp) => {
@ -277,6 +278,38 @@ class Notification extends ImmutablePureComponent {
);
}
renderScheduledStatus (notification) {
const { intl, unread } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-scheduled-status focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.scheduled_status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='clock-o' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.scheduled_status' defaultMessage='Your scheduled post has been posted' />
</span>
</div>
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div>
</HotKeys>
);
}
renderPoll (notification, account) {
const { intl, unread } = this.props;
const ownPoll = me === account.get('id');
@ -402,6 +435,8 @@ class Notification extends ImmutablePureComponent {
return this.renderReblog(notification, link);
case 'status':
return this.renderStatus(notification, link);
case 'scheduled_status':
return this.renderScheduledStatus(notification, link);
case 'poll':
return this.renderPoll(notification, account);
case 'emoji_reaction':

View file

@ -1,4 +1,4 @@
import React, {Fragment} from 'react';
import React, { Fragment } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
@ -20,20 +20,12 @@ const messages = defineMessages({
clearConfirm: { id: 'confirmations.clear.confirm', defaultMessage: 'Clear' },
});
const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => {
const statusIds = state.getIn(['compose', 'references']).toList().sort((a, b) => b - a);
return {
statusIds,
};
};
return mapStateToProps;
};
const mapStateToProps = (state) => ({
statusIds: state.getIn(['compose', 'references']).toList().sort((a, b) => b - a),
});
export default @injectIntl
@connect(makeMapStateToProps)
@connect(mapStateToProps)
class ReferenceStack extends ImmutablePureComponent {
static contextTypes = {
@ -68,7 +60,7 @@ class ReferenceStack extends ImmutablePureComponent {
const { statusIds, intl } = this.props;
if (!enableStatusReference || statusIds.isEmpty()) {
return <Fragment></Fragment>;
return <Fragment />;
}
const title = (

View file

@ -0,0 +1,107 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { ThumbnailGallery } from 'mastodon/features/ui/util/async-components';
import IconButton from 'mastodon/components/icon_button';
import { injectIntl, defineMessages } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
import Bundle from 'mastodon/features/ui/components/bundle';
const messages = defineMessages({
unselect: { id: 'reference_stack.unselect', defaultMessage: 'Unselecting a post' },
});
const dateFormatOptions = {
hour12: false,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
};
export default @injectIntl
class ScheduledStatus extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
scheduledStatus: ImmutablePropTypes.map,
onDelete: PropTypes.func,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
onDeleteScheduledStatus: PropTypes.func,
onRedraftScheduledStatus: PropTypes.func,
emojiMap: ImmutablePropTypes.map,
};
handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.scheduledStatus.get('id'));
}
handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.scheduledStatus.get('id'));
}
handleDeleteClick = (e) => {
const { scheduledStatus, onDeleteScheduledStatus } = this.props;
e.stopPropagation();
onDeleteScheduledStatus(scheduledStatus.get('id'), e);
}
handleClick = (e) => {
const { scheduledStatus, onRedraftScheduledStatus } = this.props;
e.stopPropagation();
onRedraftScheduledStatus(scheduledStatus, this.context.router.history, e);
}
handleRef = c => {
this.node = c;
}
render () {
const { intl, scheduledStatus } = this.props;
if (scheduledStatus === null) {
return null;
}
const handlers = {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
return (
<HotKeys handlers={handlers}>
<div className={classNames('mini-status__wrapper', `mini-status__wrapper-${scheduledStatus.getIn(['params', 'visibility'])}`, 'focusable', { 'mini-status__wrapper-reply': !!scheduledStatus.getIn(['params', 'in_reply_to_id']) })} role='button' tabIndex={0} onClick={this.handleClick} ref={this.handleRef}>
<div className={classNames('mini-status', `mini-status-${scheduledStatus.getIn(['params', 'visibility'])}`, { 'mini-status-reply': !!scheduledStatus.getIn(['params', 'in_reply_to_id']) })} data-id={scheduledStatus.get('id')}>
<div className='mini-status__content'>
<div className='mini-status__content__text translate' dangerouslySetInnerHTML={{ __html: scheduledStatus.getIn(['params', 'text']) }} />
<Bundle fetchComponent={ThumbnailGallery} loading={this.renderLoadingMediaGallery}>
{Component => <Component media={scheduledStatus.get('media_attachments')} />}
</Bundle>
<div className='mini-status__scheduled-time'>
<Icon id='clock-o' fixedWidth />
{intl.formatDate(scheduledStatus.get('scheduled_at'), dateFormatOptions)}
</div>
</div>
<div className='mini-status__unselect'><IconButton title={intl.formatMessage(messages.unselect)} icon='times' onClick={this.handleDeleteClick} /></div>
</div>
</div>
</HotKeys>
);
}
}

View file

@ -0,0 +1,98 @@
import { debounce } from 'lodash';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ScheduledStatusContainer from '../containers/scheduled_status_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import LoadGap from 'mastodon/components/load_gap';
import ScrollableList from 'mastodon/components/scrollable_list';
export default class ScheduledStatusList extends ImmutablePureComponent {
static propTypes = {
scrollKey: PropTypes.string.isRequired,
scheduledStatuses: ImmutablePropTypes.list.isRequired,
onLoadMore: PropTypes.func,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
trackScroll: PropTypes.bool,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
prepend: PropTypes.node,
emptyMessage: PropTypes.node,
alwaysPrepend: PropTypes.bool,
timelineId: PropTypes.string,
};
static defaultProps = {
trackScroll: true,
};
getCurrentStatusIndex = id => {
return this.props.scheduledStatuses.findIndex(v => v.get('id') === id);
}
handleMoveUp = id => {
const elementIndex = this.getCurrentStatusIndex(id) - 1;
this._selectChild(elementIndex, true);
}
handleMoveDown = id => {
const elementIndex = this.getCurrentStatusIndex(id) + 1;
this._selectChild(elementIndex, false);
}
handleLoadOlder = debounce(() => {
this.props.onLoadMore(this.props.scheduledStatuses.size > 0 ? this.props.scheduledStatuses.last().get('id') : undefined);
}, 300, { leading: true })
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}
setRef = c => {
this.node = c;
}
render () {
const { scheduledStatuses, onLoadMore, timelineId, ...other } = this.props;
const { isLoading } = other;
let scrollableContent = (isLoading || scheduledStatuses.size > 0) ? (
scheduledStatuses.map((scheduledStatus, index) => scheduledStatus === null ? (
<LoadGap
key={'gap:' + scheduledStatuses.getIn([index + 1, 'id'])}
disabled={isLoading}
maxId={index > 0 ? scheduledStatuses.get([index - 1, 'id']) : null}
onClick={onLoadMore}
/>
) : (
<ScheduledStatusContainer
key={scheduledStatus.get('id')}
scheduledStatus={scheduledStatus}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
scrollKey={this.props.scrollKey}
/>
))
) : null;
return (
<ScrollableList {...other} showLoading={isLoading && scheduledStatuses.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
{scrollableContent}
</ScrollableList>
);
}
}

View file

@ -0,0 +1,59 @@
import { connect } from 'react-redux';
import ScheduledStatus from '../components/scheduled_status';
import { deleteScheduledStatus, redraftScheduledStatus } from 'mastodon/actions/scheduled_statuses';
import { openModal } from 'mastodon/actions/modal';
import { defineMessages, injectIntl } from 'react-intl';
import { deleteScheduledStatusModal } from 'mastodon/initial_state';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
redraftConfirm: { id: 'confirmations.redraft_scheduled_status.confirm', defaultMessage: 'View & redraft' },
redraftMessage: { id: 'confirmations.redraft_scheduled_status.message', defaultMessage: 'Redraft now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
const makeMapStateToProps = () => {
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
const mapStateToProps = (state) => ({
emojiMap: customEmojiMap(state),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onDeleteScheduledStatus (id, e) {
if (e.shiftKey ^ !deleteScheduledStatusModal) {
dispatch(deleteScheduledStatus(id));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteScheduledStatus(id)),
}));
}
},
onRedraftScheduledStatus (scheduledStatus, history) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0 && state.getIn(['compose', 'dirty'])) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.redraftMessage),
confirm: intl.formatMessage(messages.redraftConfirm),
onConfirm: () => dispatch(redraftScheduledStatus(scheduledStatus, history)),
}));
} else {
dispatch(redraftScheduledStatus(scheduledStatus, history));
}
});
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(ScheduledStatus));

View file

@ -0,0 +1,132 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { fetchScheduledStatuses, expandScheduledStatuses } from '../../actions/scheduled_statuses';
import Column from '../ui/components/column';
import ColumnHeader from '../../components/column_header';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import ScheduledStatusList from './components/scheduled_status_list';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash';
import { defaultColumnWidth } from 'mastodon/initial_state';
import { changeSetting } from '../../actions/settings';
import { changeColumnParams } from '../../actions/columns';
const messages = defineMessages({
heading: { id: 'column.scheduled_statuses', defaultMessage: 'Scheduled Posts' },
});
const makeMapStateToProps = () => {
const mapStateToProps = (state, { columnId }) => {
const uuid = columnId;
const columns = state.getIn(['settings', 'columns']);
const index = columns.findIndex(c => c.get('uuid') === uuid);
const columnWidth = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'columnWidth']) : state.getIn(['settings', 'scheduled_statuses', 'columnWidth']);
return {
scheduledStatuses: state.getIn(['scheduled_statuses', 'items'], ImmutableList()),
isLoading: state.getIn(['scheduled_statuses', 'isLoading'], true),
hasMore: !!state.getIn(['scheduled_statuses', 'next']),
columnWidth: columnWidth ?? defaultColumnWidth,
};
};
return mapStateToProps;
};
export default @connect(makeMapStateToProps)
@injectIntl
class ScheduledStatus extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
scheduledStatuses: ImmutablePropTypes.list.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
columnWidth: PropTypes.string,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
componentWillMount () {
this.props.dispatch(fetchScheduledStatuses());
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('SCHEDULED_STATUS', {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
setRef = c => {
this.column = c;
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandScheduledStatuses());
}, 300, { leading: true })
handleWidthChange = (value) => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(changeColumnParams(columnId, 'columnWidth', value));
} else {
dispatch(changeSetting(['scheduled_statuses', 'columnWidth'], value));
}
}
render () {
const { intl, scheduledStatuses, columnId, multiColumn, hasMore, isLoading, columnWidth } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.scheduled_statuses' defaultMessage='There are no scheduled posts. Posts with a scheduled publication date and time will show up here.' />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)} columnWidth={columnWidth}>
<ColumnHeader
icon='clock-o'
title={intl.formatMessage(messages.heading)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
columnWidth={columnWidth}
onWidthChange={this.handleWidthChange}
showBackButton
/>
<ScheduledStatusList
trackScroll={!pinned}
scheduledStatuses={scheduledStatuses}
scrollKey={`scheduled_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
onDeleteScheduledStatus={this.handleDeleteScheduledStatus}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
</Column>
);
}
}

View file

@ -66,7 +66,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onReply (status, router) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
if (state.getIn(['compose', 'text']).trim().length !== 0 && state.getIn(['compose', 'dirty'])) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),

View file

@ -33,6 +33,7 @@ import {
Directory,
Trends,
Suggestions,
ScheduledStatuses,
} from '../../ui/util/async-components';
import Icon from 'mastodon/components/icon';
import ComposePanel from './compose_panel';
@ -65,6 +66,7 @@ const componentMap = {
'DIRECTORY': Directory,
'TRENDS': Trends,
'SUGGESTIONS': Suggestions,
'SCHEDULED_STATUS': ScheduledStatuses,
};
const messages = defineMessages({

View file

@ -5,6 +5,7 @@ import Icon from 'mastodon/components/icon';
import { profile_directory, showTrends, enableLimitedTimeline, enableFederatedTimeline, enableLocalTimeline, enablePersonalTimeline } from 'mastodon/initial_state';
import NotificationsCounterIcon from './notifications_counter_icon';
import FollowRequestsNavLink from './follow_requests_nav_link';
import ScheduledStatusesNavLink from './scheduled_statuses_nav_link';
import ListPanel from './list_panel';
import FavouriteDomainPanel from './favourite_domain_panel';
import FavouriteTagPanel from './favourite_tag_panel';
@ -17,6 +18,7 @@ const NavigationPanel = () => (
{enablePersonalTimeline && <NavLink className='column-link column-link--transparent' to='/timelines/personal' data-preview-title-id='column.personal' data-preview-icon='book' ><Icon className='column-link__icon' id='book' fixedWidth /><FormattedMessage id='navigation_bar.personal_timeline' defaultMessage='Personal' /></NavLink>}
<NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
<FollowRequestsNavLink />
<ScheduledStatusesNavLink />
{enableLocalTimeline && <NavLink className='column-link column-link--transparent' exact to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>}
{enableFederatedTimeline && <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>}
<NavLink className='column-link column-link--transparent' to='/accounts/2'><Icon className='column-link__icon' id='info-circle' fixedWidth /><FormattedMessage id='navigation_bar.information_acct' defaultMessage='Fedibird info' /></NavLink>

View file

@ -0,0 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
import { fetchScheduledStatuses } from 'mastodon/actions/scheduled_statuses';
import { connect } from 'react-redux';
import { NavLink, withRouter } from 'react-router-dom';
import Icon from 'mastodon/components/icon';
import { List as ImmutableList } from 'immutable';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({
count: state.getIn(['scheduled_statuses', 'items'], ImmutableList()).size,
});
export default @withRouter
@connect(mapStateToProps)
class ScheduledStatusesNavLink extends React.Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
count: PropTypes.number.isRequired,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchScheduledStatuses());
}
render () {
const { count } = this.props;
if (count === 0) {
return null;
}
return <NavLink className='column-link column-link--transparent' to='/scheduled_statuses'><Icon className='column-link__icon' id='clock-o' fixedWidth /><FormattedMessage id='navigation_bar.scheduled_statuses' defaultMessage='Scheduled Posts' /></NavLink>;
}
}

View file

@ -69,6 +69,7 @@ import {
Trends,
Suggestions,
EmptyColumn,
ScheduledStatuses,
} from './util/async-components';
import { me, enableEmptyColumn } from '../../initial_state';
import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
@ -225,6 +226,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
<WrappedRoute path='/circles' component={Circles} content={children} />
<WrappedRoute path='/scheduled_statuses' component={ScheduledStatuses} content={children} />
<WrappedRoute path='/empty' component={EmptyColumn} content={children} />

View file

@ -233,3 +233,7 @@ export function Suggestions () {
export function EmptyColumn () {
return import(/* webpackChunkName: "features/empty" */'../../empty');
}
export function ScheduledStatuses () {
return import(/* webpackChunkName: "features/scheduled_statuses" */'../../scheduled_statuses');
}

View file

@ -14,6 +14,7 @@ export const deleteModal = getMeta('delete_modal');
export const postReferenceModal = getMeta('post_reference_modal');
export const addReferenceModal = getMeta('add_reference_modal');
export const unselectReferenceModal = getMeta('unselect_reference_modal');
export const deleteScheduledStatusModal = getMeta('delete_scheduled_status_modal');
export const me = getMeta('me');
export const searchEnabled = getMeta('search_enabled');
export const invitesEnabled = getMeta('invites_enabled');

View file

@ -128,6 +128,7 @@
"column.personal": "Personal home",
"column.pins": "Pinned posts",
"column.public": "Federated timeline",
"column.scheduled_statuses": "Scheduled Posts",
"column_back_button.label": "Back",
"column_back_button_to_pots.label": "Back to post detail",
"column_close_button.label": "Close",
@ -151,6 +152,8 @@
"community.column_settings.remote_only": "Remote only",
"community.column_settings.without_bot": "Without bot",
"community.column_settings.without_media": "Without media",
"compose_form.and": " + ",
"compose_form.delete_scheduled_status": "Delete scheduled post",
"compose_form.direct_message_warning": "This post will only be sent to the mentioned users.",
"compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
@ -168,12 +171,14 @@
"compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
"compose_form.publish": "Toot",
"compose_form.publish_loud": "{publish}!",
"compose_form.scheduled_status_warning": "Scheduled post editing in progress.",
"compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}",
"compose_form.sensitive.marked": "{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}",
"compose_form.sensitive.unmarked": "{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}",
"compose_form.spoiler.marked": "Remove content warning",
"compose_form.spoiler.unmarked": "Add content warning",
"compose_form.spoiler_placeholder": "Write your warning here",
"compose_form.update_scheduled_status": "Update scheduled post",
"confirmation_modal.cancel": "Cancel",
"confirmations.block.block_and_report": "Block & Report",
"confirmations.block.confirm": "Block",
@ -201,6 +206,8 @@
"confirmations.quote.message": "Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.redraft.confirm": "Delete & redraft",
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
"confirmations.redraft_scheduled_status.confirm": "View & redraft",
"confirmations.redraft_scheduled_status.message": "Redraft now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.reply.confirm": "Reply",
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.unfollow.confirm": "Unfollow",
@ -268,6 +275,7 @@
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven't muted any users yet.",
"empty_column.notifications": "You don't have any notifications yet. When other people interact with you, you will see it here.",
"empty_column.scheduled_statuses": "There are no scheduled posts. Posts with a scheduled publication date and time will show up here.",
"empty_column.personal": "Personal posts unavailable",
"empty_column.pinned_unavailable": "Pinned posts unavailable",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
@ -428,6 +436,7 @@
"navigation_bar.pins": "Pinned posts",
"navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.scheduled_statuses": "Scheduled Posts",
"navigation_bar.security": "Security",
"navigation_bar.short.community_timeline": "LTL",
"navigation_bar.short.getting_started": "Started",
@ -450,6 +459,7 @@
"notification.poll": "A poll you have voted in has ended",
"notification.emoji_reaction": "{name} reactioned your post",
"notification.reblog": "{name} boosted your post",
"notification.scheduled_status": "Your scheduled post has been posted",
"notification.status": "{name} just posted",
"notification.status_reference": "{name} referenced your post",
"notifications.clear": "Clear notifications",
@ -466,6 +476,7 @@
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.emoji_reaction": "Reactions:",
"notifications.column_settings.reblog": "Boosts:",
"notifications.column_settings.scheduled_statuses": "Scheduled posts:",
"notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound",
"notifications.column_settings.status": "New posts:",
@ -478,6 +489,7 @@
"notifications.filter.mentions": "Mentions",
"notifications.filter.polls": "Poll results",
"notifications.filter.emoji_reactions": "Reactions",
"notifications.filter.scheduled_statuses": "Scheduled posts",
"notifications.filter.statuses": "Updates from people you follow",
"notifications.filter.status_references": "Status references",
"notifications.grant_permission": "Grant permission.",
@ -536,6 +548,7 @@
"report.placeholder": "Additional comments",
"report.submit": "Submit",
"report.target": "Reporting {target}",
"scheduled-status-warning-indicator.cancel": "Cancel",
"search.placeholder": "Search",
"searchability.change": "Adjust status searchability",
"searchability.direct.short": "Reacted-users-only (Search)",

View file

@ -128,6 +128,7 @@
"column.personal": "自分限定ホーム",
"column.pins": "固定された投稿",
"column.public": "連合タイムライン",
"column.scheduled_statuses": "予約投稿",
"column_back_button.label": "戻る",
"column_back_button_to_pots.label": "投稿の詳細に戻る",
"column_close_button.label": "閉じる",
@ -151,6 +152,8 @@
"community.column_settings.remote_only": "リモートのみ表示",
"community.column_settings.without_bot": "Botを除外",
"community.column_settings.without_media": "メディアを除外",
"compose_form.and": " + ",
"compose_form.delete_scheduled_status": "予約投稿の削除",
"compose_form.direct_message_warning": "この投稿はメンションされた人にのみ送信されます。",
"compose_form.direct_message_warning_learn_more": "もっと詳しく",
"compose_form.hashtag_warning": "この投稿は公開設定ではないのでハッシュタグの一覧に表示されません。公開投稿だけがハッシュタグで検索できます。",
@ -168,12 +171,14 @@
"compose_form.poll.switch_to_single": "単一選択に変更",
"compose_form.publish": "トゥート",
"compose_form.publish_loud": "{publish}",
"compose_form.scheduled_status_warning": "予約投稿の編集中",
"compose_form.sensitive.hide": "メディアを閲覧注意にする",
"compose_form.sensitive.marked": "メディアに閲覧注意が設定されています",
"compose_form.sensitive.unmarked": "メディアに閲覧注意が設定されていません",
"compose_form.spoiler.marked": "本文は警告の後ろに隠されます",
"compose_form.spoiler.unmarked": "本文は隠されていません",
"compose_form.spoiler_placeholder": "ここに警告を書いてください",
"compose_form.update_scheduled_status": "予約投稿の更新",
"confirmation_modal.cancel": "キャンセル",
"confirmations.block.block_and_report": "ブロックし通報",
"confirmations.block.confirm": "ブロック",
@ -201,6 +206,8 @@
"confirmations.quote.message": "今引用すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
"confirmations.redraft.confirm": "削除して下書きに戻す",
"confirmations.redraft.message": "本当にこの投稿を削除して下書きに戻しますか? この投稿へのお気に入り登録やブーストは失われ、返信は孤立することになります。",
"confirmations.redraft_scheduled_status.confirm": "閲覧・下書きに戻す",
"confirmations.redraft_scheduled_status.message": "今下書きに戻すと現在作成中のメッセージが上書きされます。本当に実行しますか?",
"confirmations.reply.confirm": "返信",
"confirmations.reply.message": "今返信すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
"confirmations.unfollow.confirm": "フォロー解除",
@ -271,6 +278,7 @@
"empty_column.personal": "自分限定投稿はありません。",
"empty_column.pinned_unavailable": "固定された投稿はありません。",
"empty_column.referred_by_statuses": "まだ、参照している投稿はありません。誰かが投稿を参照すると、ここに表示されます。",
"empty_column.scheduled_statuses": "予約されている投稿はありません。公開日時を指定した投稿がここに表示されます。",
"empty_column.suggestions": "まだおすすめできるユーザーがいません。",
"empty_column.trends": "まだ何もトレンドがありません。",
"empty_column.public": "ここにはまだ何もありません! 公開で何かを投稿したり、他のサーバーのユーザーをフォローしたりしていっぱいにしましょう",
@ -428,6 +436,7 @@
"navigation_bar.pins": "固定した投稿",
"navigation_bar.preferences": "ユーザー設定",
"navigation_bar.public_timeline": "連合タイムライン",
"navigation_bar.scheduled_statuses": "予約投稿",
"navigation_bar.security": "セキュリティ",
"navigation_bar.short.community_timeline": "ローカル",
"navigation_bar.short.getting_started": "スタート",
@ -450,6 +459,7 @@
"notification.poll": "アンケートが終了しました",
"notification.emoji_reaction": "{name}さんがあなたの投稿にリアクションしました",
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
"notification.scheduled_status": "予約投稿が投稿されました",
"notification.status": "{name}さんが投稿しました",
"notification.status_reference": "{name}さんがあなたの投稿を参照しました",
"notifications.clear": "通知を消去",
@ -466,6 +476,7 @@
"notifications.column_settings.push": "プッシュ通知",
"notifications.column_settings.emoji_reaction": "リアクション:",
"notifications.column_settings.reblog": "ブースト:",
"notifications.column_settings.scheduled_statuses": "予約投稿:",
"notifications.column_settings.show": "カラムに表示",
"notifications.column_settings.sound": "通知音を再生",
"notifications.column_settings.status": "新しい投稿:",
@ -478,6 +489,7 @@
"notifications.filter.mentions": "返信",
"notifications.filter.polls": "アンケート結果",
"notifications.filter.emoji_reactions": "リアクション",
"notifications.filter.scheduled_statuses": "予約投稿",
"notifications.filter.statuses": "フォローしている人の新着情報",
"notifications.filter.status_references": "投稿の参照",
"notifications.grant_permission": "権限の付与",
@ -536,6 +548,7 @@
"report.placeholder": "追加コメント",
"report.submit": "通報する",
"report.target": "{target}さんを通報する",
"scheduled-status-warning-indicator.cancel": "キャンセル",
"search.placeholder": "検索",
"searchability.change": "検索範囲を変更",
"searchability.direct.short": "リアクション限定(検索)",

View file

@ -16,6 +16,7 @@ import {
COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_UNDO,
COMPOSE_UPLOAD_PROGRESS,
SCHEDULED_STATUS_SUBMIT_SUCCESS,
THUMBNAIL_UPLOAD_REQUEST,
THUMBNAIL_UPLOAD_SUCCESS,
THUMBNAIL_UPLOAD_FAIL,
@ -54,6 +55,7 @@ import {
COMPOSE_REFERENCE_ADD,
COMPOSE_REFERENCE_REMOVE,
COMPOSE_REFERENCE_RESET,
COMPOSE_SCHEDULED_EDIT_CANCEL,
} from '../actions/compose';
import { TIMELINE_DELETE, TIMELINE_EXPIRE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store';
@ -62,7 +64,7 @@ import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, Ordere
import uuid from '../uuid';
import { me } from '../initial_state';
import { unescapeHTML } from '../utils/html';
import { parseISO, format } from 'date-fns';
import { format } from 'date-fns';
const initialState = ImmutableMap({
mounted: 0,
@ -84,6 +86,7 @@ const initialState = ImmutableMap({
is_submitting: false,
is_changing_upload: false,
is_uploading: false,
dirty: false,
progress: 0,
isUploadingThumbnail: false,
thumbnailProgress: 0,
@ -114,6 +117,7 @@ const initialState = ImmutableMap({
context_references: ImmutableSet(),
prohibited_visibilities: ImmutableSet(),
prohibited_words: ImmutableSet(),
scheduled_status_id: null,
});
const initialPoll = ImmutableMap({
@ -122,18 +126,18 @@ const initialPoll = ImmutableMap({
multiple: false,
});
const statusToTextMentions = (text, privacy, status) => {
if(status === null) {
const statusToTextMentions = (text, privacy, replyStatus) => {
if(replyStatus === null) {
return text;
}
let mentions = ImmutableOrderedSet();
if (status.getIn(['account', 'id']) !== me) {
mentions = mentions.add(`@${status.getIn(['account', 'acct'])} `);
if (replyStatus.getIn(['account', 'id']) !== me) {
mentions = mentions.add(`@${replyStatus.getIn(['account', 'acct'])} `);
}
mentions = mentions.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `));
mentions = mentions.union(replyStatus.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `));
const match = /^(\s*(?:(?:@\S+)\s*)*)([\s\S]*)/.exec(text);
const extrctMentions = ImmutableOrderedSet(match[1].trim().split(/\s+/).filter(Boolean).map(mention => `${mention} `));
@ -163,6 +167,7 @@ const clearAll = state => {
map.update('media_attachments', list => list.clear());
map.set('poll', null);
map.set('idempotencyKey', uuid());
map.set('dirty', false);
map.set('datetime_form', null);
map.set('default_expires', state.get('default_expires_in') ? true : null);
map.set('scheduled', null);
@ -170,7 +175,8 @@ const clearAll = state => {
map.set('expires_action', state.get('default_expires_action', 'mark'));
map.update('references', set => set.clear());
map.update('context_references', set => set.clear());
});
map.set('scheduled_status_id', null);
});
};
const appendMedia = (state, media, file) => {
@ -284,9 +290,9 @@ const hydrate = (state, hydratedState) => {
const domParser = new DOMParser();
const expandMentions = status => {
const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement;
const fragment = domParser.parseFromString(status.get('content', ''), 'text/html').documentElement;
status.get('mentions').forEach(mention => {
status.get('mentions', ImmutableList()).forEach(mention => {
fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`;
});
@ -357,11 +363,13 @@ export default function compose(state = initialState, action) {
}
map.set('idempotencyKey', uuid());
map.set('dirty', true);
});
case COMPOSE_SPOILERNESS_CHANGE:
return state.withMutations(map => {
map.set('spoiler', !state.get('spoiler'));
map.set('idempotencyKey', uuid());
map.set('dirty', true);
if (!state.get('sensitive') && state.get('media_attachments').size >= 1) {
map.set('sensitive', true);
@ -371,7 +379,8 @@ export default function compose(state = initialState, action) {
if (!state.get('spoiler')) return state;
return state
.set('spoiler_text', action.text)
.set('idempotencyKey', uuid());
.set('idempotencyKey', uuid())
.set('dirty', true);
case COMPOSE_VISIBILITY_CHANGE:
return state.withMutations(map => {
const searchability = searchabilityCap(action.value, state.get('searchability'));
@ -380,12 +389,14 @@ export default function compose(state = initialState, action) {
map.set('privacy', action.value);
map.set('searchability', searchability);
map.set('idempotencyKey', uuid());
map.set('dirty', true);
map.set('circle_id', null);
});
case COMPOSE_SEARCHABILITY_CHANGE:
return state.withMutations(map => {
map.set('searchability', action.value);
map.set('idempotencyKey', uuid());
map.set('dirty', true);
const privacy = privacyExpand(action.value, state.get('privacy'));
@ -398,11 +409,13 @@ export default function compose(state = initialState, action) {
case COMPOSE_CIRCLE_CHANGE:
return state
.set('circle_id', action.value)
.set('idempotencyKey', uuid());
.set('idempotencyKey', uuid())
.set('dirty', true);
case COMPOSE_CHANGE:
return state
.set('text', action.text)
.set('idempotencyKey', uuid());
.set('idempotencyKey', uuid())
.set('dirty', true);
case COMPOSE_COMPOSING_CHANGE:
return state.set('is_composing', action.value);
case COMPOSE_REPLY:
@ -422,12 +435,14 @@ export default function compose(state = initialState, action) {
map.set('caretPosition', null);
map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid());
map.set('dirty', false);
map.set('datetime_form', null);
map.set('default_expires', state.get('default_expires_in') ? true : null);
map.set('scheduled', null);
map.set('expires', state.get('default_expires_in', null));
map.set('expires_action', state.get('default_expires_action', 'mark'));
map.update('context_references', set => set.clear().concat(action.context_references));
map.set('scheduled_status_id', null);
if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true);
@ -451,12 +466,14 @@ export default function compose(state = initialState, action) {
map.set('focusDate', new Date());
map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid());
map.set('dirty', false);
map.set('datetime_form', null);
map.set('default_expires', state.get('default_expires_in') ? true : null);
map.set('scheduled', null);
map.set('expires', state.get('default_expires_in', null));
map.set('expires_action', state.get('default_expires_action', 'mark'));
map.update('context_references', set => set.clear().add(action.status.get('id')));
map.set('scheduled_status_id', null);
if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true);
@ -468,6 +485,7 @@ export default function compose(state = initialState, action) {
});
case COMPOSE_REPLY_CANCEL:
case COMPOSE_QUOTE_CANCEL:
case COMPOSE_SCHEDULED_EDIT_CANCEL:
case COMPOSE_RESET:
return state.withMutations(map => {
map.set('in_reply_to', null);
@ -482,21 +500,24 @@ export default function compose(state = initialState, action) {
map.set('circle_id', null);
map.set('poll', null);
map.set('idempotencyKey', uuid());
map.set('dirty', false);
map.set('datetime_form', null);
map.set('default_expires', state.get('default_expires_in') ? true : null);
map.set('scheduled', null);
map.set('expires', state.get('default_expires_in', null));
map.set('expires_action', state.get('default_expires_action', 'mark'));
map.update('context_references', set => set.clear());
if (action.type == COMPOSE_RESET) {
if (action.type === COMPOSE_RESET || action.type === COMPOSE_SCHEDULED_EDIT_CANCEL) {
map.update('references', set => set.clear());
}
map.set('scheduled_status_id', null);
});
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
case COMPOSE_UPLOAD_CHANGE_REQUEST:
return state.set('is_changing_upload', true);
case COMPOSE_SUBMIT_SUCCESS:
case SCHEDULED_STATUS_SUBMIT_SUCCESS:
return clearAll(state);
case COMPOSE_SUBMIT_FAIL:
return state.set('is_submitting', false);
@ -547,6 +568,8 @@ export default function compose(state = initialState, action) {
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('scheduled_status_id', null);
map.set('dirty', false);
});
case COMPOSE_DIRECT:
return state.withMutations(map => {
@ -557,6 +580,8 @@ export default function compose(state = initialState, action) {
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('scheduled_status_id', null);
map.set('dirty', false);
});
case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
@ -593,27 +618,30 @@ export default function compose(state = initialState, action) {
const datetime_form = !!action.status.get('scheduled_at') || !!action.status.get('expires_at') ? true : null;
map.set('text', action.raw_text || unescapeHTML(rejectQuoteAltText(expandMentions(action.status))));
map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('quote_from', action.status.getIn(['quote', 'id']));
map.set('in_reply_to', action.status.get('in_reply_to_id', null));
map.set('quote_from', action.status.getIn(['quote', 'id'], null));
map.set('quote_from_url', action.status.getIn(['quote', 'url']));
map.set('reply_status', action.replyStatus);
map.set('privacy', action.status.get('visibility'));
map.set('searchability', action.status.get('searchability'));
map.set('circle_id', action.status.get('circle_id'));
map.set('media_attachments', action.status.get('media_attachments'));
map.set('privacy', action.status.get('visibility', state.get('default_privacy')));
map.set('searchability', action.status.get('searchability', state.get('default_searchability')));
map.set('circle_id', action.status.get('circle_id', null));
map.set('media_attachments', action.status.get('media_attachments', ImmutableList()));
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('sensitive', action.status.get('sensitive'));
map.set('dirty', false);
map.set('poll', action.status.get('poll', null));
map.set('sensitive', action.status.get('sensitive', false));
map.set('datetime_form', datetime_form);
map.set('default_expires', !datetime_form && state.get('default_expires_in') ? true : null);
map.set('scheduled', action.status.get('scheduled_at'));
map.set('expires', action.status.get('expires_at') ? format(parseISO(action.status.get('expires_at')), 'yyyy-MM-dd HH:mm') : state.get('default_expires_in', null));
map.set('scheduled', action.status.get('scheduled_at') ? format(new Date(action.status.get('scheduled_at')), 'yyyy-MM-dd HH:mm') : null);
map.set('expires', action.status.get('expires_at') ? format(new Date(action.status.get('expires_at')), 'yyyy-MM-dd HH:mm') : state.get('default_expires_in', null));
map.set('expires_action', action.status.get('expires_action') ?? state.get('default_expires_action', 'mark'));
map.update('references', set => set.clear().concat(action.status.get('status_reference_ids')).delete(action.status.getIn(['quote', 'id'])));
map.update('references', set => set.clear().concat(action.status.get('status_reference_ids', ImmutableList())).delete(action.status.getIn(['quote', 'id'], ImmutableList())));
map.update('context_references', set => set.clear().concat(action.context_references));
map.set('scheduled_status_id', action.status.get('scheduled_status_id', null));
if (action.status.get('spoiler_text').length > 0) {
if (action.status.get('spoiler_text', '').length > 0) {
map.set('spoiler', true);
map.set('spoiler_text', action.status.get('spoiler_text'));
} else {
@ -623,7 +651,7 @@ export default function compose(state = initialState, action) {
if (action.status.get('poll')) {
map.set('poll', ImmutableMap({
options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
options: action.status.getIn(['poll', 'options']).map(x => typeof x === 'string' ? x : x.get('title')),
multiple: action.status.getIn(['poll', 'multiple']),
expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
}));
@ -653,13 +681,14 @@ export default function compose(state = initialState, action) {
map.set('scheduled', null);
map.set('expires', null);
map.set('expires_action', 'mark');
map.set('dirty', true);
});
case COMPOSE_SCHEDULED_CHANGE:
return state.set('scheduled', action.value);
return state.set('scheduled', action.value).set('dirty', true);;
case COMPOSE_EXPIRES_CHANGE:
return state.set('expires', action.value);
return state.set('expires', action.value).set('dirty', true);;
case COMPOSE_EXPIRES_ACTION_CHANGE:
return state.set('expires_action', action.value);
return state.set('expires_action', action.value).set('dirty', true);;
case COMPOSE_REFERENCE_ADD:
return state.update('references', set => set.add(action.id));
case COMPOSE_REFERENCE_REMOVE:

View file

@ -11,6 +11,7 @@ import status_status_lists from './status_status_lists';
import accounts from './accounts';
import accounts_counters from './accounts_counters';
import statuses from './statuses';
import scheduled_statuses from './scheduled_statuses';
import relationships from './relationships';
import settings from './settings';
import push_notifications from './push_notifications';
@ -61,6 +62,7 @@ const reducers = {
accounts,
accounts_counters,
statuses,
scheduled_statuses,
relationships,
settings,
push_notifications,

View file

@ -0,0 +1,70 @@
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_DELETE_SUCCESS,
} from '../actions/scheduled_statuses';
import {
SCHEDULED_STATUS_SUBMIT_SUCCESS,
} from '../actions/compose';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({
next: null,
loaded: false,
isLoading: false,
items: ImmutableList(),
});
const normalizeList = (state, scheduled_statuses, next) => {
return state.withMutations(map => {
map.set('next', next);
map.set('loaded', true);
map.set('isLoading', false);
map.set('items', fromJS(scheduled_statuses));
});
};
const appendToList = (state, scheduled_statuses, next) => {
return state.withMutations(map => {
map.set('next', next);
map.set('isLoading', false);
map.set('items', map.get('items').concat(fromJS(scheduled_statuses)));
});
};
const prependOneToList = (state, scheduled_status) => {
return state.withMutations(map => {
map.set('items', map.get('items').unshift(fromJS(scheduled_status)));
});
};
const removeOneFromList = (state, id) => {
return state.withMutations(map => {
map.set('items', map.get('items').filter(item => item.get('id') !== id));
});
};
export default function statusLists(state = initialState, action) {
switch(action.type) {
case SCHEDULED_STATUSES_FETCH_REQUEST:
case SCHEDULED_STATUSES_EXPAND_REQUEST:
return state.set('isLoading', true);
case SCHEDULED_STATUSES_FETCH_FAIL:
case SCHEDULED_STATUSES_EXPAND_FAIL:
return state.set('isLoading', false);
case SCHEDULED_STATUSES_FETCH_SUCCESS:
return normalizeList(state, action.scheduled_statuses, action.next);
case SCHEDULED_STATUSES_EXPAND_SUCCESS:
return appendToList(state, action.scheduled_statuses, action.next);
case SCHEDULED_STATUS_SUBMIT_SUCCESS:
return prependOneToList(state, action.scheduled_status);
case SCHEDULED_STATUS_DELETE_SUCCESS:
return removeOneFromList(state, action.id);
default:
return state;
}
};

View file

@ -78,6 +78,7 @@ const initialState = ImmutableMap({
status: false,
emoji_reaction: false,
status_reference: false,
scheduled_status: false,
}),
quickFilter: ImmutableMap({
@ -99,6 +100,7 @@ const initialState = ImmutableMap({
status: true,
emoji_reaction: true,
status_reference: true,
scheduled_status: true,
}),
sounds: ImmutableMap({
@ -111,6 +113,7 @@ const initialState = ImmutableMap({
status: true,
emoji_reaction: true,
status_reference: true,
scheduled_status: true,
}),
}),

View file

@ -1,11 +1,11 @@
export const uniq = array => {
return array.filter((x, i, self) => self.indexOf(x) === i)
return array.filter((x, i, self) => self.indexOf(x) === i);
};
export const uniqCompact = array => {
return array.filter((x, i, self) => x && self.indexOf(x) === i)
return array.filter((x, i, self) => x && self.indexOf(x) === i);
};
export const uniqWithoutNull = array => {
return array.filter((x, i, self) => !x || self.indexOf(x) === i)
return array.filter((x, i, self) => !x || self.indexOf(x) === i);
};

View file

@ -742,7 +742,8 @@ html {
}
.status__content,
.reply-indicator__content {
.reply-indicator__content,
.scheduled-status-warning-indicator__content {
a {
color: $highlight-text-color;
}

View file

@ -796,8 +796,14 @@
background: $success-green;
}
.scheduled-status-warning-indicator {
color: $inverted-text-color;
background: $ui-primary-color;
}
.reply-indicator,
.quote-indicator {
.quote-indicator,
.scheduled-status-warning-indicator {
border-radius: 4px;
margin-bottom: 10px;
padding: 10px;
@ -807,13 +813,15 @@
}
.reply-indicator__header,
.quote-indicator__header {
.quote-indicator__header,
.scheduled-status-warning-indicator__header {
margin-bottom: 5px;
overflow: hidden;
}
.reply-indicator__cancel,
.quote-indicator__cancel,
.scheduled-status-warning-indicator__cancel,
.expires-indicator__cancel {
float: right;
line-height: 24px;
@ -973,6 +981,11 @@
}
}
.scheduled-status-warning-indicator__content {
margin-top: 3px;
margin-right: 5px;
}
.announcements__item__content {
word-wrap: break-word;
overflow-y: auto;
@ -1511,7 +1524,8 @@
}
.reply-indicator__content,
.quote-indicator__content {
.quote-indicator__content,
.scheduled-status-warning-indicator__content {
color: $inverted-text-color;
font-size: 14px;
@ -8526,6 +8540,12 @@ noscript {
flex: 0 0 auto;
line-height: 24px;
}
&__scheduled-time {
color: $light-text-color;
margin-top: 4px;
text-align: end;
}
}
.thumbnail-gallery {

View file

@ -38,6 +38,7 @@ class UserSettingsDecorator
post_reference_modal
add_reference_modal
unselect_reference_modal
delete_scheduled_status_modal
auto_play_gif
expand_spoiers
reduce_motion

View file

@ -27,6 +27,7 @@ class Notification < ApplicationRecord
'Poll' => :poll,
'EmojiReaction' => :emoji_reaction,
'StatusReference' => :status_reference,
'ScheduledStatus' => :scheduled_status,
}.freeze
TYPES = %i(
@ -39,6 +40,7 @@ class Notification < ApplicationRecord
poll
emoji_reaction
status_reference
scheduled_status
).freeze
TARGET_STATUS_INCLUDES_BY_TYPE = {
@ -99,6 +101,8 @@ class Notification < ApplicationRecord
emoji_reaction&.status
when :status_reference
status_reference&.status
when :scheduled_status
status
end
end
@ -141,6 +145,8 @@ class Notification < ApplicationRecord
notification.emoji_reaction.status = cached_status
when :status_reference
notification.status_reference.status = cached_status
when :scheduled_status
notification.status = cached_status
end
end
@ -157,7 +163,7 @@ class Notification < ApplicationRecord
return unless new_record?
case activity_type
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'EmojiReaction'
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'EmojiReaction', 'ScheduledStatus'
self.from_account_id = activity&.account_id
when 'Mention', 'StatusReference'
self.from_account_id = activity&.status&.account_id

View file

@ -137,7 +137,7 @@ class User < ApplicationRecord
:new_features_policy,
:theme_instance_ticker, :theme_public,
:enable_status_reference, :match_visibility_of_references,
:post_reference_modal, :add_reference_modal, :unselect_reference_modal,
:post_reference_modal, :add_reference_modal, :unselect_reference_modal, :delete_scheduled_status_modal,
:hexagon_avatar, :enable_empty_column,
:content_font_size, :info_font_size, :content_emoji_reaction_size, :emoji_scale, :picker_emoji_size,
:hide_bot_on_public_timeline, :confirm_follow_from_bot,

View file

@ -69,6 +69,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:post_reference_modal] = object.current_account.user.setting_post_reference_modal
store[:add_reference_modal] = object.current_account.user.setting_add_reference_modal
store[:unselect_reference_modal] = object.current_account.user.setting_unselect_reference_modal
store[:delete_scheduled_status_modal] = object.current_account.user.setting_delete_scheduled_status_modal
store[:enable_empty_column] = object.current_account.user.setting_enable_empty_column
store[:content_font_size] = object.current_account.user.setting_content_font_size
store[:info_font_size] = object.current_account.user.setting_info_font_size

View file

@ -13,7 +13,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
end
def status_type?
[:favourite, :reblog, :status, :mention, :poll, :emoji_reaction, :status_reference].include?(object.type)
[:favourite, :reblog, :status, :mention, :poll, :emoji_reaction, :status_reference, :scheduled_status].include?(object.type)
end
def reblog?

View file

@ -10,6 +10,6 @@ class REST::ScheduledStatusSerializer < ActiveModel::Serializer
end
def params
object.params.without(:application_id)
object.params.without('application_id')
end
end

View file

@ -54,6 +54,10 @@ class NotifyService < BaseService
FeedManager.instance.filter?(:status_references, @notification.status_reference.status, @recipient)
end
def blocked_scheduled_status?
false
end
def following_sender?
return @following_sender if defined?(@following_sender)
@following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)
@ -160,7 +164,7 @@ class NotifyService < BaseService
def blocked?
blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway
blocked ||= from_self? && @notification.type != :poll # Skip for interactions with self
blocked ||= from_self? && !%i(poll scheduled_status).include?(@notification.type) # Skip for interactions with self
return blocked if message? && from_staff?

View file

@ -25,6 +25,7 @@ class PostStatusService < BaseService
# @option [String] :idempotency Optional idempotency key
# @option [Boolean] :with_rate_limit
# @option [String] :searchability
# @option [Boolean] :notify Optional notification of completion of schedule post
# @return [Status]
def call(account, options = {})
@account = account
@ -53,11 +54,17 @@ class PostStatusService < BaseService
redis.setex(idempotency_key, 3_600, @status.id) if idempotency_given?
create_notification!
@status
end
private
def create_notification!
NotifyService.new.call(@status.account, :scheduled_status, @status) if @options[:notify] && @status.account.local?
end
def status_from_uri(uri)
ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
end
@ -81,7 +88,6 @@ class PostStatusService < BaseService
@visibility = :limited if @visibility&.to_sym != :direct && @in_reply_to&.limited_visibility?
@searchability = searchability
@scheduled_at = @options[:scheduled_at].is_a?(Time) ? @options[:scheduled_at] : @options[:scheduled_at]&.to_datetime&.to_time
@scheduled_at = nil if scheduled_in_the_past?
if @quote_id.nil? && md = @text.match(/QT:\s*\[\s*(https:\/\/.+?)\s*\]/)
@quote_id = quote_from_url(md[1])&.id
@text.sub!(/QT:\s*\[.*?\]/, '')
@ -273,11 +279,12 @@ class PostStatusService < BaseService
def scheduled_options
@options.tap do |options_hash|
options_hash[:in_reply_to_id] = options_hash.delete(:thread)&.id
options_hash[:application_id] = options_hash.delete(:application)&.id
options_hash[:scheduled_at] = nil
options_hash[:idempotency] = nil
options_hash[:with_rate_limit] = false
options_hash[:in_reply_to_id] = options_hash.delete(:thread)&.id&.to_s
options_hash[:application_id] = options_hash.delete(:application)&.id
options_hash[:scheduled_at] = nil
options_hash[:idempotency] = nil
options_hash[:status_reference_ids] = options_hash[:status_reference_ids]&.map(&:to_s)&.filter{ |id| id != options_hash[:quote_id] }
options_hash[:with_rate_limit] = false
end
end
end

View file

@ -86,6 +86,7 @@
= f.input :setting_post_reference_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
= f.input :setting_add_reference_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
= f.input :setting_unselect_reference_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
= f.input :setting_delete_scheduled_status_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
%h4= t 'appearance.sensitive_content'

View file

@ -13,6 +13,8 @@ class PublishScheduledStatusWorker
scheduled_status.account,
options_with_objects(scheduled_status.params.with_indifferent_access)
)
remove_scheduled_status(scheduled_status)
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid
true
end
@ -21,6 +23,11 @@ class PublishScheduledStatusWorker
options.tap do |options_hash|
options_hash[:application] = Doorkeeper::Application.find(options_hash.delete(:application_id)) if options[:application_id]
options_hash[:thread] = Status.find(options_hash.delete(:in_reply_to_id)) if options_hash[:in_reply_to_id]
options_hash[:notify] = true
end
end
def remove_scheduled_status(scheduled_status)
Redis.current.publish("timeline:#{scheduled_status.account.id}", Oj.dump(event: :scheduled_status, payload: scheduled_status.id.to_s))
end
end

View file

@ -259,6 +259,7 @@ en:
setting_default_search_searchability: Search range
setting_default_sensitive: Always mark media as sensitive
setting_delete_modal: Show confirmation dialog before deleting a post
setting_delete_scheduled_status_modal: Show confirmation dialog before removing a scheduled status
setting_disable_account_delete: Disable account delete
setting_disable_block: Disable block
setting_disable_clear_all_notifications: Disable clear all notifications

View file

@ -255,6 +255,7 @@ ja:
setting_default_search_searchability: 検索の対象とする範囲
setting_default_sensitive: メディアを常に閲覧注意としてマークする
setting_delete_modal: 投稿を削除する前に確認ダイアログを表示する
setting_delete_scheduled_status_modal: 予約投稿を削除する前に確認ダイアログを表示する
setting_disable_account_delete: アカウント削除を無効にする
setting_disable_block: ブロックを無効にする
setting_disable_clear_all_notifications: 通知の全消去を無効にする

View file

@ -111,6 +111,7 @@ defaults: &defaults
post_reference_modal: false
add_reference_modal: true
unselect_reference_modal: false
delete_scheduled_status_modal: true
hexagon_avatar: false
enable_empty_column: false
hide_bot_on_public_timeline: false