about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/home_controller.rb2
-rw-r--r--app/helpers/stream_entries_helper.rb4
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js8
-rw-r--r--app/lib/activitypub/activity/create.rb14
-rw-r--r--app/lib/activitypub/tag_manager.rb8
-rw-r--r--app/lib/bangtags.rb25
-rw-r--r--app/models/account.rb9
-rw-r--r--app/models/chat_account.rb17
-rw-r--r--app/models/concerns/status_threading_concern.rb2
-rw-r--r--app/models/featured_tag.rb6
-rw-r--r--app/models/status.rb30
-rw-r--r--app/models/tag.rb27
-rw-r--r--app/presenters/status_relationships_presenter.rb2
-rw-r--r--app/serializers/rest/status_serializer.rb7
-rw-r--r--app/services/fan_out_on_write_service.rb37
-rw-r--r--app/services/post_status_service.rb78
-rw-r--r--app/services/process_hashtags_service.rb23
-rw-r--r--app/validators/status_pin_validator.rb2
-rw-r--r--app/workers/activitypub/distribute_poll_update_worker.rb2
-rw-r--r--app/workers/activitypub/distribution_worker.rb2
20 files changed, 229 insertions, 76 deletions
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index 17cf9e07b..97a0a72d9 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -23,7 +23,7 @@ class HomeController < ApplicationController
       when 'statuses'
         status = Status.find_by(id: matches[2])
 
-        if status && (status.public_visibility? || status.unlisted_visibility?)
+        if status && status.distributable?
           redirect_to(ActivityPub::TagManager.instance.url_for(status))
           return
         end
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index 80b52223e..a7d113a22 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -202,6 +202,10 @@ module StreamEntriesHelper
       fa_icon 'globe fw'
     when 'unlisted'
       fa_icon 'unlock fw'
+    when 'local'
+      fa_icon 'users fw'
+    when 'chat'
+      fa_icon 'paper-plane fw'
     when 'private'
       fa_icon 'lock fw'
     when 'direct'
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 43876e450..8747d51f5 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -116,6 +116,10 @@ function statusToTextMentions(state, status) {
     set = set.add(`@${status.getIn(['account', 'acct'])} `);
   }
 
+  set = set.union(status.get('tags').filter(
+    tag => tag.get('name') && tag.get('name').startsWith("chat.")
+  ).map(tag => `#${tag.get('name')} `));
+
   return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
 };
 
@@ -126,6 +130,10 @@ function apiStatusToTextMentions (state, status) {
     set = set.add(`@${status.account.acct} `);
   }
 
+  set = set.union(status.tags.filter(
+    tag => tag.name && tag.name.startsWith("chat.")
+  ).map(tag => `#${tag.name} `));
+
   return set.union(status.mentions.filter(
     mention => mention.id !== me
   ).map(
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 263dbbb87..2b267c0d7 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -153,7 +153,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   def attach_tags(status)
     @tags.each do |tag|
       status.tags << tag
-      TrendingTags.record_use!(tag, status.account, status.created_at) if status.distributable?
+      tag.chatters.find_or_create_by(account_id: status.account) if tag.chat?
+      next unless status.distributable? && !tag.chat?
+      TrendingTags.record_use!(tag, status.account, status.created_at)
     end
 
     @mentions.each do |mention|
@@ -181,7 +183,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
     hashtag = tag['name'].gsub(/\A#/, '').gsub(':', '.').mb_chars.downcase
 
-    return if !@options[:imported] && hashtag.starts_with?('self.', '_self.', 'local.', '_local.')
+    return if !@options[:imported] && (
+      hashtag.in?(%w(self .self local .local chat.local .chat.local)) ||
+      hashtag.starts_with?('self.', '.self', 'local.', '.local', 'chat.local.', '.chat.local.')
+    )
+
+    if tag['name'].starts_with?('chat.', '.chat.')
+      @params[:visibility] = :chat
+      @params[:thread] = nil
+    end
 
     hashtag = Tag.where(name: hashtag).first_or_create!(name: hashtag)
 
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 595291342..baec9da21 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -62,9 +62,9 @@ class ActivityPub::TagManager
     case status.visibility
     when 'public'
       [COLLECTIONS[:public]]
-    when 'unlisted', 'private'
+    when 'unlisted', 'private', 'local'
       [account_followers_url(status.account)]
-    when 'direct', 'limited'
+    when 'direct', 'limited', 'chat'
       if status.account.silenced?
         # Only notify followers if the account is locally silenced
         account_ids = status.active_mentions.pluck(:account_id)
@@ -89,11 +89,11 @@ class ActivityPub::TagManager
     case status.visibility
     when 'public'
       cc << account_followers_url(status.account)
-    when 'unlisted'
+    when 'unlisted', 'local'
       cc << COLLECTIONS[:public]
     end
 
-    unless status.direct_visibility? || status.limited_visibility?
+    unless status.direct_visibility? || status.limited_visibility? || status.chat_visibility?
       if status.account.silenced?
         # Only notify followers if the account is locally silenced
         account_ids = status.active_mentions.pluck(:account_id)
diff --git a/app/lib/bangtags.rb b/app/lib/bangtags.rb
index 4f4452486..e106dc582 100644
--- a/app/lib/bangtags.rb
+++ b/app/lib/bangtags.rb
@@ -452,8 +452,9 @@ class Bangtags
             'group'       => :private,
 
             'unlisted'    => :unlisted,
-            'local'       => :unlisted,
-            'monsterpit'  => :unlisted,
+
+            'local'       => :local,
+            'monsterpit'  => :local,
 
             'public'      => :public,
             'world'       => :public,
@@ -552,9 +553,25 @@ class Bangtags
   end
 
   def add_tags(to_status, *tags)
-    records = []
-    valid_name = /^[[:word:]:_\-]*[[:alpha:]:_·\-][[:word:]:_\-]*$/
+    valid_name = /^[[:word:]:._\-]*[[:alpha:]:._·\-][[:word:]:._\-]*$/
     tags = tags.select {|t| t.present? && valid_name.match?(t)}.uniq
     ProcessHashtagsService.new.call(to_status, tags)
+    to_status.save
+  end
+
+  def del_tags(from_status, *tags)
+    valid_name = /^[[:word:]:._\-]*[[:alpha:]:._·\-][[:word:]:._\-]*$/
+    tags = tags.select {|t| t.present? && valid_name.match?(t)}.uniq
+    tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
+      name.gsub!(/[:.]+/, '.')
+      next if name.blank? || name == '.'
+      if name.ends_with?('.')
+        filtered_tags = from_status.tags.select { |t| t.name == name || t.name.starts_with?(name) }
+      else
+        filtered_tags = from_status.tags.select { |t| t.name == name }
+      end
+      from_status.tags.destroy(filtered_tags)
+    end
+    from_status.save
   end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index 97ebd14d3..7040f138b 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -71,7 +71,8 @@ class Account < ApplicationRecord
 
   LOCAL_DOMAINS = ENV.fetch('LOCAL_DOMAINS', '').chomp.split(/\.?\s+/).freeze
 
-  enum protocol: [:ostatus, :activitypub]
+  has_many :chat_accounts, dependent: :destroy, inverse_of: :account
+  has_many :chat_tags, through: :chat_accounts, source: :tag
 
   validates :username, presence: true
 
@@ -545,6 +546,7 @@ class Account < ApplicationRecord
 
   before_create :generate_keys
   before_create :set_domain_from_inbox_url
+  before_create :set_chat_support
   before_validation :prepare_contents, if: :local?
   before_validation :prepare_username, on: :create
   before_destroy :clean_feed_manager
@@ -567,6 +569,11 @@ class Account < ApplicationRecord
     nil
   end
 
+  def set_chat_support
+    return unless local?
+    self.supports_chat = true
+  end
+
   def generate_keys
     return unless local? && !Rails.env.test?
 
diff --git a/app/models/chat_account.rb b/app/models/chat_account.rb
new file mode 100644
index 000000000..41589a395
--- /dev/null
+++ b/app/models/chat_account.rb
@@ -0,0 +1,17 @@
+# == Schema Information
+#
+# Table name: chat_accounts
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  tag_id     :bigint(8)        not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class ChatAccount < ApplicationRecord
+  belongs_to :account, inverse_of: :chat_accounts
+  belongs_to :tag, inverse_of: :chat_accounts
+
+  validates :account_id, uniqueness: { scope: :tag_id }
+end
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index 15eb695cd..1e5c52c46 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -12,7 +12,7 @@ module StatusThreadingConcern
   end
 
   def self_replies(limit)
-    account.statuses.where(in_reply_to_id: id, visibility: [:public, :unlisted]).reorder(id: :asc).limit(limit)
+    account.statuses.where(in_reply_to_id: id, visibility: [:public, :unlisted, :local]).reorder(id: :asc).limit(limit)
   end
 
   private
diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb
index d06ae26a8..f4015fb07 100644
--- a/app/models/featured_tag.rb
+++ b/app/models/featured_tag.rb
@@ -31,12 +31,12 @@ class FeaturedTag < ApplicationRecord
   end
 
   def decrement(deleted_status_id)
-    update(statuses_count: [0, statuses_count - 1].max, last_status_at: account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).where.not(id: deleted_status_id).select(:created_at).first&.created_at)
+    update(statuses_count: [0, statuses_count - 1].max, last_status_at: account.statuses.where(visibility: %i(public unlisted local)).tagged_with(tag).where.not(id: deleted_status_id).select(:created_at).first&.created_at)
   end
 
   def reset_data
-    self.statuses_count = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).count
-    self.last_status_at = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).select(:created_at).first&.created_at
+    self.statuses_count = account.statuses.where(visibility: %i(public unlisted local)).tagged_with(tag).count
+    self.last_status_at = account.statuses.where(visibility: %i(public unlisted local)).tagged_with(tag).select(:created_at).first&.created_at
   end
 
   private
diff --git a/app/models/status.rb b/app/models/status.rb
index 30af341cc..8315491f7 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -53,7 +53,7 @@ class Status < ApplicationRecord
 
   update_index('statuses#status', :proper) if Chewy.enabled?
 
-  enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
+  enum visibility: [:public, :unlisted, :private, :direct, :limited, :local, :chat], _suffix: :visibility
 
   belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
 
@@ -103,7 +103,8 @@ class Status < ApplicationRecord
   scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
   scope :reblogs, -> { where('statuses.reblog_of_id IS NOT NULL') } # all reblogs
   scope :with_public_visibility, -> { where(visibility: :public) }
-  scope :public_browsable, -> { where(visibility: [:public, :unlisted]) }
+  scope :public_local_visibility, -> { where(visibility: [:public, :local]) }
+  scope :public_browsable, -> { where(visibility: [:public, :unlisted, :local, :chat]) }
   scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
   scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
   scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
@@ -240,7 +241,7 @@ class Status < ApplicationRecord
   end
 
   def distributable?
-    public_visibility? || unlisted_visibility?
+    public_visibility? || unlisted_visibility? || local_visibility?
   end
 
   def with_media?
@@ -261,6 +262,11 @@ class Status < ApplicationRecord
     @emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
   end
 
+  def chat_tags
+    return @chat_tags if defined?(@chat_tags)
+    @chat_tags = tags.only_chat
+  end
+
   def mark_for_mass_destruction!
     @marked_for_mass_destruction = true
   end
@@ -313,7 +319,7 @@ class Status < ApplicationRecord
       pattern = sanitize_sql_like(term)
       pattern = "#{pattern}"
       scope = Status.where("tsv @@ plainto_tsquery('english', ?)", pattern)
-      query = scope.where(visibility: :public)
+      query = scope.public_local_visibility
       if account.present?
         query = query
           .or(scope.where(account: account))
@@ -333,7 +339,7 @@ class Status < ApplicationRecord
     end
 
     def as_home_timeline(account)
-      where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
+      where(account: [account] + account.following, visibility: [:public, :unlisted, :local, :private])
     end
 
     def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
@@ -390,7 +396,7 @@ class Status < ApplicationRecord
 
     def as_tag_timeline(tag, account = nil, local_only = false, priv = false)
       query = tag_timeline_scope(account, local_only, priv).tagged_with(tag)
-      apply_timeline_filters(query, account, local_only)
+      apply_timeline_filters(query, account, local_only, true)
     end
 
     def as_outbox_timeline(account)
@@ -438,7 +444,7 @@ class Status < ApplicationRecord
     end
 
     def permitted_for(target_account, account)
-      visibility = [:public, :unlisted]
+      visibility = [:public, :unlisted, :local]
 
       if account.nil?
         query = where(visibility: visibility).not_local_only
@@ -464,7 +470,7 @@ class Status < ApplicationRecord
 
     def timeline_scope(local_only = false)
       starting_scope = local_only ? Status.network : Status
-      starting_scope = starting_scope.with_public_visibility
+      starting_scope = local_only ? starting_scope.public_local_visibility : starting_scope.with_public_visibility
       if Setting.show_reblogs_in_public_timelines
         starting_scope
       else
@@ -498,19 +504,19 @@ class Status < ApplicationRecord
       end
     end
 
-    def apply_timeline_filters(query, account, local_only)
+    def apply_timeline_filters(query, account = nil, local_only = false, tag_timeline = false)
       if account.nil?
         filter_timeline_default(query)
       else
-        filter_timeline_for_account(query, account, local_only)
+        filter_timeline_for_account(query, account, local_only, tag_timeline)
       end
     end
 
-    def filter_timeline_for_account(query, account, local_only)
+    def filter_timeline_for_account(query, account, local_only, tag_timeline)
       query = query.not_excluded_by_account(account)
       query = query.not_domain_blocked_by_account(account) unless local_only
       query = query.in_chosen_languages(account) if account.chosen_languages.present?
-      query = query.reply_not_excluded_by_account(account)
+      query = query.reply_not_excluded_by_account(account) unless tag_timeline
       query = query.mention_not_excluded_by_account(account)
       query.merge(account_silencing_filter(account))
     end
diff --git a/app/models/tag.rb b/app/models/tag.rb
index d3511a54e..858f674c3 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -9,6 +9,8 @@
 #  updated_at :datetime         not null
 #  local      :boolean          default(FALSE), not null
 #  private    :boolean          default(FALSE), not null
+#  unlisted   :boolean          default(FALSE), not null
+#  chat       :boolean          default(FALSE), not null
 #
 
 class Tag < ApplicationRecord
@@ -17,6 +19,9 @@ class Tag < ApplicationRecord
   has_and_belongs_to_many :sample_accounts, -> { searchable.discoverable.popular.limit(3) }, class_name: 'Account'
 
   has_many :featured_tags, dependent: :destroy, inverse_of: :tag
+  has_many :chat_accounts, dependent: :destroy, inverse_of: :tag
+  has_many :chatters, through: :chat_accounts, source: :account
+
   has_one :account_tag_stat, dependent: :destroy
 
   HASHTAG_NAME_RE = '[[:word:]:._\-]*[[:alpha:]:._·\-][[:word:]:._\-]*'
@@ -28,10 +33,12 @@ class Tag < ApplicationRecord
   scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
   scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) }
 
-  scope :only_local, -> { where(local: true) }
-  scope :only_global, -> { where(local: false) }
+  scope :only_local, -> { where(local: true, unlisted: false) }
+  scope :only_global, -> { where(local: false, unlisted: false) }
   scope :only_private, -> { where(private: true) }
-  scope :only_public, -> { where(private: false) }
+  scope :only_unlisted, -> { where(unlisted: true) }
+  scope :only_chat, -> { where(chat: true) }
+  scope :only_public, -> { where(unlisted: false) }
 
   delegate :accounts_count,
            :accounts_count=,
@@ -73,9 +80,11 @@ class Tag < ApplicationRecord
 
   class << self
     def search_for(term, limit = 5, offset = 0)
-      pattern = sanitize_sql_like(term.strip.gsub(':', '.')) + '%'
+      term = term.strip.gsub(':', '.')
+      pattern = sanitize_sql_like(term) + '%'
 
-      Tag.where('lower(name) like lower(?)', pattern)
+      Tag.only_public.where('lower(name) like lower(?)', pattern)
+         .or(Tag.only_unlisted.where(name: term))
          .order(:name)
          .limit(limit)
          .offset(offset)
@@ -98,7 +107,11 @@ class Tag < ApplicationRecord
   end
 
   def set_scope
-    self.private = true if name.in?(['self', '_self']) || name.starts_with?('self.', '_self.')
-    self.local = true if self.private || name.in?(['local', '_local']) || name.starts_with?('local.', '_local.')
+    self.private = true if name.in?(%w(self .self)) || name.starts_with?('self.', '.self.')
+    self.unlisted = true if self.private || name.starts_with?('.')
+    self.chat = true if name.starts_with?('chat.', '.chat')
+    self.local = true if self.private ||
+      name.in?(%w(local .local chat.local .chat.local)) ||
+      name.starts_with?('local.', '.local', 'chat.local.' '.chat.local')
   end
 end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index 0c407429d..79b0e198c 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -14,7 +14,7 @@ class StatusRelationshipsPresenter
       statuses            = statuses.compact
       status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
       conversation_ids    = statuses.map(&:conversation_id).compact.uniq
-      pinnable_status_ids = statuses.map(&:proper).select { |s| s.account_id == current_account_id && %w(public unlisted private).include?(s.visibility) }.map(&:id)
+      pinnable_status_ids = statuses.map(&:proper).select { |s| s.account_id == current_account_id && %w(public unlisted local private).include?(s.visibility) }.map(&:id)
 
       @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
       @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 86e887463..a6807b3ac 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -55,11 +55,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
   end
 
   def visibility
-    # This visibility is masked behind "private"
-    # to avoid API changes because there are no
-    # UX differences
     if object.limited_visibility?
       'private'
+    elsif object.local_visibility? || object.chat_visibility?
+      'unlisted'
     else
       object.visibility
     end
@@ -121,7 +120,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
     current_user? &&
       current_user.account_id == object.account_id &&
       !object.reblog? &&
-      %w(public unlisted private).include?(object.visibility)
+      %w(public unlisted local private).include?(object.visibility)
   end
 
   def source_requested?
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 4db3d4cf4..f726dba18 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -6,7 +6,7 @@ class FanOutOnWriteService < BaseService
   def call(status)
     raise Mastodon::RaceConditionError if status.visibility.nil?
 
-    deliver_to_self(status) if status.account.local?
+    deliver_to_self(status) if status.account.local? && !status.chat_visibility?
 
     render_anonymous_payload(status)
 
@@ -16,28 +16,35 @@ class FanOutOnWriteService < BaseService
       deliver_to_own_conversation(status)
     elsif status.limited_visibility?
       deliver_to_mentioned_followers(status)
+    elsif status.chat_visibility?
+      deliver_to_mentioned_followers(status)
+      deliver_to_hashtags(status)
+    elsif status.local_visibility?
+      deliver_to_followers(status)
+      deliver_to_lists(status)
+      deliver_to_local(status) unless filtered?(status)
     else
       deliver_to_followers(status)
       deliver_to_lists(status)
-    end
 
-    return if status.reblog? && !Setting.show_reblogs_in_public_timelines
-    return if filtered?(status)
+      return if status.reblog? && !Setting.show_reblogs_in_public_timelines
+      return if filtered?(status) || (status.reblog? && filtered?(status.reblog))
 
-    if !status.reblog? && status.distributable?
-      deliver_to_hashtags(status)
-      deliver_to_public(status) if status.curated
-    end
+      if !status.reblog? && status.distributable?
+        deliver_to_hashtags(status)
+        deliver_to_public(status) if status.curated
+      end
 
-    if status.relayed?
-      status = Status.find(status.reblog_of_id)
-      return if filtered?(status)
-      render_anonymous_payload(status)
-    end
+      if status.relayed?
+        status = Status.find(status.reblog_of_id)
+        return if filtered?(status)
+        render_anonymous_payload(status)
+      end
 
-    return unless status.network? && status.public_visibility? && !status.reblog
+      return unless status.network? && status.public_visibility? && !status.reblog
 
-    deliver_to_local(status)
+      deliver_to_local(status)
+    end
   end
 
   private
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 9650aedc8..e34430f09 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -4,6 +4,15 @@ class PostStatusService < BaseService
   include Redisable
 
   MIN_SCHEDULE_OFFSET = 5.minutes.freeze
+  VISIBILITY_RANK = {
+    'public'    => 0,
+    'unlisted'  => 1,
+    'local'     => 1,
+    'private'   => 2,
+    'direct'    => 3,
+    'limited'   => 3,
+    'chat'      => 4
+  }
 
   # Post a text status update, fetch and notify remote users mentioned
   # @param [Account] account Account from which to post
@@ -30,9 +39,10 @@ class PostStatusService < BaseService
     @text        = @options[:text] || ''
     @footer      = @options[:footer]
     @in_reply_to = @options[:thread]
-    @tags        = @options[:tags]
+    @tags        = @options[:tags] || []
     @local_only  = @options[:local_only]
     @sensitive   = (@account.force_sensitive? ? true : @options[:sensitive])
+    @preloaded_tags = @options[:preloaded_tags] || []
 
     return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
 
@@ -55,9 +65,54 @@ class PostStatusService < BaseService
   private
 
   def set_footer_from_i_am
+    return if @footer.nil? || @options[:no_footer]
     name = @account.vars['_they:are']
     return if name.blank?
-    @account.vars["_they:are:#{name}"]
+    @footer = @account.vars["_they:are:#{name}"]
+  end
+
+  def set_initial_visibility
+    @visibility = @options[:visibility] || @account.user_default_visibility
+  end
+
+  def limit_visibility_if_silenced
+    @visibility = :unlisted if @visibility.in?([nil, 'public']) && @account.silenced? || @account.force_unlisted
+  end
+
+  def limit_visibility_to_reply
+    return if @in_reply_to.nil?
+    @visibility = @in_reply_to.visibility if @visibility.nil? ||
+      VISIBILITY_RANK[@visibility] < VISIBILITY_RANK[@in_reply_to.visibility]
+  end
+
+  def set_local_only
+    @local_only = true if @account.user_always_local_only? || @in_reply_to&.local_only
+  end
+
+  def set_chat
+    if @in_reply_to.present?
+      unless @in_reply_to.chat_tags.blank?
+        @preloaded_tags |= @in_reply_to.chat_tags
+        @visibility = :chat
+        @in_reply_to = nil
+      end
+    elsif @tags.present? && @tags.any? { |t| t.start_with?('chat.', '.chat.') }
+      @visibility = :chat
+      @local_only = true if @tags.any? { |t| t.in?(%w(chat.local .chat.local)) || t.start_with?('chat.local.', '.chat.local.') }
+    end
+  end
+
+  # move tags out of body so we can format them later
+  def extract_tags
+    chunks = []
+    @text.split(/^((?:#[\w:._·\-]*\s*)+)/).each do |chunk|
+      if chunk.start_with?('#')
+        @tags |= chunk[1..-1].split(/\s+/)
+      else
+        chunks << chunk
+      end
+    end
+    @text = chunks.join
   end
 
   def preprocess_attributes!
@@ -66,18 +121,17 @@ class PostStatusService < BaseService
      @text = @media.find(&:video?) ? '📹' : '🖼' if @media.size > 0
     end
 
-    @footer = set_footer_from_i_am if @footer.nil? && !@options[:no_footer]
-
-    @visibility   = @options[:visibility] || @account.user_default_visibility
-    @visibility   = :unlisted if @visibility.in?([nil, 'public']) && @account.silenced? || @account.force_unlisted
+    set_footer_from_i_am
+    extract_tags
+    set_chat
+    set_local_only
 
-    if @in_reply_to.present? && @in_reply_to.visibility.present?
-      v = %w(public unlisted private direct limited)
-      @visibility = @in_reply_to.visibility if @visibility.nil? || v.index(@visibility) < v.index(@in_reply_to.visibility)
+    unless @visibility == :chat
+      set_initial_visibility
+      limit_visibility_if_silenced
+      limit_visibility_to_reply
     end
 
-    @local_only = true if @account.user_always_local_only? || @in_reply_to&.local_only
-
     @sensitive = (@account.user_defaults_to_sensitive? || @options[:spoiler_text].present?) if @sensitive.nil?
 
     @scheduled_at = @options[:scheduled_at]&.to_datetime
@@ -94,7 +148,7 @@ class PostStatusService < BaseService
       @status = @account.statuses.create!(status_attributes)
     end
 
-    process_hashtags_service.call(@status, @tags)
+    process_hashtags_service.call(@status, @tags, @preloaded_tags)
     process_mentions_service.call(@status)
   end
 
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index 07806b4a7..fff4f5db1 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -1,24 +1,35 @@
 # frozen_string_literal: true
 
 class ProcessHashtagsService < BaseService
-  def call(status, tags = [])
+  def call(status, tags = [], preloaded_tags = [])
+    status.tags |= preloaded_tags unless preloaded_tags.blank?
+
     if status.local?
       tags = Extractor.extract_hashtags(status.text) | (tags.nil? ? [] : tags)
     end
     records = []
 
     tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
-      name = name.gsub(/[:.]+/, '.')
-      next if name.blank?
-      component_indices = name.size.times.select {|i| name[i] == '.'}
-      component_indices << name.size - 1
+      name.gsub!(/[:.]+/, '.')
+      next if name.blank? || name == '.'
+
+      chat = name.starts_with?('chat.', '.chat.')
+      if chat
+        component_indices = [name.size - 1]
+      else
+        component_indices = 1.upto(name.size).select { |i| name[i] == '.' }
+        component_indices << name.size - 1
+      end
+
       component_indices.take(6).each_with_index do |i, nest|
         frag = (nest != 5) ? name[0..i] : name
         tag = Tag.where(name: frag).first_or_create(name: frag)
 
+        tag.chatters.find_or_create_by(id: status.account_id) if chat
+
         next if status.tags.include?(tag)
         status.tags << tag
-        next if tag.local || tag.private
+        next if tag.unlisted || component_indices.size > 1
 
         records << tag
         TrendingTags.record_use!(tag, status.account, status.created_at) if status.distributable?
diff --git a/app/validators/status_pin_validator.rb b/app/validators/status_pin_validator.rb
index db19e0a76..8236dcd6e 100644
--- a/app/validators/status_pin_validator.rb
+++ b/app/validators/status_pin_validator.rb
@@ -6,7 +6,7 @@ class StatusPinValidator < ActiveModel::Validator
   def validate(pin)
     pin.errors.add(:base, I18n.t('statuses.pin_errors.reblog')) if pin.status.reblog?
     pin.errors.add(:base, I18n.t('statuses.pin_errors.ownership')) if pin.account_id != pin.status.account_id
-    pin.errors.add(:base, I18n.t('statuses.pin_errors.private')) unless %w(public unlisted private).include?(pin.status.visibility)
+    pin.errors.add(:base, I18n.t('statuses.pin_errors.private')) unless %w(public unlisted local private).include?(pin.status.visibility)
     pin.errors.add(:base, I18n.t('statuses.pin_errors.limit')) if pin.account.status_pins.count >= MAX_PINNED  && pin.account.local?
   end
 end
diff --git a/app/workers/activitypub/distribute_poll_update_worker.rb b/app/workers/activitypub/distribute_poll_update_worker.rb
index 310e42433..34eed9ab2 100644
--- a/app/workers/activitypub/distribute_poll_update_worker.rb
+++ b/app/workers/activitypub/distribute_poll_update_worker.rb
@@ -35,7 +35,7 @@ class ActivityPub::DistributePollUpdateWorker
       end
     end
 
-    @inboxes.concat(@account.followers.inboxes) unless @status.direct_visibility?
+    @inboxes.concat(@account.followers.inboxes) unless @status.direct_visibility? || @status.chat_visibility?
     @inboxes.uniq!
     @inboxes.compact!
     @inboxes
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index d83f01700..67c3054e5 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -23,7 +23,7 @@ class ActivityPub::DistributionWorker
   private
 
   def skip_distribution?
-    @status.direct_visibility? || @status.limited_visibility?
+    @status.direct_visibility? || @status.limited_visibility? || @status.chat_visibility?
   end
 
   def relayable?