From b7f4b773a0cd554084d5ad6a5923adb06b3acfc4 Mon Sep 17 00:00:00 2001 From: noellabo Date: Sat, 5 Sep 2020 11:40:12 +0900 Subject: [PATCH] Squashed commit of the following: commit b85a4685b27c49462288aba5f38723b91e936c4a Author: noellabo Date: Sat Sep 5 10:50:03 2020 +0900 Changed to remove restrictions on privacy options and allow users to switch circles when replying commit 0a8c0140c73d7c5333e4f8017964adb5061a7cf1 Author: noellabo Date: Sat Sep 5 09:33:07 2020 +0900 Change limited visibility icon commit b64adf19788d828249408454ec6afa9beb3d4872 Author: noellabo Date: Mon Aug 31 06:50:56 2020 +0900 Fix a change to limited-visibility-bearcaps replies commit ed361405b5e38857a2f42b0515a599ddcdd412cf Author: noellabo Date: Thu Aug 27 15:53:18 2020 +0900 Fix composer text when change visibility commit 4da3adddb6ffde43070d743e34c5b56e06579b30 Author: noellabo Date: Sat Aug 22 22:34:23 2020 +0900 Fix wrong circle_id when changing visibility commit 752d7fc2a3c9e34fab9993d767f83c6eae7ba55a Author: noellabo Date: Sun Aug 9 13:12:51 2020 +0900 Add circle reply and redraft commit 5978bc04a24695edce6717bda89dcf6f861ef2c4 Author: noellabo Date: Mon Jul 27 01:07:52 2020 +0900 Fix remove unused props commit 7970f69676c24b4aa9385fee8b1635c46ba52fcd Author: noellabo Date: Sun Jul 26 21:17:07 2020 +0900 Separate circle choice from privacy commit 36f6a684c0b0c895d4d0f1b9d09b05c91b104666 Author: noellabo Date: Thu Jul 23 10:54:25 2020 +0900 Add UI for posting to circles commit 7ef48003c1407275663dd603b124d292db2aa93a Author: noellabo Date: Fri Jul 24 12:55:10 2020 +0900 Fix silent mention by circle --- app/javascript/mastodon/actions/compose.js | 9 +++ app/javascript/mastodon/actions/statuses.js | 6 +- .../mastodon/containers/mastodon.js | 2 + .../compose/components/circle_dropdown.js | 80 +++++++++++++++++++ .../compose/components/compose_form.js | 10 ++- .../compose/components/privacy_dropdown.js | 3 + .../containers/circle_dropdown_container.js | 30 +++++++ .../containers/compose_form_container.js | 1 + .../compose/containers/warning_container.js | 8 +- app/javascript/mastodon/locales/en.json | 6 ++ app/javascript/mastodon/reducers/compose.js | 63 +++++++++++---- .../styles/mastodon-light/diff.scss | 4 +- .../styles/mastodon/components.scss | 54 +++++++++++++ app/javascript/styles/mastodon/rtl.scss | 9 +++ app/serializers/rest/status_serializer.rb | 18 ++++- app/services/post_status_service.rb | 5 ++ 16 files changed, 282 insertions(+), 26 deletions(-) create mode 100644 app/javascript/mastodon/features/compose/components/circle_dropdown.js create mode 100644 app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index d3f79f726eab55..8116bff4dd917f 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -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'; @@ -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: { @@ -594,6 +596,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, diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 3fc7c07023d627..b83eb653d68087 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -76,10 +76,11 @@ 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, }; }; @@ -87,6 +88,7 @@ export function redraft(status, 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')])); @@ -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 => { diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 3ac58cf7c5e678..64745fb111e1f7 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -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'; @@ -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, diff --git a/app/javascript/mastodon/features/compose/components/circle_dropdown.js b/app/javascript/mastodon/features/compose/components/circle_dropdown.js new file mode 100644 index 00000000000000..ed32e179646d5c --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/circle_dropdown.js @@ -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 ( +
+ + + {circles.isEmpty() && !limitedReply ? + + : + /* eslint-disable-next-line jsx-a11y/no-onchange */ + + } +
+ ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 910476647807c5..6f4356a7fd5e7b 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -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'; @@ -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, @@ -85,10 +87,10 @@ class ComposeForm extends ImmutablePureComponent { } // Submit disabled: - const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; + const { isSubmitting, isChangingUpload, isUploading, isCircleUnselected, anyMedia } = this.props; const fulltext = [this.props.spoilerText, countableText(this.props.text)].join(''); - if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { + if (isSubmitting || isUploading || isChangingUpload || isCircleUnselected || length(fulltext) > 500 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { return; } @@ -181,7 +183,7 @@ class ComposeForm extends ImmutablePureComponent { const { intl, onPaste, showSearch, anyMedia } = this.props; const disabled = this.props.isSubmitting; const text = [this.props.spoilerText, countableText(this.props.text)].join(''); - const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia); + const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || this.props.isCircleUnselected || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia); let publishText = ''; if (this.props.privacy !== 'public' && this.props.privacy !== 'unlisted') { @@ -246,6 +248,8 @@ class ComposeForm extends ImmutablePureComponent {
+ +
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index 5223025fb5f412..2d08a68af69638 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -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' }, }); @@ -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) }, ]; } diff --git a/app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js new file mode 100644 index 00000000000000..958a903e27d760 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js @@ -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); diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 37a0e8845b4f1b..c195245ee0ee27 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -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, }); diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js index bf0660ea9dfeb6..79fe1c25afaee1 100644 --- a/app/javascript/mastodon/features/compose/containers/warning_container.js +++ b/app/javascript/mastodon/features/compose/containers/warning_container.js @@ -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 }} />} />; } @@ -55,6 +56,10 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning return ; } + if (limitedMessageWarning) { + return } />; + } + return null; }; @@ -62,6 +67,7 @@ WarningWrapper.propTypes = { needsLockWarning: PropTypes.bool, hashtagWarning: PropTypes.bool, directMessageWarning: PropTypes.bool, + limitedMessageWarning: PropTypes.bool, }; export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 7c24dad18ca569..a54bccff16f325 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -65,6 +65,11 @@ "circles.new.title_placeholder": "New circle title", "circles.search": "Search among people following you", "circles.subheading": "Your circles", + "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.circles": "Circles", @@ -94,6 +99,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?", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index dd6decc8f9a4c3..acba91990fea54 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -27,6 +27,7 @@ import { COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_VISIBILITY_CHANGE, + COMPOSE_CIRCLE_CHANGE, COMPOSE_COMPOSING_CHANGE, COMPOSE_EMOJI_INSERT, COMPOSE_UPLOAD_CHANGE_REQUEST, @@ -54,11 +55,13 @@ const initialState = ImmutableMap({ spoiler: false, spoiler_text: '', privacy: null, + circle_id: null, text: '', focusDate: null, caretPosition: null, preselectDate: null, in_reply_to: null, + reply_status: null, is_composing: false, is_submitting: false, is_changing_upload: false, @@ -84,17 +87,31 @@ const initialPoll = ImmutableMap({ multiple: false, }); -function statusToTextMentions(state, status) { - let set = ImmutableOrderedSet([]); +const statusToTextMentions = (text, privacy, status) => { + if(status === null) { + return text; + } + + let mentions = ImmutableOrderedSet(); if (status.getIn(['account', 'id']) !== me) { - set = set.add(`@${status.getIn(['account', 'acct'])} `); + mentions = mentions.add(`@${status.getIn(['account', 'acct'])} `); } - return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join(''); + mentions = mentions.union(status.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} `)); + const others = match[2]; + + if(privacy === 'limited') { + return extrctMentions.subtract(mentions).add(others).join(''); + } else { + return mentions.union(extrctMentions).add(others).join(''); + } }; -function clearAll(state) { +const clearAll = state => { return state.withMutations(map => { map.set('text', ''); map.set('spoiler', false); @@ -102,7 +119,9 @@ function clearAll(state) { map.set('is_submitting', false); map.set('is_changing_upload', false); map.set('in_reply_to', null); + map.set('reply_status', null); map.set('privacy', state.get('default_privacy')); + map.set('circle_id', null); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); map.set('poll', null); @@ -110,7 +129,7 @@ function clearAll(state) { }); }; -function appendMedia(state, media, file) { +const appendMedia = (state, media, file) => { const prevSize = state.get('media_attachments').size; return state.withMutations(map => { @@ -129,7 +148,7 @@ function appendMedia(state, media, file) { }); }; -function removeMedia(state, mediaId) { +const removeMedia = (state, mediaId) => { const prevSize = state.get('media_attachments').size; return state.withMutations(map => { @@ -185,14 +204,10 @@ const insertEmoji = (state, position, emojiData, needsSpace) => { }; const privacyPreference = (a, b) => { - const order = ['public', 'unlisted', 'private', 'direct']; + const order = ['public', 'unlisted', 'private', 'limited', 'direct']; return order[Math.max(order.indexOf(a), order.indexOf(b), 0)]; }; -const privacyCompatibility = (visibility) => { - return visibility === 'limited' ? 'private' : visibility; -}; - const hydrate = (state, hydratedState) => { state = clearAll(state.merge(hydratedState)); @@ -284,8 +299,15 @@ export default function compose(state = initialState, action) { .set('spoiler_text', action.text) .set('idempotencyKey', uuid()); case COMPOSE_VISIBILITY_CHANGE: + return state.withMutations(map => { + map.set('text', statusToTextMentions(state.get('text'), action.value, state.get('reply_status'))); + map.set('privacy', action.value); + map.set('idempotencyKey', uuid()); + map.set('circle_id', null); + }); + case COMPOSE_CIRCLE_CHANGE: return state - .set('privacy', action.value) + .set('circle_id', action.value) .set('idempotencyKey', uuid()); case COMPOSE_CHANGE: return state @@ -294,10 +316,14 @@ export default function compose(state = initialState, action) { case COMPOSE_COMPOSING_CHANGE: return state.set('is_composing', action.value); case COMPOSE_REPLY: + const privacy = privacyPreference(action.status.get('visibility'), state.get('default_privacy')); + return state.withMutations(map => { map.set('in_reply_to', action.status.get('id')); - map.set('text', statusToTextMentions(state, action.status)); - map.set('privacy', privacyPreference(privacyCompatibility(action.status.get('visibility')), state.get('default_privacy'))); + map.set('reply_status', action.status); + map.set('text', statusToTextMentions('', privacy, action.status)); + map.set('privacy', privacy); + map.set('circle_id', null); map.set('focusDate', new Date()); map.set('caretPosition', null); map.set('preselectDate', new Date()); @@ -315,10 +341,12 @@ export default function compose(state = initialState, action) { case COMPOSE_RESET: return state.withMutations(map => { map.set('in_reply_to', null); + map.set('reply_status', null); map.set('text', ''); map.set('spoiler', false); map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); + map.set('circle_id', null); map.set('poll', null); map.set('idempotencyKey', uuid()); }); @@ -369,6 +397,7 @@ export default function compose(state = initialState, action) { return state.withMutations(map => { map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); map.set('privacy', 'direct'); + map.set('circle_id', null); map.set('focusDate', new Date()); map.set('caretPosition', null); map.set('idempotencyKey', uuid()); @@ -405,7 +434,9 @@ export default function compose(state = initialState, action) { return state.withMutations(map => { map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status))); map.set('in_reply_to', action.status.get('in_reply_to_id')); - map.set('privacy', privacyCompatibility(action.status.get('visibility'))); + map.set('reply_status', action.replyStatus); + map.set('privacy', action.status.get('visibility')); + map.set('circle_id', action.status.get('circle_id')); map.set('media_attachments', action.status.get('media_attachments')); map.set('focusDate', new Date()); map.set('caretPosition', null); diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 22ec4c642b608b..85b67bde9a17f5 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -147,6 +147,7 @@ html { .poll__option input[type="text"], .compose-form .spoiler-input__input, .compose-form__poll-wrapper select, +.circle-dropdown, .search__input, .setting-text, .box-widget input[type="text"], @@ -170,7 +171,8 @@ html { border-bottom: 0; } -.compose-form__poll-wrapper select { +.compose-form__poll-wrapper select, +.circle-dropdown__menu { background: $simple-background-color url("data:image/svg+xml;utf8,") no-repeat right 8px center / auto 16px; } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index d531703cae320a..6b9d8467766db8 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -7146,3 +7146,57 @@ noscript { } } } + +.circle-dropdown { + margin-top: 10px; + position: relative; + display: none; + background-color: $simple-background-color; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + + &.circle-dropdown--visible { + display: flex; + } + + &__icon { + padding: 6px 4px; + flex: 0 0 auto; + font-size: 18px; + color: $inverted-text-color; + } + + &__menu { + flex: 1 1 auto; + appearance: none; + box-sizing: border-box; + font-size: 14px; + line-height: 14px; + text-align: left; + color: $inverted-text-color; + display: inline-block; + width: 100%; + outline: 0; + font-family: inherit; + background: $simple-background-color url("data:image/svg+xml;utf8,") no-repeat right 8px center / auto 16px; + border: 0; + padding: 9px 30px 9px 4px; + + cursor: pointer; + transition: all 100ms ease-in; + transition-property: background-color, color; + + &:hover, + &:active, + &:focus { + background-color: rgba($action-button-color, 0.15); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + + &:focus { + background-color: rgba($action-button-color, 0.3); + } + + } +} diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss index e0b4a6f4937d28..a836cb1667798c 100644 --- a/app/javascript/styles/mastodon/rtl.scss +++ b/app/javascript/styles/mastodon/rtl.scss @@ -417,6 +417,15 @@ body.rtl { right: 0; } + .circle-dropdown .circle-dropdown__menu { + background-position: left 8px center; + } + + .circle-dropdown__menu { + text-align: right; + padding: 9px 4px 9px 30px; + } + .circle-link .circle-edit-button, .circle-link .circle-delete-button { text-align: right; diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index a81ed2138f9c39..7d6a5e55b669a6 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -11,6 +11,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :muted, if: :current_user? attribute :bookmarked, if: :current_user? attribute :pinned, if: :pinnable? + attribute :circle_id, if: :limited_owned_status? attribute :content, unless: :source_requested? attribute :text, if: :source_requested? @@ -43,8 +44,12 @@ def current_user? !current_user.nil? end + def owned_status? + current_user? && current_user.account_id == object.account_id + end + def show_application? - object.account.user_shows_application? || (current_user? && current_user.account_id == object.account_id) + object.account.user_shows_application? || owned_status? end def visibility @@ -62,6 +67,14 @@ def limited object.limited_visibility? end + def limited_owned_status? + object.limited_visibility? && owned_status? && object.in_reply_to_id.nil? + end + + def circle_id + Redis.current.get("statuses/#{object.id}/circle_id") + end + def uri ActivityPub::TagManager.instance.uri_for(object) end @@ -115,8 +128,7 @@ def pinned end def pinnable? - current_user? && - current_user.account_id == object.account_id && + owned_status? && !object.reblog? && %w(public unlisted).include?(object.visibility) end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 85eec6570708fb..45606b3f05c95a 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -73,6 +73,7 @@ def process_status! ProcessHashtagsService.new.call(@status) ProcessMentionsService.new.call(@status, @circle) + redis.setex(circle_id_key, 3.days.seconds, @circle.id) if @circle.present? end def schedule_status! @@ -118,6 +119,10 @@ def scheduled? @scheduled_at.present? end + def circle_id_key + "statuses/#{@status.id}/circle_id" + end + def idempotency_key "idempotency:status:#{@account.id}:#{@options[:idempotency]}" end