diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 8210918d869c10..eec7e176438009 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -42,7 +42,7 @@ def show expires_in 1.minute, public: true limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE - @statuses = filtered_statuses.without_reblogs.limit(limit) + @statuses = filtered_statuses.without_reblogs.without_local_only.limit(limit) @statuses = cache_collection(@statuses, Status) render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag]) end @@ -73,7 +73,11 @@ def filtered_statuses end def default_statuses - @account.statuses.where(visibility: [:public, :unlisted]) + if current_user.nil? + @account.statuses.without_local_only.where(visibility: [:public, :unlisted]) + else + @account.statuses.where(visibility: [:public, :unlisted]) + end end def only_media_scope diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 64b5cb747cd6c0..af4b6e68f97652 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -33,6 +33,7 @@ def user_settings_params 'setting_default_privacy' => source_params.fetch(:privacy, @account.user.setting_default_privacy), 'setting_default_sensitive' => source_params.fetch(:sensitive, @account.user.setting_default_sensitive), 'setting_default_language' => source_params.fetch(:language, @account.user.setting_default_language), + 'setting_default_federation' => source_params.fetch(:federation, @account.user.setting_default_federation), } end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 106fc8224e2876..a67197634657c9 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -46,7 +46,8 @@ def create application: doorkeeper_token.application, poll: status_params[:poll], idempotency: request.headers['Idempotency-Key'], - with_rate_limit: true) + with_rate_limit: true, + local_only: status_params[:local_only]) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer end @@ -85,6 +86,7 @@ def status_params :spoiler_text, :visibility, :scheduled_at, + :local_only, media_ids: [], poll: [ :multiple, diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 32b5d79487b741..ff41f79cfa54fe 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -36,6 +36,7 @@ def user_settings_params :setting_default_privacy, :setting_default_sensitive, :setting_default_language, + :setting_default_federation, :setting_unfollow_modal, :setting_boost_modal, :setting_delete_modal, diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 891403969e2f1d..80750ec569f301 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_FEDERATION_CHANGE = 'COMPOSE_FEDERATION_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; @@ -147,6 +148,7 @@ export function submitCompose(routerHistory) { spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', visibility: getState().getIn(['compose', 'privacy']), poll: getState().getIn(['compose', 'poll'], null), + local_only: !getState().getIn(['compose', 'federation']), }, { headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), @@ -592,6 +594,13 @@ export function changeComposeVisibility(value) { }; }; +export function changeComposeFederation(value) { + return { + type: COMPOSE_FEDERATION_CHANGE, + value, + }; +}; + export function insertEmojiCompose(position, emoji, needsSpace) { return { type: COMPOSE_EMOJI_INSERT, diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 9981f2449bf2d2..16f7783f05e0e0 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -24,6 +24,7 @@ const messages = defineMessages({ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, @@ -228,6 +229,7 @@ class StatusActionBar extends ImmutablePureComponent { const mutingConversation = status.get('muted'); const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; + const federated = !status.get('local_only'); let menu = []; @@ -341,6 +343,9 @@ class StatusActionBar extends ImmutablePureComponent { title={intl.formatMessage(messages.more)} /> + { !federated && + + } ); } diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index ba2d20cc7e6c3a..891eb2d64a9721 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 FederationDropdownContainer from '../containers/federation_dropdown_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import PollFormContainer from '../containers/poll_form_container'; import UploadFormContainer from '../containers/upload_form_container'; @@ -43,6 +44,7 @@ class ComposeForm extends ImmutablePureComponent { suggestions: ImmutablePropTypes.list, spoiler: PropTypes.bool, privacy: PropTypes.string, + federation: PropTypes.bool, spoilerText: PropTypes.string, focusDate: PropTypes.instanceOf(Date), caretPosition: PropTypes.number, @@ -256,6 +258,7 @@ class ComposeForm extends ImmutablePureComponent { +
diff --git a/app/javascript/mastodon/features/compose/components/federation_dropdown.js b/app/javascript/mastodon/features/compose/components/federation_dropdown.js new file mode 100644 index 00000000000000..adcf60c7c77efa --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/federation_dropdown.js @@ -0,0 +1,250 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from '../../ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import classNames from 'classnames'; + +const messages = defineMessages({ + federate_short: { id: 'federation.federated.short', defaultMessage: 'Federated' }, + federate_long: { id: 'federation.federated.long', defaultMessage: 'Allow toot to reach other instances' }, + local_only_short: { id: 'federation.local_only.short', defaultMessage: 'Local-only' }, + local_only_long: { id: 'federation.local_only.long', defaultMessage: 'Restrict this toot only to my instance' }, + change_federation: { id: 'federation.change', defaultMessage: 'Adjust status federation' }, +}); + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +class FederationDropdownMenu extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + items: PropTypes.array.isRequired, + value: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + }; + + state = { + mounted: false, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + handleKeyDown = e => { + const { items } = this.props; + const value = Boolean(e.currentTarget.getAttribute('data-index')); + const index = items.findIndex(item => { + return (item.value === value); + }); + let element; + + switch(e.key) { + case 'Escape': + this.props.onClose(); + break; + case 'Enter': + this.handleClick(e); + break; + case 'ArrowDown': + element = this.node.childNodes[index + 1]; + if (element) { + element.focus(); + this.props.onChange(Boolean(element.getAttribute('data-index'))); + } + break; + case 'ArrowUp': + element = this.node.childNodes[index - 1]; + if (element) { + element.focus(); + this.props.onChange(Boolean(element.getAttribute('data-index'))); + } + break; + case 'Home': + element = this.node.firstChild; + if (element) { + element.focus(); + this.props.onChange(Boolean(element.getAttribute('data-index'))); + } + break; + case 'End': + element = this.node.lastChild; + if (element) { + element.focus(); + this.props.onChange(Boolean(element.getAttribute('data-index'))); + } + break; + } + } + + handleClick = e => { + const value = Boolean(e.currentTarget.getAttribute('data-index')); + + e.preventDefault(); + + this.props.onClose(); + this.props.onChange(value); + } + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.focusedItem) this.focusedItem.focus(); + this.setState({ mounted: true }); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + setFocusRef = c => { + this.focusedItem = c; + } + + render () { + const { mounted } = this.state; + const { style, items, value } = this.props; + + return ( + + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays +
+ {items.map(item => ( +
+
+ +
+ +
+ {item.text} + {item.meta} +
+
+ ))} +
+ )} +
+ ); + } + +} + +@injectIntl +export default class FederationDropdown extends React.PureComponent { + + static propTypes = { + isUserTouching: PropTypes.func, + isModalOpen: PropTypes.bool.isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + value: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + open: false, + placement: null, + }; + + handleToggle = ({ target }) => { + if (this.props.isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + this.props.onModalOpen({ + actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), + onClick: this.handleModalActionClick, + }); + } + } else { + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + this.setState({ open: !this.state.open }); + } + } + + handleModalActionClick = (e) => { + e.preventDefault(); + + const { value } = this.options[e.currentTarget.getAttribute('data-index')]; + + this.props.onModalClose(); + this.props.onChange(value); + } + + handleKeyDown = e => { + switch(e.key) { + case 'Escape': + this.handleClose(); + break; + } + } + + handleClose = () => { + this.setState({ open: false }); + } + + handleChange = value => { + this.props.onChange(value); + } + + componentWillMount () { + const { intl: { formatMessage } } = this.props; + + this.options = [ + { icon: 'link', value: true, text: formatMessage(messages.federate_short), meta: formatMessage(messages.federate_long) }, + { icon: 'chain-broken', value: false, text: formatMessage(messages.local_only_short), meta: formatMessage(messages.local_only_long) }, + ]; + } + + render () { + const { value, intl } = this.props; + const { open, placement } = this.state; + + const valueOption = this.options.find(item => item.value === value); + + return ( +
+
+ +
+ + + + +
+ ); + } + +} 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..1483e943b4e14b 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -17,6 +17,7 @@ const mapStateToProps = state => ({ spoiler: state.getIn(['compose', 'spoiler']), spoilerText: state.getIn(['compose', 'spoiler_text']), privacy: state.getIn(['compose', 'privacy']), + federation: state.getIn(['compose', 'federation']), focusDate: state.getIn(['compose', 'focusDate']), caretPosition: state.getIn(['compose', 'caretPosition']), preselectDate: state.getIn(['compose', 'preselectDate']), diff --git a/app/javascript/mastodon/features/compose/containers/federation_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/federation_dropdown_container.js new file mode 100644 index 00000000000000..268f4da2dbb6dd --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/federation_dropdown_container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import FederationDropdown from '../components/federation_dropdown'; +import { changeComposeFederation } from '../../../actions/compose'; +import { openModal, closeModal } from '../../../actions/modal'; +import { isUserTouching } from '../../../is_mobile'; + +const mapStateToProps = state => ({ + isModalOpen: state.get('modal').modalType === 'ACTIONS', + value: state.getIn(['compose', 'federation']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeFederation(value)); + }, + + isUserTouching, + onModalOpen: props => dispatch(openModal('ACTIONS', props)), + onModalClose: () => dispatch(closeModal()), + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(FederationDropdown); diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 043a749ede24be..575ebfce51aedb 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -22,6 +22,7 @@ const messages = defineMessages({ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, + local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' }, }); export default @injectIntl @@ -114,6 +115,7 @@ class DetailedStatus extends ImmutablePureComponent { let media = ''; let applicationLink = ''; let reblogLink = ''; + let localOnly = ''; let reblogIcon = 'retweet'; let favouriteLink = ''; @@ -217,6 +219,10 @@ class DetailedStatus extends ImmutablePureComponent { ); } + if(status.get('local_only')) { + localOnly = · ; + } + if (this.context.router) { favouriteLink = ( @@ -252,7 +258,7 @@ class DetailedStatus extends ImmutablePureComponent {
- {visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} + {visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}{localOnly}
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 801d5d2dde636c..91a8396ce67d45 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -449,6 +449,10 @@ "defaultMessage": "This post cannot be boosted", "id": "status.cannot_reblog" }, + { + "defaultMessage": "This post is only visible by other users of your instance", + "id": "status.local_only" + }, { "defaultMessage": "Favourite", "id": "status.favourite" @@ -1125,6 +1129,31 @@ ], "path": "app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.json" }, + { + "descriptors": [ + { + "defaultMessage": "Federated", + "id": "federation.federated.short" + }, + { + "defaultMessage": "Allow toot to reach other instances", + "id": "federation.federated.long" + }, + { + "defaultMessage": "Local-only", + "id": "federation.local_only.short" + }, + { + "defaultMessage": "Restrict this toot only to my instance", + "id": "federation.local_only.long" + }, + { + "defaultMessage": "Adjust status federation", + "id": "federation.change" + } + ], + "path": "app/javascript/mastodon/features/compose/components/federation_dropdown.json" + }, { "descriptors": [ { @@ -2676,6 +2705,10 @@ { "defaultMessage": "Direct", "id": "privacy.direct.short" + }, + { + "defaultMessage": "This post is only visible by other users of your instance", + "id": "status.local_only" } ], "path": "app/javascript/mastodon/features/status/components/detailed_status.json" @@ -3257,4 +3290,4 @@ ], "path": "app/javascript/mastodon/features/video/index.json" } -] \ No newline at end of file +] diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 0c3ce2f622ea09..59a61f1eb13a05 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -176,6 +176,11 @@ "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard", "errors.unexpected_crash.report_issue": "Report issue", + "federation.change": "Adjust status federation", + "federation.federated.long": "Allow toot to reach other instances", + "federation.federated.short": "Federated", + "federation.local_only.long": "Restrict this toot only to my instance", + "federation.local_only.short": "Local-only", "follow_recommendations.done": "Done", "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.", "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!", @@ -392,6 +397,7 @@ "status.favourite": "Favourite", "status.filtered": "Filtered", "status.load_more": "Load more", + "status.local_only": "This post is only visible by other users of your instance", "status.media_hidden": "Media hidden", "status.mention": "Mention @{name}", "status.more": "More", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index 1a45d9ca97ef81..6d015a4ddfbae2 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -176,6 +176,11 @@ "error.unexpected_crash.next_steps_addons": "Tente desabilitá-los e atualizar a página. Se isso não ajudar, você ainda poderá usar o Mastodon por meio de um navegador diferente ou de um aplicativo nativo.", "errors.unexpected_crash.copy_stacktrace": "Copiar stacktrace para área de transferência", "errors.unexpected_crash.report_issue": "Denunciar problema", + "federation.change": "Ajustar federação do toot", + "federation.federated.long": "Permitir que o toot chegue a outras instâncias", + "federation.federated.short": "Federado", + "federation.local_only.long": "Restringir o toot somente à minha instância", + "federation.local_only.short": "Somente local", "follow_recommendations.done": "Done", "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.", "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!", @@ -392,6 +397,7 @@ "status.favourite": "Favoritar", "status.filtered": "Filtrado", "status.load_more": "Carregar mais", + "status.local_only": "Esse post só é visível para outros usuários da sua instância", "status.media_hidden": "Mídia escondida", "status.mention": "Mencionar @{name}", "status.more": "Mais", diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json index 0d5da25f7a1767..01524fed2b3b98 100644 --- a/app/javascript/mastodon/locales/pt-PT.json +++ b/app/javascript/mastodon/locales/pt-PT.json @@ -392,6 +392,7 @@ "status.favourite": "Adicionar aos favoritos", "status.filtered": "Filtrada", "status.load_more": "Carregar mais", + "status.local_only": "Esse post só é visível para outros usuários da sua instância", "status.media_hidden": "Media escondida", "status.mention": "Mencionar @{name}", "status.more": "Mais", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 986ebfbdff9d53..0f7f918a607e8e 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -176,6 +176,11 @@ "error.unexpected_crash.next_steps_addons": "请尝试禁用它们并刷新页面。如果没有帮助,你仍可以尝试使用其他浏览器或原生应用来使用 Mastodon。", "errors.unexpected_crash.copy_stacktrace": "把堆栈跟踪信息复制到剪贴板", "errors.unexpected_crash.report_issue": "报告问题", + "federation.change": "设置嘟文联邦可见性", + "federation.federated.long": "允许嘟文出现在其他实例上", + "federation.federated.short": "接入联邦宇宙", + "federation.local_only.long": "仅允许嘟文出现在本实例上", + "federation.local_only.short": "仅本实例可见", "follow_recommendations.done": "完成", "follow_recommendations.heading": "关注你感兴趣的用户!这里有一些推荐。", "follow_recommendations.lead": "你关注的人的嘟文将按时间顺序在你的主页上显示。 别担心,你可以随时取消关注!", @@ -392,6 +397,7 @@ "status.favourite": "喜欢", "status.filtered": "已过滤", "status.load_more": "加载更多", + "status.local_only": "这条嘟文仅本实例用户可见", "status.media_hidden": "已隐藏的媒体内容", "status.mention": "提及 @{name}", "status.more": "更多", diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index 68e99886f6ee8a..fe17e1885519d7 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -176,6 +176,11 @@ "error.unexpected_crash.next_steps_addons": "請嘗試停止使用這些附加元件然後重新載入頁面。如果問題沒有解決,你仍然可以使用不同的瀏覽器或 Mastodon 應用程式來檢視。", "errors.unexpected_crash.copy_stacktrace": "複製 stacktrace 到剪貼簿", "errors.unexpected_crash.report_issue": "舉報問題", + "federation.change": "設定嘟文聯邦可見性", + "federation.federated.long": "允許嘟文出現在其他實例上", + "federation.federated.short": "接入聯邦宇宙", + "federation.local_only.long": "僅允許嘟文出現在本實例上", + "federation.local_only.short": "僅本實例可見", "follow_recommendations.done": "Done", "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.", "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!", @@ -392,6 +397,7 @@ "status.favourite": "最愛", "status.filtered": "已過濾", "status.load_more": "載入更多", + "status.local_only": "這條嘟文僅本實例用戶可見", "status.media_hidden": "隱藏媒體內容", "status.mention": "提及 @{name}", "status.more": "更多", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 1e9527cb8258e6..6749a21ebef1a4 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -176,6 +176,11 @@ "error.unexpected_crash.next_steps_addons": "請嘗試關閉他們然後重新整理頁面。如果狀況沒有改善,您可以使用不同的瀏覽器或應用程式來檢視來使用 Mastodon。", "errors.unexpected_crash.copy_stacktrace": "複製 stacktrace 到剪貼簿", "errors.unexpected_crash.report_issue": "回報問題", + "federation.change": "設定嘟文聯邦可見性", + "federation.federated.long": "允許嘟文出現在其他實例上", + "federation.federated.short": "接入聯邦宇宙", + "federation.local_only.long": "僅允許嘟文出現在本實例上", + "federation.local_only.short": "僅本實例可見", "follow_recommendations.done": "完成", "follow_recommendations.heading": "追蹤您想檢視其貼文的人!這裡有一些建議。", "follow_recommendations.lead": "來自您追蹤的人的貼文將會按時間順序顯示在您的家 feed 上。不要害怕犯錯,您隨時都可以取消追蹤其他人!", @@ -392,6 +397,7 @@ "status.favourite": "最愛", "status.filtered": "已過濾", "status.load_more": "載入更多", + "status.local_only": "這條嘟文僅本實例用戶可見", "status.media_hidden": "隱藏媒體內容", "status.mention": "提及 @{name}", "status.more": "更多", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 4c0ba1c362224a..53f7064543a43e 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_FEDERATION_CHANGE, COMPOSE_COMPOSING_CHANGE, COMPOSE_EMOJI_INSERT, COMPOSE_UPLOAD_CHANGE_REQUEST, @@ -54,6 +55,7 @@ const initialState = ImmutableMap({ spoiler: false, spoiler_text: '', privacy: null, + federation: null, text: '', focusDate: null, caretPosition: null, @@ -72,6 +74,7 @@ const initialState = ImmutableMap({ suggestion_token: null, suggestions: ImmutableList(), default_privacy: 'public', + default_federation: true, default_sensitive: false, resetFileKey: Math.floor((Math.random() * 0x10000)), idempotencyKey: null, @@ -103,6 +106,7 @@ function clearAll(state) { map.set('is_changing_upload', false); map.set('in_reply_to', null); map.set('privacy', state.get('default_privacy')); + map.set('federation', state.get('default_federation')); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); map.set('poll', null); @@ -279,6 +283,10 @@ export default function compose(state = initialState, action) { return state .set('spoiler_text', action.text) .set('idempotencyKey', uuid()); + case COMPOSE_FEDERATION_CHANGE: + return state + .set('federation', action.value) + .set('idempotencyKey', uuid()); case COMPOSE_VISIBILITY_CHANGE: return state .set('privacy', action.value) @@ -294,6 +302,7 @@ export default function compose(state = initialState, action) { map.set('in_reply_to', action.status.get('id')); map.set('text', statusToTextMentions(state, action.status)); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.set('federation', !action.status.get('local_only')); map.set('focusDate', new Date()); map.set('caretPosition', null); map.set('preselectDate', new Date()); @@ -316,6 +325,7 @@ export default function compose(state = initialState, action) { map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); map.set('poll', null); + map.set('federation', state.get('default_federation')); map.set('idempotencyKey', uuid()); }); case COMPOSE_SUBMIT_REQUEST: @@ -402,6 +412,7 @@ export default function compose(state = initialState, action) { 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', action.status.get('visibility')); + map.set('federation', !action.status.get('local_only')); map.set('media_attachments', action.status.get('media_attachments')); map.set('focusDate', new Date()); map.set('caretPosition', null); diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index e37bc6d9f11437..c00404d5daffa4 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -20,6 +20,7 @@ def process_update user.settings['default_privacy'] = default_privacy_preference if change?('setting_default_privacy') user.settings['default_sensitive'] = default_sensitive_preference if change?('setting_default_sensitive') user.settings['default_language'] = default_language_preference if change?('setting_default_language') + user.settings['default_federation'] = default_federation_preference if change?('setting_default_federation') user.settings['unfollow_modal'] = unfollow_modal_preference if change?('setting_unfollow_modal') user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal') user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal') @@ -57,6 +58,10 @@ def default_sensitive_preference boolean_cast_setting 'setting_default_sensitive' end + def default_federation_preference + boolean_cast_setting 'setting_default_federation' + end + def unfollow_modal_preference boolean_cast_setting 'setting_unfollow_modal' end diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb index 5e4c3e1cee98bd..f84203ee11c405 100644 --- a/app/models/public_feed.rb +++ b/app/models/public_feed.rb @@ -25,7 +25,11 @@ def get(limit, max_id = nil, since_id = nil, min_id = nil) scope.merge!(without_reblogs_scope) unless with_reblogs? scope.merge!(local_only_scope) if local_only? scope.merge!(remote_only_scope) if remote_only? - scope.merge!(account_filters_scope) if account? + if account? + scope.merge!(account_filters_scope) + else + scope.merge!(instance_only_statuses_scope) + end scope.merge!(media_only_scope) if media_only? scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id) @@ -83,6 +87,10 @@ def media_only_scope Status.joins(:media_attachments).group(:id) end + def instance_only_statuses_scope + Status.where(local_only: [false, nil]) + end + def account_filters_scope Status.not_excluded_by_account(account).tap do |scope| scope.merge!(Status.not_domain_blocked_by_account(account)) unless local_only? diff --git a/app/models/status.rb b/app/models/status.rb index c7f761bc6ba58f..aae965962084d9 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -23,6 +23,7 @@ # in_reply_to_account_id :bigint(8) # poll_id :bigint(8) # deleted_at :datetime +# local_only :boolean # class Status < ApplicationRecord @@ -88,6 +89,7 @@ class Status < ApplicationRecord scope :with_accounts, ->(ids) { where(id: ids).includes(:account) } scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') } scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') } + scope :without_local_only, -> { where(local_only: [false, nil]) } scope :with_public_visibility, -> { where(visibility: :public) } scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) } scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) } @@ -161,6 +163,10 @@ def local? attributes['local'] || uri.nil? end + def local_only? + local_only + end + def in_reply_to_local_account? reply? && thread&.account&.local? end @@ -260,6 +266,8 @@ def decrement_count!(key) around_create Mastodon::Snowflake::Callbacks + before_create :set_locality + before_validation :prepare_contents, if: :local? before_validation :set_reblog before_validation :set_visibility @@ -317,7 +325,7 @@ def permitted_for(target_account, account) visibility = [:public, :unlisted] if account.nil? - where(visibility: visibility) + where(visibility: visibility).without_local_only elsif target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) # get rid of blocked peeps none elsif account.id == target_account.id # author can see own stuff @@ -411,6 +419,10 @@ def set_local self.local = account.local? end + def set_locality + self.local_only = reblog.local_only if reblog? + end + def update_statistics return unless distributable? diff --git a/app/models/user.rb b/app/models/user.rb index b3fb55dfc018a1..4cb40de8d46a4f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -124,7 +124,7 @@ class User < ApplicationRecord :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network, :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, - :disable_swiping, + :disable_swiping, :default_federation, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code, :sign_in_token_attempt diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index bcf9c3395ca974..cf1233b9421f86 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -12,6 +12,7 @@ def index? end def show? + return false if local_only? && (current_account.nil? || !current_account.local?) return false if author.suspended? if requires_mention? @@ -92,4 +93,8 @@ def following_author? def author record.account end + + def local_only? + record.local_only? + end end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 70263e0c5768c2..4778c7a28a4936 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -58,6 +58,7 @@ def compose store[:me] = object.current_account.id.to_s store[:default_privacy] = object.visibility || object.current_account.user.setting_default_privacy store[:default_sensitive] = object.current_account.user.setting_default_sensitive + store[:default_federation] = object.current_account.user.setting_default_federation end store[:text] = object.text if object.text diff --git a/app/serializers/rest/credential_account_serializer.rb b/app/serializers/rest/credential_account_serializer.rb index be0d763dc12299..fa8f900cc59417 100644 --- a/app/serializers/rest/credential_account_serializer.rb +++ b/app/serializers/rest/credential_account_serializer.rb @@ -10,6 +10,7 @@ def source privacy: user.setting_default_privacy, sensitive: user.setting_default_sensitive, language: user.setting_default_language, + federation: user.setting_default_federation, note: object.note, fields: object.fields.map(&:to_h), follow_requests_count: FollowRequest.where(target_account: object).limit(40).count, diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index bb6df90b7a61f5..495ac5ed2eb4f6 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitive, :spoiler_text, :visibility, :language, :uri, :url, :replies_count, :reblogs_count, - :favourites_count + :favourites_count, :local_only attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 85aaec4d6585d9..86ca209fc8fd83 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -90,11 +90,19 @@ def schedule_status! end end + def local_only_option(local_only, in_reply_to, federation_setting) + return in_reply_to&.local_only? if local_only.nil? # XXX temporary, just until clients implement to avoid leaking local_only posts + return federation_setting if local_only.nil? + local_only + end + def postprocess_status! LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text? DistributionWorker.perform_async(@status.id) - ActivityPub::DistributionWorker.perform_async(@status.id) - PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll + unless @status.local_only? + ActivityPub::DistributionWorker.perform_async(@status.id) + PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll + end end def validate_media! @@ -167,6 +175,7 @@ def status_attributes language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account), application: @options[:application], rate_limit: @options[:with_rate_limit], + local_only: local_only_option(@options[:local_only], @in_reply_to, @account.user&.setting_default_federation), }.compact end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 73dbb18345a7e6..ec4cb11f9aeb83 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -58,7 +58,7 @@ def create_notification(mention) if mentioned_account.local? LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention) - elsif mentioned_account.activitypub? + elsif mentioned_account.activitypub? && !@status.local_only? ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url, { synchronize_followers: !mention.status.distributable? }) end end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 744bdf5673a0e6..798ebd70021ba8 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -31,7 +31,9 @@ def call(account, reblogged_status, options = {}) reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit]) DistributionWorker.perform_async(reblog.id) - ActivityPub::DistributionWorker.perform_async(reblog.id) + unless reblogged_status.local_only? + ActivityPub::DistributionWorker.perform_async(reblog.id) + end create_notification(reblog) bump_potential_friendship(account, reblog) diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml index ada6dd2bc549f9..b2ffd71be670d6 100644 --- a/app/views/admin/reports/_status.html.haml +++ b/app/views/admin/reports/_status.html.haml @@ -38,3 +38,7 @@ · = fa_icon('eye-slash fw') = t('stream_entries.sensitive_content') + - if status.proper.local_only + · + = fa_icon('chain-broken fw') + = t('statuses.local_only') diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml index 539a700561e61d..c5c04511853eea 100644 --- a/app/views/settings/preferences/other/show.html.haml +++ b/app/views/settings/preferences/other/show.html.haml @@ -28,6 +28,9 @@ .fields-group = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label + .fields-group + = f.input :setting_default_federation, as: :boolean, wrapper: :with_label + .fields-group = f.input :setting_show_application, as: :boolean, wrapper: :with_label, recommended: true diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index 6b3b8130672a19..5f2971b2ebd58c 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -69,6 +69,10 @@ %span.detailed-status__favorites>= friendly_number_to_human status.favourites_count = " " + - if status.local_only + · + %span.detailed-status__link.modal-button.disabled< + = fa_icon 'chain-broken fw', 'title': t('statuses.local_only') - if user_signed_in? · = link_to t('statuses.open_in_web'), web_url("statuses/#{status.id}"), class: 'detailed-status__application', target: '_blank' diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index 728e6b9b096b64..19675220efad12 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -64,3 +64,6 @@ = fa_icon 'envelope fw' = link_to remote_interaction_path(status, type: :favourite), class: 'status__action-bar-button icon-button modal-button' do = fa_icon 'star fw' + - if status.local_only + %span.status__action-bar-button.icon-button.disabled{style: 'font-size: 18px; width: 23.1429px; height: 23.1429px; line-height: 23.15px;'}< + = fa_icon 'chain-broken fw', 'title': t('statuses.local_only') diff --git a/config/locales/en.yml b/config/locales/en.yml index 9d5185fe6eb744..ef9836ebc47c7e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1253,6 +1253,7 @@ en: errors: in_reply_not_found: The post you are trying to reply to does not appear to exist. language_detection: Automatically detect language + local_only: Local-only open_in_web: Open in web over_character_limit: character limit of %{max} exceeded pin_errors: diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index b9c07bd2916214..c1b9ae73952ec2 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -1233,6 +1233,7 @@ zh-CN: errors: in_reply_not_found: 你回复的嘟文似乎不存在 language_detection: 自动检测语言 + local_only: 仅本实例可见 open_in_web: 在站内打开 over_character_limit: 超过了 %{max} 字的限制 pin_errors: diff --git a/config/locales/zh-HK.yml b/config/locales/zh-HK.yml index ad9bc2ceb74904..3c8ffacefd13bb 100644 --- a/config/locales/zh-HK.yml +++ b/config/locales/zh-HK.yml @@ -1148,6 +1148,7 @@ zh-HK: errors: in_reply_not_found: 你所回覆的嘟文並不存在。 language_detection: 自動偵測語言 + local_only: 僅本實例可見 open_in_web: 開啟網頁 over_character_limit: 超過了 %{max} 字的限制 pin_errors: diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml index 852c333ca6b967..363038e5cf23e1 100644 --- a/config/locales/zh-TW.yml +++ b/config/locales/zh-TW.yml @@ -1061,6 +1061,7 @@ zh-TW: errors: in_reply_not_found: 您嘗試回覆的嘟文看起來不存在。 language_detection: 自動偵測語言 + local_only: 僅本實例可見 open_in_web: 以網頁開啟 over_character_limit: 超過了 %{max} 字的限制 pin_errors: diff --git a/config/settings.yml b/config/settings.yml index 06cee253240fac..28780b4940709c 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -17,6 +17,7 @@ defaults: &defaults timeline_preview: true show_staff_badge: true default_sensitive: false + default_federation: true hide_network: false unfollow_modal: false boost_modal: false diff --git a/db/migrate/20171210213213_add_local_only_flag_to_statuses.rb b/db/migrate/20171210213213_add_local_only_flag_to_statuses.rb new file mode 100644 index 00000000000000..af1e29d6a141c9 --- /dev/null +++ b/db/migrate/20171210213213_add_local_only_flag_to_statuses.rb @@ -0,0 +1,5 @@ +class AddLocalOnlyFlagToStatuses < ActiveRecord::Migration[5.1] + def change + add_column :statuses, :local_only, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 4ec50f89e9632b..a84d17d027abc0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -814,6 +814,7 @@ t.bigint "in_reply_to_account_id" t.bigint "poll_id" t.datetime "deleted_at" + t.boolean "local_only" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index 1cddf4abd57da9..8bce29cad27e36 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -73,6 +73,18 @@ expect(subject).to_not permit(viewer, status) end + + it 'denies access when local-only and the viewer is not logged in' do + allow(status).to receive(:local_only?) { true } + + expect(subject).to_not permit(nil, status) + end + + it 'denies access when local-only and the viewer is from another domain' do + viewer = Fabricate(:account, domain: 'remote-domain') + allow(status).to receive(:local_only?) { true } + expect(subject).to_not permit(viewer, status) + end end permissions :reblog? do diff --git a/streaming/index.js b/streaming/index.js index 7bb645a1357042..b3c248016807d8 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -613,6 +613,12 @@ const startWorker = (workerId) => { return; } + // Only send local-only statuses to logged-in users + if (payload.local_only && !req.accountId) { + log.silly(req.requestId, `Message ${payload.id} filtered because it was local-only`); + return; + } + // Only messages that may require filtering are statuses, since notifications // are already personalized and deletes do not matter if (!needsFiltering || event !== 'update') {