about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2019-02-15 18:02:45 +0100
committerThibaut Girka <thib@sitedethib.com>2019-02-15 18:02:45 +0100
commit06cc04fd239896d294210b8bbaadb7c611aaffe5 (patch)
tree1868711bbe9ee79b25ff10a585457cfa0f9ac121 /app
parent2769b5446681898e8332cb505f499dac2a6e5717 (diff)
parent8ef50706a11e115e8b4aa31b30de93738bc7e754 (diff)
Merge branch 'master' into glitch-soc/merge-upstream
Diffstat (limited to 'app')
-rw-r--r--app/chewy/statuses_index.rb4
-rw-r--r--app/controllers/auth/registrations_controller.rb1
-rw-r--r--app/helpers/settings_helper.rb12
-rw-r--r--app/javascript/mastodon/components/display_name.js22
-rw-r--r--app/javascript/mastodon/components/status.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/upload.js3
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js55
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/index.js17
-rw-r--r--app/javascript/mastodon/features/list_editor/components/edit_list_form.js70
-rw-r--r--app/javascript/mastodon/features/list_editor/index.js7
-rw-r--r--app/javascript/mastodon/reducers/list_editor.js11
-rw-r--r--app/javascript/styles/contrast/diff.scss4
-rw-r--r--app/javascript/styles/mastodon-light/diff.scss4
-rw-r--r--app/javascript/styles/mastodon/_mixins.scss31
-rw-r--r--app/javascript/styles/mastodon/about.scss19
-rw-r--r--app/javascript/styles/mastodon/components.scss99
-rw-r--r--app/lib/activitypub/activity.rb32
-rw-r--r--app/lib/activitypub/activity/announce.rb4
-rw-r--r--app/lib/activitypub/activity/create.rb49
-rw-r--r--app/models/relay.rb2
-rw-r--r--app/serializers/activitypub/activity_serializer.rb8
-rw-r--r--app/services/activitypub/process_collection_service.rb1
-rw-r--r--app/workers/activitypub/processing_worker.rb2
23 files changed, 337 insertions, 124 deletions
diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb
index d3104172c..eafc1818b 100644
--- a/app/chewy/statuses_index.rb
+++ b/app/chewy/statuses_index.rb
@@ -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
 
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 11436d7c5..efe29b53f 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -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
 
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 7a3ceca78..e868b45c0 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -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: '中文',
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
index acddf77c5..32809778a 100644
--- a/app/javascript/mastodon/components/display_name.js
+++ b/app/javascript/mastodon/components/display_name.js
@@ -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>
     );
   }
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 386404b57..3e98d374b 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -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) {
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
index 038d7ee28..629cbc36a 100644
--- a/app/javascript/mastodon/features/compose/components/upload.js
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -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}
diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
index 9c9f62d82..cdc138c8b 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
@@ -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>
     );
   }
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
index c2e026d13..0d3c97a64 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -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);
diff --git a/app/javascript/mastodon/features/list_editor/components/edit_list_form.js b/app/javascript/mastodon/features/list_editor/components/edit_list_form.js
new file mode 100644
index 000000000..3dc59c12e
--- /dev/null
+++ b/app/javascript/mastodon/features/list_editor/components/edit_list_form.js
@@ -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>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/list_editor/index.js b/app/javascript/mastodon/features/list_editor/index.js
index aab0cdd0c..48466604a 100644
--- a/app/javascript/mastodon/features/list_editor/index.js
+++ b/app/javascript/mastodon/features/list_editor/index.js
@@ -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 />
 
diff --git a/app/javascript/mastodon/reducers/list_editor.js b/app/javascript/mastodon/reducers/list_editor.js
index 02a0dabb1..91e524dd5 100644
--- a/app/javascript/mastodon/reducers/list_editor.js
+++ b/app/javascript/mastodon/reducers/list_editor.js
@@ -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);
diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss
index 7d8993a50..8429103b8 100644
--- a/app/javascript/styles/contrast/diff.scss
+++ b/app/javascript/styles/contrast/diff.scss
@@ -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%);
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index 78bc2dbb6..de03cf1a6 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -352,6 +352,8 @@
 .moved-account-widget,
 .memoriam-widget,
 .activity-stream,
-.nothing-here {
+.nothing-here,
+.directory__tag > a,
+.directory__tag > div {
   box-shadow: none;
 }
diff --git a/app/javascript/styles/mastodon/_mixins.scss b/app/javascript/styles/mastodon/_mixins.scss
index d5bafe6b6..08806599e 100644
--- a/app/javascript/styles/mastodon/_mixins.scss
+++ b/app/javascript/styles/mastodon/_mixins.scss
@@ -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;
+  }
+}
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index b6c92a09e..b078d4d24 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -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%);
     }
 
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index e29abf4f3..11823a45b 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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__hashtags {
+  .column-settings__row {
+    margin-bottom: 15px;
+  }
 
-  .column-settings__hashtag-select {
+  .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;
   }
 }
 
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 919678618..7e4e19531 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -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)
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 34d1b7cbd..04afeea20 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -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)
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index b49657d4b..1b31768d9 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -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])
diff --git a/app/models/relay.rb b/app/models/relay.rb
index 7478c110d..6934a5c62 100644
--- a/app/models/relay.rb
+++ b/app/models/relay.rb
@@ -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
 
diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb
index 50c4f6a04..b51e8c544 100644
--- a/app/serializers/activitypub/activity_serializer.rb
+++ b/app/serializers/activitypub/activity_serializer.rb
@@ -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
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
index 5c54aad89..881df478b 100644
--- a/app/services/activitypub/process_collection_service.rb
+++ b/app/services/activitypub/process_collection_service.rb
@@ -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}"
diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb
index a8a3ebf0f..a3abe72cf 100644
--- a/app/workers/activitypub/processing_worker.rb
+++ b/app/workers/activitypub/processing_worker.rb
@@ -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