about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/api/v1/statuses_controller.rb30
-rw-r--r--app/controllers/api_controller.rb9
-rw-r--r--app/javascript/mastodon/actions/statuses.js76
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js18
-rw-r--r--app/javascript/mastodon/containers/status_container.js10
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js6
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js2
-rw-r--r--app/javascript/mastodon/features/status/index.js20
-rw-r--r--app/javascript/mastodon/locales/ar.json2
-rw-r--r--app/javascript/mastodon/locales/bg.json2
-rw-r--r--app/javascript/mastodon/locales/de.json2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json16
-rw-r--r--app/javascript/mastodon/locales/en.json2
-rw-r--r--app/javascript/mastodon/locales/eo.json2
-rw-r--r--app/javascript/mastodon/locales/es.json2
-rw-r--r--app/javascript/mastodon/locales/fa.json2
-rw-r--r--app/javascript/mastodon/locales/fi.json2
-rw-r--r--app/javascript/mastodon/locales/fr.json2
-rw-r--r--app/javascript/mastodon/locales/he.json2
-rw-r--r--app/javascript/mastodon/locales/hr.json2
-rw-r--r--app/javascript/mastodon/locales/hu.json2
-rw-r--r--app/javascript/mastodon/locales/id.json2
-rw-r--r--app/javascript/mastodon/locales/io.json2
-rw-r--r--app/javascript/mastodon/locales/it.json2
-rw-r--r--app/javascript/mastodon/locales/ja.json2
-rw-r--r--app/javascript/mastodon/locales/nl.json2
-rw-r--r--app/javascript/mastodon/locales/no.json2
-rw-r--r--app/javascript/mastodon/locales/oc.json2
-rw-r--r--app/javascript/mastodon/locales/pl.json2
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json2
-rw-r--r--app/javascript/mastodon/locales/pt.json2
-rw-r--r--app/javascript/mastodon/locales/ru.json2
-rw-r--r--app/javascript/mastodon/locales/tr.json2
-rw-r--r--app/javascript/mastodon/locales/uk.json2
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json2
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json2
-rw-r--r--app/javascript/mastodon/reducers/statuses.js8
-rw-r--r--app/models/account.rb14
-rw-r--r--app/models/conversation.rb2
-rw-r--r--app/models/conversation_mute.rb14
-rw-r--r--app/models/status.rb4
-rw-r--r--app/services/notify_service.rb9
-rw-r--r--app/views/api/v1/statuses/show.rabl6
-rw-r--r--config/routes.rb2
-rw-r--r--db/migrate/20170301222600_create_mutes.rb1
-rw-r--r--db/migrate/20170508230434_create_conversation_mutes.rb10
-rw-r--r--db/schema.rb8
-rw-r--r--spec/controllers/api/v1/statuses_controller_spec.rb33
-rw-r--r--spec/fabricators/conversation_mute_fabricator.rb2
-rw-r--r--spec/models/conversation_mute_spec.rb5
-rw-r--r--spec/models/status_spec.rb48
-rw-r--r--spec/services/notify_service_spec.rb42
52 files changed, 422 insertions, 27 deletions
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 77bdaa494..9312282ed 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -1,10 +1,11 @@
 # frozen_string_literal: true
 
 class Api::V1::StatusesController < ApiController
-  before_action :authorize_if_got_token, except:            [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
-  before_action -> { doorkeeper_authorize! :write }, only:  [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
-  before_action :require_user!, except: [:show, :context, :card, :reblogged_by, :favourited_by]
-  before_action :set_status, only:      [:show, :context, :card, :reblogged_by, :favourited_by]
+  before_action :authorize_if_got_token, except:            [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite, :mute, :unmute]
+  before_action -> { doorkeeper_authorize! :write }, only:  [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite, :mute, :unmute]
+  before_action :require_user!, except:  [:show, :context, :card, :reblogged_by, :favourited_by]
+  before_action :set_status, only:       [:show, :context, :card, :reblogged_by, :favourited_by, :mute, :unmute]
+  before_action :set_conversation, only: [:mute, :unmute]
 
   respond_to :json
 
@@ -105,6 +106,22 @@ class Api::V1::StatusesController < ApiController
     render :show
   end
 
+  def mute
+    current_account.mute_conversation!(@conversation)
+
+    @mutes_map = { @conversation.id => true }
+
+    render :show
+  end
+
+  def unmute
+    current_account.unmute_conversation!(@conversation)
+
+    @mutes_map = { @conversation.id => false }
+
+    render :show
+  end
+
   private
 
   def set_status
@@ -112,6 +129,11 @@ class Api::V1::StatusesController < ApiController
     raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account)
   end
 
+  def set_conversation
+    @conversation = @status.conversation
+    raise Mastodon::ValidationError if @conversation.nil?
+  end
+
   def status_params
     params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, media_ids: [])
   end
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index 957e3c315..1c67b6fdc 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -93,11 +93,14 @@ class ApiController < ApplicationController
     if current_account.nil?
       @reblogs_map    = {}
       @favourites_map = {}
+      @mutes_map      = {}
       return
     end
 
-    status_ids      = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
-    @reblogs_map    = Status.reblogs_map(status_ids, current_account)
-    @favourites_map = Status.favourites_map(status_ids, current_account)
+    status_ids       = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
+    conversation_ids = statuses.compact.map(&:conversation_id).compact.uniq
+    @reblogs_map     = Status.reblogs_map(status_ids, current_account)
+    @favourites_map  = Status.favourites_map(status_ids, current_account)
+    @mutes_map       = Status.mutes_map(conversation_ids, current_account)
   end
 end
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 19df2c36c..5eb9bf817 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -15,6 +15,14 @@ export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
 export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
 export const CONTEXT_FETCH_FAIL    = 'CONTEXT_FETCH_FAIL';
 
+export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
+export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
+export const STATUS_MUTE_FAIL    = 'STATUS_MUTE_FAIL';
+
+export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
+export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
+export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL';
+
 export function fetchStatusRequest(id, skipLoading) {
   return {
     type: STATUS_FETCH_REQUEST,
@@ -139,3 +147,71 @@ export function fetchContextFail(id, error) {
     skipAlert: true
   };
 };
+
+export function muteStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(muteStatusRequest(id));
+
+    api(getState).post(`/api/v1/statuses/${id}/mute`).then(response => {
+      dispatch(muteStatusSuccess(id));
+    }).catch(error => {
+      dispatch(muteStatusFail(id, error));
+    });
+  };
+};
+
+export function muteStatusRequest(id) {
+  return {
+    type: STATUS_MUTE_REQUEST,
+    id
+  };
+};
+
+export function muteStatusSuccess(id) {
+  return {
+    type: STATUS_MUTE_SUCCESS,
+    id
+  };
+};
+
+export function muteStatusFail(id, error) {
+  return {
+    type: STATUS_MUTE_FAIL,
+    id,
+    error
+  };
+};
+
+export function unmuteStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(unmuteStatusRequest(id));
+
+    api(getState).post(`/api/v1/statuses/${id}/unmute`).then(response => {
+      dispatch(unmuteStatusSuccess(id));
+    }).catch(error => {
+      dispatch(unmuteStatusFail(id, error));
+    });
+  };
+};
+
+export function unmuteStatusRequest(id) {
+  return {
+    type: STATUS_UNMUTE_REQUEST,
+    id
+  };
+};
+
+export function unmuteStatusSuccess(id) {
+  return {
+    type: STATUS_UNMUTE_SUCCESS,
+    id
+  };
+};
+
+export function unmuteStatusFail(id, error) {
+  return {
+    type: STATUS_UNMUTE_FAIL,
+    id,
+    error
+  };
+};
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index b3d5e442c..4d077fb98 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -16,7 +16,9 @@ const messages = defineMessages({
   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
   open: { id: 'status.open', defaultMessage: 'Expand this status' },
-  report: { id: 'status.report', defaultMessage: 'Report @{name}' }
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+  muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+  unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
 });
 
 class StatusActionBar extends React.PureComponent {
@@ -35,7 +37,9 @@ class StatusActionBar extends React.PureComponent {
     onMute: PropTypes.func,
     onBlock: PropTypes.func,
     onReport: PropTypes.func,
+    onMuteConversation: PropTypes.func,
     me: PropTypes.number.isRequired,
+    withDismiss: PropTypes.bool,
     intl: PropTypes.object.isRequired
   };
 
@@ -76,9 +80,14 @@ class StatusActionBar extends React.PureComponent {
     this.context.router.push('/report');
   }
 
+  handleConversationMuteClick = () => {
+    this.props.onMuteConversation(this.props.status);
+  }
+
   render () {
-    const { status, me, intl } = this.props;
+    const { status, me, intl, withDismiss } = this.props;
     const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
+    const mutingConversation = status.get('muted');
 
     let menu = [];
     let reblogIcon = 'retweet';
@@ -88,6 +97,11 @@ class StatusActionBar extends React.PureComponent {
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
     menu.push(null);
 
+    if (withDismiss) {
+      menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+      menu.push(null);
+    }
+
     if (status.getIn(['account', 'id']) === me) {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index eb1f1ab79..8b7d6dc53 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -16,7 +16,7 @@ import {
   blockAccount,
   muteAccount
 } from '../actions/accounts';
-import { deleteStatus } from '../actions/statuses';
+import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses';
 import { initReport } from '../actions/reports';
 import { openModal } from '../actions/modal';
 import { createSelector } from 'reselect'
@@ -113,6 +113,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }));
   },
 
+  onMuteConversation (status) {
+    if (status.get('muted')) {
+      dispatch(unmuteStatus(status.get('id')));
+    } else {
+      dispatch(muteStatus(status.get('id')));
+    }
+  },
+
 });
 
 export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 48a0e0381..e20be99ea 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -32,7 +32,7 @@ class Notification extends ImmutablePureComponent {
   }
 
   renderMention (notification) {
-    return <StatusContainer id={notification.get('status')} />;
+    return <StatusContainer id={notification.get('status')} withDismiss />;
   }
 
   renderFavourite (notification, link) {
@@ -45,7 +45,7 @@ class Notification extends ImmutablePureComponent {
           <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
         </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted={true} />
+        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted={true} withDismiss />
       </div>
     );
   }
@@ -60,7 +60,7 @@ class Notification extends ImmutablePureComponent {
           <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
         </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted={true} />
+        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted={true} withDismiss />
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 0ed149f80..9779f4965 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -12,7 +12,7 @@ const messages = defineMessages({
   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
-  report: { id: 'status.report', defaultMessage: 'Report @{name}' }
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' },
 });
 
 class ActionBar extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 3ce55e68e..e2ba1c5b9 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -171,8 +171,24 @@ class Status extends ImmutablePureComponent {
           <div className='scrollable detailed-status__wrapper'>
             {ancestors}
 
-            <DetailedStatus status={status} autoPlayGif={autoPlayGif} me={me} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} />
-            <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} />
+            <DetailedStatus
+              status={status}
+              autoPlayGif={autoPlayGif}
+              me={me}
+              onOpenVideo={this.handleOpenVideo}
+              onOpenMedia={this.handleOpenMedia}
+            />
+
+            <ActionBar
+              status={status}
+              me={me}
+              onReply={this.handleReplyClick}
+              onFavourite={this.handleFavouriteClick}
+              onReblog={this.handleReblogClick}
+              onDelete={this.handleDeleteClick}
+              onMention={this.handleMentionClick}
+              onReport={this.handleReport}
+            />
 
             {descendants}
           </div>
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index e6f6d8c51..539b1c81f 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -142,6 +142,7 @@
   "status.load_more": "حمّل المزيد",
   "status.media_hidden": "الصورة مستترة",
   "status.mention": "أذكُر @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "وسع هذه المشاركة",
   "status.reblog": "رَقِّي",
   "status.reblogged_by": "{name} رقى",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "محتوى حساس",
   "status.show_less": "إعرض أقلّ",
   "status.show_more": "أظهر المزيد",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "تحرير",
   "tabs_bar.federated_timeline": "الموحَّد",
   "tabs_bar.home": "الرئيسية",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 7d0660c25..5101a8b76 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -142,6 +142,7 @@
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Споменаване",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.reblog": "Споделяне",
   "status.reblogged_by": "{name} сподели",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Деликатно съдържание",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Съставяне",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Начало",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 1c3a8b656..bdef91537 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -142,6 +142,7 @@
   "status.load_more": "Weitere laden",
   "status.media_hidden": "Medien versteckt",
   "status.mention": "Erwähnen",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Öffnen",
   "status.reblog": "Teilen",
   "status.reblogged_by": "{name} teilte",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Heikle Inhalte",
   "status.show_less": "Weniger anzeigen",
   "status.show_more": "Mehr anzeigen",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Schreiben",
   "tabs_bar.federated_timeline": "Föderation",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 9163a3563..94a5d456d 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -144,6 +144,14 @@
       {
         "defaultMessage": "Report @{name}",
         "id": "status.report"
+      },
+      {
+        "defaultMessage": "Mute conversation",
+        "id": "status.mute_conversation"
+      },
+      {
+        "defaultMessage": "Unmute conversation",
+        "id": "status.unmute_conversation"
       }
     ],
     "path": "app/javascript/mastodon/components/status_action_bar.json"
@@ -185,10 +193,6 @@
         "id": "video_player.expand"
       },
       {
-        "defaultMessage": "Video could not be played",
-        "id": "video_player.video_error"
-      },
-      {
         "defaultMessage": "Sensitive content",
         "id": "status.sensitive_warning"
       },
@@ -199,6 +203,10 @@
       {
         "defaultMessage": "Media hidden",
         "id": "status.media_hidden"
+      },
+      {
+        "defaultMessage": "Video could not be played",
+        "id": "video_player.video_error"
       }
     ],
     "path": "app/javascript/mastodon/components/video_player.json"
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 4dd59b883..bb86e813a 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -142,6 +142,7 @@
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mention @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boosted",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Sensitive content",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Compose",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 205af27e1..2e846cd2a 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -142,6 +142,7 @@
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mencii @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.reblog": "Diskonigi",
   "status.reblogged_by": "{name} diskonigita",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Tikla enhavo",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Ekskribi",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Hejmo",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index fec4b391e..447b384e4 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -142,6 +142,7 @@
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mencionar",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Expandir estado",
   "status.reblog": "Retoot",
   "status.reblogged_by": "Retooteado por {name}",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Contenido sensible",
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar más",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Redactar",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Inicio",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 7820da5f6..680332845 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -142,6 +142,7 @@
   "status.load_more": "بیشتر نشان بده",
   "status.media_hidden": "تصویر پنهان شده",
   "status.mention": "نام‌بردن از @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "این نوشته را باز کن",
   "status.reblog": "بازبوقیدن",
   "status.reblogged_by": "{name} بازبوقید",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "محتوای حساس",
   "status.show_less": "نهفتن",
   "status.show_more": "نمایش",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "بنویسید",
   "tabs_bar.federated_timeline": "همگانی",
   "tabs_bar.home": "خانه",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index 2621baa51..dc453e42e 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -142,6 +142,7 @@
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mainitse @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.reblog": "Buustaa",
   "status.reblogged_by": "{name} buustasi",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Arkaluontoista sisältöä",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Luo",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Koti",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index f80cf5a71..6a840fc3e 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -142,6 +142,7 @@
   "status.load_more": "Charger plus",
   "status.media_hidden": "Média caché",
   "status.mention": "Mentionner",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Déplier ce statut",
   "status.reblog": "Partager",
   "status.reblogged_by": "{name} a partagé :",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Contenu délicat",
   "status.show_less": "Replier",
   "status.show_more": "Déplier",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Composer",
   "tabs_bar.federated_timeline": "Fil public global",
   "tabs_bar.home": "Accueil",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index e428dfdf2..0cc5f4566 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -142,6 +142,7 @@
   "status.load_more": "עוד",
   "status.media_hidden": "מדיה מוסתרת",
   "status.mention": "פניה אל @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "הרחבת הודעה",
   "status.reblog": "הדהוד",
   "status.reblogged_by": "הודהד על ידי {name}",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "תוכן רגיש",
   "status.show_less": "הראה פחות",
   "status.show_more": "הראה יותר",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "חיבור",
   "tabs_bar.federated_timeline": "ציר זמן בין-קהילתי",
   "tabs_bar.home": "בבית",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 726ad9609..7f068b2a2 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -142,6 +142,7 @@
   "status.load_more": "Učitaj više",
   "status.media_hidden": "Sakriven media sadržaj",
   "status.mention": "Spomeni @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Proširi ovaj status",
   "status.reblog": "Podigni",
   "status.reblogged_by": "{name} je podigao",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Osjetljiv sadržaj",
   "status.show_less": "Pokaži manje",
   "status.show_more": "Pokaži više",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Sastavi",
   "tabs_bar.federated_timeline": "Federalni",
   "tabs_bar.home": "Dom",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index dd283d736..0dbe2dabb 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -142,6 +142,7 @@
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Említés",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.reblog": "Reblog",
   "status.reblogged_by": "{name} reblogolta",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Érzékeny tartalom",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Összeállítás",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Kezdőlap",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index ce0399a65..bdba4b5a8 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -142,6 +142,7 @@
   "status.load_more": "Tampilkan semua",
   "status.media_hidden": "Media disembunyikan",
   "status.mention": "Balasan @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Tampilkan status ini",
   "status.reblog": "Boost",
   "status.reblogged_by": "di-boost {name}",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Konten sensitif",
   "status.show_less": "Tampilkan lebih sedikit",
   "status.show_more": "Tampilkan semua",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Tulis",
   "tabs_bar.federated_timeline": "Gabungan",
   "tabs_bar.home": "Beranda",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index 5f63f2a89..5b4391688 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -142,6 +142,7 @@
   "status.load_more": "Kargar pluse",
   "status.media_hidden": "Kontenajo celita",
   "status.mention": "Mencionar @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Detaligar ca mesajo",
   "status.reblog": "Repetar",
   "status.reblogged_by": "{name} repetita",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Trubliva kontenajo",
   "status.show_less": "Montrar mine",
   "status.show_more": "Montrar plue",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Kompozar",
   "tabs_bar.federated_timeline": "Federata",
   "tabs_bar.home": "Hemo",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 38d23f44c..17b777579 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -142,6 +142,7 @@
   "status.load_more": "Mostra di più",
   "status.media_hidden": "Allegato nascosto",
   "status.mention": "Nomina @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Espandi questo post",
   "status.reblog": "Condividi",
   "status.reblogged_by": "{name} ha condiviso",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Materiale sensibile",
   "status.show_less": "Mostra meno",
   "status.show_more": "Mostra di più",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Scrivi",
   "tabs_bar.federated_timeline": "Federazione",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 0c252535f..ee836a986 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -142,6 +142,7 @@
   "status.load_more": "もっと見る",
   "status.media_hidden": "非表示のメデイア",
   "status.mention": "返信",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "詳細を表示",
   "status.reblog": "ブースト",
   "status.reblogged_by": "{name} さんにブーストされました",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "閲覧注意",
   "status.show_less": "隠す",
   "status.show_more": "もっと見る",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "投稿",
   "tabs_bar.federated_timeline": "連合",
   "tabs_bar.home": "ホーム",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 76e47e46a..82164d4ea 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -140,6 +140,7 @@
   "status.load_more": "Meer laden",
   "status.media_hidden": "Media verborgen",
   "status.mention": "Vermeld @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Toot volledig tonen",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boostte",
@@ -150,6 +151,7 @@
   "status.sensitive_warning": "Gevoelige inhoud",
   "status.show_less": "Minder tonen",
   "status.show_more": "Meer tonen",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Schrijven",
   "tabs_bar.federated_timeline": "Globaal",
   "tabs_bar.home": "Jouw tijdlijn",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 4254dd2d4..9fcf7dade 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -142,6 +142,7 @@
   "status.load_more": "Last mer",
   "status.media_hidden": "Media skjult",
   "status.mention": "Nevn @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Utvid denne statusen",
   "status.reblog": "Fremhev",
   "status.reblogged_by": "Fremhevd av {name}",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Følsomt innhold",
   "status.show_less": "Vis mindre",
   "status.show_more": "Vis mer",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Komponer",
   "tabs_bar.federated_timeline": "Felles",
   "tabs_bar.home": "Hjem",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 6830bc093..26b44af71 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -142,6 +142,7 @@
   "status.load_more": "Cargar mai",
   "status.media_hidden": "Mèdia rescondut",
   "status.mention": "Mencionar",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Desplegar aqueste estatut",
   "status.reblog": "Partejar",
   "status.reblogged_by": "{name} a partejat :",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Contengut embarrassant",
   "status.show_less": "Tornar plegar",
   "status.show_more": "Desplegar",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Compausar",
   "tabs_bar.federated_timeline": "Fil public global",
   "tabs_bar.home": "Acuèlh",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 65c7443d3..4c4d17b53 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -142,6 +142,7 @@
   "status.load_more": "Załaduj więcej",
   "status.media_hidden": "Zawartość multimedialna ukryta",
   "status.mention": "Wspomnij o @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Rozszerz ten status",
   "status.reblog": "Podbij",
   "status.reblogged_by": "{name} podbił",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Wrażliwa zawartość",
   "status.show_less": "Pokaż mniej",
   "status.show_more": "Pokaż więcej",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Napisz",
   "tabs_bar.federated_timeline": "Globalne",
   "tabs_bar.home": "Strona główna",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 999742e5b..74856015b 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -142,6 +142,7 @@
   "status.load_more": "Carregar mais",
   "status.media_hidden": "Media escondida",
   "status.mention": "Mencionar @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Expandir",
   "status.reblog": "Partilhar",
   "status.reblogged_by": "{name} partilhou",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Conteúdo sensível",
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Criar",
   "tabs_bar.federated_timeline": "Global",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 999742e5b..74856015b 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -142,6 +142,7 @@
   "status.load_more": "Carregar mais",
   "status.media_hidden": "Media escondida",
   "status.mention": "Mencionar @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Expandir",
   "status.reblog": "Partilhar",
   "status.reblogged_by": "{name} partilhou",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Conteúdo sensível",
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Criar",
   "tabs_bar.federated_timeline": "Global",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index fc73a280c..3ae78cd01 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -142,6 +142,7 @@
   "status.load_more": "Показать еще",
   "status.media_hidden": "Медиаконтент скрыт",
   "status.mention": "Упомянуть @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Развернуть статус",
   "status.reblog": "Продвинуть",
   "status.reblogged_by": "{name} продвинул(а)",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Чувствительный контент",
   "status.show_less": "Свернуть",
   "status.show_more": "Развернуть",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Написать",
   "tabs_bar.federated_timeline": "Глобальная",
   "tabs_bar.home": "Главная",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index f483762b4..b2c4036d6 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -142,6 +142,7 @@
   "status.load_more": "Daha fazla",
   "status.media_hidden": "Gizli görsel",
   "status.mention": "Bahset @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Bu gönderiyi genişlet",
   "status.reblog": "Boost'la",
   "status.reblogged_by": "{name} boost etti",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Hassas içerik",
   "status.show_less": "Daha azı",
   "status.show_more": "Daha fazlası",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Oluştur",
   "tabs_bar.federated_timeline": "Federe",
   "tabs_bar.home": "Ana sayfa",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index e4c4cf2eb..a2c50e9e0 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -142,6 +142,7 @@
   "status.load_more": "Завантажити більше",
   "status.media_hidden": "Медіаконтент приховано",
   "status.mention": "Згадати",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "Розгорнути допис",
   "status.reblog": "Передмухнути",
   "status.reblogged_by": "{name} передмухнув(-ла)",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "Непристойний зміст",
   "status.show_less": "Згорнути",
   "status.show_more": "Розгорнути",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Написати",
   "tabs_bar.federated_timeline": "Глобальна",
   "tabs_bar.home": "Головна",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index fa32ebc5e..741689968 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -142,6 +142,7 @@
   "status.load_more": "加载更多",
   "status.media_hidden": "隐藏媒体内容",
   "status.mention": "提及 @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "展开嘟文",
   "status.reblog": "转嘟",
   "status.reblogged_by": "{name} 转嘟",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "敏感内容",
   "status.show_less": "减少显示",
   "status.show_more": "显示更多",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "撰写",
   "tabs_bar.federated_timeline": "跨站",
   "tabs_bar.home": "主页",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 157fca27f..f55fd8806 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -142,6 +142,7 @@
   "status.load_more": "載入更多",
   "status.media_hidden": "隱藏媒體內容",
   "status.mention": "提及 @{name}",
+  "status.mute_conversation": "Mute conversation",
   "status.open": "展開文章",
   "status.reblog": "轉推",
   "status.reblogged_by": "{name} 轉推",
@@ -152,6 +153,7 @@
   "status.sensitive_warning": "敏感內容",
   "status.show_less": "減少顯示",
   "status.show_more": "顯示更多",
+  "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "撰寫",
   "tabs_bar.federated_timeline": "跨站",
   "tabs_bar.home": "主頁",
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 3ad924c69..282380c37 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -10,7 +10,9 @@ import {
 } from '../actions/interactions';
 import {
   STATUS_FETCH_SUCCESS,
-  CONTEXT_FETCH_SUCCESS
+  CONTEXT_FETCH_SUCCESS,
+  STATUS_MUTE_SUCCESS,
+  STATUS_UNMUTE_SUCCESS
 } from '../actions/statuses';
 import {
   TIMELINE_REFRESH_SUCCESS,
@@ -103,6 +105,10 @@ export default function statuses(state = initialState, action) {
     return state.setIn([action.status.get('id'), 'reblogged'], true);
   case REBLOG_FAIL:
     return state.setIn([action.status.get('id'), 'reblogged'], false);
+  case STATUS_MUTE_SUCCESS:
+    return state.setIn([action.id, 'muted'], true);
+  case STATUS_UNMUTE_SUCCESS:
+    return state.setIn([action.id, 'muted'], false);
   case TIMELINE_REFRESH_SUCCESS:
   case TIMELINE_EXPAND_SUCCESS:
   case ACCOUNT_TIMELINE_FETCH_SUCCESS:
diff --git a/app/models/account.rb b/app/models/account.rb
index d43cae038..bf3d92a51 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -84,6 +84,7 @@ class Account < ApplicationRecord
   # Mute relationships
   has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy
   has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
+  has_many :conversation_mutes
 
   # Media
   has_many :media_attachments, dependent: :destroy
@@ -130,6 +131,10 @@ class Account < ApplicationRecord
     mute_relationships.find_or_create_by!(target_account: other_account)
   end
 
+  def mute_conversation!(conversation)
+    conversation_mutes.find_or_create_by!(conversation: conversation)
+  end
+
   def unfollow!(other_account)
     follow = active_relationships.find_by(target_account: other_account)
     follow&.destroy
@@ -145,6 +150,11 @@ class Account < ApplicationRecord
     mute&.destroy
   end
 
+  def unmute_conversation!(conversation)
+    mute = conversation_mutes.find_by(conversation: conversation)
+    mute&.destroy!
+  end
+
   def following?(other_account)
     following.include?(other_account)
   end
@@ -157,6 +167,10 @@ class Account < ApplicationRecord
     muting.include?(other_account)
   end
 
+  def muting_conversation?(conversation)
+    conversation_mutes.where(conversation: conversation).exists?
+  end
+
   def requested?(other_account)
     follow_requests.where(target_account: other_account).exists?
   end
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index fbec961c7..3715e1049 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -10,7 +10,7 @@
 #
 
 class Conversation < ApplicationRecord
-  validates :uri, uniqueness: true
+  validates :uri, uniqueness: true, if: :uri
 
   has_many :statuses
 
diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb
new file mode 100644
index 000000000..79299b995
--- /dev/null
+++ b/app/models/conversation_mute.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: conversation_mutes
+#
+#  id              :integer          not null, primary key
+#  account_id      :integer          not null
+#  conversation_id :integer          not null
+#
+
+class ConversationMute < ApplicationRecord
+  belongs_to :account, required: true
+  belongs_to :conversation, required: true
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index 772cef238..7c39a273a 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -181,6 +181,10 @@ class Status < ApplicationRecord
       select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
     end
 
+    def mutes_map(conversation_ids, account_id)
+      ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).map { |m| [m.conversation_id, true] }.to_h
+    end
+
     def reload_stale_associations!(cached_items)
       account_ids = []
 
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 9c7eb26ef..7b377f6a8 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -43,10 +43,19 @@ class NotifyService < BaseService
     blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account))                       # Hellban
     blocked ||= (@recipient.user.settings.interactions['must_be_follower']  && !@notification.from_account.following?(@recipient)) # Options
     blocked ||= (@recipient.user.settings.interactions['must_be_following'] && !@recipient.following?(@notification.from_account)) # Options
+    blocked ||= conversation_muted?
     blocked ||= send("blocked_#{@notification.type}?")                                                                             # Type-dependent filters
     blocked
   end
 
+  def conversation_muted?
+    if @notification.target_status
+      @recipient.muting_conversation?(@notification.target_status.conversation)
+    else
+      false
+    end
+  end
+
   def create_notification
     @notification.save!
     return unless @notification.browserable?
diff --git a/app/views/api/v1/statuses/show.rabl b/app/views/api/v1/statuses/show.rabl
index 41e8983ef..4b33fb2c3 100644
--- a/app/views/api/v1/statuses/show.rabl
+++ b/app/views/api/v1/statuses/show.rabl
@@ -2,12 +2,14 @@ object @status
 
 extends 'api/v1/statuses/_show'
 
-node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? @favourites_map[status.id] : current_account.favourited?(status) }
-node(:reblogged,  if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map)    ? @reblogs_map[status.id]    : current_account.reblogged?(status) }
+node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? @favourites_map[status.id]         : current_account.favourited?(status) }
+node(:reblogged,  if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map)    ? @reblogs_map[status.id]            : current_account.reblogged?(status) }
+node(:muted,      if: proc { !current_account.nil? }) { |status| defined?(@mutes_map)      ? @mutes_map[status.conversation_id] : current_account.muting_conversation?(status.conversation) }
 
 child reblog: :reblog do
   extends 'api/v1/statuses/_show'
 
   node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? @favourites_map[status.id] : current_account.favourited?(status) }
   node(:reblogged,  if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map)    ? @reblogs_map[status.id]    : current_account.reblogged?(status) }
+  node(:muted,      if: proc { !current_account.nil? }) { false }
 end
diff --git a/config/routes.rb b/config/routes.rb
index 37b3895fd..9ff6a13bb 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -131,6 +131,8 @@ Rails.application.routes.draw do
           post :unreblog
           post :favourite
           post :unfavourite
+          post :mute
+          post :unmute
         end
       end
 
diff --git a/db/migrate/20170301222600_create_mutes.rb b/db/migrate/20170301222600_create_mutes.rb
index 8f1bb22f5..4c27eca1e 100644
--- a/db/migrate/20170301222600_create_mutes.rb
+++ b/db/migrate/20170301222600_create_mutes.rb
@@ -7,6 +7,5 @@ class CreateMutes < ActiveRecord::Migration[5.0]
     end
 
     add_index :mutes, [:account_id, :target_account_id], unique: true
-
   end
 end
diff --git a/db/migrate/20170508230434_create_conversation_mutes.rb b/db/migrate/20170508230434_create_conversation_mutes.rb
new file mode 100644
index 000000000..81edf2733
--- /dev/null
+++ b/db/migrate/20170508230434_create_conversation_mutes.rb
@@ -0,0 +1,10 @@
+class CreateConversationMutes < ActiveRecord::Migration[5.0]
+  def change
+    create_table :conversation_mutes do |t|
+      t.integer :account_id, null: false
+      t.bigint :conversation_id, null: false
+    end
+
+    add_index :conversation_mutes, [:account_id, :conversation_id], unique: true
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7f7fc5978..76624f07a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20170507141759) do
+ActiveRecord::Schema.define(version: 20170508230434) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -62,6 +62,12 @@ ActiveRecord::Schema.define(version: 20170507141759) do
     t.index ["account_id", "target_account_id"], name: "index_blocks_on_account_id_and_target_account_id", unique: true, using: :btree
   end
 
+  create_table "conversation_mutes", force: :cascade do |t|
+    t.integer "account_id",      null: false
+    t.bigint  "conversation_id", null: false
+    t.index ["account_id", "conversation_id"], name: "index_conversation_mutes_on_account_id_and_conversation_id", unique: true, using: :btree
+  end
+
   create_table "conversations", id: :bigserial, force: :cascade do |t|
     t.string   "uri"
     t.datetime "created_at", null: false
diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb
index 74faed269..ac3b2dc7d 100644
--- a/spec/controllers/api/v1/statuses_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses_controller_spec.rb
@@ -183,6 +183,39 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
         expect(user.account.favourited?(status)).to be false
       end
     end
+
+    describe 'POST #mute' do
+      let(:status) { Fabricate(:status, account: user.account) }
+
+      before do
+        post :mute, params: { id: status.id }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(:success)
+      end
+
+      it 'creates a conversation mute' do
+        expect(ConversationMute.find_by(account: user.account, conversation_id: status.conversation_id)).to_not be_nil
+      end
+    end
+
+    describe 'POST #unmute' do
+      let(:status) { Fabricate(:status, account: user.account) }
+
+      before do
+        post :mute,   params: { id: status.id }
+        post :unmute, params: { id: status.id }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(:success)
+      end
+
+      it 'destroys the conversation mute' do
+        expect(ConversationMute.find_by(account: user.account, conversation_id: status.conversation_id)).to be_nil
+      end
+    end
   end
 
   context 'without an oauth token' do
diff --git a/spec/fabricators/conversation_mute_fabricator.rb b/spec/fabricators/conversation_mute_fabricator.rb
new file mode 100644
index 000000000..84f131c26
--- /dev/null
+++ b/spec/fabricators/conversation_mute_fabricator.rb
@@ -0,0 +1,2 @@
+Fabricator(:conversation_mute) do
+end
diff --git a/spec/models/conversation_mute_spec.rb b/spec/models/conversation_mute_spec.rb
new file mode 100644
index 000000000..b602e80c1
--- /dev/null
+++ b/spec/models/conversation_mute_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe ConversationMute, type: :model do
+
+end
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index f9f5c1603..d280525fc 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -196,6 +196,54 @@ RSpec.describe Status, type: :model do
     pending
   end
 
+  describe '.mutes_map' do
+    let(:status)  { Fabricate(:status) }
+    let(:account) { Fabricate(:account) }
+
+    subject { Status.mutes_map([status.conversation.id], account) }
+
+    it 'returns a hash' do
+      expect(subject).to be_a Hash
+    end
+
+    it 'contains true value' do
+      account.mute_conversation!(status.conversation)
+      expect(subject[status.conversation.id]).to be true
+    end
+  end
+
+  describe '.favourites_map' do
+    let(:status)  { Fabricate(:status) }
+    let(:account) { Fabricate(:account) }
+
+    subject { Status.favourites_map([status], account) }
+
+    it 'returns a hash' do
+      expect(subject).to be_a Hash
+    end
+
+    it 'contains true value' do
+      Fabricate(:favourite, status: status, account: account)
+      expect(subject[status.id]).to be true
+    end
+  end
+
+  describe '.reblogs_map' do
+    let(:status)  { Fabricate(:status) }
+    let(:account) { Fabricate(:account) }
+
+    subject { Status.reblogs_map([status], account) }
+
+    it 'returns a hash' do
+      expect(subject).to be_a Hash
+    end
+
+    it 'contains true value' do
+      Fabricate(:status, account: account, reblog: status)
+      expect(subject[status.id]).to be true
+    end
+  end
+
   describe '.local_only' do
     it 'returns only statuses from local accounts' do
       local_account = Fabricate(:account, domain: nil)
diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb
index 86848e1ff..032c37a28 100644
--- a/spec/services/notify_service_spec.rb
+++ b/spec/services/notify_service_spec.rb
@@ -7,10 +7,50 @@ RSpec.describe NotifyService do
 
   let(:user) { Fabricate(:user) }
   let(:recipient) { user.account }
-  let(:activity) { Fabricate(:follow, target_account: recipient) }
+  let(:sender) { Fabricate(:account) }
+  let(:activity) { Fabricate(:follow, account: sender, target_account: recipient) }
 
   it { is_expected.to change(Notification, :count).by(1) }
 
+  it 'does not notify when sender is blocked' do
+    recipient.block!(sender)
+    is_expected.to_not change(Notification, :count)
+  end
+
+  it 'does not notify when sender is silenced and not followed' do
+    sender.update(silenced: true)
+    is_expected.to_not change(Notification, :count)
+  end
+
+  it 'does not notify when recipient is suspended' do
+    recipient.update(suspended: true)
+    is_expected.to_not change(Notification, :count)
+  end
+
+  context do
+    let(:asshole)  { Fabricate(:account, username: 'asshole') }
+    let(:reply_to) { Fabricate(:status, account: asshole) }
+    let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, thread: reply_to)) }
+
+    it 'does not notify when conversation is muted' do
+      recipient.mute_conversation!(activity.status.conversation)
+      is_expected.to_not change(Notification, :count)
+    end
+
+    it 'does not notify when it is a reply to a blocked user' do
+      recipient.block!(asshole)
+      is_expected.to_not change(Notification, :count)
+    end
+  end
+
+  context do
+    let(:sender) { recipient }
+
+    it 'does not notify when recipient is the sender' do
+      is_expected.to_not change(Notification, :count)
+    end
+  end
+
   describe 'email' do
     before do
       ActionMailer::Base.deliveries.clear