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) + + 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.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 + + deliver_to_local(status) end - - return if status.reblog? && !Setting.show_reblogs_in_public_timelines - return if filtered?(status) - - 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 - - return unless status.network? && status.public_visibility? && !status.reblog - - deliver_to_local(status) 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] + set_footer_from_i_am + extract_tags + set_chat + set_local_only - @visibility = @options[:visibility] || @account.user_default_visibility - @visibility = :unlisted if @visibility.in?([nil, 'public']) && @account.silenced? || @account.force_unlisted - - 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? diff --git a/db/migrate/20190522043937_add_chat_and_local_indexes_to_statuses.rb b/db/migrate/20190522043937_add_chat_and_local_indexes_to_statuses.rb new file mode 100644 index 000000000..ad4bb11b1 --- /dev/null +++ b/db/migrate/20190522043937_add_chat_and_local_indexes_to_statuses.rb @@ -0,0 +1,6 @@ +class AddChatAndLocalIndexesToStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + def change + add_index :statuses, [:account_id, :id, :visibility], where: 'visibility IN (0, 1, 2, 4, 5)', algorithm: :concurrently + end +end diff --git a/db/migrate/20190522060455_add_unlisted_to_tags.rb b/db/migrate/20190522060455_add_unlisted_to_tags.rb new file mode 100644 index 000000000..3c47af673 --- /dev/null +++ b/db/migrate/20190522060455_add_unlisted_to_tags.rb @@ -0,0 +1,9 @@ +class AddUnlistedToTags < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + def change + safety_assured { + add_column :tags, :unlisted, :boolean, default: false, null: false + add_index :tags, :unlisted, where: :unlisted, algorithm: :concurrently + } + end +end diff --git a/db/migrate/20190522060919_make_private_tags_unlisted.rb b/db/migrate/20190522060919_make_private_tags_unlisted.rb new file mode 100644 index 000000000..92f610d57 --- /dev/null +++ b/db/migrate/20190522060919_make_private_tags_unlisted.rb @@ -0,0 +1,5 @@ +class MakePrivateTagsUnlisted < ActiveRecord::Migration[5.2] + def up + Tag.where(private: true).in_batches.update_all(unlisted: true) + end +end diff --git a/db/migrate/20190523010615_add_chat_to_tags.rb b/db/migrate/20190523010615_add_chat_to_tags.rb new file mode 100644 index 000000000..1fc3cadfa --- /dev/null +++ b/db/migrate/20190523010615_add_chat_to_tags.rb @@ -0,0 +1,9 @@ +class AddChatToTags < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + def change + safety_assured { + add_column :tags, :chat, :boolean, default: false, null: false + add_index :tags, :chat, where: :chat, algorithm: :concurrently + } + end +end diff --git a/db/migrate/20190526052454_create_chat_accounts.rb b/db/migrate/20190526052454_create_chat_accounts.rb new file mode 100644 index 000000000..b950e36c1 --- /dev/null +++ b/db/migrate/20190526052454_create_chat_accounts.rb @@ -0,0 +1,11 @@ +class CreateChatAccounts < ActiveRecord::Migration[5.2] + def change + create_table :chat_accounts do |t| + t.references :account, foreign_key: { on_delete: :cascade }, null: false + t.references :tag, foreign_key: { on_delete: :cascade }, null: false + t.index [:account_id, :tag_id], unique: true + t.index [:tag_id, :account_id] + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 600837334..0cfefbf7f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -208,6 +208,17 @@ ActiveRecord::Schema.define(version: 2019_05_21_003909) do t.index ["status_id"], name: "index_bookmarks_on_status_id" end + create_table "chat_accounts", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "tag_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "tag_id"], name: "index_chat_accounts_on_account_id_and_tag_id", unique: true + t.index ["account_id"], name: "index_chat_accounts_on_account_id" + t.index ["tag_id", "account_id"], name: "index_chat_accounts_on_tag_id_and_account_id" + t.index ["tag_id"], name: "index_chat_accounts_on_tag_id" + end + create_table "conversation_mutes", force: :cascade do |t| t.bigint "conversation_id", null: false t.bigint "account_id", null: false @@ -687,8 +698,12 @@ ActiveRecord::Schema.define(version: 2019_05_21_003909) do t.datetime "updated_at", null: false t.boolean "local", default: false, null: false t.boolean "private", default: false, null: false + t.boolean "unlisted", default: false, null: false + t.boolean "chat", default: false, null: false t.index "lower((name)::text) text_pattern_ops", name: "hashtag_search_index" + t.index ["chat"], name: "index_tags_on_chat", where: "chat" t.index ["name"], name: "index_tags_on_name", unique: true + t.index ["unlisted"], name: "index_tags_on_unlisted", where: "unlisted" end create_table "tombstones", force: :cascade do |t| @@ -791,6 +806,8 @@ ActiveRecord::Schema.define(version: 2019_05_21_003909) do add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade add_foreign_key "bookmarks", "accounts", on_delete: :cascade add_foreign_key "bookmarks", "statuses", on_delete: :cascade + add_foreign_key "chat_accounts", "accounts", on_delete: :cascade + add_foreign_key "chat_accounts", "tags", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade diff --git a/spec/fabricators/chat_account_fabricator.rb b/spec/fabricators/chat_account_fabricator.rb new file mode 100644 index 000000000..23845a1d6 --- /dev/null +++ b/spec/fabricators/chat_account_fabricator.rb @@ -0,0 +1,2 @@ +Fabricator(:chat_account) do +end diff --git a/spec/models/chat_account_spec.rb b/spec/models/chat_account_spec.rb new file mode 100644 index 000000000..bc27272e7 --- /dev/null +++ b/spec/models/chat_account_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe ChatAccount, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end