From 004129dfbb1f76281e559910179fb384c383ef54 Mon Sep 17 00:00:00 2001 From: KMY Date: Sun, 16 Apr 2023 09:55:21 +0900 Subject: [PATCH 1/7] Set mark searchability unlisted as activitypub note --- app/lib/activitypub/tag_manager.rb | 2 +- app/models/status.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index bd9706fc42..d79ca2256e 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -188,7 +188,7 @@ class ActivityPub::TagManager def searchable_by(status) searchable_by = - case status.compute_searchability + case status.compute_searchability_activitypub when 'public' [COLLECTIONS[:public]] when 'unlisted' # Followers only in kmyblue (generics: private) diff --git a/app/models/status.rb b/app/models/status.rb index abf31c2dcf..419e6dd48b 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -383,6 +383,11 @@ class Status < ApplicationRecord 'private' end + def compute_searchability_activitypub + return 'unlisted' if public_unlisted_visibility? && public_searchability? + compute_searchability + end + after_create_commit :increment_counter_caches after_destroy_commit :decrement_counter_caches From 7e125b276f1887cbeacce035d531072f008e8075 Mon Sep 17 00:00:00 2001 From: KMY Date: Sat, 22 Apr 2023 17:10:11 +0900 Subject: [PATCH 2/7] Remove extra media attachments and support raw array --- app/javascript/mastodon/actions/importer/normalizer.js | 4 ---- app/models/media_attachment.rb | 2 +- app/models/status.rb | 8 -------- app/serializers/rest/instance_serializer.rb | 2 +- app/serializers/rest/status_serializer.rb | 3 +-- app/serializers/rest/v1/instance_serializer.rb | 2 +- 6 files changed, 4 insertions(+), 17 deletions(-) diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 69d6bf03da..b030c94b31 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -70,10 +70,6 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.emoji_reactions = normalizeEmojiReactions(status.emoji_reactions); } - if (status.media_attachments_ex) { - normalStatus.media_attachments = status.media_attachments.concat(status.media_attachments_ex); - } - if (!status.visibility_ex) { normalStatus.visibility_ex = status.visibility; } diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 6ca9af0430..cb1b6b7951 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -36,7 +36,7 @@ class MediaAttachment < ApplicationRecord include RoutingHelper LOCAL_STATUS_ATTACHMENT_MAX = 4 - ACTIVITYPUB_STATUS_ATTACHMENT_MAX = 8 + ACTIVITYPUB_STATUS_ATTACHMENT_MAX = 16 enum type: { :image => 0, :gifv => 1, :video => 2, :unknown => 3, :audio => 4 } enum processing: { :queued => 0, :in_progress => 1, :complete => 2, :failed => 3 }, _prefix: true diff --git a/app/models/status.rb b/app/models/status.rb index 419e6dd48b..d9160b0850 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -294,14 +294,6 @@ class Status < ApplicationRecord end end - def ordered_media_attachments_original_mastodon - ordered_media_attachments.take(4) - end - - def ordered_media_attachments_extra - ordered_media_attachments.drop(4).take(4) - end - def replies_count status_stat&.replies_count || 0 end diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 3d4f88c5e9..748a0b8587 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -55,6 +55,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer statuses: { max_characters: StatusLengthValidator::MAX_CHARS, max_media_attachments: MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX, + max_media_attachments_from_activitypub: MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX, characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS, }, @@ -97,7 +98,6 @@ class REST::InstanceSerializer < ActiveModel::Serializer def fedibird_capabilities capabilities = [ :emoji_reaction, - :kmyblue_extra_media_attachments, :kmyblue_visibility_public_unlisted, :enable_wide_emoji, :enable_wide_emoji_reaction, diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 7f72974a78..472f2564e2 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -22,8 +22,7 @@ class REST::StatusSerializer < ActiveModel::Serializer belongs_to :application, if: :show_application? belongs_to :account, serializer: REST::AccountSerializer - has_many :ordered_media_attachments_original_mastodon, key: :media_attachments, serializer: REST::MediaAttachmentSerializer - has_many :ordered_media_attachments_extra, key: :media_attachments_ex, serializer: REST::MediaAttachmentSerializer + has_many :ordered_media_attachments, key: :media_attachments, serializer: REST::MediaAttachmentSerializer has_many :ordered_mentions, key: :mentions has_many :tags has_many :emojis, serializer: REST::CustomEmojiSerializer diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb index d2c49ba0e3..881d685042 100644 --- a/app/serializers/rest/v1/instance_serializer.rb +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -65,6 +65,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer statuses: { max_characters: StatusLengthValidator::MAX_CHARS, max_media_attachments: MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX, + max_media_attachments_from_activitypub: MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX, characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS, }, @@ -107,7 +108,6 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer def fedibird_capabilities capabilities = [ :emoji_reaction, - :kmyblue_extra_media_attachments, :kmyblue_visibility_public_unlisted, :enable_wide_emoji, :enable_wide_emoji_reaction, From 2fef21664bde8d490efcf6445871db9b6b966b3c Mon Sep 17 00:00:00 2001 From: KMY Date: Mon, 24 Apr 2023 10:06:25 +0900 Subject: [PATCH 3/7] Add antenna and bio-searchability support --- app/controllers/antennas_controller.rb | 76 +++++++ .../api/v1/accounts/credentials_controller.rb | 1 + .../settings/profiles_controller.rb | 2 +- app/helpers/context_helper.rb | 1 + app/javascript/styles/mastodon/admin.scss | 7 + app/lib/activitypub/activity/create.rb | 18 +- app/lib/activitypub/tag_manager.rb | 4 + app/models/account.rb | 1 + app/models/antenna.rb | 203 ++++++++++++++++++ app/models/antenna_account.rb | 17 ++ app/models/antenna_domain.rb | 16 ++ app/models/antenna_tag.rb | 17 ++ app/models/concerns/account_associations.rb | 2 + app/models/list.rb | 1 + app/models/tag.rb | 2 + .../activitypub/actor_serializer.rb | 8 +- app/serializers/rest/account_serializer.rb | 2 +- .../activitypub/process_account_service.rb | 16 ++ app/services/delete_account_service.rb | 1 + app/services/fan_out_on_write_service.rb | 29 +++ app/views/antennas/_antenna.html.haml | 74 +++++++ app/views/antennas/_antenna_fields.html.haml | 51 +++++ app/views/antennas/edit.html.haml | 8 + app/views/antennas/index.html.haml | 14 ++ app/views/antennas/new.html.haml | 8 + app/views/settings/profiles/show.html.haml | 3 + config/locales/en.yml | 37 ++++ config/locales/ja.yml | 43 ++++ config/locales/simple_form.en.yml | 2 + config/locales/simple_form.ja.yml | 2 + config/navigation.rb | 1 + config/routes.rb | 1 + db/migrate/20230423002728_create_antennas.rb | 40 ++++ ...3233429_add_dissubscribable_to_accounts.rb | 6 + db/schema.rb | 67 +++++- 35 files changed, 775 insertions(+), 6 deletions(-) create mode 100644 app/controllers/antennas_controller.rb create mode 100644 app/models/antenna.rb create mode 100644 app/models/antenna_account.rb create mode 100644 app/models/antenna_domain.rb create mode 100644 app/models/antenna_tag.rb create mode 100644 app/views/antennas/_antenna.html.haml create mode 100644 app/views/antennas/_antenna_fields.html.haml create mode 100644 app/views/antennas/edit.html.haml create mode 100644 app/views/antennas/index.html.haml create mode 100644 app/views/antennas/new.html.haml create mode 100644 db/migrate/20230423002728_create_antennas.rb create mode 100644 db/migrate/20230423233429_add_dissubscribable_to_accounts.rb diff --git a/app/controllers/antennas_controller.rb b/app/controllers/antennas_controller.rb new file mode 100644 index 0000000000..b7639d0e27 --- /dev/null +++ b/app/controllers/antennas_controller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class AntennasController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :set_antenna, only: [:edit, :update, :destroy] + before_action :set_lists, only: [:new, :edit] + before_action :set_body_classes + before_action :set_cache_headers + + def index + @antennas = current_account.antennas.includes(:antenna_domains).includes(:antenna_tags).includes(:antenna_accounts) + end + + def new + @antenna = current_account.antennas.build + @antenna.antenna_domains.build + @antenna.antenna_tags.build + @antenna.antenna_accounts.build + end + + def create + @antenna = current_account.antennas.build(thin_resource_params) + + saved = @antenna.save + saved = @antenna.update(resource_params) if saved + + if saved + redirect_to antennas_path + else + render action: :new + end + end + + def edit; end + + def update + if @antenna.update(resource_params) + redirect_to antennas_path + else + render action: :edit + end + end + + def destroy + @antenna.destroy + redirect_to antennas_path + end + + private + + def set_antenna + @antenna = current_account.antennas.find(params[:id]) + end + + def set_lists + @lists = current_account.owned_lists + end + + def resource_params + params.require(:antenna).permit(:title, :list, :available, :expires_in, :keywords_raw, :exclude_keywords_raw, :domains_raw, :exclude_domains_raw, :accounts_raw, :exclude_accounts_raw, :tags_raw, :exclude_tags_raw) + end + + def thin_resource_params + params.require(:antenna).permit(:title, :list) + end + + def set_body_classes + @body_classes = 'admin' + end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end +end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index ca5b9cfba3..7f0c5fc2f0 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -30,6 +30,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController :bot, :discoverable, :searchability, + :dissubscribable, :hide_collections, fields_attributes: [:name, :value] ) diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index d743518fca..030c3765c2 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -20,7 +20,7 @@ class Settings::ProfilesController < Settings::BaseController private def account_params - params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :my_actor_type, :searchability, :group_allow_private_message, :discoverable, :hide_collections, fields_attributes: [:name, :value]) + params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :my_actor_type, :searchability, :dissubscribable, :group_allow_private_message, :discoverable, :hide_collections, fields_attributes: [:name, :value]) end def set_account diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 18c6c7916a..81e0064f03 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -23,6 +23,7 @@ module ContextHelper voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, emoji_reactions: { 'fedibird' => 'http://fedibird.com/ns#', 'emojiReactions' => { '@id' => "fedibird:emojiReactions", '@type' => '@id' } }, searchable_by: { 'fedibird' => 'http://fedibird.com/ns#', 'searchableBy' => { '@id' => "fedibird:searchableBy", '@type' => '@id' } }, + subscribable_by: { 'kmyblue' => 'http://kmy.blue/ns#', 'subscribableBy' => { '@id' => "kmyblue:subscribableBy", '@type' => '@id' } }, olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, }.freeze diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index d54d6634da..b43771ea37 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -1062,6 +1062,13 @@ a.name-tag, margin-bottom: 10px; } + .listname { + color: $dark-text-color; + font-weight: bold; + font-size: 14px; + margin-left: 16px; + } + .expiration { font-size: 13px; } diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 7b6b7a7620..ad3c211d81 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -449,10 +449,26 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end end + SCAN_SEARCHABILITY_RE = /\[searchability:(public|followers|reactors|private)\]/.freeze + def searchability searchability = searchability_from_audience - return nil if searchability.nil? + if searchability.nil? + note = @account&.note + return nil unless note.present? + + searchability_bio = note.scan(SCAN_SEARCHABILITY_RE).first + return nil unless searchability_bio + + searchability = searchability_bio[0] + return nil if searchability.nil? + + searchability = :public if searchability == 'public' + searchability = :unlisted if searchability == 'followers' + searchability = :direct if searchability == 'private' + searchability = :private if searchability == 'reactors' + end visibility = visibility_from_audience_with_silence diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index d79ca2256e..5268b18b91 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -186,6 +186,10 @@ class ActivityPub::TagManager nil end + def subscribable_by(account) + account.dissubscribable ? [] : [COLLECTIONS[:public]] + end + def searchable_by(status) searchable_by = case status.compute_searchability_activitypub diff --git a/app/models/account.rb b/app/models/account.rb index 8164df116a..92b8947ede 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -52,6 +52,7 @@ # requested_review_at :datetime # group_allow_private_message :boolean # searchability :integer default("private"), not null +# dissubscribable :boolean default(FALSE), not null # class Account < ApplicationRecord diff --git a/app/models/antenna.rb b/app/models/antenna.rb new file mode 100644 index 0000000000..89b98516b1 --- /dev/null +++ b/app/models/antenna.rb @@ -0,0 +1,203 @@ +# == Schema Information +# +# Table name: antennas +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# list_id :bigint(8) not null +# title :string default(""), not null +# keywords :jsonb +# exclude_keywords :jsonb +# any_domains :boolean default(TRUE), not null +# any_tags :boolean default(TRUE), not null +# any_accounts :boolean default(TRUE), not null +# any_keywords :boolean default(TRUE), not null +# available :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# expires_at :datetime +# with_media_only :boolean default(FALSE), not null +# +class Antenna < ApplicationRecord + include Expireable + + has_many :antenna_domains, inverse_of: :antenna, dependent: :destroy + has_many :antenna_tags, inverse_of: :antenna, dependent: :destroy + has_many :antenna_accounts, inverse_of: :antenna, dependent: :destroy + + belongs_to :account + belongs_to :list + + scope :all_keywords, -> { where(any_keywords: true) } + scope :all_domains, -> { where(any_domains: true) } + scope :all_accounts, -> { where(any_accounts: true) } + scope :all_tags, -> { where(any_tags: true) } + scope :availables, -> { where(available: true).where(Arel.sql('any_keywords = FALSE OR any_domains = FALSE OR any_accounts = FALSE OR any_tags = FALSE')) } + + def enabled? + enabled_config? && !expired? + end + + def enabled_config? + available && enabled_config_raws? + end + + def enabled_config_raws? + !(any_keywords && any_domains && any_accounts && any_tags) + end + + def expires_in + return @expires_in if defined?(@expires_in) + return nil if expires_at.nil? + + [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at } + end + + def context + context = [] + context << 'domain' if !any_domains + context << 'tag' if !any_tags + context << 'keyword' if !any_keywords + context << 'account' if !any_accounts + context + end + + def list=(list_id) + list_id = list_id.to_i if list_id.is_a?(String) + if list_id.is_a?(Numeric) + self[:list_id] = list_id + else + self[:list] = list_id + end + end + + def keywords_raw + return '' if !keywords.present? + + keywords.join("\n") + end + + def keywords_raw=(raw) + keywords = raw.split(/\R/).filter { |r| r.present? && r.length >= 2 }.uniq + self[:keywords] = keywords + self[:any_keywords] = !keywords.any? && !exclude_keywords&.any? + end + + def exclude_keywords_raw + return '' if !exclude_keywords.present? + + exclude_keywords.join("\n") + end + + def exclude_keywords_raw=(raw) + exclude_keywords = raw.split(/\R/).filter { |r| r.present? }.uniq + self[:exclude_keywords] = exclude_keywords + self[:any_keywords] = !keywords&.any? && !exclude_keywords.any? + end + + def tags_raw + antenna_tags.where(exclude: false).map(&:tag).map(&:name).join("\n") + end + + def tags_raw=(raw) + return if tags_raw == raw + + tag_names = raw.split(/\R/).filter { |r| r.present? }.map { |r| r.start_with?('#') ? r[1..-1] : r }.uniq + + antenna_tags.where(exclude: false).destroy_all + Tag.find_or_create_by_names(tag_names).each do |tag| + antenna_tags.create!(tag: tag, exclude: false) + end + self[:any_tags] = !tag_names.any? + end + + def exclude_tags_raw + antenna_tags.where(exclude: true).map(&:tag).map(&:name).join("\n") + end + + def exclude_tags_raw=(raw) + return if exclude_tags_raw == raw + + tag_names = raw.split(/\R/).filter { |r| r.present? }.map { |r| r.start_with?('#') ? r[1..-1] : r }.uniq + + antenna_tags.where(exclude: true).destroy_all + Tag.find_or_create_by_names(tag_names).each do |tag| + antenna_tags.create!(tag: tag, exclude: true) + end + end + + def domains_raw + antenna_domains.where(exclude: false).map(&:name).join("\n") + end + + def domains_raw=(raw) + return if domains_raw == raw + + domain_names = raw.split(/\R/).filter { |r| r.present? }.uniq + + antenna_domains.where(exclude: false).destroy_all + domain_names.each do |domain| + antenna_domains.create!(name: domain, exclude: false) + end + self[:any_domains] = !domain_names.any? + end + + def exclude_domains_raw + antenna_domains.where(exclude: true).map(&:name).join("\n") + end + + def exclude_domains_raw=(raw) + return if exclude_domains_raw == raw + + domain_names = raw.split(/\R/).filter { |r| r.present? }.uniq + + antenna_domains.where(exclude: true).destroy_all + domain_names.each do |domain| + antenna_domains.create!(name: domain, exclude: true) + end + end + + def accounts_raw + antenna_accounts.where(exclude: false).map(&:account).map { |account| account.domain ? "@#{account.username}@#{account.domain}" : "@#{account.username}" }.join("\n") + end + + def accounts_raw=(raw) + return if accounts_raw == raw + + account_names = raw.split(/\R/).filter { |r| r.present? }.map { |r| r.start_with?('@') ? r[1..-1] : r }.uniq + + hit = false + antenna_accounts.where(exclude: false).destroy_all + account_names.each do |name| + username, domain = name.split('@') + account = Account.find_by(username: username, domain: domain) + if account.present? + antenna_accounts.create!(account: account, exclude: false) + hit = true + end + end + self[:any_accounts] = !hit + end + + def exclude_accounts_raw + antenna_accounts.where(exclude: true).map(&:account).map { |account| account.domain ? "@#{account.username}@#{account.domain}" : "@#{account.username}" }.join("\n") + end + + def exclude_accounts_raw=(raw) + return if exclude_accounts_raw == raw + + account_names = raw.split(/\R/).filter { |r| r.present? }.map { |r| r.start_with?('@') ? r[1..-1] : r }.uniq + + hit = false + antenna_accounts.where(exclude: true).destroy_all + account_names.each do |name| + username, domain = name.split('@') + account = Account.find_by(username: username, domain: domain) + if account.present? + antenna_accounts.create!(account: account, exclude: true) + hit = true + end + end + end + +end diff --git a/app/models/antenna_account.rb b/app/models/antenna_account.rb new file mode 100644 index 0000000000..d92e5535f9 --- /dev/null +++ b/app/models/antenna_account.rb @@ -0,0 +1,17 @@ +# == Schema Information +# +# Table name: antenna_accounts +# +# id :bigint(8) not null, primary key +# antenna_id :bigint(8) not null +# account_id :bigint(8) not null +# exclude :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# +class AntennaAccount < ApplicationRecord + + belongs_to :antenna + belongs_to :account + +end diff --git a/app/models/antenna_domain.rb b/app/models/antenna_domain.rb new file mode 100644 index 0000000000..aff087292d --- /dev/null +++ b/app/models/antenna_domain.rb @@ -0,0 +1,16 @@ +# == Schema Information +# +# Table name: antenna_domains +# +# id :bigint(8) not null, primary key +# antenna_id :bigint(8) not null +# name :string +# exclude :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# +class AntennaDomain < ApplicationRecord + + belongs_to :antenna + +end diff --git a/app/models/antenna_tag.rb b/app/models/antenna_tag.rb new file mode 100644 index 0000000000..fff5208b5b --- /dev/null +++ b/app/models/antenna_tag.rb @@ -0,0 +1,17 @@ +# == Schema Information +# +# Table name: antenna_tags +# +# id :bigint(8) not null, primary key +# antenna_id :bigint(8) not null +# tag_id :bigint(8) not null +# exclude :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# +class AntennaTag < ApplicationRecord + + belongs_to :antenna + belongs_to :tag + +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index b76892a9d6..00c57f00d9 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -39,6 +39,8 @@ module AccountAssociations has_many :report_notes, dependent: :destroy has_many :custom_filters, inverse_of: :account, dependent: :destroy + has_many :antennas, inverse_of: :account, dependent: :destroy + has_many :antenna_accounts, inverse_of: :account, dependent: :destroy # Moderation notes has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account diff --git a/app/models/list.rb b/app/models/list.rb index bd1bdbd24d..c5e3a62d70 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -23,6 +23,7 @@ class List < ApplicationRecord has_many :list_accounts, inverse_of: :list, dependent: :destroy has_many :accounts, through: :list_accounts + has_many :antennas, inverse_of: :list, dependent: :destroy validates :title, presence: true diff --git a/app/models/tag.rb b/app/models/tag.rb index 554a92d901..a684f502b1 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -27,6 +27,8 @@ class Tag < ApplicationRecord has_many :featured_tags, dependent: :destroy, inverse_of: :tag has_many :followers, through: :passive_relationships, source: :account + has_one :antenna_tag, dependent: :destroy, inverse_of: :tag + HASHTAG_SEPARATORS = "_\u00B7\u30FB\u200c" HASHTAG_FIRST_SEQUENCE_CHUNK_ONE = "[[:word:]_][[:word:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}]" HASHTAG_FIRST_SEQUENCE_CHUNK_TWO = "[[:word:]#{HASHTAG_SEPARATORS}]*[[:word:]_]" diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 0a045050af..d8588af450 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -7,13 +7,13 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer context :security context_extensions :manually_approves_followers, :featured, :also_known_as, - :moved_to, :property_value, :discoverable, :olm, :suspended, :searchable_by + :moved_to, :property_value, :discoverable, :olm, :suspended, :searchable_by, :subscribable_by attributes :id, :type, :following, :followers, :inbox, :outbox, :featured, :featured_tags, :preferred_username, :name, :summary, :url, :manually_approves_followers, - :discoverable, :published, :searchable_by + :discoverable, :published, :searchable_by, :subscribable_by has_one :public_key, serializer: ActivityPub::PublicKeySerializer @@ -166,6 +166,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer ActivityPub::TagManager.instance.account_searchable_by(object) end + def subscribable_by + ActivityPub::TagManager.instance.subscribable_by(object) + end + class CustomEmojiSerializer < ActivityPub::EmojiSerializer end diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index bfbb484171..6c6a587e51 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer include FormattingHelper attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at, - :note, :url, :avatar, :avatar_static, :header, :header_static, :searchability, + :note, :url, :avatar, :avatar_static, :header, :header_static, :searchability, :dissubscribable, :followers_count, :following_count, :statuses_count, :last_status_at has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested? diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 6f08624f69..244ad3b8ce 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -78,6 +78,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.suspension_origin = :local if auto_suspend? @account.silenced_at = domain_block.created_at if auto_silence? @account.searchability = :private # not null + @account.dissubscribable = false # not null @account.save end @@ -115,6 +116,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) } @account.discoverable = @json['discoverable'] || false @account.searchability = searchability_from_audience + @account.dissubscribable = !subscribable(@account.note) end def set_fetchable_key! @@ -249,6 +251,20 @@ class ActivityPub::ProcessAccountService < BaseService end end + def subscribable_by + return nil if @json['subscribableBy'].nil? + + @subscribable_by = as_array(@json['subscribableBy']).map { |x| value_or_id(x) } + end + + def subscribable(note) + if subscribable_by.nil? + !note.include?('[subscribable:no]') + else + subscribable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) } + end + end + def property_values return unless @json['attachment'].is_a?(Array) diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb index bd606b2afa..8d51221350 100644 --- a/app/services/delete_account_service.rb +++ b/app/services/delete_account_service.rb @@ -8,6 +8,7 @@ class DeleteAccountService < BaseService account_pins active_relationships aliases + antennas block_relationships blocked_by_relationships conversation_mutes diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 9f7a8441cc..98f5494fe9 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -49,6 +49,7 @@ class FanOutOnWriteService < BaseService when :public, :unlisted, :public_unlisted, :private deliver_to_all_followers! deliver_to_lists! + deliver_to_antennas! if [:public, :public_unlisted].include?(@status.visibility.to_sym) && !@status.account.dissubscribable when :limited deliver_to_mentioned_followers! else @@ -115,6 +116,34 @@ class FanOutOnWriteService < BaseService end end + def deliver_to_antennas! + lists = [] + antennas = Antenna.availables + antennas = antennas.left_joins(:antenna_accounts).where(any_accounts: true).or(Antenna.availables.left_joins(:antenna_accounts) .where(antenna_accounts: { exclude: false, account: @status.account })) + antennas = antennas.left_joins(:antenna_domains) .where(any_domains: true) .or(Antenna.availables.left_joins(:antenna_accounts).left_joins(:antenna_domains) .where(antenna_domains: { exclude: false, name: @status.account.domain })) + antennas = antennas.left_joins(:antenna_tags) .where(any_tags: true) .or(Antenna.availables.left_joins(:antenna_accounts).left_joins(:antenna_domains).left_joins(:antenna_tags).where(antenna_tags: { exclude: false, tag: @status.tags })) + antennas = antennas.where(account: @status.account.followers) if @status.visibility.to_sym == :unlisted + antennas.in_batches do |ans| + ans.each do |antenna| + next if !antenna.enabled? + next if antenna.keywords.any? && !([nil, :public].include?(@status.searchability&.to_sym)) + next if antenna.keywords.any? && !antenna.keywords.any? { |keyword| @status.text.include?(keyword) } + next if antenna.exclude_keywords.any? && antenna.exclude_keywords.any? { |keyword| @status.text.include?(keyword) } + next if antenna.antenna_accounts.where(exclude: true, account: @status.account).any? + next if antenna.antenna_domains.where(exclude: true, name: @status.account.domain).any? + next if antenna.antenna_tags.where(exclude: true, tag: @status.tags).any? + lists << antenna.list + end + end + lists = lists.uniq + + if lists.any? + FeedInsertWorker.push_bulk(lists) do |list| + [@status.id, list.id, 'list', { 'update' => update? }] + end + end + end + def deliver_to_mentioned_followers! @status.mentions.joins(:account).merge(@account.followers_for_local_distribution).select(:id, :account_id).reorder(nil).find_in_batches do |mentions| FeedInsertWorker.push_bulk(mentions) do |mention| diff --git a/app/views/antennas/_antenna.html.haml b/app/views/antennas/_antenna.html.haml new file mode 100644 index 0000000000..3ba7d72377 --- /dev/null +++ b/app/views/antennas/_antenna.html.haml @@ -0,0 +1,74 @@ +.filters-list__item{ class: [(antenna.expired? || !antenna.enabled_config?) && 'expired'] } + = link_to edit_antenna_path(antenna), class: 'filters-list__item__title' do + = antenna.title + + - if !antenna.enabled_config? + .expiration{ title: t('antennas.index.disabled') } + = t('antennas.index.disabled') + - elsif antenna.expires? + .expiration{ title: t('antennas.index.expires_on', date: l(antenna.expires_at)) } + - if antenna.expired? + = t('invites.expired') + - else + = t('antennas.index.expires_in', distance: distance_of_time_in_words_to_now(antenna.expires_at)) + + .listname + = antenna.list.title + + .filters-list__item__permissions + %ul.permissions-list + - unless antenna.antenna_domains.empty? + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('sitemap') + .permissions-list__item__text + .permissions-list__item__text__title + = t('antennas.index.domains', count: antenna.antenna_domains.size) + .permissions-list__item__text__type + - domains = antenna.antenna_domains.map { |domain| domain.name } + - domains = domains.take(5) + ['…'] if domains.size > 5 # TODO + = domains.join(', ') + - unless antenna.antenna_accounts.empty? + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('users') + .permissions-list__item__text + .permissions-list__item__text__title + = t('antennas.index.accounts', count: antenna.antenna_accounts.size) + .permissions-list__item__text__type + - accounts = antenna.antenna_accounts.map { |account| account.account.domain ? "@#{account.account.username}@#{account.account.domain}" : "@#{account.account.username}" } + - accounts = accounts.take(5) + ['…'] if accounts.size > 5 # TODO + = accounts.join(', ') + - unless antenna.keywords.nil? || antenna.keywords.empty? + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('paragraph') + .permissions-list__item__text + .permissions-list__item__text__title + = t('antennas.index.keywords', count: antenna.keywords.size) + .permissions-list__item__text__type + - keywords = antenna.keywords + - keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO + = keywords.join(', ') + - unless antenna.antenna_tags.empty? + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('hashtag') + .permissions-list__item__text + .permissions-list__item__text__title + = t('antennas.index.tags', count: antenna.antenna_tags.size) + .permissions-list__item__text__type + - tags = antenna.antenna_tags.map { |tag| tag.tag.name } + - tags = keywords.take(5) + ['…'] if tags.size > 5 # TODO + = tags.join(', ') + + .announcements-list__item__action-bar + .announcements-list__item__meta + - if antenna.enabled_config_raws? + = t('antennas.index.contexts', contexts: antenna.context.map { |context| I18n.t("antennas.contexts.#{context}") }.join(', ')) + - else + = t('antennas.errors.empty_contexts') + + %div + = table_link_to 'pencil', t('antennas.edit.title'), edit_antenna_path(antenna) + = table_link_to 'times', t('antennas.index.delete'), antenna_path(antenna), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/antennas/_antenna_fields.html.haml b/app/views/antennas/_antenna_fields.html.haml new file mode 100644 index 0000000000..7f49139e0a --- /dev/null +++ b/app/views/antennas/_antenna_fields.html.haml @@ -0,0 +1,51 @@ +%p= t 'antennas.edit.description' +%hr.spacer/ + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :title, as: :string, wrapper: :with_label, hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt') + +.fields-row + .fields-group.fields-row__column.fields-row__column-6 + = f.input :list, collection: lists, wrapper: :with_label, label_method: lambda { |list| list.title }, label: t('antennas.edit.list'), selected: f.object.list&.id, hint: false + .fields-group.fields-row__column.fields-row__column-6 + = f.input :available, wrapper: :with_label, label: t('antennas.edit.available'), hint: false + +%hr.spacer/ +%p.hint= t 'antennas.edit.hint' +%hr.spacer/ + +%h4= t('antennas.contexts.domain') + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :domains_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.domains_raw') + .fields-row__column.fields-row__column-6.fields-group + = f.input :exclude_domains_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.exclude_domains_raw') + +%h4= t('antennas.contexts.account') +%p.hint= t 'antennas.edit.accounts_hint' + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :accounts_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.accounts_raw') + .fields-row__column.fields-row__column-6.fields-group + = f.input :exclude_accounts_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.exclude_accounts_raw') + +%h4= t('antennas.contexts.tag') + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :tags_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.tags_raw') + .fields-row__column.fields-row__column-6.fields-group + = f.input :exclude_tags_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.exclude_tags_raw') + +%h4= t('antennas.contexts.keyword') + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :keywords_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.keywords_raw') + .fields-row__column.fields-row__column-6.fields-group + = f.input :exclude_keywords_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.exclude_keywords_raw') diff --git a/app/views/antennas/edit.html.haml b/app/views/antennas/edit.html.haml new file mode 100644 index 0000000000..7452df8e40 --- /dev/null +++ b/app/views/antennas/edit.html.haml @@ -0,0 +1,8 @@ +- content_for :page_title do + = t('antennas.edit.title') + += simple_form_for @antenna, url: antenna_path(@antenna), method: :put do |f| + = render 'antenna_fields', f: f, lists: @lists + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/antennas/index.html.haml b/app/views/antennas/index.html.haml new file mode 100644 index 0000000000..7b33fcaeca --- /dev/null +++ b/app/views/antennas/index.html.haml @@ -0,0 +1,14 @@ +- content_for :page_title do + = t('antennas.index.title') + +- content_for :heading_actions do + = link_to t('antennas.new.title'), new_antenna_path, class: 'button' + +.flash-message.alert + %strong= t('antennas.beta') + +- if @antennas.empty? + .muted-hint.center-text= t 'antennas.index.empty' +- else + .applications-list + = render partial: 'antenna', collection: @antennas diff --git a/app/views/antennas/new.html.haml b/app/views/antennas/new.html.haml new file mode 100644 index 0000000000..c53c7bc002 --- /dev/null +++ b/app/views/antennas/new.html.haml @@ -0,0 +1,8 @@ +- content_for :page_title do + = t('antennas.new.title') + += simple_form_for @antenna, url: antennas_path do |f| + = render 'antenna_fields', f: f, lists: @lists + + .actions + = f.button :button, t('antennas.new.save'), type: :submit diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 31c09007ba..3d7a4325da 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -38,6 +38,9 @@ .fields-group = f.input :hide_collections, as: :boolean, wrapper: :with_label, label: t('simple_form.labels.defaults.setting_hide_network'), hint: t('simple_form.hints.defaults.setting_hide_network') + .fields-group + = f.input :dissubscribable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.dissubscribable') + %hr.spacer/ .fields-row diff --git a/config/locales/en.yml b/config/locales/en.yml index 8b41597056..fcca734d97 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -964,6 +964,43 @@ en: empty: You have no aliases. hint_html: If you want to move from another account to this one, here you can create an alias, which is required before you can proceed with moving followers from the old account to this one. This action by itself is harmless and reversible. The account migration is initiated from the old account. remove: Unlink alias + antennas: + beta: This function is in beta. + contexts: + account: Accounts + domain: Domains + keyword: Keywords + tag: Tags + edit: + accounts_hint: \@askyq or @askyq@example.com + accounts_raw: Account list + available: Available + description: アンテナは、サーバーが認識した全ての公開・ローカル公開投稿のうち、検索許可が「公開」または明示的に設定されていないもの(検索許可システムに対応していないサーバーからの投稿)、かつ購読を拒否していないすべてのアカウントからの投稿が対象です。検出された投稿は、指定したリストに追加されます。 + domains_raw: Domain list + exclude_accounts_raw: Excluding account list + exclude_domains_raw: Excluding domain list + exclude_keywords_raw: Excluding keyword list + exclude_tags_raw: Excluding hashtag list + hint: 下のリストに、絞り込み条件・除外条件を入力します。条件は複数指定することができます。1行につき1つずつ入力してください。空行、コメント、重複を含めることはできません。 + keywords_raw: Keyword list + list: Destination list + tags_raw: Hashtag list + title: Edit antenna + errors: + deprecated_api_multiple_keywords: These parameters cannot be changed from this application because they apply to more than one filter keyword. Use a more recent application or the web interface. + empty_contexts: No contexts! You must set any context filters + invalid_context: None or invalid context supplied + index: + contexts: Antennas in %{contexts} + delete: Delete + disabled: Disabled + empty: You have no antennas. + expires_in: Expires in %{distance} + expires_on: Expires on %{date} + title: Antennas + new: + save: Save new antenna + title: Add new antenna appearance: advanced_web_interface: Advanced web interface advanced_web_interface_hint: 'If you want to make use of your entire screen width, the advanced web interface allows you to configure many different columns to see as much information at the same time as you want: Home, notifications, federated timeline, any number of lists and hashtags.' diff --git a/config/locales/ja.yml b/config/locales/ja.yml index d8ec9ed394..512f878fc8 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -940,6 +940,49 @@ ja: empty: エイリアスがありません。 hint_html: 他のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。エイリアス自体は無害で、取り消すことができます。引っ越しは以前のアカウント側から開始する必要があります。 remove: エイリアスを削除 + antennas: + beta: アンテナ機能はベータ版です。今後、予告なく全データリセット・機能削除を行う場合があります。この機能の存在は外部に積極的に宣伝しないよう、ご協力をお願いします。 + contexts: + account: アカウント + domain: ドメイン + keyword: キーワード + tag: ハッシュタグ + errors: + empty_contexts: 絞り込み条件が1つも指定されていないため無効です(除外条件はカウントされません) + edit: + accounts_hint: ローカルアカウントの場合は「@info」、リモートアカウントの場合は「@info@example.com」の形式で指定します。サーバーが認識していないアカウントは保存時に自動的に削除されます。 + accounts_raw: 絞り込むアカウント + available: 有効 + description: アンテナは、サーバーが認識した全ての公開・ローカル公開投稿のうち、検索許可が「公開」または明示的に設定されていないもの(検索許可システムに対応していないサーバーからの投稿)、かつ購読を拒否していないすべてのアカウントからの投稿が対象です。検出された投稿は、指定したリストに追加されます。 + domains_raw: 絞り込むドメイン + exclude_accounts_raw: 除外するアカウント + exclude_domains_raw: 除外するドメイン + exclude_keywords_raw: 除外するキーワード + exclude_tags_raw: 除外するハッシュタグ + hint: 下のリストに、絞り込み条件・除外条件を入力します。条件は複数指定することができます。1行につき1つずつ入力してください。空行、コメント、重複を含めることはできません。絞り込み条件(除外条件ではない)は最低1つ設定しなければいけません。 + keywords_raw: 絞り込むキーワード + list: 投稿配置先リスト + tags_raw: 絞り込むハッシュタグ + title: アンテナを編集 + index: + accounts: + other: "%{count}件のアカウント" + contexts: "%{contexts}のアンテナ" + delete: 削除 + disabled: 無効 + domains: + other: "%{count}件のドメイン" + empty: アンテナはありません。 + expires_in: "%{distance}で期限切れ" + expires_on: 有効期限 %{date} + keywords: + other: "%{count}件のキーワード" + tags: + other: "%{count}件のタグ" + title: アンテナ + new: + save: 新規アンテナを保存 + title: 新規アンテナを追加 appearance: advanced_web_interface: 上級者向けUI advanced_web_interface_hint: ディスプレイを幅いっぱいまで活用したい場合、上級者向け UI をおすすめします。ホーム、通知、連合タイムライン、更にはリストやハッシュタグなど、様々な異なるカラムから望む限りの情報を一度に受け取れるような設定が可能になります。 diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index d307489744..73b780a478 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -38,6 +38,7 @@ en: current_username: To confirm, please enter the username of the current account digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence discoverable: Allow your account to be discovered by strangers through recommendations, trends and other features + dissubscribable: Your post is not picked by antenna email: You will be sent a confirmation e-mail fields: You can have up to 4 items displayed as a table on your profile group: Reps sent to this account will be automatically BT'd and distributed to all accounts you follow! @@ -179,6 +180,7 @@ en: data: Data discoverable: Suggest account to others display_name: Display name + dissubscribable: Reject any subscriptions email: E-mail address expires_in: Expire after fields: Profile metadata diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index 009639dbef..608f8cf458 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -38,6 +38,7 @@ ja: current_username: 確認のため、現在のアカウントのユーザー名を入力してください digest: 長期間使用していない場合と不在時に返信を受けた場合のみ送信されます discoverable: レコメンド、トレンド、その他の機能により、あなたのアカウントを他の人から見つけられるようにします + dissubscribable: あなたの投稿はすべてのアンテナに掲載されなくなります。Fedibirdからの購読やMisskeyのアンテナを拒否することはできません email: 確認のメールが送信されます fields: プロフィールに表として4つまでの項目を表示することができます group: このアカウントに送られたメンションは自動でBTされ、フォローしている全てのアカウントに配信されます @@ -181,6 +182,7 @@ ja: data: データ discoverable: ディレクトリに掲載する display_name: 表示名 + dissubscribable: 購読を拒否する email: メールアドレス expires_in: 有効期限 fields: プロフィール補足情報 diff --git a/config/navigation.rb b/config/navigation.rb index 758ea96d15..88f10ff355 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -17,6 +17,7 @@ SimpleNavigation::Configuration.run do |navigation| n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_path, if: -> { current_user.functional? } n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? } + n.item :antennas, safe_join([fa_icon('wifi fw'), t('antennas.index.title')]), antennas_path, highlights_on: %r{/antennas}, if: -> { current_user.functional? } n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_path, if: -> { current_user.functional_or_moved? } n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_path do |s| diff --git a/config/routes.rb b/config/routes.rb index dbdb4cd712..ac76770c86 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -216,6 +216,7 @@ Rails.application.routes.draw do end end end + resources :antennas, except: [:show] resource :relationships, only: [:show, :update] resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update] diff --git a/db/migrate/20230423002728_create_antennas.rb b/db/migrate/20230423002728_create_antennas.rb new file mode 100644 index 0000000000..b2a3c89ac4 --- /dev/null +++ b/db/migrate/20230423002728_create_antennas.rb @@ -0,0 +1,40 @@ +class CreateAntennas < ActiveRecord::Migration[6.1] + def change + create_table :antennas do |t| + t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade } + t.belongs_to :list, null: false, foreign_key: { on_delete: :cascade } + t.string :title, null: false, default: '' + t.jsonb :keywords + t.jsonb :exclude_keywords + t.boolean :any_domains, null: false, default: true, index: true + t.boolean :any_tags, null: false, default: true, index: true + t.boolean :any_accounts, null: false, default: true, index: true + t.boolean :any_keywords, null: false, default: true, index: true + t.boolean :available, null: false, default: true, index: true + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + t.datetime :expires_at + end + create_table :antenna_domains do |t| + t.belongs_to :antenna, null: false, foreign_key: { on_delete: :cascade } + t.string :name, index: true + t.boolean :exclude, null: false, default: false, index: true + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + create_table :antenna_tags do |t| + t.belongs_to :antenna, null: false, foreign_key: { on_delete: :cascade } + t.belongs_to :tag, null: false, foreign_key: { on_delete: :cascade } + t.boolean :exclude, null: false, default: false, index: true + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + create_table :antenna_accounts do |t| + t.belongs_to :antenna, null: false, foreign_key: { on_delete: :cascade } + t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade } + t.boolean :exclude, null: false, default: false, index: true + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + end +end diff --git a/db/migrate/20230423233429_add_dissubscribable_to_accounts.rb b/db/migrate/20230423233429_add_dissubscribable_to_accounts.rb new file mode 100644 index 0000000000..b466241239 --- /dev/null +++ b/db/migrate/20230423233429_add_dissubscribable_to_accounts.rb @@ -0,0 +1,6 @@ +class AddDissubscribableToAccounts < ActiveRecord::Migration[6.1] + def change + add_column :antennas, :with_media_only, :boolean, null: false, default: false, index: true + add_column :accounts, :dissubscribable, :boolean, null: false, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 82581d71e5..e1f54b00c7 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: 2023_04_14_010523) do +ActiveRecord::Schema.define(version: 2023_04_23_233429) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -189,6 +189,7 @@ ActiveRecord::Schema.define(version: 2023_04_14_010523) do t.datetime "requested_review_at" t.boolean "group_allow_private_message" t.integer "searchability", default: 2, null: false + t.boolean "dissubscribable", default: false, null: false t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id", where: "(moved_to_account_id IS NOT NULL)" @@ -251,6 +252,63 @@ ActiveRecord::Schema.define(version: 2023_04_14_010523) do t.bigint "status_ids", array: true end + create_table "antenna_accounts", force: :cascade do |t| + t.bigint "antenna_id", null: false + t.bigint "account_id", null: false + t.boolean "exclude", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_antenna_accounts_on_account_id" + t.index ["antenna_id"], name: "index_antenna_accounts_on_antenna_id" + t.index ["exclude"], name: "index_antenna_accounts_on_exclude" + end + + create_table "antenna_domains", force: :cascade do |t| + t.bigint "antenna_id", null: false + t.string "name" + t.boolean "exclude", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["antenna_id"], name: "index_antenna_domains_on_antenna_id" + t.index ["exclude"], name: "index_antenna_domains_on_exclude" + t.index ["name"], name: "index_antenna_domains_on_name" + end + + create_table "antenna_tags", force: :cascade do |t| + t.bigint "antenna_id", null: false + t.bigint "tag_id", null: false + t.boolean "exclude", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["antenna_id"], name: "index_antenna_tags_on_antenna_id" + t.index ["exclude"], name: "index_antenna_tags_on_exclude" + t.index ["tag_id"], name: "index_antenna_tags_on_tag_id" + end + + create_table "antennas", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "list_id", null: false + t.string "title", default: "", null: false + t.jsonb "keywords" + t.jsonb "exclude_keywords" + t.boolean "any_domains", default: true, null: false + t.boolean "any_tags", default: true, null: false + t.boolean "any_accounts", default: true, null: false + t.boolean "any_keywords", default: true, null: false + t.boolean "available", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "expires_at" + t.boolean "with_media_only", default: false, null: false + t.index ["account_id"], name: "index_antennas_on_account_id" + t.index ["any_accounts"], name: "index_antennas_on_any_accounts" + t.index ["any_domains"], name: "index_antennas_on_any_domains" + t.index ["any_keywords"], name: "index_antennas_on_any_keywords" + t.index ["any_tags"], name: "index_antennas_on_any_tags" + t.index ["available"], name: "index_antennas_on_available" + t.index ["list_id"], name: "index_antennas_on_list_id" + end + create_table "appeals", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "account_warning_id", null: false @@ -1173,6 +1231,13 @@ ActiveRecord::Schema.define(version: 2023_04_14_010523) do add_foreign_key "announcement_reactions", "accounts", on_delete: :cascade add_foreign_key "announcement_reactions", "announcements", on_delete: :cascade add_foreign_key "announcement_reactions", "custom_emojis", on_delete: :cascade + add_foreign_key "antenna_accounts", "accounts", on_delete: :cascade + add_foreign_key "antenna_accounts", "antennas", on_delete: :cascade + add_foreign_key "antenna_domains", "antennas", on_delete: :cascade + add_foreign_key "antenna_tags", "antennas", on_delete: :cascade + add_foreign_key "antenna_tags", "tags", on_delete: :cascade + add_foreign_key "antennas", "accounts", on_delete: :cascade + add_foreign_key "antennas", "lists", on_delete: :cascade add_foreign_key "appeals", "account_warnings", on_delete: :cascade add_foreign_key "appeals", "accounts", column: "approved_by_account_id", on_delete: :nullify add_foreign_key "appeals", "accounts", column: "rejected_by_account_id", on_delete: :nullify From 9d4b306f083fd4d4a7c4a49a9d3e0d1b3ba8da49 Mon Sep 17 00:00:00 2001 From: KMY Date: Mon, 24 Apr 2023 17:02:57 +0900 Subject: [PATCH 4/7] Add antenna list account validation --- app/models/antenna.rb | 6 ++++++ config/locales/en.yml | 1 + config/locales/ja.yml | 1 + 3 files changed, 8 insertions(+) diff --git a/app/models/antenna.rb b/app/models/antenna.rb index 89b98516b1..0b1d3c1d0e 100644 --- a/app/models/antenna.rb +++ b/app/models/antenna.rb @@ -34,6 +34,12 @@ class Antenna < ApplicationRecord scope :all_tags, -> { where(any_tags: true) } scope :availables, -> { where(available: true).where(Arel.sql('any_keywords = FALSE OR any_domains = FALSE OR any_accounts = FALSE OR any_tags = FALSE')) } + validate :list_owner + + def list_owner + raise Mastodon::ValidationError, I18n.t('antennas.errors.invalid_list_owner') if list.account != account + end + def enabled? enabled_config? && !expired? end diff --git a/config/locales/en.yml b/config/locales/en.yml index fcca734d97..080e9aa0c3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -990,6 +990,7 @@ en: deprecated_api_multiple_keywords: These parameters cannot be changed from this application because they apply to more than one filter keyword. Use a more recent application or the web interface. empty_contexts: No contexts! You must set any context filters invalid_context: None or invalid context supplied + invalid_list_owner: This list is not yours index: contexts: Antennas in %{contexts} delete: Delete diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 512f878fc8..ae588aaed3 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -949,6 +949,7 @@ ja: tag: ハッシュタグ errors: empty_contexts: 絞り込み条件が1つも指定されていないため無効です(除外条件はカウントされません) + invalid_list_owner: これはあなたのリストではありません edit: accounts_hint: ローカルアカウントの場合は「@info」、リモートアカウントの場合は「@info@example.com」の形式で指定します。サーバーが認識していないアカウントは保存時に自動的に削除されます。 accounts_raw: 絞り込むアカウント From 69df23d8d934ab6272bc409a8e579a3ab7f59087 Mon Sep 17 00:00:00 2001 From: KMY Date: Mon, 24 Apr 2023 17:04:37 +0900 Subject: [PATCH 5/7] Add block support with antenna --- app/services/fan_out_on_write_service.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 98f5494fe9..e05e54c62d 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -126,6 +126,7 @@ class FanOutOnWriteService < BaseService antennas.in_batches do |ans| ans.each do |antenna| next if !antenna.enabled? + next if @status.account.blocking?(antenna.account) next if antenna.keywords.any? && !([nil, :public].include?(@status.searchability&.to_sym)) next if antenna.keywords.any? && !antenna.keywords.any? { |keyword| @status.text.include?(keyword) } next if antenna.exclude_keywords.any? && antenna.exclude_keywords.any? { |keyword| @status.text.include?(keyword) } From 0d5a6adc43d0b58d4232172a6cd9ebea83937164 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 25 Apr 2023 10:30:30 +0900 Subject: [PATCH 6/7] Add account to antenna webui --- .../api/v1/accounts/antennas_controller.rb | 18 ++ .../api/v1/antennas/accounts_controller.rb | 82 ++++++++ app/controllers/api/v1/antennas_controller.rb | 28 +++ app/javascript/mastodon/actions/antennas.js | 179 ++++++++++++++++++ .../features/account/components/header.jsx | 5 +- .../account_timeline/components/header.jsx | 6 + .../containers/header_container.jsx | 6 + .../antenna_adder/components/account.jsx | 43 +++++ .../antenna_adder/components/antenna.jsx | 69 +++++++ .../mastodon/features/antenna_adder/index.jsx | 69 +++++++ .../features/ui/components/modal_root.jsx | 2 + .../features/ui/util/async-components.js | 4 + .../mastodon/reducers/antenna_adder.js | 47 +++++ .../mastodon/reducers/antenna_editor.js | 36 ++++ app/javascript/mastodon/reducers/antennas.js | 25 +++ app/javascript/mastodon/reducers/index.js | 6 + app/models/antenna_account.rb | 2 + app/models/concerns/account_associations.rb | 4 + app/serializers/rest/antenna_serializer.rb | 9 + config/routes.rb | 5 + 20 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/accounts/antennas_controller.rb create mode 100644 app/controllers/api/v1/antennas/accounts_controller.rb create mode 100644 app/controllers/api/v1/antennas_controller.rb create mode 100644 app/javascript/mastodon/actions/antennas.js create mode 100644 app/javascript/mastodon/features/antenna_adder/components/account.jsx create mode 100644 app/javascript/mastodon/features/antenna_adder/components/antenna.jsx create mode 100644 app/javascript/mastodon/features/antenna_adder/index.jsx create mode 100644 app/javascript/mastodon/reducers/antenna_adder.js create mode 100644 app/javascript/mastodon/reducers/antenna_editor.js create mode 100644 app/javascript/mastodon/reducers/antennas.js create mode 100644 app/serializers/rest/antenna_serializer.rb diff --git a/app/controllers/api/v1/accounts/antennas_controller.rb b/app/controllers/api/v1/accounts/antennas_controller.rb new file mode 100644 index 0000000000..957a4fb555 --- /dev/null +++ b/app/controllers/api/v1/accounts/antennas_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::AntennasController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' } + before_action :require_user! + before_action :set_account + + def index + @antennas = @account.suspended? ? [] : @account.joined_antennas.where(account: current_account) + render json: @antennas, each_serializer: REST::AntennaSerializer + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end +end diff --git a/app/controllers/api/v1/antennas/accounts_controller.rb b/app/controllers/api/v1/antennas/accounts_controller.rb new file mode 100644 index 0000000000..d7e6fad895 --- /dev/null +++ b/app/controllers/api/v1/antennas/accounts_controller.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::AccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_antenna + + after_action :insert_pagination_headers, only: :show + + def create + ApplicationRecord.transaction do + antenna_accounts.each do |account| + @antenna.antenna_accounts.create!(account: account, exclude: false) + @antenna.update!(any_accounts: false) + end + end + + render_empty + end + + def destroy + AntennaAccount.where(antenna: @antenna, account_id: account_ids).destroy_all + @antenna.update!(any_accounts: true) if !@antenna.antenna_accounts.where(exclude: false).any? + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def antenna_accounts + Account.find(account_ids) + end + + def account_ids + Array(resource_params[:account_ids]) + end + + def resource_params + params.permit(account_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_list_accounts_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/antennas_controller.rb b/app/controllers/api/v1/antennas_controller.rb new file mode 100644 index 0000000000..2e47af4a44 --- /dev/null +++ b/app/controllers/api/v1/antennas_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Api::V1::AntennasController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show] + + before_action :require_user! + before_action :set_antenna, except: [:index] + + rescue_from ArgumentError do |e| + render json: { error: e.to_s }, status: 422 + end + + def index + @antennas = Antenna.where(account: current_account).all + render json: @antennas, each_serializer: REST::AntennaSerializer + end + + def show + render json: @antenna, serializer: REST::AntennaSerializer + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:id]) + end +end diff --git a/app/javascript/mastodon/actions/antennas.js b/app/javascript/mastodon/actions/antennas.js new file mode 100644 index 0000000000..31b612e12e --- /dev/null +++ b/app/javascript/mastodon/actions/antennas.js @@ -0,0 +1,179 @@ +import api from '../api'; +import { importFetchedAccounts } from './importer'; + +export const ANTENNAS_FETCH_REQUEST = 'ANTENNAS_FETCH_REQUEST'; +export const ANTENNAS_FETCH_SUCCESS = 'ANTENNAS_FETCH_SUCCESS'; +export const ANTENNAS_FETCH_FAIL = 'ANTENNAS_FETCH_FAIL'; + +export const ANTENNA_ACCOUNTS_FETCH_REQUEST = 'ANTENNA_ACCOUNTS_FETCH_REQUEST'; +export const ANTENNA_ACCOUNTS_FETCH_SUCCESS = 'ANTENNA_ACCOUNTS_FETCH_SUCCESS'; +export const ANTENNA_ACCOUNTS_FETCH_FAIL = 'ANTENNA_ACCOUNTS_FETCH_FAIL'; + +export const ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST = 'ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST'; +export const ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS = 'ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS'; +export const ANTENNA_EDITOR_ADD_ACCOUNT_FAIL = 'ANTENNA_EDITOR_ADD_ACCOUNT_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL'; + +export const ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST'; +export const ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS'; +export const ANTENNA_ADDER_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_ANTENNAS_FETCH_FAIL'; + +export const ANTENNA_ADDER_RESET = 'ANTENNA_ADDER_RESET'; +export const ANTENNA_ADDER_SETUP = 'ANTENNA_ADDER_SETUP'; + +export const fetchAntennas = () => (dispatch, getState) => { + dispatch(fetchAntennasRequest()); + + api(getState).get('/api/v1/antennas') + .then(({ data }) => dispatch(fetchAntennasSuccess(data))) + .catch(err => dispatch(fetchAntennasFail(err))); +}; + +export const fetchAntennasRequest = () => ({ + type: ANTENNAS_FETCH_REQUEST, +}); + +export const fetchAntennasSuccess = antennas => ({ + type: ANTENNAS_FETCH_SUCCESS, + antennas, +}); + +export const fetchAntennasFail = error => ({ + type: ANTENNAS_FETCH_FAIL, + error, +}); + +export const fetchAntennaAccounts = antennaId => (dispatch, getState) => { + dispatch(fetchAntennaAccountsRequest(antennaId)); + + api(getState).get(`/api/v1/antennas/${antennaId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchAntennaAccountsSuccess(antennaId, data)); + }).catch(err => dispatch(fetchAntennaAccountsFail(antennaId, err))); +}; + +export const fetchAntennaAccountsRequest = id => ({ + type: ANTENNA_ACCOUNTS_FETCH_REQUEST, + id, +}); + +export const fetchAntennaAccountsSuccess = (id, accounts, next) => ({ + type: ANTENNA_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +export const fetchAntennaAccountsFail = (id, error) => ({ + type: ANTENNA_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +export const addAccountToAntenna = (antennaId, accountId) => (dispatch, getState) => { + dispatch(addAccountToAntennaRequest(antennaId, accountId)); + + api(getState).post(`/api/v1/antennas/${antennaId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addAccountToAntennaSuccess(antennaId, accountId))) + .catch(err => dispatch(addAccountToAntennaFail(antennaId, accountId, err))); +}; + +export const addAccountToAntennaRequest = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST, + antennaId, + accountId, +}); + +export const addAccountToAntennaSuccess = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS, + antennaId, + accountId, +}); + +export const addAntennaToAntennaFail = (antennaId, accountId, error) => ({ + type: ANTENNA_EDITOR_ADD_ACCOUNT_FAIL, + antennaId, + accountId, + error, +}); + +export const removeAccountFromAntennaEditor = accountId => (dispatch, getState) => { + dispatch(removeAccountFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); +}; + +export const removeAccountFromAntenna = (antennaId, accountId) => (dispatch, getState) => { + dispatch(removeAccountFromAntennaRequest(antennaId, accountId)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeAccountFromAntennaSuccess(antennaId, accountId))) + .catch(err => dispatch(removeAccountFromAntennaFail(antennaId, accountId, err))); +}; + +export const removeAccountFromAntennaRequest = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST, + antennaId, + accountId, +}); + +export const removeAccountFromAntennaSuccess = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS, + antennaId, + accountId, +}); + +export const removeAccountFromAntennaFail = (antennaId, accountId, error) => ({ + type: ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL, + antennaId, + accountId, + error, +}); + +export const addToAntennaAdder = antennaId => (dispatch, getState) => { + dispatch(addAccountToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); +}; + +export const removeFromAntennaAdder = antennaId => (dispatch, getState) => { + dispatch(removeAccountFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); +}; + +export const fetchAccountAntennas = accountId => (dispatch, getState) => { + dispatch(fetchAccountAntennasRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/antennas`) + .then(({ data }) => dispatch(fetchAccountAntennasSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountAntennasFail(accountId, err))); +}; + +export const fetchAccountAntennasRequest = id => ({ + type: ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST, + id, +}); + +export const fetchAccountAntennasSuccess = (id, antennas) => ({ + type: ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS, + id, + antennas, +}); + +export const fetchAccountAntennasFail = (id, err) => ({ + type: ANTENNA_ADDER_ANTENNAS_FETCH_FAIL, + id, + err, +}); + +export const resetAntennaAdder = () => ({ + type: ANTENNA_ADDER_RESET, +}); + +export const setupAntennaAdder = accountId => (dispatch, getState) => { + dispatch({ + type: ANTENNA_ADDER_SETUP, + account: getState().getIn(['accounts', accountId]), + }); + dispatch(fetchAntennas()); + dispatch(fetchAccountAntennas(accountId)); +}; + diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index 539d725741..007cd273c5 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -53,6 +53,7 @@ const messages = defineMessages({ endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, + add_or_remove_from_antenna: { id: 'account.add_or_remove_from_antenna', defaultMessage: 'Add or Remove from antennas' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' }, languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' }, @@ -98,6 +99,7 @@ class Header extends ImmutablePureComponent { onUnblockDomain: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, + onAddToAntenna: PropTypes.func.isRequired, onEditAccountNote: PropTypes.func.isRequired, onChangeLanguages: PropTypes.func.isRequired, onInteractionModal: PropTypes.func.isRequired, @@ -263,8 +265,9 @@ class Header extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle }); menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList }); - menu.push(null); } + menu.push({ text: intl.formatMessage(messages.add_or_remove_from_antenna), action: this.props.onAddToAntenna }); + menu.push(null); if (account.getIn(['relationship', 'muting'])) { menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); diff --git a/app/javascript/mastodon/features/account_timeline/components/header.jsx b/app/javascript/mastodon/features/account_timeline/components/header.jsx index bffa5554b3..058555d9dc 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.jsx +++ b/app/javascript/mastodon/features/account_timeline/components/header.jsx @@ -22,6 +22,7 @@ export default class Header extends ImmutablePureComponent { onUnblockDomain: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, + onAddToAntenna: PropTypes.func.isRequired, onChangeLanguages: PropTypes.func.isRequired, onInteractionModal: PropTypes.func.isRequired, onOpenAvatar: PropTypes.func.isRequired, @@ -90,6 +91,10 @@ export default class Header extends ImmutablePureComponent { this.props.onAddToList(this.props.account); }; + handleAddToAntenna = () => { + this.props.onAddToAntenna(this.props.account); + }; + handleEditAccountNote = () => { this.props.onEditAccountNote(this.props.account); }; @@ -131,6 +136,7 @@ export default class Header extends ImmutablePureComponent { onUnblockDomain={this.handleUnblockDomain} onEndorseToggle={this.handleEndorseToggle} onAddToList={this.handleAddToList} + onAddToAntenna={this.handleAddToAntenna} onEditAccountNote={this.handleEditAccountNote} onChangeLanguages={this.handleChangeLanguages} onInteractionModal={this.handleInteractionModal} diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx index f53cd24807..ad35adeda7 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx @@ -146,6 +146,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); }, + onAddToAntenna (account) { + dispatch(openModal('ANTENNA_ADDER', { + accountId: account.get('id'), + })); + }, + onChangeLanguages (account) { dispatch(openModal('SUBSCRIBED_LANGUAGES', { accountId: account.get('id'), diff --git a/app/javascript/mastodon/features/antenna_adder/components/account.jsx b/app/javascript/mastodon/features/antenna_adder/components/account.jsx new file mode 100644 index 0000000000..1369aac074 --- /dev/null +++ b/app/javascript/mastodon/features/antenna_adder/components/account.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from '../../../selectors'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import { injectIntl } from 'react-intl'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + + +export default @connect(makeMapStateToProps) +@injectIntl +class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { account } = this.props; + return ( +
+
+
+
+ +
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/antenna_adder/components/antenna.jsx b/app/javascript/mastodon/features/antenna_adder/components/antenna.jsx new file mode 100644 index 0000000000..28d5a3c161 --- /dev/null +++ b/app/javascript/mastodon/features/antenna_adder/components/antenna.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import { removeFromAntennaAdder, addToAntennaAdder } from '../../../actions/antennas'; +import Icon from 'mastodon/components/icon'; + +const messages = defineMessages({ + remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' }, + add: { id: 'lists.account.add', defaultMessage: 'Add to list' }, +}); + +const MapStateToProps = (state, { antennaId, added }) => ({ + antenna: state.get('antennas').get(antennaId), + added: typeof added === 'undefined' ? state.getIn(['antennaAdder', 'antennas', 'items']).includes(antennaId) : added, +}); + +const mapDispatchToProps = (dispatch, { antennaId }) => ({ + onRemove: () => dispatch(removeFromAntennaAdder(antennaId)), + onAdd: () => dispatch(addToAntennaAdder(antennaId)), +}); + +export default @connect(MapStateToProps, mapDispatchToProps) +@injectIntl +class Antenna extends ImmutablePureComponent { + + static propTypes = { + antenna: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onRemove: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + added: PropTypes.bool, + }; + + static defaultProps = { + added: false, + }; + + render () { + const { antenna, intl, onRemove, onAdd, added } = this.props; + + let button; + + if (added) { + button = ; + } else { + button = ; + } + + return ( +
+
+
+ + {antenna.get('title')} +
+ +
+ {button} +
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/antenna_adder/index.jsx b/app/javascript/mastodon/features/antenna_adder/index.jsx new file mode 100644 index 0000000000..2834689c3c --- /dev/null +++ b/app/javascript/mastodon/features/antenna_adder/index.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { injectIntl } from 'react-intl'; +import { setupAntennaAdder, resetAntennaAdder } from '../../actions/antennas'; +import { createSelector } from 'reselect'; +import Antenna from './components/antenna'; +import Account from './components/account'; +// hack + +const getOrderedAntennas = createSelector([state => state.get('antennas')], antennas => { + if (!antennas) { + return antennas; + } + + return antennas.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); +}); + +const mapStateToProps = state => ({ + antennaIds: getOrderedAntennas(state).map(antenna=>antenna.get('id')), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: accountId => dispatch(setupAntennaAdder(accountId)), + onReset: () => dispatch(resetAntennaAdder()), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class AntennaAdder extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + antennaIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize, accountId } = this.props; + onInitialize(accountId); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { accountId, antennaIds } = this.props; + + return ( +
+
+ +
+ +
+ {antennaIds.map(AntennaId => )} +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 5a17349771..f32f2efbe2 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -20,6 +20,7 @@ import { EmbedModal, ListEditor, ListAdder, + AntennaAdder, CompareHistoryModal, FilterModal, InteractionModal, @@ -43,6 +44,7 @@ const MODAL_COMPONENTS = { 'LIST_EDITOR': ListEditor, 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), 'LIST_ADDER': ListAdder, + 'ANTENNA_ADDER': AntennaAdder, 'COMPARE_HISTORY': CompareHistoryModal, 'FILTER': FilterModal, 'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal, diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 71ab6a67c5..8a35374cbf 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -150,6 +150,10 @@ export function ListAdder () { return import(/*webpackChunkName: "features/list_adder" */'../../list_adder'); } +export function AntennaAdder () { + return import(/*webpackChunkName: "features/antenna_adder" */'../../antenna_adder'); +} + export function Tesseract () { return import(/*webpackChunkName: "tesseract" */'tesseract.js'); } diff --git a/app/javascript/mastodon/reducers/antenna_adder.js b/app/javascript/mastodon/reducers/antenna_adder.js new file mode 100644 index 0000000000..d3b98d63fd --- /dev/null +++ b/app/javascript/mastodon/reducers/antenna_adder.js @@ -0,0 +1,47 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + ANTENNA_ADDER_RESET, + ANTENNA_ADDER_SETUP, + ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST, + ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS, + ANTENNA_ADDER_ANTENNAS_FETCH_FAIL, + ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS, + ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS, +} from '../actions/antennas'; + +const initialState = ImmutableMap({ + accountId: null, + + antennas: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), +}); + +export default function antennaAdderReducer(state = initialState, action) { + switch(action.type) { + case ANTENNA_ADDER_RESET: + return initialState; + case ANTENNA_ADDER_SETUP: + return state.withMutations(map => { + map.set('accountId', action.account.get('id')); + }); + case ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST: + return state.setIn(['antennas', 'isLoading'], true); + case ANTENNA_ADDER_ANTENNAS_FETCH_FAIL: + return state.setIn(['antennas', 'isLoading'], false); + case ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS: + return state.update('antennas', antennas => antennas.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.antennas.map(item => item.id))); + })); + case ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS: + return state.updateIn(['antennas', 'items'], antenna => antenna.unshift(action.antennaId)); + case ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS: + return state.updateIn(['antennas', 'items'], antenna => antenna.filterNot(item => item === action.antennaId)); + default: + return state; + } +} diff --git a/app/javascript/mastodon/reducers/antenna_editor.js b/app/javascript/mastodon/reducers/antenna_editor.js new file mode 100644 index 0000000000..2219aa7dfc --- /dev/null +++ b/app/javascript/mastodon/reducers/antenna_editor.js @@ -0,0 +1,36 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + ANTENNA_ACCOUNTS_FETCH_REQUEST, + ANTENNA_ACCOUNTS_FETCH_SUCCESS, + ANTENNA_ACCOUNTS_FETCH_FAIL, +} from '../actions/antennas'; + +const initialState = ImmutableMap({ + antennaId: null, + isSubmitting: false, + isChanged: false, + title: '', + + accounts: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), +}); + +export default function antennaEditorReducer(state = initialState, action) { + switch(action.type) { + case ANTENNA_ACCOUNTS_FETCH_REQUEST: + return state.setIn(['accounts', 'isLoading'], true); + case ANTENNA_ACCOUNTS_FETCH_FAIL: + return state.setIn(['accounts', 'isLoading'], false); + case ANTENNA_ACCOUNTS_FETCH_SUCCESS: + return state.update('accounts', accounts => accounts.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.accounts.map(item => item.id))); + })); + default: + return state; + } +} diff --git a/app/javascript/mastodon/reducers/antennas.js b/app/javascript/mastodon/reducers/antennas.js new file mode 100644 index 0000000000..e20cab981a --- /dev/null +++ b/app/javascript/mastodon/reducers/antennas.js @@ -0,0 +1,25 @@ +import { + ANTENNAS_FETCH_SUCCESS, +} from '../actions/antennas'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const initialState = ImmutableMap(); + +const normalizeAntenna = (state, antenna) => state.set(antenna.id, fromJS(antenna)); + +const normalizeAntennas = (state, antennas) => { + antennas.forEach(antenna => { + state = normalizeAntenna(state, antenna); + }); + + return state; +}; + +export default function antennas(state = initialState, action) { + switch(action.type) { + case ANTENNAS_FETCH_SUCCESS: + return normalizeAntennas(state, action.antennas); + default: + return state; + } +} diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 69771ad1b3..61fe90a63e 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -28,6 +28,9 @@ import custom_emojis from './custom_emojis'; import lists from './lists'; import listEditor from './list_editor'; import listAdder from './list_adder'; +import antennas from './antennas'; +import antennaEditor from './antenna_editor'; +import antennaAdder from './antenna_adder'; import filters from './filters'; import conversations from './conversations'; import suggestions from './suggestions'; @@ -74,6 +77,9 @@ const reducers = { lists, listEditor, listAdder, + antennas, + antennaEditor, + antennaAdder, filters, conversations, suggestions, diff --git a/app/models/antenna_account.rb b/app/models/antenna_account.rb index d92e5535f9..38e2f83b67 100644 --- a/app/models/antenna_account.rb +++ b/app/models/antenna_account.rb @@ -14,4 +14,6 @@ class AntennaAccount < ApplicationRecord belongs_to :antenna belongs_to :account + validates :account_id, uniqueness: { scope: :antenna_id } + end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 00c57f00d9..c284f5ce57 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -48,6 +48,10 @@ module AccountAssociations has_many :account_warnings, dependent: :destroy, inverse_of: :account has_many :strikes, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account + # Antennas (that the account is on, not owned by the account) + has_many :antenna_accounts, inverse_of: :account, dependent: :destroy + has_many :joined_antennas, class_name: 'Antenna', through: :antenna_accounts, source: :antenna + # Lists (that the account is on, not owned by the account) has_many :list_accounts, inverse_of: :account, dependent: :destroy has_many :lists, through: :list_accounts diff --git a/app/serializers/rest/antenna_serializer.rb b/app/serializers/rest/antenna_serializer.rb new file mode 100644 index 0000000000..4a4a148669 --- /dev/null +++ b/app/serializers/rest/antenna_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::AntennaSerializer < ActiveModel::Serializer + attributes :id, :title + + def id + object.id.to_s + end +end diff --git a/config/routes.rb b/config/routes.rb index ac76770c86..db5d788e37 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -602,6 +602,7 @@ Rails.application.routes.draw do resources :followers, only: :index, controller: 'accounts/follower_accounts' resources :following, only: :index, controller: 'accounts/following_accounts' resources :lists, only: :index, controller: 'accounts/lists' + resources :antennas, only: :index, controller: 'accounts/antennas' resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs' resources :featured_tags, only: :index, controller: 'accounts/featured_tags' @@ -633,6 +634,10 @@ Rails.application.routes.draw do resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts' end + resources :antennas, only: [:index, :create, :show, :update, :destroy] do + resource :accounts, only: [:show, :create, :destroy], controller: 'antennas/accounts' + end + namespace :featured_tags do get :suggestions, to: 'suggestions#index' end From 8ddb9a0317e7e3a753b786fd12f6afcff599342a Mon Sep 17 00:00:00 2001 From: KMY Date: Sat, 22 Apr 2023 17:34:12 +0900 Subject: [PATCH 7/7] Update privacy long description --- app/javascript/mastodon/locales/ja.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 71090f4620..c1342e0452 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -459,11 +459,11 @@ "privacy.direct.short": "指定された相手のみ", "privacy.private.long": "フォロワーのみ閲覧可", "privacy.private.short": "フォロワーのみ", - "privacy.public.long": "誰でも閲覧可、全てのTL", + "privacy.public.long": "誰でも閲覧可、ホーム+ローカル+連合TL", "privacy.public.short": "公開", - "privacy.public_unlisted.long": "誰でも閲覧可、ローカル+ホームTL", + "privacy.public_unlisted.long": "誰でも閲覧可、ホーム+ローカルTL", "privacy.public_unlisted.short": "ローカル公開", - "privacy.unlisted.long": "誰でも閲覧可、ホームTL", + "privacy.unlisted.long": "誰でも閲覧可、ホームTL", "privacy.unlisted.short": "未収載", "privacy_policy.last_updated": "{date}に更新", "privacy_policy.title": "プライバシーポリシー",