about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.env.production.sample8
-rw-r--r--.travis.yml2
-rw-r--r--README.md3
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx3
-rw-r--r--app/assets/javascripts/components/locales/en.jsx6
-rw-r--r--app/assets/javascripts/components/locales/fr.jsx27
-rw-r--r--app/assets/stylesheets/forms.scss13
-rw-r--r--app/controllers/api/v1/accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/notifications_controller.rb2
-rw-r--r--app/controllers/api/v1/timelines_controller.rb2
-rw-r--r--app/controllers/api_controller.rb2
-rw-r--r--app/controllers/settings/preferences_controller.rb7
-rw-r--r--app/lib/feed_manager.rb26
-rw-r--r--app/models/account.rb10
-rw-r--r--app/models/status.rb15
-rw-r--r--app/models/user.rb1
-rw-r--r--app/services/fan_out_on_write_service.rb5
-rw-r--r--app/services/follow_remote_account_service.rb3
-rw-r--r--app/services/notify_service.rb2
-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.rb2
-rw-r--r--app/services/search_service.rb4
-rw-r--r--app/services/update_remote_profile_service.rb26
-rw-r--r--app/views/settings/preferences/show.html.haml4
-rw-r--r--config/environments/production.rb2
-rw-r--r--config/i18n-tasks.yml3
-rw-r--r--config/initializers/rack-attack.rb2
-rw-r--r--config/locales/fr.yml32
-rw-r--r--config/locales/simple_form.de.yml3
-rw-r--r--config/locales/simple_form.en.yml3
-rw-r--r--config/locales/simple_form.fr.yml11
-rw-r--r--spec/controllers/api/salmon_controller_spec.rb1
-rw-r--r--spec/controllers/api/subscriptions_controller_spec.rb3
-rw-r--r--spec/services/process_feed_service_spec.rb1
-rw-r--r--spec/services/update_remote_profile_service_spec.rb8
36 files changed, 139 insertions, 109 deletions
diff --git a/.env.production.sample b/.env.production.sample
index a3da10b97..e75bf9671 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -6,16 +6,13 @@ DB_USER=postgres
 DB_NAME=postgres
 DB_PASS=
 DB_PORT=5432
-NEO4J_HOST=neo4j
-NEO4J_PORT=7474
 
 # Federation
 LOCAL_DOMAIN=example.com
 LOCAL_HTTPS=true
 
 # Application secrets
-# These are arbitrary strings. They should be long and cryptographically secure.
-# For Docker, `docker-compose run --rm web rake secret` will generate them.
+# Generate each with the `rake secret` task
 PAPERCLIP_SECRET=
 SECRET_KEY_BASE=
 
@@ -25,3 +22,6 @@ SMTP_PORT=587
 SMTP_LOGIN=
 SMTP_PASSWORD=
 SMTP_FROM_ADDRESS=notifications@example.com
+
+# Optional asset host for multi-server setups
+# CDN_HOST=assets.example.com
diff --git a/.travis.yml b/.travis.yml
index f6841779d..fe4549edd 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -11,8 +11,6 @@ env:
     - LOCAL_DOMAIN=cb6e6126.ngrok.io
     - LOCAL_HTTPS=true
     - RAILS_ENV=test
-    - NEO4J_HOST=localhost
-    - NEO4J_PORT=7575
 
 addons:
   postgresql: 9.4
diff --git a/README.md b/README.md
index 25d179d86..304e37935 100644
--- a/README.md
+++ b/README.md
@@ -60,8 +60,6 @@ Consult the example configuration file, `.env.production.sample` for the full li
 
 - PostgreSQL
 - Redis
-- Neo4J (optional)
-  - GraphAware NodeRank
 
 ## Running with Docker and Docker-Compose
 
@@ -91,7 +89,6 @@ The container has two volumes, for the assets and for user uploads. The default
 - `rake mastodon:push:clear` unsubscribes from PuSH notifications for remote users that have no local followers. You may not want to actually do that, to keep a fuller footprint of the fediverse or in case your users will soon re-follow
 - `rake mastodon:push:refresh` re-subscribes PuSH for expiring remote users, this should be run periodically from a cronjob and quite often as the expiration time depends on the particular hub of the remote user
 - `rake mastodon:feeds:clear` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
-- `rake mastodon:graphs:sync` re-imports all follow relationships into Neo4J. Only for troubleshooting
 
 Running any of these tasks via docker-compose would look like this:
 
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/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index e6ae25453..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",
@@ -49,7 +49,7 @@ const en = {
   "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/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx
index 4673305f7..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,21 +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 ?",
+  "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é",
   "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/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index 306f474d6..22b05946e 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -48,11 +48,16 @@ code {
       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;
     }
   }
 
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 5546ee588..ffa8b04fb 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -128,6 +128,6 @@ class Api::V1::AccountsController < ApiController
       end
     end
 
-    raw.map { |status| cached_keys_with_value[status.cache_key] || uncached[status.id] }
+    raw.map { |status| cached_keys_with_value[status.cache_key] || uncached[status.id] }.compact
   end
 end
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index d74b99a86..b23d7570d 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -39,6 +39,6 @@ class Api::V1::NotificationsController < ApiController
       end
     end
 
-    raw.map { |notification| cached_keys_with_value[notification.cache_key] || uncached[notification.id] }
+    raw.map { |notification| cached_keys_with_value[notification.cache_key] || uncached[notification.id] }.compact
   end
 end
diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb
index b1d7c3052..3debbdfc4 100644
--- a/app/controllers/api/v1/timelines_controller.rb
+++ b/app/controllers/api/v1/timelines_controller.rb
@@ -87,6 +87,6 @@ class Api::V1::TimelinesController < ApiController
       end
     end
 
-    raw.map { |status| cached_keys_with_value[status.cache_key] || uncached[status.id] }
+    raw.map { |status| cached_keys_with_value[status.cache_key] || uncached[status.id] }.compact
   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/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/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 a60a23e14..65fad2f47 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -66,12 +66,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 +116,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}"
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/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/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/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/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..3159a4ded 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
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..26ab84d75 100644
--- a/app/services/update_remote_profile_service.rb
+++ b/app/services/update_remote_profile_service.rb
@@ -2,24 +2,22 @@
 
 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)
+    author_xml = xml.at_xpath('./xmlns:author') || xml.at_xpath('./dfrn:owner', dfrn: DFRN_NS)
+    hub_link   = xml.at_xpath('./xmlns:link[@rel="hub"]')
 
-    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
-
-    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/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/config/environments/production.rb b/config/environments/production.rb
index dcb659d6c..0672cd587 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -13,7 +13,7 @@ Rails.application.configure do
   # Full error reports are disabled and caching is turned on.
   config.consider_all_requests_local       = false
   config.action_controller.perform_caching = true
-  config.action_controller.asset_host      = ENV['CDN_HOST']
+  config.action_controller.asset_host      = ENV['CDN_HOST'] if ENV.key?('CDN_HOST')
 
   # Disable serving static files from the `/public` folder by default since
   # Apache or NGINX already handles this.
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index d345ce6c0..4dc6985b7 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -30,9 +30,6 @@ search:
     - app/assets/fonts
     - app/assets/videos
 
-ignore_missing:
-  - '{devise,simple_form}.*'
-
 ignore_unused:
   - 'activerecord.attributes.*'
   - '{devise,will_paginate,doorkeeper}.*'
diff --git a/config/initializers/rack-attack.rb b/config/initializers/rack-attack.rb
index 6d9286e66..0e96f5381 100644
--- a/config/initializers/rack-attack.rb
+++ b/config/initializers/rack-attack.rb
@@ -11,7 +11,7 @@ class Rack::Attack
     headers = {
       'X-RateLimit-Limit'     => match_data[:limit].to_s,
       'X-RateLimit-Remaining' => '0',
-      'X-RateLimit-Reset'     => (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
+      'X-RateLimit-Reset'     => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6)
     }
 
     [429, headers, [{ error: 'Throttled' }.to_json]]
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index c9258381c..f78cd0de5 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -7,44 +7,44 @@ fr:
     source_code: "Code source"
     terms: "Conditions d’utilisation"
   accounts:
-    follow: "S’abonner"
+    follow: "Suivre"
     followers: "Abonnés"
     following: "Abonnements"
-    nothing_here: "Rien à voir ici&nbsp;!"
-    people_followed_by: "Personnes auxquelles %{name} est abonné⋅e"
-    people_who_follow: "Personnes abonnées à %{name}"
+    nothing_here: "Rien à voir ici !"
+    people_followed_by: "Personnes suivies par %{name}"
+    people_who_follow: "Personnes qui suivent %{name}"
     posts: "Statuts"
-    unfollow: "Se désabonner"
+    unfollow: "Ne plus suivre"
   application_mailer:
     signature: "Notifications de Mastodon depuis %{instance}"
   auth:
     change_password: "Changer de mot de passe"
-    didnt_get_confirmation: "Vous n’avez pas reçu les consignes de confirmation&nbsp;?"
-    forgot_password: "Mode passe oublié&nbsp;?"
+    didnt_get_confirmation: "Vous n’avez pas reçu les consignes de confirmation ?"
+    forgot_password: "Mode passe oublié ?"
     login: "Se connecter"
     register: "S’inscrire"
     resend_confirmation: "Envoyer à nouveau les consignes de confirmation"
     reset_password: "Réinitialiser le mot de passe"
     set_new_password: "Établir le nouveau mot de passe"
   generic:
-    changes_saved_msg: "Les modifications ont été enregistrées avec succès&nbsp;!"
+    changes_saved_msg: "Les modifications ont été enregistrées avec succès !"
     powered_by: "propulsé par %{link}"
     save_changes: "Enregistrer les modifications"
     validation_errors:
-      one: "Quelque chose ne va pas&nbsp;! Vérifiez l’erreur ci-dessous."
-      other: "Quelques choses ne vont pas&nbsp;! Vérifiez les erreurs ci-dessous."
+      one: "Quelque chose ne va pas ! Vérifiez l’erreur ci-dessous."
+      other: "Quelques choses ne vont pas ! Vérifiez les erreurs ci-dessous."
   notification_mailer:
     favourite:
-      body: "%{name} a ajouté votre statut à ses favoris&nbsp;:"
+      body: "%{name} a ajouté votre statut à ses favoris :"
       subject: "%{name} a ajouté votre statut à ses favoris"
     follow:
-      body: "%{name} s’est abonné⋅e à vos statuts&nbsp;!"
-      subject: "%{name} s’est abonné⋅e à vos statuts"
+      body: "%{name} vous suit !"
+      subject: "%{name} vous suit"
     mention:
-      body: "%{name} vous a mentionné⋅e dans&nbsp;:"
+      body: "%{name} vous a mentionné⋅e dans :"
       subject: "%{name} vous a mentionné⋅e"
     reblog:
-      body: "%{name} a partagé votre statut&nbsp;:"
+      body: "%{name} a partagé votre statut :"
       subject: "%{name} a partagé votre statut"
   pagination:
     next: "Suivant"
@@ -54,6 +54,6 @@ fr:
     preferences: "Préférences"
   stream_entries:
     favourited: "a ajouté à ses favoris un statut de"
-    is_now_following: "s’est abonné⋅e à"
+    is_now_following: "suit désormais"
   will_paginate:
     page_gap: "&hellip;"
diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml
index 36c5141a2..47e30ccb4 100644
--- a/config/locales/simple_form.de.yml
+++ b/config/locales/simple_form.de.yml
@@ -16,6 +16,9 @@ de:
         password: Passwort
         silenced: Öffentliche Beiträge nicht auflisten
         username: Nutzername
+      interactions:
+        must_be_follower: Benachrichtigungen von nicht-Folgern blockieren
+        must_be_following: Benachrichtigungen von Nutzern blockieren, denen ich nicht folge
       notification_emails:
         favourite: E-mail senden, wenn jemand meinen Beitrag favorisiert
         follow: E-mail senden, wenn mir jemand folgt
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index a7d958c06..1e975af14 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -16,6 +16,9 @@ en:
         password: Password
         silenced: Unlisted mode
         username: Username
+      interactions:
+        must_be_follower: Block notifications from non-followers
+        must_be_following: Block notifications from people you don't follow
       notification_emails:
         favourite: Send e-mail when someone favourites your status
         follow: Send e-mail when someone follows you
diff --git a/config/locales/simple_form.fr.yml b/config/locales/simple_form.fr.yml
index 7333c9e11..73905a7b3 100644
--- a/config/locales/simple_form.fr.yml
+++ b/config/locales/simple_form.fr.yml
@@ -15,12 +15,15 @@ fr:
         note: Présentation
         password: Mot de passe
         silenced: Ne pas apparaître dans le fil public
-        username: Nom d’utilisateur
+        username: Identifiant
       notification_emails:
-        favourite: Envoyer un courriel lorsque quelqu’un ajoute un de mes statuts à ses favoris
-        follow: Envoyer un courriel lorsque quelqu’un s’abonne à mes statuts
+        favourite: Envoyer un courriel lorsque quelqu’un ajoute mes statut à ses favoris
+        follow: Envoyer un courriel lorsque quelqu’un me suit
         mention: Envoyer un courriel lorsque quelqu’un me mentionne
-        reblog: Envoyer un courriel lorsque quelqu’un partage un de mes statuts
+        reblog: Envoyer un courriel lorsque quelqu’un partage mes statuts
+      interactions:
+        must_be_follower: Masquer les notifications des personnes qui ne vous suivent pas
+        must_be_following: Masquer les notifications des personnes que vous ne suivez pas
     'no': Non
     required:
       mark: "*"
diff --git a/spec/controllers/api/salmon_controller_spec.rb b/spec/controllers/api/salmon_controller_spec.rb
index 6897caeeb..3d3a973d2 100644
--- a/spec/controllers/api/salmon_controller_spec.rb
+++ b/spec/controllers/api/salmon_controller_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Api::SalmonController, type: :controller do
   let(:account) { Fabricate(:user, account: Fabricate(:account, username: 'catsrgr8')).account }
 
   before do
+    stub_request(:post, "https://pubsubhubbub.superfeedr.com/").to_return(:status => 200, :body => "", :headers => {})
     stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt'))
     stub_request(:get, "https://quitter.no/.well-known/webfinger?resource=acct:gargron@quitter.no").to_return(request_fixture('webfinger.txt'))
     stub_request(:get, "https://quitter.no/api/statuses/user_timeline/7477.atom").to_return(request_fixture('feed.txt'))
diff --git a/spec/controllers/api/subscriptions_controller_spec.rb b/spec/controllers/api/subscriptions_controller_spec.rb
index 2af6cb725..44841176a 100644
--- a/spec/controllers/api/subscriptions_controller_spec.rb
+++ b/spec/controllers/api/subscriptions_controller_spec.rb
@@ -23,6 +23,7 @@ RSpec.describe Api::SubscriptionsController, type: :controller do
     let(:feed) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom')) }
 
     before do
+      stub_request(:post, "https://quitter.no/main/push/hub").to_return(:status => 200, :body => "", :headers => {})
       stub_request(:get, "https://quitter.no/avatar/7477-300-20160211190340.png").to_return(request_fixture('avatar.txt'))
       stub_request(:head, "https://quitter.no/notice/1269244").to_return(status: 404)
       stub_request(:head, "https://quitter.no/notice/1265331").to_return(status: 404)
@@ -37,7 +38,7 @@ RSpec.describe Api::SubscriptionsController, type: :controller do
       stub_request(:head, "https://social.umeahackerspace.se/user/2").to_return(status: 404)
       stub_request(:head, "https://gs.kawa-kun.com/user/2").to_return(status: 404)
       stub_request(:head, "https://mastodon.social/users/Gargron").to_return(status: 404)
-      
+
       request.env['HTTP_X_HUB_SIGNATURE'] = "sha1=#{OpenSSL::HMAC.hexdigest('sha1', 'abc', feed)}"
       request.env['RAW_POST_DATA'] = feed
 
diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb
index e4e5858ea..5e57d823b 100644
--- a/spec/services/process_feed_service_spec.rb
+++ b/spec/services/process_feed_service_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe ProcessFeedService do
   subject { ProcessFeedService.new }
 
   before do
+    stub_request(:post, "https://pubsubhubbub.superfeedr.com/").to_return(:status => 200, :body => "", :headers => {})
     stub_request(:get, "http://kickass.zone/system/accounts/avatars/000/000/001/large/eris.png").to_return(request_fixture('avatar.txt'))
     stub_request(:get, "http://kickass.zone/system/media_attachments/files/000/000/002/original/morpheus_linux.jpg?1476059910").to_return(request_fixture('attachment1.txt'))
     stub_request(:get, "http://kickass.zone/system/media_attachments/files/000/000/003/original/gizmo.jpg?1476060065").to_return(request_fixture('attachment2.txt'))
diff --git a/spec/services/update_remote_profile_service_spec.rb b/spec/services/update_remote_profile_service_spec.rb
index 1ffcfbfac..c3d76c653 100644
--- a/spec/services/update_remote_profile_service_spec.rb
+++ b/spec/services/update_remote_profile_service_spec.rb
@@ -1,7 +1,7 @@
 require 'rails_helper'
 
 RSpec.describe UpdateRemoteProfileService do
-  let(:xml) { Nokogiri::XML(File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom'))).at_xpath('//xmlns:author') }
+  let(:xml) { Nokogiri::XML(File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom'))).at_xpath('//xmlns:feed') }
 
   subject { UpdateRemoteProfileService.new }
 
@@ -13,7 +13,7 @@ RSpec.describe UpdateRemoteProfileService do
     let(:remote_account) { Fabricate(:account, username: 'bob', domain: 'example.com') }
 
     before do
-      subject.(xml, remote_account)
+      subject.call(xml, remote_account)
     end
 
     it 'downloads new avatar' do
@@ -34,10 +34,10 @@ RSpec.describe UpdateRemoteProfileService do
   end
 
   context 'with unchanged details' do
-    let(:remote_account) { Fabricate(:account, username: 'bob', domain: 'example.com',display_name: 'DIGITAL CAT', note: 'Software engineer, free time musician and DIGITAL SPORTS enthusiast. Likes cats. Warning: May contain memes', avatar_remote_url: 'https://quitter.no/avatar/7477-300-20160211190340.png') }
+    let(:remote_account) { Fabricate(:account, username: 'bob', domain: 'example.com', display_name: 'DIGITAL CAT', note: 'Software engineer, free time musician and DIGITAL SPORTS enthusiast. Likes cats. Warning: May contain memes', avatar_remote_url: 'https://quitter.no/avatar/7477-300-20160211190340.png') }
 
     before do
-      subject.(xml, remote_account)
+      subject.call(xml, remote_account)
     end
 
     it 'does not re-download avatar' do