From c1f6d22ad2423a2c7661e73b47564cf80c657cdd Mon Sep 17 00:00:00 2001 From: KMY Date: Sat, 12 Aug 2023 22:49:28 +0900 Subject: [PATCH] Add limited_scope support --- app/helpers/context_helper.rb | 1 + app/javascript/mastodon/components/status.jsx | 6 ++- .../report/components/status_check_box.jsx | 4 +- .../status/components/detailed_status.jsx | 4 +- .../features/ui/components/boost_modal.jsx | 4 +- app/lib/activitypub/activity/create.rb | 1 + app/lib/activitypub/parser/status_parser.rb | 8 ++++ app/lib/activitypub/tag_manager.rb | 4 ++ app/models/status.rb | 2 + .../activitypub/note_serializer.rb | 8 +++- app/serializers/rest/status_serializer.rb | 6 ++- .../rest/v1/instance_serializer.rb | 1 + app/services/post_status_service.rb | 1 + ...812130612_add_limited_scope_to_statuses.rb | 7 +++ db/schema.rb | 3 +- spec/lib/activitypub/activity/create_spec.rb | 45 +++++++++++++++++++ spec/services/post_status_service_spec.rb | 12 ++++- 17 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 db/migrate/20230812130612_add_limited_scope_to_statuses.rb diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 51338688f4..ffa8e479e6 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -24,6 +24,7 @@ module ContextHelper 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' } }, + limited_scope: { 'kmyblue' => 'http://kmy.blue/ns#', 'limitedScope' => { '@id' => 'kmyblue:limitedScope', '@type' => '@id' } }, other_setting: { 'fedibird' => 'http://fedibird.com/ns#', 'otherSetting' => 'fedibird:otherSetting' }, references: { 'fedibird' => 'http://fedibird.com/ns#', 'references' => { '@id' => 'fedibird:references', '@type' => '@id' } }, olm: { diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 06e0bd2aa1..3aa021ba14 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -70,6 +70,7 @@ const messages = defineMessages({ login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, + mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, }); @@ -400,10 +401,11 @@ class Status extends ImmutablePureComponent { 'login': { icon: 'key', text: intl.formatMessage(messages.login_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, + 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; - let visibilityIcon = visibilityIconInfo[status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; + let visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { const minHandlers = this.props.muted ? {} : { @@ -564,7 +566,7 @@ class Status extends ImmutablePureComponent { statusAvatar = ; } - visibilityIcon = visibilityIconInfo[status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; + visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; let emojiReactionsBar = null; if (!this.props.withoutEmojiReactions && status.get('emoji_reactions')) { diff --git a/app/javascript/mastodon/features/report/components/status_check_box.jsx b/app/javascript/mastodon/features/report/components/status_check_box.jsx index 78c34b1ee4..6ce956e9cc 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.jsx +++ b/app/javascript/mastodon/features/report/components/status_check_box.jsx @@ -21,6 +21,7 @@ const messages = defineMessages({ login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, + mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, }); @@ -53,10 +54,11 @@ class StatusCheckBox extends PureComponent { 'login': { icon: 'key', text: intl.formatMessage(messages.login_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, + 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; - const visibilityIcon = visibilityIconInfo[status.get('visibility_ex')]; + const visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')]; const labelComponent = (
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx index ec4a3f253b..83a9c23069 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.jsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx @@ -31,6 +31,7 @@ const messages = defineMessages({ login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, + mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, searchability_public_short: { id: 'searchability.public.short', defaultMessage: 'Public' }, searchability_private_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' }, @@ -251,10 +252,11 @@ class DetailedStatus extends ImmutablePureComponent { 'login': { icon: 'key', text: intl.formatMessage(messages.login_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, + 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; - const visibilityIcon = visibilityIconInfo[status.get('visibility_ex')]; + const visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')]; const visibilityLink = <> ยท ; const searchabilityIconInfo = { diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.jsx b/app/javascript/mastodon/features/ui/components/boost_modal.jsx index 55c14df00c..49bb835163 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/boost_modal.jsx @@ -28,6 +28,7 @@ const messages = defineMessages({ login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, + mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, }); @@ -96,10 +97,11 @@ class BoostModal extends ImmutablePureComponent { 'login': { icon: 'key', text: intl.formatMessage(messages.login_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, + 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; - const visibilityIcon = visibilityIconInfo[status.get('visibility_ex')]; + const visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')]; return (
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 4f5207addb..833597d240 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -133,6 +133,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity reply: @status_parser.reply, sensitive: @account.sensitized? || @status_parser.sensitive || false, visibility: @status_parser.visibility, + limited_scope: @status_parser.limited_scope, searchability: searchability, thread: replied_to_status, conversation: conversation_from_uri(@object['conversation']), diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index 0e38861838..34d1497c1e 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -86,6 +86,14 @@ class ActivityPub::Parser::StatusParser end end + def limited_scope + if @object['limitedScope'] == 'Mutual' + :mutual + else + :none + end + end + def language if content_language_map? @object['contentMap'].keys.first diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 3d334296b2..2977ddcec0 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -221,6 +221,10 @@ class ActivityPub::TagManager nil end + def limited_scope(status) + status.mutual_limited? ? 'Mutual' : '' + end + def subscribable_by(account) account.dissubscribable ? [] : [COLLECTIONS[:public]] end diff --git a/app/models/status.rb b/app/models/status.rb index 99bc0290f0..fd4c792f79 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -29,6 +29,7 @@ # ordered_media_attachment_ids :bigint(8) is an Array # searchability :integer # markdown :boolean default(FALSE) +# limited_scope :integer # require 'ostruct' @@ -54,6 +55,7 @@ class Status < ApplicationRecord enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10, login: 11 }, _suffix: :visibility enum searchability: { public: 0, private: 1, direct: 2, limited: 3, unsupported: 4, public_unlisted: 10 }, _suffix: :searchability + enum limited_scope: { none: 0, mutual: 1 }, _suffix: :limited belongs_to :application, class_name: 'Doorkeeper::Application', optional: true diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 6b5b9e9310..ec16d3b567 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -3,13 +3,13 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer include FormattingHelper - context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :searchable_by, :references + context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :searchable_by, :references, :limited_scope attributes :id, :type, :summary, :in_reply_to, :published, :url, :attributed_to, :to, :cc, :sensitive, :atom_uri, :in_reply_to_atom_uri, - :conversation, :searchable_by + :conversation, :searchable_by, :limited_scope attribute :references, if: :not_private_post? @@ -148,6 +148,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer ActivityPub::TagManager.instance.searchable_by(object) end + def limited_scope + ActivityPub::TagManager.instance.limited_scope(object) + end + def local? object.account.local? end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 1b4dec20dc..f833d044f2 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer include FormattingHelper attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, - :sensitive, :spoiler_text, :visibility, :visibility_ex, :language, + :sensitive, :spoiler_text, :visibility, :visibility_ex, :limited_scope, :language, :uri, :url, :replies_count, :reblogs_count, :searchability, :markdown, :status_reference_ids, :status_references_count, :status_referred_by_count, :favourites_count, :emoji_reactions, :emoji_reactions_count, :reactions, :edited_at @@ -69,6 +69,10 @@ class REST::StatusSerializer < ActiveModel::Serializer object.visibility end + def limited_scope + !object.none_limited? && object.limited_visibility? ? object.limited_scope : nil + end + def searchability object.compute_searchability end diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb index e6d7f35e10..1c6c976216 100644 --- a/app/serializers/rest/v1/instance_serializer.rb +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -128,6 +128,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer :kmyblue_visibility_login, :status_reference, :visibility_mutual, + :kmyblue_limited_scope, ] capabilities << :profile_search unless Chewy.enabled? diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 030dc8441e..e844926977 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -247,6 +247,7 @@ class PostStatusService < BaseService spoiler_text: @options[:spoiler_text] || '', markdown: @markdown, visibility: @visibility, + limited_scope: @visibility == :limited ? :mutual : :none, searchability: @searchability, language: valid_locale_cascade(@options[:language], @account.user&.preferred_posting_language, I18n.default_locale), application: @options[:application], diff --git a/db/migrate/20230812130612_add_limited_scope_to_statuses.rb b/db/migrate/20230812130612_add_limited_scope_to_statuses.rb new file mode 100644 index 0000000000..b11e0862be --- /dev/null +++ b/db/migrate/20230812130612_add_limited_scope_to_statuses.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddLimitedScopeToStatuses < ActiveRecord::Migration[7.0] + def change + add_column :statuses, :limited_scope, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 593f282fab..40cb404813 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[7.0].define(version: 2023_08_12_083752) do +ActiveRecord::Schema[7.0].define(version: 2023_08_12_130612) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1122,6 +1122,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_12_083752) do t.bigint "ordered_media_attachment_ids", array: true t.integer "searchability" t.boolean "markdown", default: false + t.integer "limited_scope" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["account_id"], name: "index_statuses_on_account_id" t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)" diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 151da1c4e5..de07a490bb 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -290,6 +290,7 @@ RSpec.describe ActivityPub::Activity::Create do expect(status).to_not be_nil expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'none' end it 'creates silent mention' do @@ -298,6 +299,50 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'when limited_scope' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: ActivityPub::TagManager.instance.uri_for(recipient), + limitedScope: 'Mutual', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'mutual' + end + end + + context 'when invalid limited_scope' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: ActivityPub::TagManager.instance.uri_for(recipient), + limitedScope: 'IdosdsazsF', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'none' + end + end + context 'when direct' do let(:recipient) { Fabricate(:account) } diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 8626ab919e..62a67c393e 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -168,7 +168,17 @@ RSpec.describe PostStatusService, type: :service do status = subject.call(account, text: 'test status update') expect(ProcessMentionsService).to have_received(:new) - expect(mention_service).to have_received(:call).with(status, save_records: false) + expect(mention_service).to have_received(:call).with(status, limited_type: '', save_records: false) + end + + it 'mutual visibility' do + account = Fabricate(:account) + text = 'This is an English text.' + + status = subject.call(account, text: text, visibility: 'mutual') + + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'mutual' end it 'safeguards mentions' do