Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add UI for posting circles #14384

Closed
wants to merge 11 commits into from
6 changes: 6 additions & 0 deletions app/controllers/api/v1/statuses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ def set_thread
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
end

def set_circle
@circle = status_params[:circle_id].blank? ? nil : current_account.owned_circles.find(status_params[:circle_id])
rescue ActiveRecord::RecordNotFound
render json: { error: I18n.t('statuses.errors.circle_not_found') }, status: 404
end

def status_params
params.permit(
:status,
Expand Down
9 changes: 9 additions & 0 deletions app/javascript/mastodon/actions/compose.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_CIRCLE_CHANGE = 'COMPOSE_CIRCLE_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';

Expand Down Expand Up @@ -146,6 +147,7 @@ export function submitCompose(routerHistory) {
sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']),
circle_id: getState().getIn(['compose', 'circle_id']),
poll: getState().getIn(['compose', 'poll'], null),
}, {
headers: {
Expand Down Expand Up @@ -592,6 +594,13 @@ export function changeComposeVisibility(value) {
};
};

export function changeComposeCircle(value) {
return {
type: COMPOSE_CIRCLE_CHANGE,
value,
};
};

export function insertEmojiCompose(position, emoji, needsSpace) {
return {
type: COMPOSE_EMOJI_INSERT,
Expand Down
6 changes: 4 additions & 2 deletions app/javascript/mastodon/actions/statuses.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,19 @@ export function fetchStatusFail(id, error, skipLoading) {
};
};

export function redraft(status, raw_text) {
export function redraft(status, replyStatus, raw_text) {
return {
type: REDRAFT,
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')]));
Expand All @@ -100,7 +102,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
dispatch(importFetchedAccount(response.data.account));

if (withRedraft) {
dispatch(redraft(status, response.data.text));
dispatch(redraft(status, replyStatus, response.data.text));
ensureComposeIsVisible(getState, routerHistory);
}
}).catch(error => {
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/mastodon/containers/mastodon.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ScrollContext } from 'react-router-scroll-4';
import UI from '../features/ui';
import Introduction from '../features/introduction';
import { fetchCustomEmojis } from '../actions/custom_emojis';
import { fetchCircles } from '../actions/circles';
import { hydrateStore } from '../actions/store';
import { connectUserStream } from '../actions/streaming';
import { IntlProvider, addLocaleData } from 'react-intl';
Expand All @@ -25,6 +26,7 @@ const hydrateAction = hydrateStore(initialState);

store.dispatch(hydrateAction);
store.dispatch(fetchCustomEmojis());
store.dispatch(fetchCircles());

const mapStateToProps = state => ({
showIntroduction: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import IconButton from 'mastodon/components/icon_button';
import { createSelector } from 'reselect';

const messages = defineMessages({
circle_unselect: { id: 'circle.unselect', defaultMessage: '(Select circle)' },
circle_reply: { id: 'circle.reply', defaultMessage: '(Reply to circle context)' },
circle_open_circle_column: { id: 'circle.open_circle_column', defaultMessage: 'Open circle column' },
circle_add_new_circle: { id: 'circle.add_new_circle', defaultMessage: '(Add new circle)' },
circle_select: { id: 'circle.select', defaultMessage: 'Select circle' },
});

const getOrderedCircles = createSelector([state => state.get('circles')], circles => {
if (!circles) {
return circles;
}

return circles.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});

const mapStateToProps = (state) => {
return {
circles: getOrderedCircles(state),
};
};

export default @connect(mapStateToProps)
@injectIntl
class CircleDropdown extends React.PureComponent {

static contextTypes = {
router: PropTypes.object,
};

static propTypes = {
circles: ImmutablePropTypes.list,
value: PropTypes.string.isRequired,
visible: PropTypes.bool.isRequired,
limitedReply: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onOpenCircleColumn: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};

handleChange = e => {
this.props.onChange(e.target.value);
};

handleOpenCircleColumn = () => {
this.props.onOpenCircleColumn(this.context.router ? this.context.router.history : null);
};

render () {
const { circles, value, visible, limitedReply, intl } = this.props;

return (
<div className={classNames('circle-dropdown', { 'circle-dropdown--visible': visible })}>
<IconButton icon='user-circle' className='circle-dropdown__icon' title={intl.formatMessage(messages.circle_open_circle_column)} style={{ width: 'auto', height: 'auto' }} onClick={this.handleOpenCircleColumn} />

{circles.isEmpty() && !limitedReply ?
<button className='circle-dropdown__menu' onClick={this.handleOpenCircleColumn}>{intl.formatMessage(messages.circle_add_new_circle)}</button>
:
/* eslint-disable-next-line jsx-a11y/no-onchange */
<select className='circle-dropdown__menu' title={intl.formatMessage(messages.circle_select)} value={value} onChange={this.handleChange}>
<option value='' key='unselect'>{intl.formatMessage(limitedReply ? messages.circle_reply : messages.circle_unselect)}</option>
{circles.map(circle =>
<option value={circle.get('id')} key={circle.get('id')}>{circle.get('title')}</option>,
)}
</select>
}
</div>
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl } from 'react-intl';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import CircleDropdownContainer from '../containers/circle_dropdown_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollFormContainer from '../containers/poll_form_container';
import UploadFormContainer from '../containers/upload_form_container';
Expand Down Expand Up @@ -50,6 +51,7 @@ class ComposeForm extends ImmutablePureComponent {
isSubmitting: PropTypes.bool,
isChangingUpload: PropTypes.bool,
isUploading: PropTypes.bool,
isCircleUnselected: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired,
Expand Down Expand Up @@ -82,11 +84,11 @@ class ComposeForm extends ImmutablePureComponent {
}

canSubmit = () => {
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
const { isSubmitting, isChangingUpload, isUploading, isCircleUnselected, anyMedia } = this.props;
const fulltext = this.getFulltextForCharacterCounting();
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;

return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia));
return !(isSubmitting || isUploading || isChangingUpload || isCircleUnselected || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia));
}

handleSubmit = () => {
Expand Down Expand Up @@ -252,6 +254,8 @@ class ComposeForm extends ImmutablePureComponent {
<div className='character-counter__wrapper'><CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} /></div>
</div>

<CircleDropdownContainer />

<div className='compose-form__publish'>
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block /></div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const messages = defineMessages({
private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' },
limited_long: { id: 'privacy.limited.long', defaultMessage: 'Visible for circle users only' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
});

Expand Down Expand Up @@ -236,6 +238,7 @@ class PrivacyDropdown extends React.PureComponent {
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
{ icon: 'user-circle', value: 'limited', text: formatMessage(messages.limited_short), meta: formatMessage(messages.limited_long) },
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import CircleDropdown from '../components/circle_dropdown';
import { changeComposeCircle } from '../../../actions/compose';

const mapStateToProps = state => {
let value = state.getIn(['compose', 'circle_id']);
value = value === null ? '' : value;

return {
value: value,
visible: state.getIn(['compose', 'privacy']) === 'limited',
limitedReply: state.getIn(['compose', 'privacy']) === 'limited' && state.getIn(['compose', 'reply_status', 'visibility']) === 'limited',
};
};

const mapDispatchToProps = dispatch => ({

onChange (value) {
dispatch(changeComposeCircle(value));
},

onOpenCircleColumn (router) {
if(router && router.location.pathname !== '/circles') {
router.push('/circles');
}
},

});

export default connect(mapStateToProps, mapDispatchToProps)(CircleDropdown);
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const mapStateToProps = state => ({
isSubmitting: state.getIn(['compose', 'is_submitting']),
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']),
isCircleUnselected: state.getIn(['compose', 'privacy']) === 'limited' && state.getIn(['compose', 'reply_status', 'visibility']) !== 'limited' && !state.getIn(['compose', 'circle_id']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ const mapStateToProps = state => ({
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
limitedMessageWarning: state.getIn(['compose', 'privacy']) === 'limited',
});

const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, limitedMessageWarning }) => {
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> }} />} />;
}
Expand All @@ -55,13 +56,18 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
return <Warning message={message} />;
}

if (limitedMessageWarning) {
return <Warning message={<FormattedMessage id='compose_form.limited_message_warning' defaultMessage='This toot will only be sent to users in the circle.' />} />;
}

return null;
};

WarningWrapper.propTypes = {
needsLockWarning: PropTypes.bool,
hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool,
limitedMessageWarning: PropTypes.bool,
};

export default connect(mapStateToProps)(WarningWrapper);
6 changes: 6 additions & 0 deletions app/javascript/mastodon/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this component.",
"bundle_modal_error.retry": "Try again",
"circle.add_new_circle": "(Add new circle)",
"circle.open_circle_column": "Open circle column",
"circle.reply": "(Reply to circle context)",
"circle.select": "Select circle",
"circle.unselect": "(Select circle)",
"column.blocks": "Blocked users",
"column.bookmarks": "Bookmarks",
"column.community": "Local timeline",
Expand Down Expand Up @@ -87,6 +92,7 @@
"compose_form.direct_message_warning": "This toot will only be sent to the mentioned users.",
"compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
"compose_form.limited_message_warning": "This toot will only be sent to users in the circle.",
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked",
"compose_form.placeholder": "What's on your mind?",
Expand Down