about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/components/actions/suggestions.jsx37
-rw-r--r--app/assets/javascripts/components/components/status.jsx2
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx4
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx2
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx3
-rw-r--r--app/assets/javascripts/components/features/compose/components/suggestions_box.jsx86
-rw-r--r--app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/compose/index.jsx15
-rw-r--r--app/assets/javascripts/components/locales/de.jsx2
-rw-r--r--app/assets/javascripts/components/locales/en.jsx8
-rw-r--r--app/assets/javascripts/components/locales/es.jsx2
-rw-r--r--app/assets/javascripts/components/locales/fr.jsx29
-rw-r--r--app/assets/javascripts/components/locales/hu.jsx55
-rw-r--r--app/assets/javascripts/components/locales/index.jsx8
-rw-r--r--app/assets/javascripts/components/locales/pt.jsx2
-rw-r--r--app/assets/javascripts/components/reducers/accounts.jsx2
-rw-r--r--app/assets/javascripts/components/reducers/user_lists.jsx4
-rw-r--r--app/assets/stylesheets/application.scss1
-rw-r--r--app/assets/stylesheets/forms.scss18
-rw-r--r--app/assets/stylesheets/tables.scss25
-rw-r--r--app/controllers/admin/pubsubhubbub_controller.rb11
-rw-r--r--app/controllers/api/push_controller.rb37
-rw-r--r--app/controllers/api/v1/accounts_controller.rb31
-rw-r--r--app/controllers/api/v1/notifications_controller.rb23
-rw-r--r--app/controllers/api/v1/statuses_controller.rb10
-rw-r--r--app/controllers/api/v1/timelines_controller.rb25
-rw-r--r--app/controllers/api_controller.rb2
-rw-r--r--app/controllers/application_controller.rb24
-rw-r--r--app/controllers/settings/preferences_controller.rb7
-rw-r--r--app/helpers/admin/pubsubhubbub_helper.rb2
-rw-r--r--app/helpers/atom_builder_helper.rb6
-rw-r--r--app/helpers/settings_helper.rb2
-rw-r--r--app/lib/feed_manager.rb26
-rw-r--r--app/models/account.rb27
-rw-r--r--app/models/feed.rb27
-rw-r--r--app/models/follow.rb28
-rw-r--r--app/models/follow_suggestion.rb50
-rw-r--r--app/models/media_attachment.rb2
-rw-r--r--app/models/status.rb15
-rw-r--r--app/models/subscription.rb29
-rw-r--r--app/models/user.rb1
-rw-r--r--app/services/fan_out_on_write_service.rb5
-rw-r--r--app/services/favourite_service.rb2
-rw-r--r--app/services/follow_remote_account_service.rb3
-rw-r--r--app/services/follow_service.rb3
-rw-r--r--app/services/notify_service.rb2
-rw-r--r--app/services/post_status_service.rb3
-rw-r--r--app/services/process_feed_service.rb2
-rw-r--r--app/services/process_hashtags_service.rb2
-rw-r--r--app/services/process_interaction_service.rb4
-rw-r--r--app/services/pubsubhubbub/subscribe_service.rb13
-rw-r--r--app/services/pubsubhubbub/unsubscribe_service.rb15
-rw-r--r--app/services/reblog_service.rb2
-rw-r--r--app/services/remove_status_service.rb5
-rw-r--r--app/services/search_service.rb4
-rw-r--r--app/services/update_remote_profile_service.rb26
-rw-r--r--app/views/accounts/show.atom.ruby3
-rw-r--r--app/views/admin/pubsubhubbub/index.html.haml20
-rw-r--r--app/views/settings/preferences/show.html.haml4
-rw-r--r--app/workers/processing_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/confirmation_worker.rb36
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb30
-rw-r--r--app/workers/pubsubhubbub/distribution_worker.rb18
-rw-r--r--app/workers/removal_worker.rb9
-rw-r--r--app/workers/salmon_worker.rb2
-rw-r--r--app/workers/thread_resolve_worker.rb8
66 files changed, 491 insertions, 430 deletions
diff --git a/app/assets/javascripts/components/actions/suggestions.jsx b/app/assets/javascripts/components/actions/suggestions.jsx
deleted file mode 100644
index 6b3aa69dd..000000000
--- a/app/assets/javascripts/components/actions/suggestions.jsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import api from '../api';
-
-export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
-export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
-export const SUGGESTIONS_FETCH_FAIL    = 'SUGGESTIONS_FETCH_FAIL';
-
-export function fetchSuggestions() {
-  return (dispatch, getState) => {
-    dispatch(fetchSuggestionsRequest());
-
-    api(getState).get('/api/v1/accounts/suggestions').then(response => {
-      dispatch(fetchSuggestionsSuccess(response.data));
-    }).catch(error => {
-      dispatch(fetchSuggestionsFail(error));
-    });
-  };
-};
-
-export function fetchSuggestionsRequest() {
-  return {
-    type: SUGGESTIONS_FETCH_REQUEST
-  };
-};
-
-export function fetchSuggestionsSuccess(accounts) {
-  return {
-    type: SUGGESTIONS_FETCH_SUCCESS,
-    accounts: accounts
-  };
-};
-
-export function fetchSuggestionsFail(error) {
-  return {
-    type: SUGGESTIONS_FETCH_FAIL,
-    error: error
-  };
-};
diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx
index 603561ab3..df5f0f2c2 100644
--- a/app/assets/javascripts/components/components/status.jsx
+++ b/app/assets/javascripts/components/components/status.jsx
@@ -82,7 +82,7 @@ const Status = React.createClass({
       );
     }
 
-    if (status.get('media_attachments').size > 0) {
+    if (status.get('media_attachments').size > 0 && !this.props.muted) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
         media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} />;
       } else {
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index c9f037ec2..c42582bfd 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -39,6 +39,8 @@ import en from 'react-intl/locale-data/en';
 import de from 'react-intl/locale-data/de';
 import es from 'react-intl/locale-data/es';
 import fr from 'react-intl/locale-data/fr';
+import pt from 'react-intl/locale-data/pt';
+import hu from 'react-intl/locale-data/hu';
 import getMessagesForLocale from '../locales';
 
 const store = configureStore();
@@ -47,7 +49,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({
   basename: '/web'
 });
 
-addLocaleData([...en, ...de, ...es, ...fr]);
+addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu]);
 
 const Mastodon = React.createClass({
 
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index d39a06062..b890e15c1 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -47,7 +47,7 @@ const Header = React.createClass({
     const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
 
     return (
-      <div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}>
+      <div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', backgroundPosition: 'center', position: 'relative' }}>
         <div style={{ background: 'rgba(47, 52, 65, 0.9)', padding: '20px 10px' }}>
           <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
             <div style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
index 5ad1ca172..b16731c05 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -55,7 +55,8 @@ const textareaStyle = {
   padding: '10px',
   fontFamily: 'Roboto',
   fontSize: '14px',
-  margin: '0'
+  margin: '0',
+  resize: 'vertical'
 };
 
 const renderInputComponent = inputProps => (
diff --git a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx b/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx
deleted file mode 100644
index 6850629ba..000000000
--- a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import AccountContainer from '../../../containers/account_container';
-import { FormattedMessage } from 'react-intl';
-
-const outerStyle = {
-  position: 'relative'
-};
-
-const headerStyle = {
-  fontSize: '14px',
-  fontWeight: '500',
-  display: 'block',
-  padding: '10px',
-  color: '#9baec8',
-  background: '#454b5e',
-  overflow: 'hidden'
-};
-
-const nextStyle = {
-  display: 'inline-block',
-  float: 'right',
-  fontWeight: '400',
-  color: '#2b90d9'
-};
-
-const SuggestionsBox = React.createClass({
-
-  propTypes: {
-    accountIds: ImmutablePropTypes.list,
-    perWindow: React.PropTypes.number
-  },
-
-  getInitialState () {
-    return {
-      index: 0
-    };
-  },
-
-  getDefaultProps () {
-    return {
-      perWindow: 2
-    };
-  },
-
-  mixins: [PureRenderMixin],
-
-  handleNextClick (e) {
-    e.preventDefault();
-
-    let newIndex = this.state.index + 1;
-
-    if (this.props.accountIds.skip(this.props.perWindow * newIndex).size === 0) {
-      newIndex = 0;
-    }
-
-    this.setState({ index: newIndex });
-  },
-
-  render () {
-    const { accountIds, perWindow } = this.props;
-
-    if (!accountIds || accountIds.size === 0) {
-      return <div />;
-    }
-
-    let nextLink = '';
-
-    if (accountIds.size > perWindow) {
-      nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}><FormattedMessage id='suggestions_box.refresh' defaultMessage='Refresh' /></a>;
-    }
-
-    return (
-      <div style={outerStyle}>
-        <strong style={headerStyle}>
-          <FormattedMessage id='suggestions_box.who_to_follow' defaultMessage='Who to follow' /> {nextLink}
-        </strong>
-
-        {accountIds.skip(perWindow * this.state.index).take(perWindow).map(accountId => <AccountContainer key={accountId} id={accountId} withNote={false} />)}
-      </div>
-    );
-  }
-
-});
-
-export default SuggestionsBox;
diff --git a/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx b/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx
deleted file mode 100644
index 944ceed85..000000000
--- a/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { connect }           from 'react-redux';
-import SuggestionsBox        from '../components/suggestions_box';
-
-const mapStateToProps = (state) => ({
-  accountIds: state.getIn(['user_lists', 'suggestions'])
-});
-
-export default connect(mapStateToProps)(SuggestionsBox);
diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx
index 5c1b22e00..4017c8949 100644
--- a/app/assets/javascripts/components/features/compose/index.jsx
+++ b/app/assets/javascripts/components/features/compose/index.jsx
@@ -3,9 +3,7 @@ import ComposeFormContainer from './containers/compose_form_container';
 import UploadFormContainer from './containers/upload_form_container';
 import NavigationContainer from './containers/navigation_container';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import SuggestionsContainer from './containers/suggestions_container';
 import SearchContainer from './containers/search_container';
-import { fetchSuggestions } from '../../actions/suggestions';
 import { connect } from 'react-redux';
 import { mountCompose, unmountCompose } from '../../actions/compose';
 
@@ -19,7 +17,6 @@ const Compose = React.createClass({
 
   componentDidMount () {
     this.props.dispatch(mountCompose());
-    this.props.dispatch(fetchSuggestions());
   },
 
   componentWillUnmount () {
@@ -29,14 +26,10 @@ const Compose = React.createClass({
   render () {
     return (
       <Drawer>
-        <div style={{ flex: '1 1 auto' }}>
-          <SearchContainer />
-          <NavigationContainer />
-          <ComposeFormContainer />
-          <UploadFormContainer />
-        </div>
-
-        <SuggestionsContainer />
+        <SearchContainer />
+        <NavigationContainer />
+        <ComposeFormContainer />
+        <UploadFormContainer />
       </Drawer>
     );
   }
diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx
index 85412635e..4e2a70edb 100644
--- a/app/assets/javascripts/components/locales/de.jsx
+++ b/app/assets/javascripts/components/locales/de.jsx
@@ -41,8 +41,6 @@ const en = {
   "search.placeholder": "Suche",
   "search.account": "Konto",
   "search.hashtag": "Hashtag",
-  "suggestions_box.who_to_follow": "Wem folgen",
-  "suggestions_box.refresh": "Aktualisieren",
   "upload_button.label": "Media-Datei anfügen",
   "upload_form.undo": "Entfernen",
   "notification.follow": "{name} folgt dir",
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index 0ea324f66..41a44e3dc 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -5,9 +5,9 @@ const en = {
   "status.mention": "Mention",
   "status.delete": "Delete",
   "status.reply": "Reply",
-  "status.reblog": "Reblog",
+  "status.reblog": "Boost",
   "status.favourite": "Favourite",
-  "status.reblogged_by": "{name} reblogged",
+  "status.reblogged_by": "{name} boosted",
   "status.sensitive_warning": "Sensitive content",
   "status.sensitive_toggle": "Click to view",
   "video_player.toggle_sound": "Toggle sound",
@@ -45,13 +45,11 @@ const en = {
   "search.placeholder": "Search",
   "search.account": "Account",
   "search.hashtag": "Hashtag",
-  "suggestions_box.who_to_follow": "Who to follow",
-  "suggestions_box.refresh": "Refresh",
   "upload_button.label": "Add media",
   "upload_form.undo": "Undo",
   "notification.follow": "{name} followed you",
   "notification.favourite": "{name} favourited your status",
-  "notification.reblog": "{name} reblogged your status",
+  "notification.reblog": "{name} boosted your status",
   "notification.mention": "{name} mentioned you"
 };
 
diff --git a/app/assets/javascripts/components/locales/es.jsx b/app/assets/javascripts/components/locales/es.jsx
index 47377e5ae..d4434bba7 100644
--- a/app/assets/javascripts/components/locales/es.jsx
+++ b/app/assets/javascripts/components/locales/es.jsx
@@ -42,8 +42,6 @@ const es = {
   "search.placeholder": "Buscar",
   "search.account": "Cuenta",
   "search.hashtag": "Etiqueta",
-  "suggestions_box.who_to_follow": "A quién seguir",
-  "suggestions_box.refresh": "Refrescar",
   "upload_button.label": "Añadir medio",
   "upload_form.undo": "Deshacer",
   "notification.follow": "{name} le esta ahora siguiendo",
diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx
index 0cf4c5d52..c4458a145 100644
--- a/app/assets/javascripts/components/locales/fr.jsx
+++ b/app/assets/javascripts/components/locales/fr.jsx
@@ -7,22 +7,24 @@ const fr = {
   "status.reply": "Répondre",
   "status.reblog": "Partager",
   "status.favourite": "Ajouter aux favoris",
-  "status.reblogged_by": "{name} a partagé",
+  "status.reblogged_by": "{name} a partagé :",
+  "status.sensitive_warning": "Contenu délicat",
+  "status.sensitive_toggle": "Cliquer pour dévoiler",
   "video_player.toggle_sound": "Mettre/Couper le son",
   "account.mention": "Mentionner",
   "account.edit_profile": "Modifier le profil",
   "account.unblock": "Débloquer",
-  "account.unfollow": "Se désabonner",
+  "account.unfollow": "Ne plus suivre",
   "account.block": "Bloquer",
-  "account.follow": "S’abonner",
+  "account.follow": "Suivre",
   "account.posts": "Statuts",
   "account.follows": "Abonnements",
   "account.followers": "Abonnés",
   "account.follows_you": "Vous suit",
   "getting_started.heading": "Pour commencer",
-  "getting_started.about_addressing": "Vous pouvez vous abonner aux statuts de quelqu’un en entrant dans le champs de recherche leur nom d’utilisateur et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.",
-  "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, le nom d’utilisateur suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.",
-  "getting_started.about_developer": "Pour s’abonner au développeur de ce projet, c’est Gargron@mastodon.social",
+  "getting_started.about_addressing": "Vous pouvez vous suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.",
+  "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.",
+  "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social",
   "column.home": "Accueil",
   "column.mentions": "Mentions",
   "column.public": "Fil public",
@@ -32,23 +34,22 @@ const fr = {
   "tabs_bar.mentions": "Mentions",
   "tabs_bar.public": "Public",
   "tabs_bar.notifications": "Notifications",
-  "compose_form.placeholder": "Qu’avez vous en tête&nbsp;?",
+  "compose_form.placeholder": "Qu’avez-vous en tête ?",
   "compose_form.publish": "Pouet",
+  "compose_form.sensitive": "Marquer le contenu comme délicat", 
   "navigation_bar.settings": "Paramètres",
   "navigation_bar.public_timeline": "Public",
-  "navigation_bar.logout": "Se déconnecter",
+  "navigation_bar.logout": "Déconnexion",
   "reply_indicator.cancel": "Annuler",
   "search.placeholder": "Chercher",
   "search.account": "Compte",
   "search.hashtag": "Mot-clé",
-  "suggestions_box.who_to_follow": "Suggestions",
-  "suggestions_box.refresh": "Rafraîchir",
   "upload_button.label": "Joindre un média",
   "upload_form.undo": "Annuler",
-  "notification.follow": "{name} s’est abonné⋅e à vos statuts",
-  "notification.favourite": "{name} a ajouté votre statut à ses favoris",
-  "notification.reblog": "{name} a partagé votre statut",
-  "notification.mention": "{name} vous a mentionné⋅e"
+  "notification.follow": "{name} vous suit.",
+  "notification.favourite": "{name} a ajouté à ses favoris :",
+  "notification.reblog": "{name} a partagé votre statut :",
+  "notification.mention": "{name} vous a mentionné⋅e :"
 };
 
 export default fr;
diff --git a/app/assets/javascripts/components/locales/hu.jsx b/app/assets/javascripts/components/locales/hu.jsx
new file mode 100644
index 000000000..4a446965c
--- /dev/null
+++ b/app/assets/javascripts/components/locales/hu.jsx
@@ -0,0 +1,55 @@
+const hu = {
+  "column_back_button.label": "Vissza",
+  "lightbox.close": "Bezárás",
+  "loading_indicator.label": "Betöltés...",
+  "status.mention": "Említés",
+  "status.delete": "Törlés",
+  "status.reply": "Válasz",
+  "status.reblog": "Reblog",
+  "status.favourite": "Kedvenc",
+  "status.reblogged_by": "{name} reblogolta",
+  "status.sensitive_warning": "Érzékeny tartalom",
+  "status.sensitive_toggle": "Katt a megtekintéshez",
+  "video_player.toggle_sound": "Hang kapcsolása",
+  "account.mention": "Említés",
+  "account.edit_profile": "Profil szerkesztése",
+  "account.unblock": "Blokkolás levétele",
+  "account.unfollow": "Követés abbahagyása",
+  "account.block": "Blokkolás",
+  "account.follow": "Követés",
+  "account.posts": "Posts",
+  "account.follows": "Követők",
+  "account.followers": "Követők",
+  "account.follows_you": "Követnek téged",
+  "getting_started.heading": "Első lépések",
+  "getting_started.about_addressing": "Követhetsz embereket felhasználónevük és a doménjük ismeretében, amennyiben megadod ezt az e-mail-szerű címet az oldalsáv tetején lévő rubrikában.",
+  "getting_started.about_shortcuts": "Ha a célzott személy azonos doménen tartózkodik, a felhasználónév elegendő. Ugyanez érvényes mikor személyeket említesz az állapotokban.",
+  "getting_started.about_developer": "A projekt fejlesztője követhető, mint Gargron@mastodon.social",
+  "column.home": "Kezdőlap",
+  "column.mentions": "Említések",
+  "column.public": "Nyilvános",
+  "column.notifications": "Értesítések",
+  "tabs_bar.compose": "Összeállítás",
+  "tabs_bar.home": "Kezdőlap",
+  "tabs_bar.mentions": "Említések",
+  "tabs_bar.public": "Nyilvános",
+  "tabs_bar.notifications": "Notifications",
+  "compose_form.placeholder": "Mire gondolsz?",
+  "compose_form.publish": "Tülk!",
+  "compose_form.sensitive": "Tartalom érzékenynek jelölése",
+  "navigation_bar.settings": "Beállítások",
+  "navigation_bar.public_timeline": "Nyilvános időfolyam",
+  "navigation_bar.logout": "Kijelentkezés",
+  "reply_indicator.cancel": "Mégsem",
+  "search.placeholder": "Keresés",
+  "search.account": "Fiók",
+  "search.hashtag": "Hashtag",
+  "upload_button.label": "Média hozzáadása",
+  "upload_form.undo": "Mégsem",
+  "notification.follow": "{name} követ téged",
+  "notification.favourite": "{name} kedvencnek jelölte az állapotod",
+  "notification.reblog": "{name} reblogolta az állapotod",
+  "notification.mention": "{name} megemlített"
+};
+
+export default hu;
diff --git a/app/assets/javascripts/components/locales/index.jsx b/app/assets/javascripts/components/locales/index.jsx
index 7fb43dd33..f172b1c51 100644
--- a/app/assets/javascripts/components/locales/index.jsx
+++ b/app/assets/javascripts/components/locales/index.jsx
@@ -1,11 +1,17 @@
 import en from './en';
 import de from './de';
 import es from './es';
+import hu from './hu';
+import fr from './fr';
+import pt from './pt';
 
 const locales = {
   en,
   de,
-  es
+  es,
+  hu,
+  fr,
+  pt
 };
 
 export default function getMessagesForLocale (locale) {
diff --git a/app/assets/javascripts/components/locales/pt.jsx b/app/assets/javascripts/components/locales/pt.jsx
index 02b21f3cb..e67bd80ac 100644
--- a/app/assets/javascripts/components/locales/pt.jsx
+++ b/app/assets/javascripts/components/locales/pt.jsx
@@ -40,8 +40,6 @@ const pt = {
   "search.placeholder": "Busca",
   "search.account": "Conta",
   "search.hashtag": "Hashtag",
-  "suggestions_box.who_to_follow": "Quem seguir",
-  "suggestions_box.refresh": "Recarregar",
   "upload_button.label": "Adicionar media",
   "upload_form.undo": "Desfazer"
 };
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index 68247a98c..52be648b3 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -8,7 +8,6 @@ import {
   ACCOUNT_TIMELINE_FETCH_SUCCESS,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS
 } from '../actions/accounts';
-import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
 import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
 import {
   REBLOG_SUCCESS,
@@ -71,7 +70,6 @@ export default function accounts(state = initialState, action) {
     case ACCOUNT_FETCH_SUCCESS:
     case NOTIFICATIONS_UPDATE:
       return normalizeAccount(state, action.account);
-    case SUGGESTIONS_FETCH_SUCCESS:
     case FOLLOWERS_FETCH_SUCCESS:
     case FOLLOWERS_EXPAND_SUCCESS:
     case FOLLOWING_FETCH_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx
index 65598f8a0..3608e4209 100644
--- a/app/assets/javascripts/components/reducers/user_lists.jsx
+++ b/app/assets/javascripts/components/reducers/user_lists.jsx
@@ -4,7 +4,6 @@ import {
   FOLLOWING_FETCH_SUCCESS,
   FOLLOWING_EXPAND_SUCCESS
 } from '../actions/accounts';
-import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
 import {
   REBLOGS_FETCH_SUCCESS,
   FAVOURITES_FETCH_SUCCESS
@@ -14,7 +13,6 @@ import Immutable from 'immutable';
 const initialState = Immutable.Map({
   followers: Immutable.Map(),
   following: Immutable.Map(),
-  suggestions: Immutable.List(),
   reblogged_by: Immutable.Map(),
   favourited_by: Immutable.Map()
 });
@@ -42,8 +40,6 @@ export default function userLists(state = initialState, action) {
       return normalizeList(state, 'following', action.id, action.accounts, action.next);
     case FOLLOWING_EXPAND_SUCCESS:
       return appendToList(state, 'following', action.id, action.accounts, action.next);
-    case SUGGESTIONS_FETCH_SUCCESS:
-      return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id)));
     case REBLOGS_FETCH_SUCCESS:
       return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
     case FAVOURITES_FETCH_SUCCESS:
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 05a309365..bbbeafefe 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -234,3 +234,4 @@ body {
 @import 'stream_entries';
 @import 'components';
 @import 'about';
+@import 'tables';
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index 306f474d6..81270edf6 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -44,15 +44,20 @@ code {
     label {
       font-family: 'Roboto';
       font-size: 14px;
-      color: #9baec8;
+      color: white;
       display: block;
     }
 
-    input[type=checkbox] {
-      display: inline-block;
+    label.checkbox {
       position: relative;
-      top: 3px;
-      margin-right: 8px;
+	    padding-left: 25px;
+    }
+
+    input[type=checkbox] {
+	    position: absolute;
+	    left: 0;
+      top: 1px;
+      margin: 0;
     }
   }
 
@@ -161,11 +166,10 @@ code {
   text-align: center;
 
   a {
-    color: #9baec8;
+    color: white;
     text-decoration: none;
 
     &:hover {
-      color: #d9e1e8;
       text-decoration: underline;
     }
   }
diff --git a/app/assets/stylesheets/tables.scss b/app/assets/stylesheets/tables.scss
new file mode 100644
index 000000000..89b35891d
--- /dev/null
+++ b/app/assets/stylesheets/tables.scss
@@ -0,0 +1,25 @@
+.table {
+  width: 100%;
+  max-width: 100%;
+  border-spacing: 0;
+  border-collapse: collapse;
+
+  th, td {
+    padding: 8px;
+    line-height: 1.42857143;
+    vertical-align: top;
+    border-top: 1px solid #ddd;
+    text-align: left;
+  }
+
+  & > thead > tr > th {
+    vertical-align: bottom;
+    border-bottom: 2px solid #ddd;
+    border-top: 0;
+    font-weight: 500;
+  }
+}
+
+samp {
+  font-family: 'Roboto Mono', monospace;
+}
diff --git a/app/controllers/admin/pubsubhubbub_controller.rb b/app/controllers/admin/pubsubhubbub_controller.rb
new file mode 100644
index 000000000..7e6bc75ea
--- /dev/null
+++ b/app/controllers/admin/pubsubhubbub_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Admin::PubsubhubbubController < ApplicationController
+  before_action :require_admin!
+
+  layout 'public'
+
+  def index
+    @subscriptions = Subscription.order('id desc').includes(:account).paginate(page: params[:page], per_page: 40)
+  end
+end
diff --git a/app/controllers/api/push_controller.rb b/app/controllers/api/push_controller.rb
new file mode 100644
index 000000000..78d4e36e6
--- /dev/null
+++ b/app/controllers/api/push_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class Api::PushController < ApiController
+  def update
+    mode          = params['hub.mode']
+    topic         = params['hub.topic']
+    callback      = params['hub.callback']
+    lease_seconds = params['hub.lease_seconds']
+    secret        = params['hub.secret']
+
+    case mode
+    when 'subscribe'
+      response, status = Pubsubhubbub::SubscribeService.new.call(topic_to_account(topic), callback, secret, lease_seconds)
+    when 'unsubscribe'
+      response, status = Pubsubhubbub::UnsubscribeService.new.call(topic_to_account(topic), callback)
+    else
+      response = "Unknown mode: #{mode}"
+      status   = 422
+    end
+
+    render plain: response, status: status
+  end
+
+  private
+
+  def topic_to_account(topic_url)
+    return if topic_url.blank?
+
+    uri    = Addressable::URI.parse(topic_url)
+    params = Rails.application.routes.recognize_path(uri.path)
+    domain = uri.host + (uri.port ? ":#{uri.port}" : '')
+
+    return unless TagManager.instance.local_domain?(domain) && params[:controller] == 'accounts' && params[:action] == 'show' && params[:format] == 'atom'
+
+    Account.find_local(params[:username])
+  end
+end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 4ae900583..9a356196c 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -46,19 +46,9 @@ class Api::V1::AccountsController < ApiController
     render action: :index
   end
 
-  def common_followers
-    @accounts = @account.common_followers_with(current_user.account)
-    render action: :index
-  end
-
-  def suggestions
-    @accounts = FollowSuggestion.get(current_user.account_id)
-    render action: :index
-  end
-
   def statuses
     @statuses = @account.statuses.paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
-    @statuses = cache(@statuses)
+    @statuses = cache_collection(@statuses, Status)
 
     set_maps(@statuses)
     set_counters_maps(@statuses)
@@ -121,23 +111,4 @@ class Api::V1::AccountsController < ApiController
     @followed_by = Account.followed_by_map([@account.id], current_user.account_id)
     @blocking    = Account.blocking_map([@account.id], current_user.account_id)
   end
-
-  def cache(raw)
-    uncached_ids           = []
-    cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key))
-
-    raw.each do |status|
-      uncached_ids << status.id unless cached_keys_with_value.key?(status.cache_key)
-    end
-
-    unless uncached_ids.empty?
-      uncached = Status.where(id: uncached_ids).with_includes.map { |s| [s.id, s] }.to_h
-
-      uncached.values.each do |status|
-        Rails.cache.write(status.cache_key, status)
-      end
-    end
-
-    raw.map { |status| cached_keys_with_value[status.cache_key] || uncached[status.id] }
-  end
 end
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index d74b99a86..a24e0beb7 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -8,7 +8,7 @@ class Api::V1::NotificationsController < ApiController
 
   def index
     @notifications = Notification.where(account: current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
-    @notifications = cache(@notifications)
+    @notifications = cache_collection(@notifications, Notification)
     statuses       = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status)
 
     set_maps(statuses)
@@ -20,25 +20,4 @@ class Api::V1::NotificationsController < ApiController
 
     set_pagination_headers(next_path, prev_path)
   end
-
-  private
-
-  def cache(raw)
-    uncached_ids           = []
-    cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key))
-
-    raw.each do |notification|
-      uncached_ids << notification.id unless cached_keys_with_value.key?(notification.cache_key)
-    end
-
-    unless uncached_ids.empty?
-      uncached = Notification.where(id: uncached_ids).with_includes.map { |n| [n.id, n] }.to_h
-
-      uncached.values.each do |notification|
-        Rails.cache.write(notification.cache_key, notification)
-      end
-    end
-
-    raw.map { |notification| cached_keys_with_value[notification.cache_key] || uncached[notification.id] }
-  end
 end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index a693ce00d..a0b15cfbc 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -58,7 +58,7 @@ class Api::V1::StatusesController < ApiController
 
   def destroy
     @status = Status.where(account_id: current_user.account).find(params[:id])
-    RemoveStatusService.new.call(@status)
+    RemovalWorker.perform_async(@status.id)
     render_empty
   end
 
@@ -68,8 +68,12 @@ class Api::V1::StatusesController < ApiController
   end
 
   def unreblog
-    RemoveStatusService.new.call(Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first!)
-    @status = Status.find(params[:id])
+    reblog         = Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first!
+    @status        = reblog.reblog
+    @reblogged_map = { @status.id => false }
+
+    RemovalWorker.perform_async(reblog.id)
+    
     render action: :show
   end
 
diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb
index b1d7c3052..89e54e2cf 100644
--- a/app/controllers/api/v1/timelines_controller.rb
+++ b/app/controllers/api/v1/timelines_controller.rb
@@ -8,6 +8,7 @@ class Api::V1::TimelinesController < ApiController
 
   def home
     @statuses = Feed.new(:home, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
+    @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
     set_counters_maps(@statuses)
@@ -23,6 +24,7 @@ class Api::V1::TimelinesController < ApiController
 
   def mentions
     @statuses = Feed.new(:mentions, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
+    @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
     set_counters_maps(@statuses)
@@ -38,7 +40,7 @@ class Api::V1::TimelinesController < ApiController
 
   def public
     @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
-    @statuses = cache(@statuses)
+    @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
     set_counters_maps(@statuses)
@@ -55,7 +57,7 @@ class Api::V1::TimelinesController < ApiController
   def tag
     @tag      = Tag.find_by(name: params[:id].downcase)
     @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
-    @statuses = cache(@statuses)
+    @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
     set_counters_maps(@statuses)
@@ -71,22 +73,7 @@ class Api::V1::TimelinesController < ApiController
 
   private
 
-  def cache(raw)
-    uncached_ids           = []
-    cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key))
-
-    raw.each do |status|
-      uncached_ids << status.id unless cached_keys_with_value.key?(status.cache_key)
-    end
-
-    unless uncached_ids.empty?
-      uncached = Status.where(id: uncached_ids).with_includes.map { |s| [s.id, s] }.to_h
-
-      uncached.values.each do |status|
-        Rails.cache.write(status.cache_key, status)
-      end
-    end
-
-    raw.map { |status| cached_keys_with_value[status.cache_key] || uncached[status.id] }
+  def cache_collection(raw)
+    super(raw, Status)
   end
 end
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index a3a2a3275..d2d3bc4a4 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -48,7 +48,7 @@ class ApiController < ApplicationController
 
     response.headers['X-RateLimit-Limit']     = match_data[:limit].to_s
     response.headers['X-RateLimit-Remaining'] = (match_data[:limit] - match_data[:count]).to_s
-    response.headers['X-RateLimit-Reset']     = (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
+    response.headers['X-RateLimit-Reset']     = (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6)
   end
 
   def set_pagination_headers(next_path = nil, prev_path = nil)
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index effb4ed78..ba0098c71 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -14,7 +14,6 @@ class ApplicationController < ActionController::Base
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
   before_action :set_locale
-  before_action :check_rack_mini_profiler
 
   def raise_not_found
     raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}"
@@ -32,8 +31,8 @@ class ApplicationController < ActionController::Base
     I18n.locale = I18n.default_locale
   end
 
-  def check_rack_mini_profiler
-    Rack::MiniProfiler.authorize_request if current_user && current_user.admin?
+  def require_admin!
+    redirect_to root_path unless current_user&.admin?
   end
 
   protected
@@ -53,4 +52,23 @@ class ApplicationController < ActionController::Base
   def current_account
     @current_account ||= current_user.try(:account)
   end
+
+  def cache_collection(raw, klass)
+    uncached_ids           = []
+    cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key))
+
+    raw.each do |item|
+      uncached_ids << item.id unless cached_keys_with_value.key?(item.cache_key)
+    end
+
+    unless uncached_ids.empty?
+      uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h
+
+      uncached.values.each do |item|
+        Rails.cache.write(item.cache_key, item)
+      end
+    end
+
+    raw.map { |item| cached_keys_with_value[item.cache_key] || uncached[item.id] }.compact
+  end
 end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 5be8719ae..cacc03b65 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -14,7 +14,10 @@ class Settings::PreferencesController < ApplicationController
     current_user.settings(:notification_emails).favourite = user_params[:notification_emails][:favourite] == '1'
     current_user.settings(:notification_emails).mention   = user_params[:notification_emails][:mention]   == '1'
 
-    if current_user.update(user_params.except(:notification_emails))
+    current_user.settings(:interactions).must_be_follower  = user_params[:interactions][:must_be_follower]  == '1'
+    current_user.settings(:interactions).must_be_following = user_params[:interactions][:must_be_following] == '1'
+
+    if current_user.update(user_params.except(:notification_emails, :interactions))
       redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg')
     else
       render action: :show
@@ -24,6 +27,6 @@ class Settings::PreferencesController < ApplicationController
   private
 
   def user_params
-    params.require(:user).permit(:locale, notification_emails: [:follow, :reblog, :favourite, :mention])
+    params.require(:user).permit(:locale, notification_emails: [:follow, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following])
   end
 end
diff --git a/app/helpers/admin/pubsubhubbub_helper.rb b/app/helpers/admin/pubsubhubbub_helper.rb
new file mode 100644
index 000000000..41c874a62
--- /dev/null
+++ b/app/helpers/admin/pubsubhubbub_helper.rb
@@ -0,0 +1,2 @@
+module Admin::PubsubhubbubHelper
+end
diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb
index 52190adae..13faaa261 100644
--- a/app/helpers/atom_builder_helper.rb
+++ b/app/helpers/atom_builder_helper.rb
@@ -116,9 +116,9 @@ module AtomBuilderHelper
   end
 
   def link_avatar(xml, account)
-    single_link_avatar(xml, account, :large,  300)
-    single_link_avatar(xml, account, :medium, 96)
-    single_link_avatar(xml, account, :small,  48)
+    single_link_avatar(xml, account, :large, 300)
+    # single_link_avatar(xml, account, :medium, 96)
+    # single_link_avatar(xml, account, :small,  48)
   end
 
   def logo(xml, url)
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 75ee2f8d9..26c4cd58f 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -5,7 +5,9 @@ module SettingsHelper
     en: 'English',
     de: 'Deutsch',
     es: 'Español',
+    pt: 'Português',
     fr: 'Français',
+    hu: 'Magyar',
   }.freeze
 
   def human_locale(locale)
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index c8512476d..b812ad1f4 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -68,30 +68,34 @@ class FeedManager
   def filter_from_home?(status, receiver)
     should_filter = false
 
-    if status.reply? && !status.thread.account.nil?                      # Filter out if it's a reply
-      should_filter   = !receiver.following?(status.thread.account)      # and I'm not following the person it's a reply to
-      should_filter &&= !(receiver.id == status.thread.account_id)       # and it's not a reply to me
-      should_filter &&= !(status.account_id == status.thread.account_id) # and it's not a self-reply
-    elsif status.reblog?                                                 # Filter out a reblog
-      should_filter = receiver.blocking?(status.reblog.account)          # if I'm blocking the reblogged person
+    if status.reply? && !status.thread.account.nil?                         # Filter out if it's a reply
+      should_filter   = !receiver.following?(status.thread.account)         # and I'm not following the person it's a reply to
+      should_filter &&= !(receiver.id == status.thread.account_id)          # and it's not a reply to me
+      should_filter &&= !(status.account_id == status.thread.account_id)    # and it's not a self-reply
+    elsif status.reblog?                                                    # Filter out a reblog
+      should_filter = receiver.blocking?(status.reblog.account)             # if I'm blocking the reblogged person
     end
 
+    should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked
+
     should_filter
   end
 
   def filter_from_mentions?(status, receiver)
-    should_filter   = receiver.id == status.account_id            # Filter if I'm mentioning myself
-    should_filter ||= receiver.blocking?(status.account)          # or it's from someone I blocked
+    should_filter   = receiver.id == status.account_id                      # Filter if I'm mentioning myself
+    should_filter ||= receiver.blocking?(status.account)                    # or it's from someone I blocked
+    should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account)) # or if it mentions someone I blocked
 
-    if status.reply? && !status.thread.account.nil?               # or it's a reply
-      should_filter ||= receiver.blocking?(status.thread.account) # to a user I blocked
+    if status.reply? && !status.thread.account.nil?                         # or it's a reply
+      should_filter ||= receiver.blocking?(status.thread.account)           # to a user I blocked
     end
 
     should_filter
   end
 
   def filter_from_public?(status, receiver)
-    should_filter = receiver.blocking?(status.account)
+    should_filter   = receiver.blocking?(status.account)
+    should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account))
 
     if status.reply? && !status.thread.account.nil?
       should_filter ||= receiver.blocking?(status.thread.account)
diff --git a/app/models/account.rb b/app/models/account.rb
index 16d654195..0f3d0dda2 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -13,12 +13,12 @@ class Account < ApplicationRecord
   validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?'
 
   # Avatar upload
-  has_attached_file :avatar, styles: { large: '300x300#', medium: '96x96#', small: '48x48#' }
+  has_attached_file :avatar, styles: { large: '300x300#' }, convert_options: { all: '-strip' }
   validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
   validates_attachment_size :avatar, less_than: 2.megabytes
 
   # Header upload
-  has_attached_file :header, styles: { medium: '700x335#' }
+  has_attached_file :header, styles: { medium: '700x335#' }, convert_options: { all: '-strip' }
   validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
   validates_attachment_size :header, less_than: 2.megabytes
 
@@ -44,8 +44,12 @@ class Account < ApplicationRecord
   has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
   has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
 
+  # Media
   has_many :media_attachments, dependent: :destroy
 
+  # PuSH subscriptions
+  has_many :subscriptions, dependent: :destroy
+
   pg_search_scope :search_for, against: { username: 'A', domain: 'B' }, using: { tsearch: { prefix: true } }
 
   scope :remote, -> { where.not(domain: nil) }
@@ -66,12 +70,12 @@ class Account < ApplicationRecord
 
   def unfollow!(other_account)
     follow = active_relationships.find_by(target_account: other_account)
-    follow.destroy unless follow.nil?
+    follow&.destroy
   end
 
   def unblock!(other_account)
     block = block_relationships.find_by(target_account: other_account)
-    block.destroy unless block.nil?
+    block&.destroy
   end
 
   def following?(other_account)
@@ -116,7 +120,11 @@ class Account < ApplicationRecord
   end
 
   def avatar_remote_url=(url)
-    self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url
+    parsed_url = URI.parse(url)
+
+    return if !%w(http https).include?(parsed_url.scheme) || self[:avatar_remote_url] == url
+
+    self.avatar              = parsed_url
     self[:avatar_remote_url] = url
   rescue OpenURI::HTTPError => e
     Rails.logger.debug "Error fetching remote avatar: #{e}"
@@ -130,15 +138,6 @@ class Account < ApplicationRecord
     username
   end
 
-  def common_followers_with(other_account)
-    results  = Neography::Rest.new.execute_query('MATCH (a {account_id: {a_id}})-[:follows]->(b)-[:follows]->(c {account_id: {c_id}}) RETURN b.account_id', a_id: id, c_id: other_account.id)
-    ids      = results['data'].map(&:first)
-    accounts = Account.where(id: ids).with_counters.limit(20).map { |a| [a.id, a] }.to_h
-    ids.map { |id| accounts[id] }.compact
-  rescue Neography::NeographyError, Excon::Error::Socket
-    []
-  end
-
   class << self
     def find_local!(username)
       find_remote!(username, nil)
diff --git a/app/models/feed.rb b/app/models/feed.rb
index 45cb923d1..7b181d529 100644
--- a/app/models/feed.rb
+++ b/app/models/feed.rb
@@ -16,8 +16,8 @@ class Feed
       RegenerationWorker.perform_async(@account.id, @type)
       @statuses = Status.send("as_#{@type}_timeline", @account).paginate_by_max_id(limit, nil, nil)
     else
-      status_map = cache(unhydrated)
-      @statuses = unhydrated.map { |id| status_map[id] }.compact
+      status_map = Status.where(id: unhydrated).map { |s| [s.id, s] }.to_h
+      @statuses  = unhydrated.map { |id| status_map[id] }.compact
     end
 
     @statuses
@@ -25,29 +25,6 @@ class Feed
 
   private
 
-  def cache(ids)
-    raw                    = Status.where(id: ids).to_a
-    uncached_ids           = []
-    cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key))
-
-    raw.each do |status|
-      uncached_ids << status.id unless cached_keys_with_value.key?(status.cache_key)
-    end
-
-    unless uncached_ids.empty?
-      uncached = Status.where(id: uncached_ids).with_includes.map { |s| [s.id, s] }.to_h
-
-      uncached.values.each do |status|
-        Rails.cache.write(status.cache_key, status)
-      end
-    end
-
-    cached = cached_keys_with_value.values.map { |s| [s.id, s] }.to_h
-    cached.merge!(uncached) unless uncached_ids.empty?
-
-    cached
-  end
-
   def key
     FeedManager.instance.key(@type, @account.id)
   end
diff --git a/app/models/follow.rb b/app/models/follow.rb
index cc5bceb75..f83490caa 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -27,32 +27,4 @@ class Follow < ApplicationRecord
   def title
     destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}"
   end
-
-  after_create  :add_to_graph
-  after_destroy :remove_from_graph
-
-  def sync!
-    add_to_graph
-  end
-
-  private
-
-  def add_to_graph
-    neo = Neography::Rest.new
-
-    a = neo.create_unique_node('account_index', 'Account', account_id.to_s, account_id: account_id)
-    b = neo.create_unique_node('account_index', 'Account', target_account_id.to_s, account_id: target_account_id)
-
-    neo.create_unique_relationship('follow_index', 'Follow', id.to_s, 'follows', a, b)
-  rescue Neography::NeographyError, Excon::Error::Socket => e
-    Rails.logger.error e
-  end
-
-  def remove_from_graph
-    neo = Neography::Rest.new
-    rel = neo.get_relationship_index('follow_index', 'Follow', id.to_s)
-    neo.delete_relationship(rel)
-  rescue Neography::NeographyError, Excon::Error::Socket => e
-    Rails.logger.error e
-  end
 end
diff --git a/app/models/follow_suggestion.rb b/app/models/follow_suggestion.rb
deleted file mode 100644
index 2daa40dcb..000000000
--- a/app/models/follow_suggestion.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-class FollowSuggestion
-  class << self
-    def get(for_account_id, limit = 10)
-      neo = Neography::Rest.new
-
-      query = <<END
-MATCH (a {account_id: {id}})-[:follows]->(b)-[:follows]->(c)
-WHERE a <> c
-AND NOT (a)-[:follows]->(c)
-RETURN DISTINCT c.account_id, count(b), c.nodeRank
-ORDER BY count(b) DESC, c.nodeRank DESC
-LIMIT {limit}
-END
-
-      results = neo.execute_query(query, id: for_account_id, limit: limit)
-
-      if results.empty? || results['data'].empty?
-        results = fallback(for_account_id, limit)
-      elsif results['data'].size < limit
-        results['data'] = (results['data'] + fallback(for_account_id, limit - results['data'].size)['data']).uniq
-      end
-
-      account_ids  = results['data'].map(&:first)
-      blocked_ids  = Block.where(account_id: for_account_id).pluck(:target_account_id)
-      accounts_map = Account.where(id: account_ids - blocked_ids).with_counters.map { |a| [a.id, a] }.to_h
-
-      account_ids.map { |id| accounts_map[id] }.compact
-    rescue Neography::NeographyError, Excon::Error::Socket => e
-      Rails.logger.error e
-      return []
-    end
-
-    private
-
-    def fallback(for_account_id, limit)
-      neo = Neography::Rest.new
-
-      query = <<END
-MATCH (b)
-RETURN b.account_id
-ORDER BY b.nodeRank DESC
-LIMIT {limit}
-END
-
-      neo.execute_query(query, id: for_account_id, limit: limit)
-    end
-  end
-end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index bfbf00d76..f1b9b8112 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -16,6 +16,8 @@ class MediaAttachment < ApplicationRecord
 
   validates :account, presence: true
 
+  default_scope { order('id asc') }
+
   def local?
     remote_url.blank?
   end
diff --git a/app/models/status.rb b/app/models/status.rb
index 3402929bf..f9dcd97e4 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -97,7 +97,10 @@ class Status < ApplicationRecord
     end
 
     def as_public_timeline(account = nil)
-      query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id').where('accounts.silenced = FALSE')
+      query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
+              .where('accounts.silenced = FALSE')
+              .where('statuses.in_reply_to_id IS NULL')
+              .where('statuses.reblog_of_id IS NULL')
       query = filter_timeline(query, account) unless account.nil?
       query
     end
@@ -106,6 +109,8 @@ class Status < ApplicationRecord
       query = tag.statuses
                  .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
                  .where('accounts.silenced = FALSE')
+                 .where('statuses.in_reply_to_id IS NULL')
+                 .where('statuses.reblog_of_id IS NULL')
       query = filter_timeline(query, account) unless account.nil?
       query
     end
@@ -123,13 +128,7 @@ class Status < ApplicationRecord
     def filter_timeline(query, account)
       blocked = Block.where(account: account).pluck(:target_account_id)
       return query if blocked.empty?
-
-      query
-        .joins('LEFT OUTER JOIN statuses AS parents ON statuses.in_reply_to_id = parents.id')
-        .joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id')
-        .where('statuses.account_id NOT IN (?)', blocked)
-        .where('(parents.id IS NULL OR parents.account_id NOT IN (?))', blocked)
-        .where('(reblogs.id IS NULL OR reblogs.account_id NOT IN (?))', blocked)
+      query.where('statuses.account_id NOT IN (?)', blocked)
     end
   end
 
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
new file mode 100644
index 000000000..497cabb09
--- /dev/null
+++ b/app/models/subscription.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class Subscription < ApplicationRecord
+  MIN_EXPIRATION = 3600 * 24 * 7
+  MAX_EXPIRATION = 3600 * 24 * 30
+
+  belongs_to :account
+
+  validates :callback_url, presence: true
+  validates :callback_url, uniqueness: { scope: :account_id }
+
+  scope :active, -> { where(confirmed: true).where('expires_at > ?', Time.now.utc) }
+
+  def lease_seconds=(str)
+    self.expires_at = Time.now.utc + [[MIN_EXPIRATION, str.to_i].max, MAX_EXPIRATION].min.seconds
+  end
+
+  def lease_seconds
+    (expires_at - Time.now.utc).to_i
+  end
+
+  before_validation :set_min_expiration
+
+  private
+
+  def set_min_expiration
+    self.lease_seconds = 0 unless expires_at
+  end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 366172e9a..423833d47 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -15,6 +15,7 @@ class User < ApplicationRecord
 
   has_settings do |s|
     s.key :notification_emails, defaults: { follow: false, reblog: false, favourite: false, mention: false }
+    s.key :interactions, defaults: { must_be_follower: false, must_be_following: false }
   end
 
   def send_devise_notification(notification, *args)
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 78301c6ca..40d8a0fee 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -41,14 +41,17 @@ class FanOutOnWriteService < BaseService
   end
 
   def deliver_to_hashtags(status)
-    Rails.logger.debug "Delivering status #{status.id} to hashtags"
+    return if status.reblog? || status.reply?
 
+    Rails.logger.debug "Delivering status #{status.id} to hashtags"
     status.tags.find_each do |tag|
       FeedManager.instance.broadcast("hashtag:#{tag.name}", type: 'update', id: status.id)
     end
   end
 
   def deliver_to_public(status)
+    return if status.reblog? || status.reply?
+
     Rails.logger.debug "Delivering status #{status.id} to public timeline"
     FeedManager.instance.broadcast(:public, type: 'update', id: status.id)
   end
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 781b03b40..2f280e03f 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -7,7 +7,9 @@ class FavouriteService < BaseService
   # @return [Favourite]
   def call(account, status)
     favourite = Favourite.create!(account: account, status: status)
+
     HubPingWorker.perform_async(account.id)
+    Pubsubhubbub::DistributionWorker.perform_async(favourite.stream_entry.id)
 
     if status.local?
       NotifyService.new.call(status.account, favourite)
diff --git a/app/services/follow_remote_account_service.rb b/app/services/follow_remote_account_service.rb
index 37339d8ed..f640222b0 100644
--- a/app/services/follow_remote_account_service.rb
+++ b/app/services/follow_remote_account_service.rb
@@ -80,8 +80,7 @@ class FollowRemoteAccountService < BaseService
   end
 
   def get_profile(xml, account)
-    author = xml.at_xpath('/xmlns:feed/xmlns:author') || xml.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS)
-    update_remote_profile_service.call(author, account)
+    update_remote_profile_service.call(xml.at_xpath('/xmlns:feed'), account)
   end
 
   def update_remote_profile_service
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index a57e1b28a..09fa295e3 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -19,7 +19,10 @@ class FollowService < BaseService
     end
 
     merge_into_timeline(target_account, source_account)
+
     HubPingWorker.perform_async(source_account.id)
+    Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id)
+
     follow
   end
 
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 772adfb90..1efd326b0 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -36,6 +36,8 @@ class NotifyService < BaseService
     blocked   = false
     blocked ||= @recipient.id == @notification.from_account.id
     blocked ||= @recipient.blocking?(@notification.from_account)
+    blocked ||= (@recipient.user.settings(:interactions).must_be_follower  && !@notification.from_account.following?(@recipient))
+    blocked ||= (@recipient.user.settings(:interactions).must_be_following && !@recipient.following?(@notification.from_account))
     blocked ||= send("blocked_#{@notification.type}?")
     blocked
   end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 76366e984..979a157e9 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -14,8 +14,11 @@ class PostStatusService < BaseService
     attach_media(status, options[:media_ids])
     process_mentions_service.call(status)
     process_hashtags_service.call(status)
+
     DistributionWorker.perform_async(status.id)
     HubPingWorker.perform_async(account.id)
+    Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+
     status
   end
 
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index 1cd801b80..a7a4cb2b0 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -16,7 +16,7 @@ class ProcessFeedService < BaseService
 
   def update_author(xml, account)
     return if xml.at_xpath('/xmlns:feed').nil?
-    UpdateRemoteProfileService.new.call(xml.at_xpath('/xmlns:feed/xmlns:author'), account)
+    UpdateRemoteProfileService.new.call(xml.at_xpath('/xmlns:feed'), account, true)
   end
 
   def process_entries(xml, account)
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index 3bf3471ec..fa14c44da 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -4,7 +4,7 @@ class ProcessHashtagsService < BaseService
   def call(status, tags = [])
     tags = status.text.scan(Tag::HASHTAG_RE).map(&:first) if status.local?
 
-    tags.map(&:downcase).uniq.each do |tag|
+    tags.map { |str| str.mb_chars.downcase }.uniq.each do |tag|
       status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
     end
   end
diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb
index e7bb3c73b..6b2f6e2d2 100644
--- a/app/services/process_interaction_service.rb
+++ b/app/services/process_interaction_service.rb
@@ -26,7 +26,7 @@ class ProcessInteractionService < BaseService
     end
 
     if salmon.verify(envelope, account.keypair)
-      update_remote_profile_service.call(xml.at_xpath('/xmlns:entry/xmlns:author'), account)
+      update_remote_profile_service.call(xml.at_xpath('/xmlns:entry'), account, true)
 
       case verb(xml)
       when :follow
@@ -74,7 +74,7 @@ class ProcessInteractionService < BaseService
   end
 
   def delete_post!(xml, account)
-    status = Status.find(activity_id(xml))
+    status = Status.find(xml.at_xpath('//xmlns:id').content)
 
     return if status.nil?
 
diff --git a/app/services/pubsubhubbub/subscribe_service.rb b/app/services/pubsubhubbub/subscribe_service.rb
new file mode 100644
index 000000000..343376d77
--- /dev/null
+++ b/app/services/pubsubhubbub/subscribe_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::SubscribeService < BaseService
+  def call(account, callback, secret, lease_seconds)
+    return ['Invalid topic URL', 422] if account.nil?
+    return ['Invalid callback URL', 422] unless !callback.blank? && callback =~ /\A#{URI.regexp(%w(http https))}\z/
+
+    subscription = Subscription.where(account: account, callback_url: callback).first_or_create!(account: account, callback_url: callback)
+    Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'subscribe', secret, lease_seconds)
+
+    ['', 202]
+  end
+end
diff --git a/app/services/pubsubhubbub/unsubscribe_service.rb b/app/services/pubsubhubbub/unsubscribe_service.rb
new file mode 100644
index 000000000..62459a0aa
--- /dev/null
+++ b/app/services/pubsubhubbub/unsubscribe_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::UnsubscribeService < BaseService
+  def call(account, callback)
+    return ['Invalid topic URL', 422] if account.nil?
+
+    subscription = Subscription.where(account: account, callback_url: callback)
+
+    unless subscription.nil?
+      Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'unsubscribe')
+    end
+
+    ['', 202]
+  end
+end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 6543d4ae7..39fdb4ea7 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -7,8 +7,10 @@ class ReblogService < BaseService
   # @return [Status]
   def call(account, reblogged_status)
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
+
     DistributionWorker.perform_async(reblog.id)
     HubPingWorker.perform_async(account.id)
+    Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
 
     if reblogged_status.local?
       NotifyService.new.call(reblogged_status.account, reblog)
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 689abc97b..4e03661da 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -10,6 +10,11 @@ class RemoveStatusService < BaseService
     remove_from_public(status)
 
     status.destroy!
+
+    if status.account.local?
+      HubPingWorker.perform_async(status.account.id)
+      Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+    end
   end
 
   private
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 598c7d02c..1ae1d5a80 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -2,9 +2,9 @@
 
 class SearchService < BaseService
   def call(query, limit, resolve = false)
-    return if query.blank?
+    return if query.blank? || query.start_with?('#')
 
-    username, domain = query.split('@')
+    username, domain = query.gsub(/\A@/, '').split('@')
 
     results = if domain.nil?
                 Account.search_for(username)
diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb
index 2909ae12a..56b25816f 100644
--- a/app/services/update_remote_profile_service.rb
+++ b/app/services/update_remote_profile_service.rb
@@ -2,24 +2,24 @@
 
 class UpdateRemoteProfileService < BaseService
   POCO_NS = 'http://portablecontacts.net/spec/1.0'
+  DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
 
-  def call(author_xml, account)
-    return if author_xml.nil?
+  def call(xml, account, resubscribe = false)
+    return if xml.nil?
 
-    account.display_name = if author_xml.at_xpath('./poco:displayName', poco: POCO_NS).nil?
-                             account.username
-                           else
-                             author_xml.at_xpath('./poco:displayName', poco: POCO_NS).content
-                           end
+    author_xml = xml.at_xpath('./xmlns:author') || xml.at_xpath('./dfrn:owner', dfrn: DFRN_NS)
+    hub_link   = xml.at_xpath('./xmlns:link[@rel="hub"]')
 
-    unless author_xml.at_xpath('./poco:note').nil?
-      account.note = author_xml.at_xpath('./poco:note', poco: POCO_NS).content
-    end
-
-    unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil?
-      account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]').attribute('href').value
+    unless author_xml.nil?
+      account.display_name      = author_xml.at_xpath('./poco:displayName', poco: POCO_NS).content unless author_xml.at_xpath('./poco:displayName', poco: POCO_NS).nil?
+      account.note              = author_xml.at_xpath('./poco:note', poco: POCO_NS).content unless author_xml.at_xpath('./poco:note').nil?
+      account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]')['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]')['href'].blank?
     end
 
+    old_hub_url     = account.hub_url
+    account.hub_url = hub_link['href'] if !hub_link.nil? && !hub_link['href'].blank? && (hub_link['href'] != old_hub_url)
     account.save!
+
+    SubscribeService.new.call(account) if resubscribe && (account.hub_url != old_hub_url)
   end
 end
diff --git a/app/views/accounts/show.atom.ruby b/app/views/accounts/show.atom.ruby
index d7b2201d4..558c777f0 100644
--- a/app/views/accounts/show.atom.ruby
+++ b/app/views/accounts/show.atom.ruby
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
 Nokogiri::XML::Builder.new do |xml|
   feed(xml) do
     simple_id  xml, account_url(@account, format: 'atom')
@@ -12,6 +14,7 @@ Nokogiri::XML::Builder.new do |xml|
 
     link_alternate xml, TagManager.instance.url_for(@account)
     link_self      xml, account_url(@account, format: 'atom')
+    link_hub       xml, api_push_url
     link_hub       xml, Rails.configuration.x.hub_url
     link_salmon    xml, api_salmon_url(@account.id)
 
diff --git a/app/views/admin/pubsubhubbub/index.html.haml b/app/views/admin/pubsubhubbub/index.html.haml
new file mode 100644
index 000000000..bb897eb89
--- /dev/null
+++ b/app/views/admin/pubsubhubbub/index.html.haml
@@ -0,0 +1,20 @@
+%table.table
+  %thead
+    %tr
+      %th Topic
+      %th Callback URL
+      %th Confirmed
+      %th Expires in
+  %tbody
+    - @subscriptions.each do |subscription|
+      %tr
+        %td
+          %samp= subscription.account.acct
+        %td
+          %samp= subscription.callback_url
+        %td
+          - if subscription.confirmed?
+            %i.fa.fa-check
+        %td= distance_of_time_in_words(Time.now, subscription.expires_at)
+
+= will_paginate @subscriptions, pagination_options
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 693702ff7..db5b9fb48 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -12,6 +12,10 @@
     = ff.input :favourite, as: :boolean, wrapper: :with_label
     = ff.input :mention, as: :boolean, wrapper: :with_label
 
+  = f.simple_fields_for :interactions, current_user.settings(:interactions) do |ff|
+    = ff.input :must_be_follower, as: :boolean, wrapper: :with_label
+    = ff.input :must_be_following, as: :boolean, wrapper: :with_label
+
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
 
diff --git a/app/workers/processing_worker.rb b/app/workers/processing_worker.rb
index 3b11a4c5e..b31cd0aaf 100644
--- a/app/workers/processing_worker.rb
+++ b/app/workers/processing_worker.rb
@@ -2,6 +2,8 @@
 
 class ProcessingWorker
   include Sidekiq::Worker
+  
+  sidekiq_options backtrace: true
 
   def perform(account_id, body)
     ProcessFeedService.new.call(body, Account.find(account_id))
diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb
new file mode 100644
index 000000000..489bd8359
--- /dev/null
+++ b/app/workers/pubsubhubbub/confirmation_worker.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::ConfirmationWorker
+  include Sidekiq::Worker
+  include RoutingHelper
+
+  sidekiq_options queue: 'push'
+
+  def perform(subscription_id, mode, secret = nil, lease_seconds = nil)
+    subscription = Subscription.find(subscription_id)
+    challenge    = SecureRandom.hex
+
+    subscription.secret        = secret
+    subscription.lease_seconds = lease_seconds
+    subscription.confirmed     = true
+
+    response = HTTP.headers(user_agent: 'Mastodon/PubSubHubbub')
+                   .timeout(:per_operation, write: 20, connect: 20, read: 50)
+                   .get(subscription.callback_url, params: {
+                          'hub.topic' => account_url(subscription.account, format: :atom),
+                          'hub.mode'          => mode,
+                          'hub.challenge'     => challenge,
+                          'hub.lease_seconds' => subscription.lease_seconds,
+                        })
+
+    body = response.body.to_s
+
+    Rails.logger.debug "Confirming PuSH subscription for #{subscription.callback_url} with challenge #{challenge}: #{body}"
+
+    if mode == 'subscribe' && body == challenge
+      subscription.save!
+    elsif (mode == 'unsubscribe' && body == challenge) || !subscription.confirmed?
+      subscription.destroy!
+    end
+  end
+end
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
new file mode 100644
index 000000000..6d526c2b1
--- /dev/null
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::DeliveryWorker
+  include Sidekiq::Worker
+  include RoutingHelper
+
+  sidekiq_options queue: 'push'
+
+  def perform(subscription_id, payload)
+    subscription = Subscription.find(subscription_id)
+    headers      = {}
+
+    headers['User-Agent']      = 'Mastodon/PubSubHubbub'
+    headers['Link']            = LinkHeader.new([[api_push_url, [%w(rel hub)]], [account_url(subscription.account, format: :atom), [%w(rel self)]]]).to_s
+    headers['X-Hub-Signature'] = signature(subscription.secret, payload) unless subscription.secret.blank?
+
+    response = HTTP.timeout(:per_operation, write: 50, connect: 20, read: 50)
+                   .headers(headers)
+                   .post(subscription.callback_url, body: payload)
+
+    raise "Delivery failed for #{subscription.callback_url}: HTTP #{response.code}" unless response.code > 199 && response.code < 300
+  end
+
+  private
+
+  def signature(secret, payload)
+    hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), secret, payload)
+    "sha1=#{hmac}"
+  end
+end
diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb
new file mode 100644
index 000000000..b0ddc71c1
--- /dev/null
+++ b/app/workers/pubsubhubbub/distribution_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::DistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(stream_entry_id)
+    stream_entry = StreamEntry.find(stream_entry_id)
+    account      = stream_entry.account
+    renderer     = AccountsController.renderer.new(method: 'get', http_host: Rails.configuration.x.local_domain, https: Rails.configuration.x.use_https)
+    payload      = renderer.render(:show, assigns: { account: account, entries: [stream_entry] }, formats: [:atom])
+
+    Subscription.where(account: account).active.select('id').find_each do |subscription|
+      Pubsubhubbub::DeliveryWorker.perform_async(subscription.id, payload)
+    end
+  end
+end
diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb
new file mode 100644
index 000000000..7470c54f5
--- /dev/null
+++ b/app/workers/removal_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class RemovalWorker
+  include Sidekiq::Worker
+
+  def perform(status_id)
+    RemoveStatusService.new.call(Status.find(status_id))
+  end
+end
\ No newline at end of file
diff --git a/app/workers/salmon_worker.rb b/app/workers/salmon_worker.rb
index 24fb94012..0903ca487 100644
--- a/app/workers/salmon_worker.rb
+++ b/app/workers/salmon_worker.rb
@@ -2,6 +2,8 @@
 
 class SalmonWorker
   include Sidekiq::Worker
+  
+  sidekiq_options backtrace: true
 
   def perform(account_id, body)
     ProcessInteractionService.new.call(body, Account.find(account_id))
diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb
index 700161989..84eae73be 100644
--- a/app/workers/thread_resolve_worker.rb
+++ b/app/workers/thread_resolve_worker.rb
@@ -7,9 +7,9 @@ class ThreadResolveWorker
     child_status  = Status.find(child_status_id)
     parent_status = FetchRemoteStatusService.new.call(parent_url)
 
-    unless parent_status.nil?
-      child_status.thread = parent_status
-      child_status.save!
-    end
+    return if parent_status.nil?
+
+    child_status.thread = parent_status
+    child_status.save!
   end
 end