Merge branch 'master' into glitch-soc/merge-upstream

This commit is contained in:
Thibaut Girka 2019-02-15 18:02:45 +01:00
commit 06cc04fd23
30 changed files with 408 additions and 148 deletions

View file

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

View file

@ -92,13 +92,13 @@ GEM
aws-sigv4 (1.0.3) aws-sigv4 (1.0.3)
bcrypt (3.1.12) bcrypt (3.1.12)
benchmark-ips (2.7.2) benchmark-ips (2.7.2)
better_errors (2.5.0) better_errors (2.5.1)
coderay (>= 1.0.0) coderay (>= 1.0.0)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
binding_of_caller (0.8.0) binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
bootsnap (1.3.2) bootsnap (1.4.0)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.4.0) brakeman (4.4.0)
browser (2.5.3) browser (2.5.3)
@ -205,7 +205,7 @@ GEM
tzinfo tzinfo
excon (0.62.0) excon (0.62.0)
fabrication (2.20.1) fabrication (2.20.1)
faker (1.9.1) faker (1.9.3)
i18n (>= 0.7) i18n (>= 0.7)
faraday (0.15.0) faraday (0.15.0)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
@ -347,7 +347,7 @@ GEM
mini_mime (1.0.1) mini_mime (1.0.1)
mini_portile2 (2.4.0) mini_portile2 (2.4.0)
minitest (5.11.3) minitest (5.11.3)
msgpack (1.2.4) msgpack (1.2.6)
multi_json (1.13.1) multi_json (1.13.1)
multipart-post (2.0.0) multipart-post (2.0.0)
necromancer (0.4.0) necromancer (0.4.0)
@ -402,7 +402,7 @@ GEM
pg (1.1.4) pg (1.1.4)
pghero (2.2.0) pghero (2.2.0)
activerecord activerecord
pkg-config (1.3.2) pkg-config (1.3.3)
powerpack (0.1.2) powerpack (0.1.2)
premailer (1.11.1) premailer (1.11.1)
addressable addressable
@ -565,7 +565,7 @@ GEM
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 3) sidekiq (>= 3)
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (6.0.8) sidekiq-unique-jobs (6.0.9)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 6.0) sidekiq (>= 4.0, < 6.0)
thor (~> 0) thor (~> 0)
@ -662,7 +662,7 @@ DEPENDENCIES
aws-sdk-s3 (~> 1.30) aws-sdk-s3 (~> 1.30)
better_errors (~> 2.5) better_errors (~> 2.5)
binding_of_caller (~> 0.7) binding_of_caller (~> 0.7)
bootsnap (~> 1.3) bootsnap (~> 1.4)
brakeman (~> 4.4) brakeman (~> 4.4)
browser browser
bullet (~> 5.9) 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| crutch :mentions do |collection|
data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id) data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
@ -50,7 +50,7 @@ class StatusesIndex < Chewy::Index
root date_detection: false do root date_detection: false do
field :account_id, type: 'long' 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' field :stemmed, type: 'text', analyzer: 'content'
end end

View file

@ -29,6 +29,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
resource.invite_code = params[:invite_code] if resource.invite_code.blank? resource.invite_code = params[:invite_code] if resource.invite_code.blank?
resource.agreement = true 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? resource.build_account if resource.account.nil?
end end

View file

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

View file

@ -11,26 +11,36 @@ export default class DisplayName extends React.PureComponent {
}; };
render () { render () {
const { account, others, localDomain } = this.props; const { others, localDomain } = this.props;
const displayNameHtml = { __html: account.get('display_name_html') };
let suffix; let displayName, suffix, account;
if (others && others.size > 1) { 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 { } else {
if (others) {
account = others.first();
} else {
account = this.props.account;
}
let acct = account.get('acct'); let acct = account.get('acct');
if (acct.indexOf('@') === -1 && localDomain) { if (acct.indexOf('@') === -1 && localDomain) {
acct = `${acct}@${localDomain}`; acct = `${acct}@${localDomain}`;
} }
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
suffix = <span className='display-name__account'>@{acct}</span>; suffix = <span className='display-name__account'>@{acct}</span>;
} }
return ( return (
<span className='display-name'> <span className='display-name'>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /></bdi> {suffix} {displayName} {suffix}
</span> </span>
); );
} }

View file

@ -86,7 +86,7 @@ class Status extends ImmutablePureComponent {
// Track height changes we know about to compensate scrolling // Track height changes we know about to compensate scrolling
componentDidMount () { 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 () { getSnapshotBeforeUpdate () {
@ -99,7 +99,7 @@ class Status extends ImmutablePureComponent {
// Compensate height changes // Compensate height changes
componentDidUpdate (prevProps, prevState, snapshot) { 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) { if (doShowCard && !this.didShowCard) {
this.didShowCard = true; this.didShowCard = true;
if (snapshot !== null && this.props.updateScrollBottom) { if (snapshot !== null && this.props.updateScrollBottom) {

View file

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

View file

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

View file

@ -41,15 +41,19 @@ class HashtagTimeline extends React.PureComponent {
title = () => { title = () => {
let title = [this.props.params.id]; let title = [this.props.params.id];
if (this.additionalFor('any')) { 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')) { 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')) { 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; return title;
} }
@ -77,9 +81,10 @@ class HashtagTimeline extends React.PureComponent {
let all = (tags.all || []).map(tag => tag.value); let all = (tags.all || []).map(tag => tag.value);
let none = (tags.none || []).map(tag => tag.value); let none = (tags.none || []).map(tag => tag.value);
[id, ...any].map((tag) => { [id, ...any].map(tag => {
this.disconnects.push(dispatch(connectHashtagStream(id, tag, (status) => { this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
let tags = status.tags.map(tag => tag.name); let tags = status.tags.map(tag => tag.name);
return all.filter(tag => tags.includes(tag)).length === all.length && return all.filter(tag => tags.includes(tag)).length === all.length &&
none.filter(tag => tags.includes(tag)).length === 0; none.filter(tag => tags.includes(tag)).length === 0;
}))); })));
@ -95,12 +100,14 @@ class HashtagTimeline extends React.PureComponent {
const { dispatch } = this.props; const { dispatch } = this.props;
const { id, tags } = this.props.params; const { id, tags } = this.props.params;
this._subscribe(dispatch, id, tags);
dispatch(expandHashtagTimeline(id, { tags })); dispatch(expandHashtagTimeline(id, { tags }));
} }
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
const { dispatch, params } = this.props; const { dispatch, params } = this.props;
const { id, tags } = nextProps.params; const { id, tags } = nextProps.params;
if (id !== params.id || !isEqual(tags, params.tags)) { if (id !== params.id || !isEqual(tags, params.tags)) {
this._unsubscribe(); this._unsubscribe();
this._subscribe(dispatch, id, tags); 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 { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists';
import Account from './components/account'; import Account from './components/account';
import Search from './components/search'; import Search from './components/search';
import EditListForm from './components/edit_list_form';
import Motion from '../ui/util/optional_motion'; import Motion from '../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
title: state.getIn(['listEditor', 'title']),
accountIds: state.getIn(['listEditor', 'accounts', 'items']), accountIds: state.getIn(['listEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']), searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
}); });
@ -33,7 +33,6 @@ class ListEditor extends ImmutablePureComponent {
onInitialize: PropTypes.func.isRequired, onInitialize: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired, onReset: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
accountIds: ImmutablePropTypes.list.isRequired, accountIds: ImmutablePropTypes.list.isRequired,
searchAccountIds: ImmutablePropTypes.list.isRequired, searchAccountIds: ImmutablePropTypes.list.isRequired,
}; };
@ -49,12 +48,12 @@ class ListEditor extends ImmutablePureComponent {
} }
render () { render () {
const { title, accountIds, searchAccountIds, onClear } = this.props; const { accountIds, searchAccountIds, onClear } = this.props;
const showSearch = searchAccountIds.size > 0; const showSearch = searchAccountIds.size > 0;
return ( return (
<div className='modal-root__modal list-editor'> <div className='modal-root__modal list-editor'>
<h4>{title}</h4> <EditListForm />
<Search /> <Search />

View file

@ -22,6 +22,7 @@ import {
const initialState = ImmutableMap({ const initialState = ImmutableMap({
listId: null, listId: null,
isSubmitting: false, isSubmitting: false,
isChanged: false,
title: '', title: '',
accounts: ImmutableMap({ accounts: ImmutableMap({
@ -47,10 +48,16 @@ export default function listEditorReducer(state = initialState, action) {
map.set('isSubmitting', false); map.set('isSubmitting', false);
}); });
case LIST_EDITOR_TITLE_CHANGE: 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_CREATE_REQUEST:
case LIST_UPDATE_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_CREATE_FAIL:
case LIST_UPDATE_FAIL: case LIST_UPDATE_FAIL:
return state.set('isSubmitting', false); 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, .status__content a,
.reply-indicator__content a { .reply-indicator__content a {
color: lighten($ui-highlight-color, 12%); color: lighten($ui-highlight-color, 12%);

View file

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

View file

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

View file

@ -476,7 +476,7 @@
opacity: 0; opacity: 0;
transition: opacity .1s ease; transition: opacity .1s ease;
input { textarea {
background: transparent; background: transparent;
color: $secondary-text-color; color: $secondary-text-color;
border: 0; border: 0;
@ -3056,14 +3056,41 @@ a.status-card.compact:hover {
display: block; display: block;
font-weight: 500; font-weight: 500;
margin-bottom: 10px; margin-bottom: 10px;
}
.column-settings__hashtag-select { .column-settings__hashtags {
.column-settings__row {
margin-bottom: 15px;
}
.column-select {
&__control { &__control {
@include search-input(); @include search-input();
} }
&__placeholder {
color: $dark-text-color;
padding-left: 2px;
font-size: 12px;
}
&__value-container {
padding-left: 6px;
}
&__multi-value { &__multi-value {
background: lighten($ui-base-color, 8%); 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, &__multi-value__label,
@ -3071,9 +3098,42 @@ a.status-card.compact:hover {
color: $darker-text-color; color: $darker-text-color;
} }
&__indicator-separator, &__clear-indicator,
&__dropdown-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 { .search-popout {
background: $simple-background-color; @include search-popout();
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;
}
} }
noscript { noscript {
@ -5130,7 +5163,7 @@ noscript {
.icon-button { .icon-button {
flex: 0 0 auto; flex: 0 0 auto;
margin-left: 5px; margin: 0 5px;
} }
} }

View file

@ -4,6 +4,9 @@ class ActivityPub::Activity
include JsonLdHelper include JsonLdHelper
include Redisable include Redisable
SUPPORTED_TYPES = %w(Note).freeze
CONVERTED_TYPES = %w(Image Video Article Page).freeze
def initialize(json, account, **options) def initialize(json, account, **options)
@json = json @json = json
@account = account @account = account
@ -71,6 +74,18 @@ class ActivityPub::Activity
@object_uri ||= value_or_id(@object) @object_uri ||= value_or_id(@object)
end 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) def distribute(status)
crawl_links(status) crawl_links(status)
@ -120,6 +135,23 @@ class ActivityPub::Activity
redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri) redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
end 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 def fetch_remote_original_status
if object_uri.start_with?('http') if object_uri.start_with?('http')
return if ActivityPub::TagManager.instance.local_uri?(object_uri) return if ActivityPub::TagManager.instance.local_uri?(object_uri)

View file

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

View file

@ -1,12 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::Activity::Create < ActivityPub::Activity class ActivityPub::Activity::Create < ActivityPub::Activity
SUPPORTED_TYPES = %w(Note).freeze
CONVERTED_TYPES = %w(Image Video Article Page).freeze
def perform def perform
return if unsupported_object_type? || invalid_origin?(@object['id']) return if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity?
return if Tombstone.exists?(uri: @object['id'])
RedisLock.acquire(lock_options) do |lock| RedisLock.acquire(lock_options) do |lock|
if lock.acquired? if lock.acquired?
@ -318,22 +314,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty? @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
end end
def unsupported_object_type?
@object.is_a?(String) || !(supported_object_type? || converted_object_type?)
end
def unsupported_media_type?(mime_type) def unsupported_media_type?(mime_type)
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type) mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
end 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? def skip_download?
return @skip_download if defined?(@skip_download) return @skip_download if defined?(@skip_download)
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media? @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? !replied_to_status.nil? && replied_to_status.account.local?
end 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 def forward_for_reply
return unless @json['signature'].present? && reply_to_local? return unless @json['signature'].present? && reply_to_local?
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url]) 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)) payload = Oj.dump(follow_activity(activity_id))
update!(state: :pending, follow_activity_id: 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) ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
end end
@ -37,6 +38,7 @@ class Relay < ApplicationRecord
payload = Oj.dump(unfollow_activity(activity_id)) payload = Oj.dump(unfollow_activity(activity_id))
update!(state: :idle, follow_activity_id: nil) update!(state: :idle, follow_activity_id: nil)
DeliveryFailureTracker.new(inbox_url).track_success!
ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
end end

View file

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

View file

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

View file

@ -46,14 +46,14 @@ class Rack::Attack
end end
throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req| 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 end
throttle('throttle_unauthenticated_api', limit: 7_500, period: 5.minutes) do |req| throttle('throttle_unauthenticated_api', limit: 7_500, period: 5.minutes) do |req|
req.ip if req.api_request? req.ip if req.api_request?
end 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') req.authenticated_user_id if req.post? && req.path.start_with?('/api/v1/media')
end end
@ -61,6 +61,13 @@ class Rack::Attack
req.ip if req.post? && req.path == '/api/v1/accounts' req.ip if req.post? && req.path == '/api/v1/accounts'
end 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| throttle('protected_paths', limit: 25, period: 5.minutes) do |req|
req.ip if req.post? && req.path =~ PROTECTED_PATHS_REGEX req.ip if req.post? && req.path =~ PROTECTED_PATHS_REGEX
end end

View file

@ -46,7 +46,7 @@ cs:
choices_html: 'Volby uživatele %{name}:' choices_html: 'Volby uživatele %{name}:'
follow: Sledovat follow: Sledovat
followers: followers:
few: Sledovatelé few: Sledující
one: Sledující one: Sledující
other: Sledujících other: Sledujících
following: Sledovaných following: Sledovaných
@ -618,7 +618,7 @@ cs:
lock_link: Zamkněte svůj účet lock_link: Zamkněte svůj účet
purge: Odstranit ze sledujících purge: Odstranit ze sledujících
success: 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... 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... 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>. 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} 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:" mention: "%{name} vás zmínil/a v:"
new_followers_summary: 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á! 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é! other: Navíc jste získal/a %{count} nových sledujících, zatímco jste byl/a pryč! Úžasné!
subject: subject:

View file

@ -9,7 +9,7 @@ WorkingDirectory=/home/mastodon/live
Environment="NODE_ENV=production" Environment="NODE_ENV=production"
Environment="PORT=4000" Environment="PORT=4000"
Environment="STREAMING_CLUSTER_NUM=1" Environment="STREAMING_CLUSTER_NUM=1"
ExecStart=/usr/bin/npm run start ExecStart=/usr/bin/node ./streaming
TimeoutSec=15 TimeoutSec=15
Restart=always 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 # 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: *
# User-agent: * Disallow: /media_proxy/
# Disallow: /

View file

@ -1,7 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe ActivityPub::Activity::Announce do 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(:recipient) { Fabricate(:account) }
let(:status) { Fabricate(:status, account: recipient) } let(:status) { Fabricate(:status, account: recipient) }
@ -11,19 +11,60 @@ RSpec.describe ActivityPub::Activity::Announce do
id: 'foo', id: 'foo',
type: 'Announce', type: 'Announce',
actor: ActivityPub::TagManager.instance.uri_for(sender), actor: ActivityPub::TagManager.instance.uri_for(sender),
object: ActivityPub::TagManager.instance.uri_for(status), object: object_json,
}.with_indifferent_access }.with_indifferent_access
end 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 before do
subject.perform subject.perform
end end
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 it 'creates a reblog by sender of status' do
expect(sender.reblogged?(status)).to be true expect(sender.reblogged?(status)).to be true
end end
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 end