Merge pull request #916 from ThibG/glitch-soc/merge-upstream

Merge upstream changes
rebase/4.0.0rc2
ThibG 2019-02-16 13:43:23 +01:00 committed by GitHub
commit ab9082b332
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 543 additions and 212 deletions

View File

@ -24,7 +24,7 @@ gem 'streamio-ffmpeg', '~> 3.0'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.6'
gem 'bootsnap', '~> 1.3', require: false
gem 'bootsnap', '~> 1.4', require: false
gem 'browser'
gem 'charlock_holmes', '~> 0.7.6'
gem 'iso-639'

View File

@ -92,13 +92,13 @@ GEM
aws-sigv4 (1.0.3)
bcrypt (3.1.12)
benchmark-ips (2.7.2)
better_errors (2.5.0)
better_errors (2.5.1)
coderay (>= 1.0.0)
erubi (>= 1.0.0)
rack (>= 0.9.0)
binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1)
bootsnap (1.3.2)
bootsnap (1.4.0)
msgpack (~> 1.0)
brakeman (4.4.0)
browser (2.5.3)
@ -205,7 +205,7 @@ GEM
tzinfo
excon (0.62.0)
fabrication (2.20.1)
faker (1.9.1)
faker (1.9.3)
i18n (>= 0.7)
faraday (0.15.0)
multipart-post (>= 1.2, < 3)
@ -347,7 +347,7 @@ GEM
mini_mime (1.0.1)
mini_portile2 (2.4.0)
minitest (5.11.3)
msgpack (1.2.4)
msgpack (1.2.6)
multi_json (1.13.1)
multipart-post (2.0.0)
necromancer (0.4.0)
@ -402,7 +402,7 @@ GEM
pg (1.1.4)
pghero (2.2.0)
activerecord
pkg-config (1.3.2)
pkg-config (1.3.3)
powerpack (0.1.2)
premailer (1.11.1)
addressable
@ -565,7 +565,7 @@ GEM
rufus-scheduler (~> 3.2)
sidekiq (>= 3)
tilt (>= 1.4.0)
sidekiq-unique-jobs (6.0.8)
sidekiq-unique-jobs (6.0.9)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 6.0)
thor (~> 0)
@ -662,7 +662,7 @@ DEPENDENCIES
aws-sdk-s3 (~> 1.30)
better_errors (~> 2.5)
binding_of_caller (~> 0.7)
bootsnap (~> 1.3)
bootsnap (~> 1.4)
brakeman (~> 4.4)
browser
bullet (~> 5.9)

View File

@ -31,7 +31,7 @@ class StatusesIndex < Chewy::Index
},
}
define_type ::Status.unscoped.without_reblogs do
define_type ::Status.unscoped.without_reblogs.includes(:media_attachments) do
crutch :mentions do |collection|
data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
@ -50,7 +50,7 @@ class StatusesIndex < Chewy::Index
root date_detection: false do
field :account_id, type: 'long'
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].join("\n\n") } do
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).join("\n\n") } do
field :stemmed, type: 'text', analyzer: 'content'
end

View File

@ -29,6 +29,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
resource.invite_code = params[:invite_code] if resource.invite_code.blank?
resource.agreement = true
resource.current_sign_in_ip = request.remote_ip if resource.current_sign_in_ip.nil?
resource.build_account if resource.account.nil?
end

View File

@ -4,7 +4,7 @@ module SettingsHelper
HUMAN_LOCALES = {
en: 'English',
ar: 'العربية',
ast: 'l\'asturianu',
ast: 'Asturianu',
bg: 'Български',
ca: 'Català',
co: 'Corsu',
@ -30,16 +30,16 @@ module SettingsHelper
ja: '日本語',
ka: 'ქართული',
ko: '한국어',
lv: 'Latviešu valoda',
lv: 'Latviešu',
ml: 'മലയാളം',
ms: 'بهاس ملايو',
ms: 'Bahasa Melayu',
nl: 'Nederlands',
no: 'Norsk',
oc: 'Occitan',
pl: 'Polszczyzna',
pl: 'Polski',
pt: 'Português',
'pt-BR': 'Português do Brasil',
ro: 'Limba română',
ro: 'Română',
ru: 'Русский',
sk: 'Slovenčina',
sl: 'Slovenščina',
@ -49,7 +49,7 @@ module SettingsHelper
sv: 'Svenska',
ta: 'தமிழ்',
te: 'తెలుగు',
th: 'ภาษาไทย',
th: 'ไทย',
tr: 'Türkçe',
uk: 'Українська',
zh: '中文',

View File

@ -1,10 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
import AsyncSelect from 'react-select/lib/Async';
const messages = defineMessages({
placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' },
});
@injectIntl
export default class ColumnSettings extends React.PureComponent {
@ -25,6 +30,7 @@ export default class ColumnSettings extends React.PureComponent {
tags (mode) {
let tags = this.props.settings.getIn(['tags', mode]) || [];
if (tags.toJSON) {
return tags.toJSON();
} else {
@ -32,33 +38,36 @@ export default class ColumnSettings extends React.PureComponent {
}
};
onSelect = (mode) => {
return (value) => {
this.props.onChange(['tags', mode], value);
};
};
onSelect = mode => value => this.props.onChange(['tags', mode], value);
onToggle = () => {
if (this.state.open && this.hasTags()) {
this.props.onChange('tags', {});
}
this.setState({ open: !this.state.open });
};
noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions);
modeSelect (mode) {
return (
<div className='column-settings__section'>
{this.modeLabel(mode)}
<div className='column-settings__row'>
<span className='column-settings__section'>
{this.modeLabel(mode)}
</span>
<AsyncSelect
isMulti
autoFocus
value={this.tags(mode)}
settings={this.props.settings}
settingPath={['tags', mode]}
onChange={this.onSelect(mode)}
loadOptions={this.props.onLoad}
classNamePrefix='column-settings__hashtag-select'
className='column-select__container'
classNamePrefix='column-select'
name='tags'
placeholder={this.props.intl.formatMessage(messages.placeholder)}
noOptionsMessage={this.noOptionsMessage}
/>
</div>
);
@ -66,11 +75,15 @@ export default class ColumnSettings extends React.PureComponent {
modeLabel (mode) {
switch(mode) {
case 'any': return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />;
case 'all': return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />;
case 'none': return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />;
case 'any':
return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />;
case 'all':
return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />;
case 'none':
return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />;
default:
return '';
}
return '';
};
render () {
@ -78,23 +91,21 @@ export default class ColumnSettings extends React.PureComponent {
<div>
<div className='column-settings__row'>
<div className='setting-toggle'>
<Toggle
id='hashtag.column_settings.tag_toggle'
onChange={this.onToggle}
checked={this.state.open}
/>
<Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} />
<span className='setting-toggle__label'>
<FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' />
</span>
</div>
</div>
{this.state.open &&
{this.state.open && (
<div className='column-settings__hashtags'>
{this.modeSelect('any')}
{this.modeSelect('all')}
{this.modeSelect('none')}
</div>
}
)}
</div>
);
}

View File

@ -82,3 +82,34 @@
font-size: 16px;
}
}
@mixin search-popout() {
background: $simple-background-color;
border-radius: 4px;
padding: 10px 14px;
padding-bottom: 14px;
margin-top: 10px;
color: $light-text-color;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
h4 {
text-transform: uppercase;
color: $light-text-color;
font-size: 13px;
font-weight: 500;
margin-bottom: 10px;
}
li {
padding: 4px 0;
}
ul {
margin-bottom: 10px;
}
em {
font-weight: 500;
color: $inverted-text-color;
}
}

View File

@ -49,15 +49,9 @@ $small-breakpoint: 960px;
}
}
strong,
em {
display: inline;
margin: 0;
padding: 0;
font-weight: 700;
background: transparent;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: lighten($darker-text-color, 10%);
}
@ -798,7 +792,7 @@ $small-breakpoint: 960px;
width: 100%;
display: flex;
flex-direction: row-reverse;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
}
@ -848,14 +842,7 @@ $small-breakpoint: 960px;
}
strong {
display: inline;
margin: 0;
padding: 0;
font-weight: 700;
background: transparent;
font-family: inherit;
font-size: inherit;
line-height: inherit;
font-weight: 500;
color: lighten($darker-text-color, 10%);
}

View File

@ -339,14 +339,41 @@
display: block;
font-weight: 500;
margin-bottom: 10px;
}
.column-settings__hashtag-select {
.column-settings__hashtags {
.column-settings__row {
margin-bottom: 15px;
}
.column-select {
&__control {
@include search-input();
}
&__placeholder {
color: $dark-text-color;
padding-left: 2px;
font-size: 12px;
}
&__value-container {
padding-left: 6px;
}
&__multi-value {
background: lighten($ui-base-color, 8%);
&__remove {
cursor: pointer;
&:hover,
&:active,
&:focus {
background: lighten($ui-base-color, 12%);
color: lighten($darker-text-color, 4%);
}
}
}
&__multi-value__label,
@ -354,9 +381,42 @@
color: $darker-text-color;
}
&__indicator-separator,
&__clear-indicator,
&__dropdown-indicator {
display: none;
cursor: pointer;
transition: none;
color: $dark-text-color;
&:hover,
&:active,
&:focus {
color: lighten($dark-text-color, 4%);
}
}
&__indicator-separator {
background-color: lighten($ui-base-color, 8%);
}
&__menu {
@include search-popout();
padding: 0;
background: $ui-secondary-color;
}
&__menu-list {
padding: 6px;
}
&__option {
color: $inverted-text-color;
border-radius: 4px;
font-size: 14px;
&--is-focused,
&--is-selected {
background: darken($ui-secondary-color, 10%);
}
}
}
}

View File

@ -157,29 +157,7 @@
}
.drawer--search--popout {
box-sizing: border-box;
margin-top: 10px;
border-radius: 4px;
padding: 10px 14px 14px 14px;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
color: $light-text-color;
background: $simple-background-color;
h4 {
margin-bottom: 10px;
color: $light-text-color;
font-size: 13px;
font-weight: 500;
text-transform: uppercase;
}
ul { margin-bottom: 10px }
li { padding: 4px 0 }
em {
color: $inverted-text-color;
font-weight: 500;
}
@include search-popout();
}
.drawer--account {

View File

@ -13,6 +13,10 @@
}
}
.rich-formatting a,
.rich-formatting p a,
.rich-formatting li a,
.landing-page__short-description p a,
.status__content a,
.reply-indicator__content a {
color: lighten($ui-highlight-color, 12%);

View File

@ -11,26 +11,36 @@ export default class DisplayName extends React.PureComponent {
};
render () {
const { account, others, localDomain } = this.props;
const displayNameHtml = { __html: account.get('display_name_html') };
const { others, localDomain } = this.props;
let suffix;
let displayName, suffix, account;
if (others && others.size > 1) {
suffix = `+${others.size}`;
displayName = others.take(2).map(a => <bdi key={a.get('id')}><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi>).reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else {
if (others) {
account = others.first();
} else {
account = this.props.account;
}
let acct = account.get('acct');
if (acct.indexOf('@') === -1 && localDomain) {
acct = `${acct}@${localDomain}`;
}
suffix = <span className='display-name__account'>@{acct}</span>;
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
suffix = <span className='display-name__account'>@{acct}</span>;
}
return (
<span className='display-name'>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /></bdi> {suffix}
{displayName} {suffix}
</span>
);
}

View File

@ -86,7 +86,7 @@ class Status extends ImmutablePureComponent {
// Track height changes we know about to compensate scrolling
componentDidMount () {
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status.get('card');
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
}
getSnapshotBeforeUpdate () {
@ -99,7 +99,7 @@ class Status extends ImmutablePureComponent {
// Compensate height changes
componentDidUpdate (prevProps, prevState, snapshot) {
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status.get('card');
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
if (doShowCard && !this.didShowCard) {
this.didShowCard = true;
if (snapshot !== null && this.props.updateScrollBottom) {

View File

@ -108,9 +108,8 @@ class Upload extends ImmutablePureComponent {
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<input
<textarea
placeholder={intl.formatMessage(messages.description)}
type='text'
value={description}
maxLength={420}
onFocus={this.handleInputFocus}

View File

@ -1,10 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
import AsyncSelect from 'react-select/lib/Async';
const messages = defineMessages({
placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' },
});
export default @injectIntl
class ColumnSettings extends React.PureComponent {
@ -25,6 +30,7 @@ class ColumnSettings extends React.PureComponent {
tags (mode) {
let tags = this.props.settings.getIn(['tags', mode]) || [];
if (tags.toJSON) {
return tags.toJSON();
} else {
@ -32,33 +38,36 @@ class ColumnSettings extends React.PureComponent {
}
};
onSelect = (mode) => {
return (value) => {
this.props.onChange(['tags', mode], value);
};
};
onSelect = mode => value => this.props.onChange(['tags', mode], value);
onToggle = () => {
if (this.state.open && this.hasTags()) {
this.props.onChange('tags', {});
}
this.setState({ open: !this.state.open });
};
noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions);
modeSelect (mode) {
return (
<div className='column-settings__section'>
{this.modeLabel(mode)}
<div className='column-settings__row'>
<span className='column-settings__section'>
{this.modeLabel(mode)}
</span>
<AsyncSelect
isMulti
autoFocus
value={this.tags(mode)}
settings={this.props.settings}
settingPath={['tags', mode]}
onChange={this.onSelect(mode)}
loadOptions={this.props.onLoad}
classNamePrefix='column-settings__hashtag-select'
className='column-select__container'
classNamePrefix='column-select'
name='tags'
placeholder={this.props.intl.formatMessage(messages.placeholder)}
noOptionsMessage={this.noOptionsMessage}
/>
</div>
);
@ -66,11 +75,15 @@ class ColumnSettings extends React.PureComponent {
modeLabel (mode) {
switch(mode) {
case 'any': return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />;
case 'all': return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />;
case 'none': return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />;
case 'any':
return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />;
case 'all':
return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />;
case 'none':
return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />;
default:
return '';
}
return '';
};
render () {
@ -78,23 +91,21 @@ class ColumnSettings extends React.PureComponent {
<div>
<div className='column-settings__row'>
<div className='setting-toggle'>
<Toggle
id='hashtag.column_settings.tag_toggle'
onChange={this.onToggle}
checked={this.state.open}
/>
<Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} />
<span className='setting-toggle__label'>
<FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' />
</span>
</div>
</div>
{this.state.open &&
{this.state.open && (
<div className='column-settings__hashtags'>
{this.modeSelect('any')}
{this.modeSelect('all')}
{this.modeSelect('none')}
</div>
}
)}
</div>
);
}

View File

@ -41,15 +41,19 @@ class HashtagTimeline extends React.PureComponent {
title = () => {
let title = [this.props.params.id];
if (this.additionalFor('any')) {
title.push(' ', <FormattedMessage id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
}
if (this.additionalFor('all')) {
title.push(' ', <FormattedMessage id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
}
if (this.additionalFor('none')) {
title.push(' ', <FormattedMessage id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
}
return title;
}
@ -77,9 +81,10 @@ class HashtagTimeline extends React.PureComponent {
let all = (tags.all || []).map(tag => tag.value);
let none = (tags.none || []).map(tag => tag.value);
[id, ...any].map((tag) => {
this.disconnects.push(dispatch(connectHashtagStream(id, tag, (status) => {
[id, ...any].map(tag => {
this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
let tags = status.tags.map(tag => tag.name);
return all.filter(tag => tags.includes(tag)).length === all.length &&
none.filter(tag => tags.includes(tag)).length === 0;
})));
@ -95,12 +100,14 @@ class HashtagTimeline extends React.PureComponent {
const { dispatch } = this.props;
const { id, tags } = this.props.params;
this._subscribe(dispatch, id, tags);
dispatch(expandHashtagTimeline(id, { tags }));
}
componentWillReceiveProps (nextProps) {
const { dispatch, params } = this.props;
const { id, tags } = nextProps.params;
if (id !== params.id || !isEqual(tags, params.tags)) {
this._unsubscribe();
this._subscribe(dispatch, id, tags);

View File

@ -0,0 +1,70 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']),
disabled: !state.getIn(['listEditor', 'isChanged']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeListEditorTitle(value)),
onSubmit: () => dispatch(submitListEditor(false)),
});
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class ListForm extends React.PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
}
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
}
handleClick = () => {
this.props.onSubmit();
}
render () {
const { value, disabled, intl } = this.props;
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<input
className='setting-text'
value={value}
onChange={this.handleChange}
/>
<IconButton
disabled={disabled}
icon='check'
title={title}
onClick={this.handleClick}
/>
</form>
);
}
}

View File

@ -7,11 +7,11 @@ import { injectIntl } from 'react-intl';
import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists';
import Account from './components/account';
import Search from './components/search';
import EditListForm from './components/edit_list_form';
import Motion from '../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
const mapStateToProps = state => ({
title: state.getIn(['listEditor', 'title']),
accountIds: state.getIn(['listEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
});
@ -33,7 +33,6 @@ class ListEditor extends ImmutablePureComponent {
onInitialize: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
accountIds: ImmutablePropTypes.list.isRequired,
searchAccountIds: ImmutablePropTypes.list.isRequired,
};
@ -49,12 +48,12 @@ class ListEditor extends ImmutablePureComponent {
}
render () {
const { title, accountIds, searchAccountIds, onClear } = this.props;
const { accountIds, searchAccountIds, onClear } = this.props;
const showSearch = searchAccountIds.size > 0;
return (
<div className='modal-root__modal list-editor'>
<h4>{title}</h4>
<EditListForm />
<Search />

View File

@ -22,6 +22,7 @@ import {
const initialState = ImmutableMap({
listId: null,
isSubmitting: false,
isChanged: false,
title: '',
accounts: ImmutableMap({
@ -47,10 +48,16 @@ export default function listEditorReducer(state = initialState, action) {
map.set('isSubmitting', false);
});
case LIST_EDITOR_TITLE_CHANGE:
return state.set('title', action.value);
return state.withMutations(map => {
map.set('title', action.value);
map.set('isChanged', true);
});
case LIST_CREATE_REQUEST:
case LIST_UPDATE_REQUEST:
return state.set('isSubmitting', true);
return state.withMutations(map => {
map.set('isSubmitting', true);
map.set('isChanged', false);
});
case LIST_CREATE_FAIL:
case LIST_UPDATE_FAIL:
return state.set('isSubmitting', false);

View File

@ -13,6 +13,10 @@
}
}
.rich-formatting a,
.rich-formatting p a,
.rich-formatting li a,
.landing-page__short-description p a,
.status__content a,
.reply-indicator__content a {
color: lighten($ui-highlight-color, 12%);

View File

@ -352,6 +352,8 @@
.moved-account-widget,
.memoriam-widget,
.activity-stream,
.nothing-here {
.nothing-here,
.directory__tag > a,
.directory__tag > div {
box-shadow: none;
}

View File

@ -41,3 +41,34 @@
font-size: 16px;
}
}
@mixin search-popout() {
background: $simple-background-color;
border-radius: 4px;
padding: 10px 14px;
padding-bottom: 14px;
margin-top: 10px;
color: $light-text-color;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
h4 {
text-transform: uppercase;
color: $light-text-color;
font-size: 13px;
font-weight: 500;
margin-bottom: 10px;
}
li {
padding: 4px 0;
}
ul {
margin-bottom: 10px;
}
em {
font-weight: 500;
color: $inverted-text-color;
}
}

View File

@ -49,15 +49,9 @@ $small-breakpoint: 960px;
}
}
strong,
em {
display: inline;
margin: 0;
padding: 0;
font-weight: 700;
background: transparent;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: lighten($darker-text-color, 10%);
}
@ -796,7 +790,7 @@ $small-breakpoint: 960px;
width: 100%;
display: flex;
flex-direction: row-reverse;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
}
@ -846,14 +840,7 @@ $small-breakpoint: 960px;
}
strong {
display: inline;
margin: 0;
padding: 0;
font-weight: 700;
background: transparent;
font-family: inherit;
font-size: inherit;
line-height: inherit;
font-weight: 500;
color: lighten($darker-text-color, 10%);
}

View File

@ -476,7 +476,7 @@
opacity: 0;
transition: opacity .1s ease;
input {
textarea {
background: transparent;
color: $secondary-text-color;
border: 0;
@ -3056,14 +3056,41 @@ a.status-card.compact:hover {
display: block;
font-weight: 500;
margin-bottom: 10px;
}
.column-settings__hashtag-select {
.column-settings__hashtags {
.column-settings__row {
margin-bottom: 15px;
}
.column-select {
&__control {
@include search-input();
}
&__placeholder {
color: $dark-text-color;
padding-left: 2px;
font-size: 12px;
}
&__value-container {
padding-left: 6px;
}
&__multi-value {
background: lighten($ui-base-color, 8%);
&__remove {
cursor: pointer;
&:hover,
&:active,
&:focus {
background: lighten($ui-base-color, 12%);
color: lighten($darker-text-color, 4%);
}
}
}
&__multi-value__label,
@ -3071,9 +3098,42 @@ a.status-card.compact:hover {
color: $darker-text-color;
}
&__indicator-separator,
&__clear-indicator,
&__dropdown-indicator {
display: none;
cursor: pointer;
transition: none;
color: $dark-text-color;
&:hover,
&:active,
&:focus {
color: lighten($dark-text-color, 4%);
}
}
&__indicator-separator {
background-color: lighten($ui-base-color, 8%);
}
&__menu {
@include search-popout();
padding: 0;
background: $ui-secondary-color;
}
&__menu-list {
padding: 6px;
}
&__option {
color: $inverted-text-color;
border-radius: 4px;
font-size: 14px;
&--is-focused,
&--is-selected {
background: darken($ui-secondary-color, 10%);
}
}
}
}
@ -4867,34 +4927,7 @@ a.status-card.compact:hover {
}
.search-popout {
background: $simple-background-color;
border-radius: 4px;
padding: 10px 14px;
padding-bottom: 14px;
margin-top: 10px;
color: $light-text-color;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
h4 {
text-transform: uppercase;
color: $light-text-color;
font-size: 13px;
font-weight: 500;
margin-bottom: 10px;
}
li {
padding: 4px 0;
}
ul {
margin-bottom: 10px;
}
em {
font-weight: 500;
color: $inverted-text-color;
}
@include search-popout();
}
noscript {
@ -5130,7 +5163,7 @@ noscript {
.icon-button {
flex: 0 0 auto;
margin-left: 5px;
margin: 0 5px;
}
}

View File

@ -4,6 +4,9 @@ class ActivityPub::Activity
include JsonLdHelper
include Redisable
SUPPORTED_TYPES = %w(Note).freeze
CONVERTED_TYPES = %w(Image Video Article Page).freeze
def initialize(json, account, **options)
@json = json
@account = account
@ -71,6 +74,18 @@ class ActivityPub::Activity
@object_uri ||= value_or_id(@object)
end
def unsupported_object_type?
@object.is_a?(String) || !(supported_object_type? || converted_object_type?)
end
def supported_object_type?
equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
end
def converted_object_type?
equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
end
def distribute(status)
crawl_links(status)
@ -120,6 +135,23 @@ class ActivityPub::Activity
redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
end
def status_from_object
# If the status is already known, return it
status = status_from_uri(object_uri)
return status unless status.nil?
# If the boosted toot is embedded and it is a self-boost, handle it like a Create
unless unsupported_object_type?
actor_id = value_or_id(first_of_value(@object['attributedTo'])) || @account.uri
if actor_id == @account.uri
return ActivityPub::Activity.factory({ 'type' => 'Create', 'actor' => actor_id, 'object' => @object }, @account).perform
end
end
# If the status is not from the actor, try to fetch it
return fetch_remote_original_status if value_or_id(first_of_value(@json['attributedTo'])) == @account.uri
end
def fetch_remote_original_status
if object_uri.start_with?('http')
return if ActivityPub::TagManager.instance.local_uri?(object_uri)

View File

@ -2,9 +2,7 @@
class ActivityPub::Activity::Announce < ActivityPub::Activity
def perform
original_status = status_from_uri(object_uri)
original_status ||= fetch_remote_original_status
original_status = status_from_object
return if original_status.nil? || delete_arrived_first?(@json['id']) || !announceable?(original_status)
status = Status.find_by(account: @account, reblog: original_status)

View File

@ -1,12 +1,8 @@
# frozen_string_literal: true
class ActivityPub::Activity::Create < ActivityPub::Activity
SUPPORTED_TYPES = %w(Note).freeze
CONVERTED_TYPES = %w(Image Video Article Page).freeze
def perform
return if unsupported_object_type? || invalid_origin?(@object['id'])
return if Tombstone.exists?(uri: @object['id'])
return if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity?
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
@ -318,22 +314,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
end
def unsupported_object_type?
@object.is_a?(String) || !(supported_object_type? || converted_object_type?)
end
def unsupported_media_type?(mime_type)
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
end
def supported_object_type?
equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
end
def converted_object_type?
equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
end
def skip_download?
return @skip_download if defined?(@skip_download)
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
@ -352,6 +336,37 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
!replied_to_status.nil? && replied_to_status.account.local?
end
def related_to_local_activity?
fetch? || followed_by_local_accounts? || requested_through_relay? ||
responds_to_followed_account? || addresses_local_accounts?
end
def fetch?
!@options[:delivery]
end
def followed_by_local_accounts?
@account.passive_relationships.exists?
end
def requested_through_relay?
@options[:relayed_through_account] && Relay.find_by(inbox_url: @options[:relayed_through_account].inbox_url)&.enabled?
end
def responds_to_followed_account?
!replied_to_status.nil? && (replied_to_status.account.local? || replied_to_status.account.passive_relationships.exists?)
end
def addresses_local_accounts?
return true if @options[:delivered_to_account_id]
local_usernames = (as_array(@object['to']) + as_array(@object['cc'])).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
return false if local_usernames.empty?
Account.local.where(username: local_usernames).exists?
end
def forward_for_reply
return unless @json['signature'].present? && reply_to_local?
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])

View File

@ -29,6 +29,7 @@ class Relay < ApplicationRecord
payload = Oj.dump(follow_activity(activity_id))
update!(state: :pending, follow_activity_id: activity_id)
DeliveryFailureTracker.new(inbox_url).track_success!
ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
end
@ -37,6 +38,7 @@ class Relay < ApplicationRecord
payload = Oj.dump(unfollow_activity(activity_id))
update!(state: :idle, follow_activity_id: nil)
DeliveryFailureTracker.new(inbox_url).track_success!
ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
end

View File

@ -3,8 +3,8 @@
class ActivityPub::ActivitySerializer < ActiveModel::Serializer
attributes :id, :type, :actor, :published, :to, :cc
has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :announce?
attribute :proper_uri, key: :object, if: :announce?
has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :owned_announce?
attribute :proper_uri, key: :object, if: :owned_announce?
attribute :atom_uri, if: :announce?
def id
@ -42,4 +42,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer
def announce?
object.reblog?
end
def owned_announce?
announce? && object.account == object.proper.account && object.proper.private_visibility?
end
end

View File

@ -44,6 +44,7 @@ class ActivityPub::ProcessCollectionService < BaseService
end
def verify_account!
@options[:relayed_through_account] = @account
@account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
rescue JSON::LD::JsonLdError => e
Rails.logger.debug "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}"

View File

@ -6,6 +6,6 @@ class ActivityPub::ProcessingWorker
sidekiq_options backtrace: true
def perform(account_id, body, delivered_to_account_id = nil)
ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id)
ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true)
end
end

View File

@ -46,14 +46,14 @@ class Rack::Attack
end
throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req|
req.api_request? && req.authenticated_user_id
req.authenticated_user_id if req.api_request?
end
throttle('throttle_unauthenticated_api', limit: 7_500, period: 5.minutes) do |req|
req.ip if req.api_request?
end
throttle('throttle_media', limit: 30, period: 30.minutes) do |req|
throttle('throttle_api_media', limit: 30, period: 30.minutes) do |req|
req.authenticated_user_id if req.post? && req.path.start_with?('/api/v1/media')
end
@ -61,6 +61,13 @@ class Rack::Attack
req.ip if req.post? && req.path == '/api/v1/accounts'
end
API_DELETE_REBLOG_REGEX = /\A\/api\/v1\/statuses\/[\d]+\/unreblog/.freeze
API_DELETE_STATUS_REGEX = /\A\/api\/v1\/statuses\/[\d]+/.freeze
throttle('throttle_api_delete', limit: 30, period: 30.minutes) do |req|
req.authenticated_user_id if (req.post? && req.path =~ API_DELETE_REBLOG_REGEX) || (req.delete? && req.path =~ API_DELETE_STATUS_REGEX)
end
throttle('protected_paths', limit: 25, period: 5.minutes) do |req|
req.ip if req.post? && req.path =~ PROTECTED_PATHS_REGEX
end

View File

@ -46,7 +46,7 @@ cs:
choices_html: 'Volby uživatele %{name}:'
follow: Sledovat
followers:
few: Sledovatelé
few: Sledující
one: Sledující
other: Sledujících
following: Sledovaných
@ -618,7 +618,7 @@ cs:
lock_link: Zamkněte svůj účet
purge: Odstranit ze sledujících
success:
few: V průběhu blokování sledovatelů ze %{count} domén...
few: V průběhu blokování sledujících ze %{count} domén...
one: V průběhu blokování sledujících z jedné domény...
other: V průběhu blokování sledujících z %{count} domén...
true_privacy_html: Berte prosím na vědomí, že <strong>skutečného soukromí se dá dosáhnout pouze za pomoci end-to-end šifrování</strong>.
@ -688,7 +688,7 @@ cs:
body: Zde najdete stručný souhrn zpráv, které jste zmeškal/a od vaší poslední návštěvy %{since}
mention: "%{name} vás zmínil/a v:"
new_followers_summary:
few: Navíc jste získal/a %{count} nové sledovatele, zatímco jste byl/a pryč! Skvělé!
few: Navíc jste získal/a %{count} nové sledující, zatímco jste byl/a pryč! Skvělé!
one: Navíc jste získal/a jednoho nového sledujícího, zatímco jste byl/a pryč! Hurá!
other: Navíc jste získal/a %{count} nových sledujících, zatímco jste byl/a pryč! Úžasné!
subject:

View File

@ -9,7 +9,7 @@ WorkingDirectory=/home/mastodon/live
Environment="NODE_ENV=production"
Environment="PORT=4000"
Environment="STREAMING_CLUSTER_NUM=1"
ExecStart=/usr/bin/npm run start
ExecStart=/usr/bin/node ./streaming
TimeoutSec=15
Restart=always

View File

@ -1,5 +1,4 @@
# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /
User-agent: *
Disallow: /media_proxy/

View File

@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe ActivityPub::Activity::Announce do
let(:sender) { Fabricate(:account) }
let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers') }
let(:recipient) { Fabricate(:account) }
let(:status) { Fabricate(:status, account: recipient) }
@ -11,19 +11,60 @@ RSpec.describe ActivityPub::Activity::Announce do
id: 'foo',
type: 'Announce',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: ActivityPub::TagManager.instance.uri_for(status),
object: object_json,
}.with_indifferent_access
end
describe '#perform' do
subject { described_class.new(json, sender) }
subject { described_class.new(json, sender) }
before do
sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender))
end
describe '#perform' do
before do
subject.perform
end
it 'creates a reblog by sender of status' do
expect(sender.reblogged?(status)).to be true
context 'a known status' do
let(:object_json) do
ActivityPub::TagManager.instance.uri_for(status)
end
it 'creates a reblog by sender of status' do
expect(sender.reblogged?(status)).to be true
end
end
context 'self-boost of a previously unknown status with missing attributedTo' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: 'http://example.com/followers',
}
end
it 'creates a reblog by sender of status' do
expect(sender.reblogged?(sender.statuses.first)).to be true
end
end
context 'self-boost of a previously unknown status with correct attributedTo' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
attributedTo: ActivityPub::TagManager.instance.uri_for(sender),
to: 'http://example.com/followers',
}
end
it 'creates a reblog by sender of status' do
expect(sender.reblogged?(sender.statuses.first)).to be true
end
end
end
end