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