diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 6d44a4b451163..dd9f402ebbca9 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -188,6 +188,8 @@ export default class StatusActionBar extends ImmutablePureComponent {
reblogIcon = 'envelope';
} else if (status.get('visibility') === 'private') {
reblogIcon = 'lock';
+ } else if (status.get('visibility') === 'local') {
+ reblogIcon = 'users';
}
if (status.get('in_reply_to_id', null) === null) {
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index e19778fd2edb4..45eb28225e7f9 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -13,6 +13,8 @@ const messages = defineMessages({
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
+ local_short: { id: 'privacy.local.short', defaultMessage: 'Local' },
+ local_long: { id: 'privacy.local.long', defaultMessage: 'Post to followers and the local timeline' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
@@ -215,6 +217,7 @@ export default class PrivacyDropdown extends React.PureComponent {
this.options = [
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
+ { icon: 'users', value: 'local', text: formatMessage(messages.local_short), meta: formatMessage(messages.local_long) },
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
];
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index f5977c02cd709..42ae10cffe1c5 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -154,8 +154,9 @@ export default class ActionBar extends React.PureComponent {
let reblogIcon = 'retweet';
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
+ else if (status.get('visibility') === 'local') reblogIcon = 'users';
- let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private');
+ let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private' || status.get('visibility') === 'local');
return (
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 2f5242e26c8d0..e424e720d8363 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -823,7 +823,15 @@
"id": "privacy.unlisted.long"
},
{
- "defaultMessage": "Followers-only",
+ "defaultMessage": "Local",
+ "id": "privacy.local.short"
+ },
+ {
+ "defaultMessage": "Post to local timeline and followers only",
+ "id": "privacy.local.long"
+ },
+ {
+ "defaultMessage": "Listeners Only",
"id": "privacy.private.short"
},
{
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 0a0c987d65d52..6d09b8d8938c8 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -250,6 +250,8 @@
"privacy.public.short": "Public",
"privacy.unlisted.long": "Do not post to public timelines",
"privacy.unlisted.short": "Unlisted",
+ "privacy.local.long": "Post to local timeline and followers only",
+ "privacy.local.short": "Local",
"regeneration_indicator.label": "Loading…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
"relative_time.days": "{number}d",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index d40445657f819..39098fdc77408 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -163,7 +163,7 @@ const insertEmoji = (state, position, emojiData, needsSpace) => {
};
const privacyPreference = (a, b) => {
- const order = ['public', 'unlisted', 'private', 'direct'];
+ const order = ['public', 'unlisted', 'local', 'private', 'direct'];
return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
};
diff --git a/app/models/status.rb b/app/models/status.rb
index 35655bff2377d..77294ecff76bf 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -35,7 +35,7 @@ class Status < ApplicationRecord
update_index('statuses#status', :proper) if Chewy.enabled?
- enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility
+ enum visibility: [:public, :unlisted, :private, :direct, :local], _suffix: :visibility
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
@@ -74,6 +74,7 @@ class Status < ApplicationRecord
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
scope :with_public_visibility, -> { where(visibility: :public) }
+ scope :with_local_visibility, -> { where(visibility: :local).or(where(visibility: :public)) }
scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: false }) }
scope :including_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: true }) }
@@ -358,9 +359,15 @@ def permitted_for(target_account, account)
def timeline_scope(local_only = false)
starting_scope = local_only ? Status.local : Status
- starting_scope
- .with_public_visibility
- .without_reblogs
+ if local_only
+ starting_scope
+ .with_local_visibility
+ .without_reblogs
+ else
+ starting_scope
+ .with_public_visibility
+ .without_reblogs
+ end
end
def apply_timeline_filters(query, account, local_only)
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index 6addc8a8a8ebe..3e460d786cc0d 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -12,7 +12,9 @@ def index?
end
def show?
- if direct?
+ if local?
+ current_account.nil? || current_account.local?
+ elsif direct?
owned? || mention_exists?
elsif private?
owned? || following_author? || mention_exists?
@@ -45,6 +47,10 @@ def direct?
record.direct_visibility?
end
+ def local?
+ record.local_visibility?
+ end
+
def owned?
author.id == current_account&.id
end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 5efd3edb2e428..8f872c9336410 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -18,14 +18,24 @@ def call(status)
deliver_to_lists(status)
end
- return if status.account.silenced? || !status.public_visibility? || status.reblog?
+ return if status.account.silenced? || status.reblog?
+ return if !status.public_visibility? && !status.local_visibility?
- deliver_to_hashtags(status)
+ if status.local_visibility?
+ deliver_to_local_hashtags(status)
+ else
+ deliver_to_hashtags(status)
+ end
return if status.reply? && status.in_reply_to_account_id != status.account_id
- deliver_to_public(status)
- deliver_to_media(status) if status.media_attachments.any?
+ if status.local_visibility?
+ deliver_to_local(status)
+ deliver_to_local_media(status) if status.media_attachments.any?
+ else
+ deliver_to_public(status)
+ deliver_to_media(status) if status.media_attachments.any?
+ end
end
private
@@ -79,6 +89,14 @@ def deliver_to_hashtags(status)
end
end
+ def deliver_to_local_hashtags(status)
+ Rails.logger.debug "Delivering status #{status.id} to local hashtags"
+
+ status.tags.pluck(:name).each do |hashtag|
+ Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if status.local?
+ end
+ end
+
def deliver_to_public(status)
Rails.logger.debug "Delivering status #{status.id} to public timeline"
@@ -93,6 +111,18 @@ def deliver_to_media(status)
Redis.current.publish('timeline:public:local:media', @payload) if status.local?
end
+ def deliver_to_local(status)
+ Rails.logger.debug "Delivering status #{status.id} to local timeline"
+
+ Redis.current.publish('timeline:public:local', @payload) if status.local?
+ end
+
+ def deliver_to_local_media(status)
+ Rails.logger.debug "Delivering status #{status.id} to local media timeline"
+
+ Redis.current.publish('timeline:public:local:media', @payload) if status.local?
+ end
+
def deliver_to_direct_timelines(status)
Rails.logger.debug "Delivering status #{status.id} to direct timelines"
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 300eae547a0ea..ca3a20a03225a 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -39,9 +39,11 @@ def call(account, text, in_reply_to = nil, **options)
LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
DistributionWorker.perform_async(status.id)
- Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
- ActivityPub::DistributionWorker.perform_async(status.id)
- ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local?
+ unless status.local_visibility?
+ Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+ ActivityPub::DistributionWorker.perform_async(status.id)
+ ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local?
+ end
if options[:idempotency].present?
redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id)
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index cf7471c9893eb..9bf82b46ada07 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -7,7 +7,7 @@ def call(status, tags = [])
tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
tag = Tag.where(name: name).first_or_create(name: name)
status.tags << tag
- TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility?
+ TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility? || status.local_visibility?
end
end
end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index b4641c4b4aba3..fbeb186592f8f 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -48,6 +48,8 @@ def create_notification(mention)
if mentioned_account.local?
LocalNotificationWorker.perform_async(mention.id)
+ elsif @status.local_visibility?
+ return
elsif mentioned_account.ostatus? && !@status.stream_entry.hidden?
NotificationWorker.perform_async(ostatus_xml, @status.account_id, mentioned_account.id)
elsif mentioned_account.activitypub?
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 33ddef8b88d94..70e2b1f8cbe3f 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -10,6 +10,7 @@ class ReblogService < BaseService
# @return [Status]
def call(account, reblogged_status)
reblogged_status = reblogged_status.reblog if reblogged_status.reblog?
+ return if reblogged_status.local_visibility?
authorize_with account, reblogged_status, :reblog?
@@ -20,9 +21,12 @@ def call(account, reblogged_status)
reblog = account.statuses.create!(reblog: reblogged_status, text: '')
DistributionWorker.perform_async(reblog.id)
- Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
- ActivityPub::DistributionWorker.perform_async(reblog.id)
-
+
+ unless reblogged_status.local_visibility?
+ Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
+ ActivityPub::DistributionWorker.perform_async(reblog.id)
+ end
+
create_notification(reblog)
bump_potential_friendship(account, reblog)
@@ -33,7 +37,7 @@ def call(account, reblogged_status)
def create_notification(reblog)
reblogged_status = reblog.reblog
-
+
if reblogged_status.account.local?
NotifyService.new.call(reblogged_status.account, reblog)
elsif reblogged_status.account.ostatus?
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index c2bfd4f2f13b4..531b766499aa7 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -23,7 +23,7 @@ def perform(status_id)
private
def skip_distribution?
- @status.direct_visibility?
+ @status.direct_visibility? || @status.direct_visibility?
end
def relayable?
diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb
index fe99fc05f2956..7b5566d00b531 100644
--- a/app/workers/activitypub/reply_distribution_worker.rb
+++ b/app/workers/activitypub/reply_distribution_worker.rb
@@ -21,7 +21,7 @@ def perform(status_id)
private
def skip_distribution?
- @status.private_visibility? || @status.direct_visibility?
+ @status.private_visibility? || @status.direct_visibility? || @status.local_visibility?
end
def inboxes
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e2960b3099c92..3e0813330b128 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -777,6 +777,8 @@ en:
public_long: Everyone can see
unlisted: Unlisted
unlisted_long: Everyone can see, but not listed on public timelines
+ local: Local
+ local_long: Everyone can see on public account pages, but not sent to other sites.
stream_entries:
pinned: Pinned toot
reblogged: boosted