about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json27
-rw-r--r--app/lib/feed_manager.rb23
-rw-r--r--app/models/glitch/keyword_mute.rb72
-rw-r--r--config/locales/devise.zh-CN.yml12
-rw-r--r--config/locales/doorkeeper.zh-CN.yml4
-rw-r--r--config/locales/simple_form.zh-CN.yml1
-rw-r--r--config/locales/zh-CN.yml31
-rw-r--r--spec/lib/feed_manager_spec.rb16
-rw-r--r--spec/models/glitch/keyword_mute_spec.rb93
-rw-r--r--streaming/index.js2
10 files changed, 202 insertions, 79 deletions
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index 0f4bbb7c1..bbdf34d2f 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -1,6 +1,6 @@
 {
   "account.block": "屏蔽 @{name}",
-  "account.block_domain": "隐藏一切来自 {domain} 的嘟文",
+  "account.block_domain": "隐藏来自 {domain} 的内容",
   "account.disclaimer_full": "此处显示的信息可能不是全部内容。",
   "account.edit_profile": "修改个人资料",
   "account.follow": "关注",
@@ -9,15 +9,17 @@
   "account.follows_you": "关注了你",
   "account.media": "媒体",
   "account.mention": "提及 @{name}",
-  "account.mute": "静音 @{name}",
+  "account.mute": "隐藏 @{name}",
+  "account.mute_notifications": "隐藏来自 @{name} 的通知",
+  "account.unmute_notifications": "不再隐藏来自 @{name} 的通知",
   "account.posts": "嘟文",
   "account.report": "举报 @{name}",
   "account.requested": "正在等待对方同意。点击以取消发送关注请求",
   "account.share": "分享 @{name} 的个人资料",
   "account.unblock": "不再屏蔽 @{name}",
-  "account.unblock_domain": "不再隐藏 {domain}",
+  "account.unblock_domain": "不再隐藏来自 {domain} 的内容",
   "account.unfollow": "取消关注",
-  "account.unmute": "不再静音 @{name}",
+  "account.unmute": "不再隐藏 @{name}",
   "account.view_full_profile": "查看完整资料",
   "boost_modal.combo": "下次按住 {combo} 即可跳过此提示",
   "bundle_column_error.body": "载入组件出错。",
@@ -31,7 +33,7 @@
   "column.favourites": "收藏过的嘟文",
   "column.follow_requests": "关注请求",
   "column.home": "主页",
-  "column.mutes": "被静音的用户",
+  "column.mutes": "被隐藏的用户",
   "column.notifications": "通知",
   "column.pins": "置顶嘟文",
   "column.public": "跨站公共时间轴",
@@ -57,10 +59,10 @@
   "confirmations.block.message": "想好了,真的要屏蔽 {name}?",
   "confirmations.delete.confirm": "删除",
   "confirmations.delete.message": "想好了,真的要删除这条嘟文?",
-  "confirmations.domain_block.confirm": "隐藏整个网站",
-  "confirmations.domain_block.message": "你真的真的确定要隐藏整个 {domain}?多数情况下,屏蔽或静音几个特定的用户就应该能满足你的需要了。",
-  "confirmations.mute.confirm": "静音",
-  "confirmations.mute.message": "想好了,真的要静音 {name}?",
+  "confirmations.domain_block.confirm": "隐藏整个网站的内容",
+  "confirmations.domain_block.message": "你真的真的确定要隐藏所有来自 {domain} 的内容吗?多数情况下,屏蔽或隐藏几个特定的用户就应该能满足你的需要了。",
+  "confirmations.mute.confirm": "隐藏",
+  "confirmations.mute.message": "想好了,真的要隐藏 {name}?",
   "confirmations.unfollow.confirm": "取消关注",
   "confirmations.unfollow.message": "确定要取消关注 {name} 吗?",
   "embed.instructions": "要在你的网站上嵌入这条嘟文,请复制以下代码。",
@@ -104,6 +106,7 @@
   "loading_indicator.label": "加载中……",
   "media_gallery.toggle_visible": "切换显示/隐藏",
   "missing_indicator.label": "找不到内容",
+  "mute_modal.hide_notifications": "隐藏来自这个用户的通知",
   "navigation_bar.blocks": "被屏蔽的用户",
   "navigation_bar.community_timeline": "本站时间轴",
   "navigation_bar.edit_profile": "修改个人资料",
@@ -111,7 +114,7 @@
   "navigation_bar.follow_requests": "关注请求",
   "navigation_bar.info": "关于本站",
   "navigation_bar.logout": "注销",
-  "navigation_bar.mutes": "被静音的用户",
+  "navigation_bar.mutes": "被隐藏的用户",
   "navigation_bar.pins": "置顶嘟文",
   "navigation_bar.preferences": "首选项",
   "navigation_bar.public_timeline": "跨站公共时间轴",
@@ -184,7 +187,7 @@
   "status.media_hidden": "隐藏媒体内容",
   "status.mention": "提及 @{name}",
   "status.more": "更多",
-  "status.mute_conversation": "静音此对话",
+  "status.mute_conversation": "隐藏此对话",
   "status.open": "展开嘟文",
   "status.pin": "在个人资料页面置顶",
   "status.reblog": "转嘟",
@@ -197,7 +200,7 @@
   "status.share": "分享",
   "status.show_less": "隐藏内容",
   "status.show_more": "显示内容",
-  "status.unmute_conversation": "不再静音此对话",
+  "status.unmute_conversation": "不再隐藏此对话",
   "status.unpin": "在个人资料页面取消置顶",
   "tabs_bar.compose": "撰写",
   "tabs_bar.federated_timeline": "跨站",
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 76365c7d3..5d7f47c6f 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -149,7 +149,7 @@ class FeedManager
     return false if receiver_id == status.account_id
     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
 
-    return true if keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id))
+    return true if keyword_filter?(status, receiver_id)
 
     check_for_mutes = [status.account_id]
     check_for_mutes.concat(status.mentions.pluck(:account_id))
@@ -178,16 +178,23 @@ class FeedManager
     false
   end
 
-  def keyword_filter?(status, matcher)
-    should_filter   = matcher =~ status.text
-    should_filter ||= matcher =~ status.spoiler_text
+  def keyword_filter?(status, receiver_id)
+    text_matcher = Glitch::KeywordMute.text_matcher_for(receiver_id)
+    tag_matcher  = Glitch::KeywordMute.tag_matcher_for(receiver_id)
+
+    should_filter   = text_matcher.matches?(status.text)
+    should_filter ||= text_matcher.matches?(status.spoiler_text)
+    should_filter ||= tag_matcher.matches?(status.tags)
 
     if status.reblog?
-      should_filter ||= matcher =~ status.reblog.text
-      should_filter ||= matcher =~ status.reblog.spoiler_text
+      reblog = status.reblog
+
+      should_filter ||= text_matcher.matches?(reblog.text)
+      should_filter ||= text_matcher.matches?(reblog.spoiler_text)
+      should_filter ||= tag_matcher.matches?(status.tags)
     end
 
-    !!should_filter
+    should_filter
   end
 
   def filter_from_mentions?(status, receiver_id)
@@ -199,7 +206,7 @@ class FeedManager
 
     should_filter   = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?                                     # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
     should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
-    should_filter ||= keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id))                                              # or if the mention contains a muted keyword
+    should_filter ||= keyword_filter?(status, receiver_id)                                                                               # or if the mention contains a muted keyword
 
     should_filter
   end
diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb
index 009de1880..a2481308f 100644
--- a/app/models/glitch/keyword_mute.rb
+++ b/app/models/glitch/keyword_mute.rb
@@ -16,51 +16,85 @@ class Glitch::KeywordMute < ApplicationRecord
 
   validates_presence_of :keyword
 
-  after_commit :invalidate_cached_matcher
+  after_commit :invalidate_cached_matchers
 
-  def self.matcher_for(account_id)
-    Matcher.new(account_id)
+  def self.text_matcher_for(account_id)
+    TextMatcher.new(account_id)
+  end
+
+  def self.tag_matcher_for(account_id)
+    TagMatcher.new(account_id)
   end
 
   private
 
-  def invalidate_cached_matcher
-    Rails.cache.delete("keyword_mutes:regex:#{account_id}")
+  def invalidate_cached_matchers
+    Rails.cache.delete(TextMatcher.cache_key(account_id))
+    Rails.cache.delete(TagMatcher.cache_key(account_id))
   end
 
-  class Matcher
+  class RegexpMatcher
     attr_reader :account_id
     attr_reader :regex
 
     def initialize(account_id)
       @account_id = account_id
-      regex_text = Rails.cache.fetch("keyword_mutes:regex:#{account_id}") { regex_text_for_account }
+      regex_text = Rails.cache.fetch(self.class.cache_key(account_id)) { make_regex_text }
       @regex = /#{regex_text}/
     end
 
-    def =~(str)
-      regex =~ str
+    protected
+
+    def keywords
+      Glitch::KeywordMute.where(account_id: account_id).pluck(:whole_word, :keyword)
     end
 
-    private
+    def boundary_regex_for_keyword(keyword)
+      sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
+      eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
 
-    def keywords
-      Glitch::KeywordMute.where(account_id: account_id).select(:keyword, :id, :whole_word)
+      /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/
+    end
+  end
+
+  class TextMatcher < RegexpMatcher
+    def self.cache_key(account_id)
+      format('keyword_mutes:regex:text:%s', account_id)
+    end
+
+    def matches?(str)
+      !!(regex =~ str)
     end
 
-    def regex_text_for_account
-      kws = keywords.find_each.with_object([]) do |kw, a|
-        a << (kw.whole_word ? boundary_regex_for_keyword(kw.keyword) : kw.keyword)
+    private
+
+    def make_regex_text
+      kws = keywords.map! do |whole_word, keyword|
+        whole_word ? boundary_regex_for_keyword(keyword) : keyword
       end
 
       Regexp.union(kws).source
     end
+  end
 
-    def boundary_regex_for_keyword(keyword)
-      sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
-      eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
+  class TagMatcher < RegexpMatcher
+    def self.cache_key(account_id)
+      format('keyword_mutes:regex:tag:%s', account_id)
+    end
 
-      /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/
+    def matches?(tags)
+      tags.pluck(:name).any? { |n| regex =~ n }
+    end
+
+    private
+
+    def make_regex_text
+      kws = keywords.map! do |whole_word, keyword|
+        term = (Tag::HASHTAG_RE =~ keyword) ? $1 : keyword
+        whole_word ? boundary_regex_for_keyword(term) : term
+      end
+
+      Regexp.union(kws).source
     end
   end
 end
diff --git a/config/locales/devise.zh-CN.yml b/config/locales/devise.zh-CN.yml
index 0e40fcc90..5560e12b3 100644
--- a/config/locales/devise.zh-CN.yml
+++ b/config/locales/devise.zh-CN.yml
@@ -8,7 +8,7 @@ zh-CN:
     failure:
       already_authenticated: 你已经登录。
       inactive: 你还没有激活帐户。
-      invalid: " %{authentication_keys} 或密码错误。"
+      invalid: "%{authentication_keys}或密码错误。"
       last_attempt: 你还有最后一次尝试机会,再次失败你的帐户将被锁定。
       locked: 你的帐户已被锁定。
       not_found_in_database: "%{authentication_keys}或密码错误。"
@@ -53,10 +53,10 @@ zh-CN:
       unlocked: 你的帐户已成功解锁。登录以继续。
   errors:
     messages:
-      already_confirmed: 已经确认,请重新登录。
-      confirmation_period_expired: 注册帐户后须在 %{period}以内确认。请重新注册。
-      expired: 邮件确认已过期,请重新注册。
-      not_found: 找不到。
-      not_locked: 未锁定。
+      already_confirmed: 已经确认成功,请尝试登录
+      confirmation_period_expired: 必须在 %{period}以内确认。请重新发起请求
+      expired: 已过期。请重新发起请求
+      not_found: 找不到
+      not_locked: 未被锁定
       not_saved:
         other: 发生 %{count} 个错误,导致%{resource}保存失败:
diff --git a/config/locales/doorkeeper.zh-CN.yml b/config/locales/doorkeeper.zh-CN.yml
index c7e8f368d..0eb5a0ab6 100644
--- a/config/locales/doorkeeper.zh-CN.yml
+++ b/config/locales/doorkeeper.zh-CN.yml
@@ -77,10 +77,10 @@ zh-CN:
         title: 已授权的应用列表
     errors:
       messages:
-        access_denied: 用户或服务器拒绝了请求
+        access_denied: 资源所有者或服务器拒绝了请求
         credential_flow_not_configured: 由于 Doorkeeper.configure.resource_owner_from_credentials 尚未配置,应用验证授权流程失败。
         invalid_client: 由于应用信息未知、未提交认证信息或使用了不支持的认证方式,认证失败
-        invalid_grant: 授权方式无效,或者登录回调地址无效、过期或已被撤销
+        invalid_grant: 授权方式无效、过期或已被撤销、与授权请求中的回调地址不一致,或使用了其他应用的回调地址
         invalid_redirect_uri: 无效的登录回调地址
         invalid_request: 请求缺少必要的参数,或者参数值、格式不正确
         invalid_resource_owner: 资源所有者认证无效,或找不到所有者
diff --git a/config/locales/simple_form.zh-CN.yml b/config/locales/simple_form.zh-CN.yml
index f8c9461ad..23a1f59da 100644
--- a/config/locales/simple_form.zh-CN.yml
+++ b/config/locales/simple_form.zh-CN.yml
@@ -50,6 +50,7 @@ zh-CN:
       interactions:
         must_be_follower: 屏蔽来自未关注你的用户的通知
         must_be_following: 屏蔽来自你未关注的用户的通知
+        must_be_following_dm: 屏蔽来自你未关注的用户的私信
       notification_emails:
         digest: 发送摘要邮件
         favourite: 当有用户收藏了你的嘟文时,发送电子邮件提醒我
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index b50c34fd0..bba70f2e4 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -1,7 +1,7 @@
 ---
 zh-CN:
   about:
-    about_hashtag_html: 这里展示的是带有话题标签 <strong>#%{hashtag}</strong> 的公开嘟文。如果你在象毛世界中拥有一个帐户,就可以加入讨论。
+    about_hashtag_html: 这里展示的是带有话题标签 <strong>#%{hashtag}</strong> 的公开嘟文。如果你想与他们互动,你需要在任意一个 Mastodon 实例或与其兼容的网站上拥有一个帐户。
     about_mastodon_html: Mastodon(长毛象)是一个基于开放式网络协议和自由、开源软件建立的社交网络,有着类似于电子邮件的分布式设计。
     about_this: 关于本实例
     closed_registrations: 这个实例目前没有开放注册。不过,你可以前往其他实例注册一个帐户,同样可以加入到这个网络中哦!
@@ -40,14 +40,15 @@ zh-CN:
     following: 正在关注
     media: 媒体
     nothing_here: 这里神马都没有!
-    people_followed_by: 正关注
-    people_who_follow: 粉丝
+    people_followed_by: "%{name} 关注的人"
+    people_who_follow: 关注 %{name} 的人
     posts: 嘟文
     posts_with_replies: 嘟文和回复
     remote_follow: 跨站关注
     reserved_username: 此用户名已保留
     roles:
       admin: 管理员
+      moderator: 协管
     unfollow: 取消关注
   admin:
     account_moderation_notes:
@@ -88,7 +89,7 @@ zh-CN:
       memorialize: 设置为追悼帐户
       moderation:
         all: 全部
-        silenced: 已静音
+        silenced: 已隐藏
         suspended: 已封禁
         title: 帐户状态
       moderation_notes: 管理记录
@@ -122,11 +123,11 @@ zh-CN:
         created_reports: 这个帐户提交的举报
         report: 个举报
         targeted_reports: 针对这个帐户的举报
-      silence: 静音
+      silence: 隐藏
       statuses: 嘟文
       subscribe: 订阅
       title: 用户
-      undo_silenced: 解除静音
+      undo_silenced: 解除隐藏
       undo_suspension: 解除封禁
       unsubscribe: 取消订阅
       username: 用户名
@@ -164,23 +165,23 @@ zh-CN:
         create: 添加域名屏蔽
         hint: 域名屏蔽不会阻止该域名下的帐户进入本站的数据库,但是会对来自这个域名的帐户自动进行预先设置的管理操作。
         severity:
-          desc_html: 选择<strong>自动静音</strong>会将该域名下帐户发送的嘟文设置为仅关注者可见;选择<strong>自动封禁</strong>会将该域名下帐户发送的嘟文、媒体文件以及个人资料数据从本实例上删除;如果你只是想拒绝接收来自该域名的任何媒体文件,请选择<strong>无</strong>。
+          desc_html: 选择<strong>自动隐藏</strong>会将该域名下帐户发送的嘟文设置为仅关注者可见;选择<strong>自动封禁</strong>会将该域名下帐户发送的嘟文、媒体文件以及个人资料数据从本实例上删除;如果你只是想拒绝接收来自该域名的任何媒体文件,请选择<strong>无</strong>。
           noop: 无
-          silence: 自动静音
+          silence: 自动隐藏
           suspend: 自动封禁
         title: 添加域名屏蔽
       reject_media: 拒绝接收媒体文件
       reject_media_hint: 删除本地已缓存的媒体文件,并且不再接收来自该域名的任何媒体文件。此选项不影响封禁
       severities:
         noop: 无
-        silence: 自动静音
+        silence: 自动隐藏
         suspend: 自动封禁
       severity: 屏蔽级别
       show:
         affected_accounts: 将会影响到数据库中的 %{count} 个帐户
         retroactive:
-          silence: 对此域名的所有帐户取消静音
-          suspend: 对此域名的所有帐户取消封禁
+          silence: 对此域名的所有帐户解除隐藏
+          suspend: 对此域名的所有帐户解除封禁
         title: 撤销对 %{domain} 的域名屏蔽
         undo: 撤销
       title: 域名屏蔽
@@ -218,7 +219,7 @@ zh-CN:
       reported_account: 举报用户
       reported_by: 举报人
       resolved: 已处理
-      silence_account: 静音用户
+      silence_account: 隐藏用户
       status: 状态
       suspend_account: 封禁用户
       target: 被举报人
@@ -361,7 +362,7 @@ zh-CN:
     blocks: 屏蔽的用户
     csv: CSV
     follows: 关注的用户
-    mutes: 静音的用户
+    mutes: 隐藏的用户
     storage: 媒体文件存储
   followers:
     domain: 域名
@@ -386,10 +387,10 @@ zh-CN:
     types:
       blocking: 屏蔽列表
       following: 关注列表
-      muting: 静音列表
+      muting: 隐藏列表
     upload: 上传
   in_memoriam_html: 谨此悼念。
-  landing_strip_html: "<strong>%{name}</strong> 是一位来自 %{link_to_root_path} 的用户。如果你想关注这个人或者与这个人互动,你需要在任意一个 Mastodon 实例或与其兼容的网站上拥有一个帐户。"
+  landing_strip_html: "<strong>%{name}</strong> 是一位来自 %{link_to_root_path} 的用户。如果你想关注他们或者与他们互动,你需要在任意一个 Mastodon 实例或与其兼容的网站上拥有一个帐户。"
   landing_strip_signup_html: 还没有这种帐户?你可以<a href="%{sign_up_path}">在本站注册一个</a>。
   media_attachments:
     validations:
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index ba96b6e7e..f87ef383a 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -164,6 +164,22 @@ RSpec.describe FeedManager do
 
         expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
       end
+
+      it 'returns true for a status with a tag that matches a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'jorts')
+        status = Fabricate(:status, account: bob)
+	status.tags << Fabricate(:tag, name: 'jorts')
+
+        expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+      end
+
+      it 'returns true for a status with a tag that matches an octothorpe-prefixed muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: '#jorts')
+        status = Fabricate(:status, account: bob)
+	status.tags << Fabricate(:tag, name: 'jorts')
+
+        expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+      end
     end
 
     context 'for mentions feed' do
diff --git a/spec/models/glitch/keyword_mute_spec.rb b/spec/models/glitch/keyword_mute_spec.rb
index 9685c6493..0ffc7b18f 100644
--- a/spec/models/glitch/keyword_mute_spec.rb
+++ b/spec/models/glitch/keyword_mute_spec.rb
@@ -4,8 +4,8 @@ RSpec.describe Glitch::KeywordMute, type: :model do
   let(:alice) { Fabricate(:account, username: 'alice').tap(&:save!) }
   let(:bob) { Fabricate(:account, username: 'bob').tap(&:save!) }
 
-  describe '.matcher_for' do
-    let(:matcher) { Glitch::KeywordMute.matcher_for(alice) }
+  describe '.text_matcher_for' do
+    let(:matcher) { Glitch::KeywordMute.text_matcher_for(alice.id) }
 
     describe 'with no mutes' do
       before do
@@ -13,7 +13,7 @@ RSpec.describe Glitch::KeywordMute, type: :model do
       end
 
       it 'does not match' do
-        expect(matcher =~ 'This is a hot take').to be_falsy
+        expect(matcher.matches?('This is a hot take')).to be_falsy
       end
     end
 
@@ -21,75 +21,136 @@ RSpec.describe Glitch::KeywordMute, type: :model do
       it 'does not match keywords set by a different account' do
         Glitch::KeywordMute.create!(account: bob, keyword: 'take')
 
-        expect(matcher =~ 'This is a hot take').to be_falsy
+        expect(matcher.matches?('This is a hot take')).to be_falsy
       end
 
       it 'does not match if no keywords match the status text' do
         Glitch::KeywordMute.create!(account: alice, keyword: 'cold')
 
-        expect(matcher =~ 'This is a hot take').to be_falsy
+        expect(matcher.matches?('This is a hot take')).to be_falsy
       end
 
       it 'considers word boundaries when matching' do
         Glitch::KeywordMute.create!(account: alice, keyword: 'bob', whole_word: true)
 
-        expect(matcher =~ 'bobcats').to be_falsy
+        expect(matcher.matches?('bobcats')).to be_falsy
       end
 
       it 'matches substrings if whole_word is false' do
         Glitch::KeywordMute.create!(account: alice, keyword: 'take', whole_word: false)
 
-        expect(matcher =~ 'This is a shiitake mushroom').to be_truthy
+        expect(matcher.matches?('This is a shiitake mushroom')).to be_truthy
       end
 
       it 'matches keywords at the beginning of the text' do
         Glitch::KeywordMute.create!(account: alice, keyword: 'take')
 
-        expect(matcher =~ 'Take this').to be_truthy
+        expect(matcher.matches?('Take this')).to be_truthy
       end
 
       it 'matches keywords at the end of the text' do
         Glitch::KeywordMute.create!(account: alice, keyword: 'take')
 
-        expect(matcher =~ 'This is a hot take').to be_truthy
+        expect(matcher.matches?('This is a hot take')).to be_truthy
       end
 
       it 'matches if at least one keyword case-insensitively matches the text' do
         Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
 
-        expect(matcher =~ 'This is a HOT take').to be_truthy
+        expect(matcher.matches?('This is a HOT take')).to be_truthy
       end
 
       it 'maintains case-insensitivity when combining keywords into a single matcher' do
         Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
         Glitch::KeywordMute.create!(account: alice, keyword: 'cold')
 
-        expect(matcher =~ 'This is a HOT take').to be_truthy
+        expect(matcher.matches?('This is a HOT take')).to be_truthy
       end
 
       it 'matches keywords surrounded by non-alphanumeric ornamentation' do
         Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
 
-        expect(matcher =~ '(hot take)').to be_truthy
+        expect(matcher.matches?('(hot take)')).to be_truthy
       end
 
       it 'escapes metacharacters in keywords' do
         Glitch::KeywordMute.create!(account: alice, keyword: '(hot take)')
 
-        expect(matcher =~ '(hot take)').to be_truthy
+        expect(matcher.matches?('(hot take)')).to be_truthy
       end
 
       it 'uses case-folding rules appropriate for more than just English' do
         Glitch::KeywordMute.create!(account: alice, keyword: 'großeltern')
 
-        expect(matcher =~ 'besuch der grosseltern').to be_truthy
+        expect(matcher.matches?('besuch der grosseltern')).to be_truthy
       end
 
       it 'matches keywords that are composed of multiple words' do
         Glitch::KeywordMute.create!(account: alice, keyword: 'a shiitake')
 
-        expect(matcher =~ 'This is a shiitake').to be_truthy
-        expect(matcher =~ 'This is shiitake').to_not be_truthy
+        expect(matcher.matches?('This is a shiitake')).to be_truthy
+        expect(matcher.matches?('This is shiitake')).to_not be_truthy
+      end
+    end
+  end
+
+  describe '.tag_matcher_for' do
+    let(:matcher) { Glitch::KeywordMute.tag_matcher_for(alice.id) }
+    let(:status) { Fabricate(:status) }
+
+    describe 'with no mutes' do
+      before do
+        Glitch::KeywordMute.delete_all
+      end
+
+      it 'does not match' do
+        status.tags << Fabricate(:tag, name: 'xyzzy')
+
+        expect(matcher.matches?(status.tags)).to be false
+      end
+    end
+
+    describe 'with mutes' do
+      it 'does not match keywords set by a different account' do
+        status.tags << Fabricate(:tag, name: 'xyzzy')
+        Glitch::KeywordMute.create!(account: bob, keyword: 'take')
+
+        expect(matcher.matches?(status.tags)).to be false
+      end
+
+      it 'matches #xyzzy when given the mute "#xyzzy"' do
+        status.tags << Fabricate(:tag, name: 'xyzzy')
+        Glitch::KeywordMute.create!(account: alice, keyword: '#xyzzy')
+
+        expect(matcher.matches?(status.tags)).to be true
+      end
+
+      it 'matches #thingiverse when given the non-whole-word mute "#thing"' do
+        status.tags << Fabricate(:tag, name: 'thingiverse')
+        Glitch::KeywordMute.create!(account: alice, keyword: '#thing', whole_word: false)
+
+        expect(matcher.matches?(status.tags)).to be true
+      end
+
+      it 'matches #hashtag when given the mute "##hashtag""' do
+        status.tags << Fabricate(:tag, name: 'hashtag')
+        Glitch::KeywordMute.create!(account: alice, keyword: '##hashtag')
+
+        expect(matcher.matches?(status.tags)).to be true
+      end
+
+      it 'matches #oatmeal when given the non-whole-word mute "oat"' do
+        status.tags << Fabricate(:tag, name: 'oatmeal')
+        Glitch::KeywordMute.create!(account: alice, keyword: 'oat', whole_word: false)
+
+        expect(matcher.matches?(status.tags)).to be true
+      end
+
+      it 'does not match #oatmeal when given the mute "#oat"' do
+        status.tags << Fabricate(:tag, name: 'oatmeal')
+        Glitch::KeywordMute.create!(account: alice, keyword: 'oat')
+
+        expect(matcher.matches?(status.tags)).to be false
       end
     end
   end
diff --git a/streaming/index.js b/streaming/index.js
index 42df63031..3048802e3 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -515,7 +515,7 @@ const startWorker = (workerId) => {
     });
   }, 30000);
 
-  server.listen(process.env.PORT || 4000, () => {
+  server.listen(process.env.PORT || 4000, process.env.BIND || '0.0.0.0', () => {
     log.info(`Worker ${workerId} now listening on ${server.address().address}:${server.address().port}`);
   });