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') {