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