Commit e90ec1d0 authored by Jeffrey Phillips Freeman's avatar Jeffrey Phillips Freeman 💥
Browse files

Merge branch 'instance_only_statuses' into qoto-fixed

parents 6a14ca8c 8126ac03
......@@ -42,7 +42,7 @@ class AccountsController < ApplicationController
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 @@ class AccountsController < ApplicationController
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
......
......@@ -33,6 +33,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
'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
......@@ -52,7 +52,8 @@ class Api::V1::StatusesController < Api::BaseController
poll: status_params[:poll],
idempotency: request.headers['Idempotency-Key'],
with_rate_limit: true,
quote_id: status_params[:quote_id].presence)
quote_id: status_params[:quote_id].presence,
local_only: status_params[:local_only])
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
end
......@@ -101,6 +102,7 @@ class Api::V1::StatusesController < Api::BaseController
:quote_id,
:expires_at,
:expires_action,
:local_only,
media_ids: [],
poll: [
:multiple,
......
......@@ -36,6 +36,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_default_privacy,
:setting_default_sensitive,
:setting_default_language,
:setting_default_federation,
:setting_unfollow_modal,
:setting_unsubscribe_modal,
:setting_boost_modal,
......
......@@ -50,6 +50,7 @@ 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_FEDERATION_CHANGE = 'COMPOSE_FEDERATION_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
......@@ -169,6 +170,7 @@ export function submitCompose(routerHistory) {
circle_id: getState().getIn(['compose', 'circle_id']),
poll: getState().getIn(['compose', 'poll'], null),
quote_id: getState().getIn(['compose', 'quote_from'], null),
local_only: !getState().getIn(['compose', 'federation']),
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
......@@ -625,6 +627,13 @@ export function changeComposeCircle(value) {
};
};
export function changeComposeFederation(value) {
return {
type: COMPOSE_FEDERATION_CHANGE,
value,
};
};
export function insertEmojiCompose(position, emoji, needsSpace) {
return {
type: COMPOSE_EMOJI_INSERT,
......
......@@ -27,6 +27,7 @@ const messages = defineMessages({
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
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' },
......@@ -253,6 +254,7 @@ class StatusActionBar extends ImmutablePureComponent {
const anonymousAccess = !me;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const account = status.get('account');
const federated = !status.get('local_only');
let menu = [];
......@@ -372,6 +374,9 @@ class StatusActionBar extends ImmutablePureComponent {
title={intl.formatMessage(messages.more)}
/>
</div>
{ !federated &&
<IconButton className='status__action-bar-button' disabled title={intl.formatMessage(messages.local_only)} icon='chain-broken' />
}
</div>
);
}
......
......@@ -13,6 +13,7 @@ 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 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';
......@@ -45,6 +46,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,
......@@ -246,6 +248,7 @@ class ComposeForm extends ImmutablePureComponent {
<PollButtonContainer />
<PrivacyDropdownContainer />
<SpoilerButtonContainer />
<FederationDropdownContainer />
</div>
<div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
</div>
......
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 detectPassiveEvents 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 = detectPassiveEvents.hasSupport ? { 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 (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ 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
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
{items.map(item => (
<div role='option' tabIndex='0' key={item.value} data-index={item.value ? item.value : undefined} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
))}
</div>
)}
</Motion>
);
}
}
@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 (
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
<IconButton
className='privacy-dropdown__value-icon'
icon={valueOption.icon}
title={intl.formatMessage(messages.change_federation)}
size={18}
expanded={open}
active={open}
inverted
onClick={this.handleToggle}
style={{ height: null, lineHeight: '27px' }}
/>
</div>
<Overlay show={open} placement={placement} target={this}>
<FederationDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</Overlay>
</div>
);
}
}
......@@ -97,6 +97,16 @@ class SearchResults extends ImmutablePureComponent {
<div className='search-results__section'>
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
<div className='search-results__info'>
<FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
</div>
</div>
);
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
statuses = (
<div className='search-results__section'>
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
<div className='search-results__info'>
<FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
</div>
......
......@@ -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']),
......
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);
......@@ -24,6 +24,7 @@ const messages = defineMessages({
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' },
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' },
});
const mapStateToProps = (state, props) => {
......@@ -170,6 +171,7 @@ class DetailedStatus extends ImmutablePureComponent {
let media = '';
let applicationLink = '';
let reblogLink = '';
let localOnly = '';
let reblogIcon = 'retweet';
let favouriteLink = '';
......@@ -361,6 +363,10 @@ class DetailedStatus extends ImmutablePureComponent {
);
}
if(status.get('local_only')) {
localOnly = <span> · <i className='fa fa-chain-broken' title={intl.formatMessage(messages.local_only)} /></span>;
}
if (this.context.router) {
favouriteLink = (
<Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
......@@ -409,7 +415,7 @@ class DetailedStatus extends ImmutablePureComponent {
</time>
</span>
}
{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}{localOnly}
</div>
</div>
</div>
......
......@@ -169,6 +169,11 @@
"error.unexpected_crash.next_steps": "حاول إعادة إنعاش الصفحة. إن لم تُحلّ المشكلة ، يمكنك دائمًا استخدام ماستدون عبر متصفّح آخر أو تطبيق أصلي.",
"errors.unexpected_crash.copy_stacktrace": "انسخ تتبع الارتباطات إلى الحافظة",
"errors.unexpected_crash.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_request.authorize": "ترخيص",
"follow_request.reject": "رفض",
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
......@@ -379,6 +384,7 @@
"status.favourite": "أضف إلى المفضلة",
"status.filtered": "مُصفّى",
"status.load_more": "حمّل المزيد",
"status.local_only": "This post is only visible by other users of your instance",
"status.media_hidden": "الصورة مستترة",
"status.mention": "أذكُر @{name}",
"status.more": "المزيد",
......
......@@ -169,6 +169,11 @@
"error.unexpected_crash.next_steps": "Try 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_request.authorize": "Autorizar",
"follow_request.reject": "Refugar",
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
......
......@@ -169,6 +169,11 @@
"error.unexpected_crash.next_steps": "Try 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_request.authorize": "Authorize",
"follow_request.reject": "Reject",
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
......@@ -379,6 +384,7 @@
"status.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": "Споменаване",
"status.more": "More",
......
......@@ -169,6 +169,11 @@
"error.unexpected_crash.next_steps": "Prova recarregant la pàgina. Si això no ajuda, encara podries ser capaç d'utilitzar Mastodon a través d'un navegador diferent o amb una app nativa.",
"errors.unexpected_crash.copy_stacktrace": "Còpia stacktrace al porta-retalls",
"errors.unexpected_crash.report_issue": "Informa d'un problema",
"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_request.authorize": "Autoritzar",
"follow_request.reject": "Rebutjar",
"follow_requests.unlocked_explanation": "Tot i que el teu compte no està bloquejat, el personal de {domain} ha pensat que és possible que vulguis revisar les sol·licituds de seguiment d’aquests comptes de forma manual.",
......@@ -379,6 +384,7 @@
"status.favourite": "Favorit",
"status.filtered": "Filtrat",
"status.load_more": "Carrega més",
"status.local_only": "This post is only visible by other users of your instance",
"status.media_hidden": "Multimèdia amagat",
"status.mention": "Esmentar @{name}",
"status.more": "Més",
......
......@@ -169,6 +169,11 @@
"error.unexpected_crash.next_steps": "Pruvate d'attualizà sta pagina. S'ellu persiste u prublemu, pudete forse sempre accede à Mastodon dapoi un'alltru navigatore o applicazione.",
"errors.unexpected_crash.copy_stacktrace": "Cupià stacktrace nant'à u fermacarta",
"errors.unexpected_crash.report_issue": "Palisà prublemu",
"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_request.authorize": "Auturizà",
"follow_request.reject": "Righjittà",
"follow_requests.unlocked_explanation": "U vostru contu ùn hè micca privatu, ma a squadra d'amministrazione di {domain} pensa chì e dumande d'abbunamentu di questi conti anu bisognu