From 5ff87a88192564c75de0b483df4b4c4527dedf61 Mon Sep 17 00:00:00 2001 From: Essem Date: Sat, 30 Nov 2024 23:14:17 -0600 Subject: [PATCH] Add bubble timeline --- .../admin/bubble_domains_controller.rb | 49 ++++++++++ .../api/v1/timelines/public_controller.rb | 3 +- .../flavours/glitch/actions/streaming.js | 9 ++ .../flavours/glitch/actions/timelines.js | 2 + .../glitch/features/firehose/index.jsx | 93 +++++++++++++------ .../flavours/glitch/features/ui/index.jsx | 1 + .../flavours/glitch/locales/en.json | 3 + .../material-icons/400-24px/bubble_chart.svg | 1 + app/models/bubble_domain.rb | 43 +++++++++ app/models/public_feed.rb | 14 ++- app/models/status.rb | 2 + app/models/tag_feed.rb | 1 + app/policies/bubble_domain_policy.rb | 7 ++ .../bubble_domains/_bubble_domain.html.haml | 5 + .../admin/bubble_domains/index.html.haml | 18 ++++ app/views/admin/bubble_domains/new.html.haml | 11 +++ config/locales/en.yml | 10 ++ config/navigation.rb | 1 + config/routes.rb | 1 + config/routes/admin.rb | 2 + .../20240114042123_create_bubble_domains.rb | 13 +++ db/schema.rb | 7 ++ lib/mastodon/cli/bubble_domains.rb | 81 ++++++++++++++++ lib/mastodon/cli/main.rb | 4 + streaming/index.js | 18 ++++ 25 files changed, 369 insertions(+), 30 deletions(-) create mode 100644 app/controllers/admin/bubble_domains_controller.rb create mode 100644 app/javascript/material-icons/400-24px/bubble_chart.svg create mode 100644 app/models/bubble_domain.rb create mode 100644 app/policies/bubble_domain_policy.rb create mode 100644 app/views/admin/bubble_domains/_bubble_domain.html.haml create mode 100644 app/views/admin/bubble_domains/index.html.haml create mode 100644 app/views/admin/bubble_domains/new.html.haml create mode 100644 db/migrate/20240114042123_create_bubble_domains.rb create mode 100644 lib/mastodon/cli/bubble_domains.rb diff --git a/app/controllers/admin/bubble_domains_controller.rb b/app/controllers/admin/bubble_domains_controller.rb new file mode 100644 index 00000000000000..e90b9d5e0b6f02 --- /dev/null +++ b/app/controllers/admin/bubble_domains_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Admin + class BubbleDomainsController < BaseController + before_action :set_bubble_domain, except: [:index, :new, :create] + + def index + authorize :bubble_domain, :update? + @bubble_domains = BubbleDomain.all + end + + def new + authorize :bubble_domain, :update? + @bubble_domain = BubbleDomain.new(domain: params[:_domain]) + end + + def create + authorize :bubble_domain, :update? + + domain = TagManager.instance.normalize_domain(resource_params[:domain]) + + @bubble_domain = BubbleDomain.new(domain: domain) + + if @bubble_domain.save! + log_action :create, @bubble_domain + redirect_to admin_bubble_domains_path, notice: I18n.t('admin.bubble_domains.created_msg') + else + render :new + end + end + + def destroy + authorize :bubble_domain, :update? + @bubble_domain.destroy + log_action :destroy, @bubble_domain + redirect_to admin_bubble_domains_path + end + + private + + def set_bubble_domain + @bubble_domain = BubbleDomain.find(params[:id]) + end + + def resource_params + params.require(:bubble_domain).permit(:domain) + end + end +end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index cd5445617be0ec..8f4cd6c1e754e0 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' } - PERMITTED_PARAMS = %i(local remote limit only_media allow_local_only).freeze + PERMITTED_PARAMS = %i(local remote bubble limit only_media allow_local_only).freeze def show cache_if_unauthenticated! @@ -34,6 +34,7 @@ def public_feed PublicFeed.new( current_account, local: truthy_param?(:local), + bubble: truthy_param?(:bubble), remote: truthy_param?(:remote), only_media: truthy_param?(:only_media), allow_local_only: truthy_param?(:allow_local_only), diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index fa7af7055e6180..52ea4c56ffc044 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -23,6 +23,7 @@ import { fillPublicTimelineGaps, fillCommunityTimelineGaps, fillListTimelineGaps, + fillBubbleTimelineGaps, } from './timelines'; /** @@ -164,6 +165,14 @@ export const connectUserStream = () => export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) }); +/** + * @param {Object} options + * @param {boolean} [options.onlyMedia] + * @returns {function(): void} + */ +export const connectBubbleStream = ({ onlyMedia } = {}) => + connectTimelineStream(`bubble${onlyMedia ? ':media' : ''}`, `public:bubble${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillBubbleTimelineGaps({ onlyMedia })) }); + /** * @param {Object} options * @param {boolean} [options.onlyMedia] diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index 1d5a696c92da5b..5e1512b40a51a6 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -159,6 +159,7 @@ export function fillTimelineGaps(timelineId, path, params = {}) { export const expandHomeTimeline = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }); export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }); export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }); +export const expandBubbleTimeline = ({ maxId, onlyMedia } = {}) => expandTimeline(`bubble${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { bubble: true, max_id: maxId, only_media: !!onlyMedia }); export const expandDirectTimeline = ({ maxId } = {}) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }); export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId }); export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged }); @@ -178,6 +179,7 @@ export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = export const fillHomeTimelineGaps = () => fillTimelineGaps('home', '/api/v1/timelines/home', {}); export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia, allow_local_only: !!allowLocalOnly }); export const fillCommunityTimelineGaps = ({ onlyMedia } = {}) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }); +export const fillBubbleTimelineGaps = ({ onlyMedia } = {}) => fillTimelineGaps(`bubble${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { bubble: true, only_media: !!onlyMedia }); export const fillListTimelineGaps = (id) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}); export function expandTimelineRequest(timeline, isLoadingMore) { diff --git a/app/javascript/flavours/glitch/features/firehose/index.jsx b/app/javascript/flavours/glitch/features/firehose/index.jsx index 84b87b8b2ae60a..7004ec4d444bdb 100644 --- a/app/javascript/flavours/glitch/features/firehose/index.jsx +++ b/app/javascript/flavours/glitch/features/firehose/index.jsx @@ -10,8 +10,8 @@ import { useIdentity } from '@/flavours/glitch/identity_context'; import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import { addColumn } from 'flavours/glitch/actions/columns'; import { changeSetting } from 'flavours/glitch/actions/settings'; -import { connectPublicStream, connectCommunityStream } from 'flavours/glitch/actions/streaming'; -import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines'; +import { connectPublicStream, connectCommunityStream, connectBubbleStream } from 'flavours/glitch/actions/streaming'; +import { expandPublicTimeline, expandCommunityTimeline, expandBubbleTimeline } from 'flavours/glitch/actions/timelines'; import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner'; import SettingText from 'flavours/glitch/components/setting_text'; import { domain } from 'flavours/glitch/initial_state'; @@ -93,6 +93,9 @@ const Firehose = ({ feedType, multiColumn }) => { case 'public': dispatch(addColumn('PUBLIC', { other: { onlyMedia, allowLocalOnly }, regex: { body: regex } })); break; + case 'bubble': + dispatch(addColumn('BUBBLE', { other: { onlyMedia }, regex: { body: regex } })); + break; case 'public:remote': dispatch(addColumn('REMOTE', { other: { onlyMedia, onlyRemote: true }, regex: { body: regex } })); break; @@ -107,6 +110,9 @@ const Firehose = ({ feedType, multiColumn }) => { case 'community': dispatch(expandCommunityTimeline({ maxId, onlyMedia })); break; + case 'bubble': + dispatch(expandBubbleTimeline({ maxId, onlyMedia })); + break; case 'public': dispatch(expandPublicTimeline({ maxId, onlyMedia, allowLocalOnly })); break; @@ -130,6 +136,12 @@ const Firehose = ({ feedType, multiColumn }) => { disconnect = dispatch(connectCommunityStream({ onlyMedia })); } break; + case 'bubble': + dispatch(expandBubbleTimeline({ onlyMedia })); + if (signedIn) { + disconnect = dispatch(connectBubbleStream({ onlyMedia })); + } + break; case 'public': dispatch(expandPublicTimeline({ onlyMedia, allowLocalOnly })); if (signedIn) { @@ -147,35 +159,58 @@ const Firehose = ({ feedType, multiColumn }) => { return () => disconnect?.(); }, [dispatch, signedIn, feedType, onlyMedia, allowLocalOnly]); - const prependBanner = feedType === 'community' ? ( - + let prependBanner; + let emptyMessage; + + if (feedType === 'community') { + prependBanner = ( + + + + ); + emptyMessage = ( - - ) : ( - + ); + } else if (feedType === 'bubble') { + prependBanner = ( + + + + ); + emptyMessage = ( - - ); - - const emptyMessage = feedType === 'community' ? ( - - ) : ( - - ); + ); + } else { + prependBanner = ( + + + + ); + emptyMessage = ( + + ); + } return ( @@ -196,6 +231,10 @@ const Firehose = ({ feedType, multiColumn }) => { + + + + diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index 01edfda00db185..7611bac6e07e09 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -215,6 +215,7 @@ class SwitchingColumnsArea extends PureComponent { + diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json index ca2b0d894eb71a..4e87dcc9d7dba9 100644 --- a/app/javascript/flavours/glitch/locales/en.json +++ b/app/javascript/flavours/glitch/locales/en.json @@ -35,11 +35,14 @@ "confirmations.missing_media_description.edit": "Edit media", "confirmations.missing_media_description.message": "At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.", "direct.group_by_conversations": "Group by conversation", + "dismissable_banner.bubble_timeline": "These are the most recent public posts from people on the social web whose accounts are on other servers selected by {domain}.", + "empty_column.bubble": "The bubble timeline is currently empty, but something might show up here soon!", "favourite_modal.favourite": "Favourite post?", "federation.federated.long": "Allow this post to reach other servers", "federation.federated.short": "Federated", "federation.local_only.long": "Prevent this post from reaching other servers", "federation.local_only.short": "Local-only", + "firehose.bubble": "Bubble servers", "firehose.column_settings.allow_local_only": "Show local-only posts in \"All\"", "home.column_settings.advanced": "Advanced", "home.column_settings.filter_regex": "Filter out by regular expressions", diff --git a/app/javascript/material-icons/400-24px/bubble_chart.svg b/app/javascript/material-icons/400-24px/bubble_chart.svg new file mode 100644 index 00000000000000..6a5b4868627997 --- /dev/null +++ b/app/javascript/material-icons/400-24px/bubble_chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/models/bubble_domain.rb b/app/models/bubble_domain.rb new file mode 100644 index 00000000000000..69d8591af9b33d --- /dev/null +++ b/app/models/bubble_domain.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: bubble_domains +# +# id :bigint(8) not null, primary key +# domain :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class BubbleDomain < ApplicationRecord + include Paginable + include DomainNormalizable + include DomainMaterializable + + validates :domain, presence: true, uniqueness: true, domain: true + + scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } + + def to_log_human_identifier + domain + end + + class << self + def in_bubble?(domain) + !rule_for(domain).nil? + end + + def bubble_domains + pluck(:domain) + end + + def rule_for(domain) + return if domain.blank? + + uri = Addressable::URI.new.tap { |u| u.host = domain.delete('/') } + + find_by(domain: uri.normalized_host) + end + end +end diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb index 72e6249c397ca3..6ff42ee1b2a886 100644 --- a/app/models/public_feed.rb +++ b/app/models/public_feed.rb @@ -6,6 +6,7 @@ class PublicFeed # @option [Boolean] :with_replies # @option [Boolean] :with_reblogs # @option [Boolean] :local + # @option [Boolean] :bubble # @option [Boolean] :remote # @option [Boolean] :only_media # @option [Boolean] :allow_local_only @@ -26,6 +27,7 @@ def get(limit, max_id = nil, since_id = nil, min_id = nil) scope.merge!(without_replies_scope) unless with_replies? scope.merge!(without_reblogs_scope) unless with_reblogs? scope.merge!(local_only_scope) if local_only? + scope.merge!(bubble_only_scope) if bubble_only? scope.merge!(remote_only_scope) if remote_only? scope.merge!(account_filters_scope) if account? scope.merge!(media_only_scope) if media_only? @@ -51,11 +53,15 @@ def with_replies? end def local_only? - options[:local] && !options[:remote] + options[:local] && !options[:remote] && !options[:bubble] + end + + def bubble_only? + options[:bubble] && !options[:local] && !options[:remote] end def remote_only? - options[:remote] && !options[:local] + options[:remote] && !options[:local] && !options[:bubble] end def account? @@ -78,6 +84,10 @@ def local_only_scope Status.local end + def bubble_only_scope + Status.bubble + end + def remote_only_scope Status.remote end diff --git a/app/models/status.rb b/app/models/status.rb index 2825ade5247786..a7fb756c52196f 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -134,6 +134,8 @@ class Status < ApplicationRecord scope :not_local_only, -> { where(local_only: [false, nil]) } + scope :bubble, -> { left_outer_joins(:account).where(accounts: { domain: BubbleDomain.bubble_domains }) } + after_create_commit :trigger_create_webhooks after_update_commit :trigger_update_webhooks diff --git a/app/models/tag_feed.rb b/app/models/tag_feed.rb index a4d371e4c162dc..a021b4173968b9 100644 --- a/app/models/tag_feed.rb +++ b/app/models/tag_feed.rb @@ -30,6 +30,7 @@ def get(limit, max_id = nil, since_id = nil, min_id = nil) scope.merge!(tagged_with_all_scope) scope.merge!(tagged_with_none_scope) scope.merge!(local_only_scope) if local_only? + scope.merge!(bubble_only_scope) if bubble_only? scope.merge!(remote_only_scope) if remote_only? scope.merge!(account_filters_scope) if account? scope.merge!(media_only_scope) if media_only? diff --git a/app/policies/bubble_domain_policy.rb b/app/policies/bubble_domain_policy.rb new file mode 100644 index 00000000000000..bcb38a8360379d --- /dev/null +++ b/app/policies/bubble_domain_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class BubbleDomainPolicy < ApplicationPolicy + def update? + role.can?(:manage_federation) + end +end diff --git a/app/views/admin/bubble_domains/_bubble_domain.html.haml b/app/views/admin/bubble_domains/_bubble_domain.html.haml new file mode 100644 index 00000000000000..a2aaa19268dbee --- /dev/null +++ b/app/views/admin/bubble_domains/_bubble_domain.html.haml @@ -0,0 +1,5 @@ +%tr + %td + %samp= bubble_domain.domain + %td + = table_link_to 'close', t('admin.bubble_domains.delete'), admin_bubble_domain_path(bubble_domain), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/admin/bubble_domains/index.html.haml b/app/views/admin/bubble_domains/index.html.haml new file mode 100644 index 00000000000000..e2d3f1327f43ef --- /dev/null +++ b/app/views/admin/bubble_domains/index.html.haml @@ -0,0 +1,18 @@ +- content_for :page_title do + = t('admin.bubble_domains.title') + +.simple_form + %p.hint= t('admin.bubble_domains.description_html') + = link_to t('admin.bubble_domains.add'), new_admin_bubble_domain_path, class: 'block-button' + +- unless @bubble_domains.empty? + %hr.spacer + + .table-wrapper + %table.table + %thead + %tr + %th= t('admin.bubble_domains.domain') + %th + %tbody + = render @bubble_domains diff --git a/app/views/admin/bubble_domains/new.html.haml b/app/views/admin/bubble_domains/new.html.haml new file mode 100644 index 00000000000000..65c18f4f0fe942 --- /dev/null +++ b/app/views/admin/bubble_domains/new.html.haml @@ -0,0 +1,11 @@ +- content_for :page_title do + = t('.title') + += simple_form_for @bubble_domain, url: admin_bubble_domains_path do |f| + = render 'shared/error_messages', object: @bubble_domain + + .field-group + = f.input :domain, as: :string, wrapper: :with_label, label: t('admin.bubble_domains.domain') + + .actions + = f.button :button, t('.save'), type: :submit diff --git a/config/locales/en.yml b/config/locales/en.yml index 2971fe1f25ad7e..3e99130e55f7f8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -323,6 +323,16 @@ en: unpublish: Unpublish unpublished_msg: Announcement successfully unpublished! updated_msg: Announcement successfully updated! + bubble_domains: + add: Add to bubble + created_msg: Successfully added domain to bubble + delete: Delete + description_html: The bubble timeline is a public timeline consisting of posts from other servers that you select. It can help users on your server discover interesting content and new people to talk to without the noise of the full remote and public timelines. + domain: Domain + new: + save: Save + title: Add new bubble domain + title: Bubble critical_update_pending: Critical update pending custom_emojis: assign_category: Assign category diff --git a/config/navigation.rb b/config/navigation.rb index d3fda7bf3c0420..fdfd8bcb7d6682 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -79,6 +79,7 @@ s.item :custom_emojis, safe_join([material_symbol('mood'), t('admin.custom_emojis.title')]), admin_custom_emojis_path, highlights_on: %r{/admin/custom_emojis}, if: -> { current_user.can?(:manage_custom_emojis) } s.item :webhooks, safe_join([material_symbol('inbox'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}, if: -> { current_user.can?(:manage_webhooks) } s.item :relays, safe_join([material_symbol('captive_portal'), t('admin.relays.title')]), admin_relays_path, highlights_on: %r{/admin/relays}, if: -> { !limited_federation_mode? && current_user.can?(:manage_federation) } + s.item :bubble_domains, safe_join([material_symbol('bubble_chart'), t('admin.bubble_domains.title')]), admin_bubble_domains_path, highlights_on: %r{/admin/bubble_domains}, if: -> { current_user.can?(:manage_federation) } end n.item :sidekiq, safe_join([material_symbol('diamond'), 'Sidekiq']), sidekiq_path, link_html: { target: 'sidekiq' }, if: -> { current_user.can?(:view_devops) } diff --git a/config/routes.rb b/config/routes.rb index 867aedf707d7e6..2ea4cf8966733c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,7 @@ def redirect_with_vary(path) /home /public /public/local + /public/bubble /public/remote /conversations /lists/(*any) diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 517cd91e06ddd1..104e39940ea871 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -70,6 +70,8 @@ end end + resources :bubble_domains, only: [:index, :new, :create, :destroy] + resources :instances, only: [:index, :show, :destroy], constraints: { id: %r{[^/]+} }, format: 'html' do member do post :clear_delivery_errors diff --git a/db/migrate/20240114042123_create_bubble_domains.rb b/db/migrate/20240114042123_create_bubble_domains.rb new file mode 100644 index 00000000000000..83c87956bf2474 --- /dev/null +++ b/db/migrate/20240114042123_create_bubble_domains.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateBubbleDomains < ActiveRecord::Migration[7.1] + def change + create_table :bubble_domains do |t| + t.string :domain, default: '', null: false + + t.timestamps + end + + add_index :bubble_domains, :domain, name: :index_bubble_domains_on_domain, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 18b175b9ab6cce..09d4fa81b4e0a9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -314,6 +314,13 @@ t.index ["status_id"], name: "index_bookmarks_on_status_id" end + create_table "bubble_domains", force: :cascade do |t| + t.string "domain", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["domain"], name: "index_bubble_domains_on_domain", unique: true + end + create_table "bulk_import_rows", force: :cascade do |t| t.bigint "bulk_import_id", null: false t.jsonb "data" diff --git a/lib/mastodon/cli/bubble_domains.rb b/lib/mastodon/cli/bubble_domains.rb new file mode 100644 index 00000000000000..f3f042ba23b4e3 --- /dev/null +++ b/lib/mastodon/cli/bubble_domains.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'concurrent' +require_relative 'base' + +module Mastodon::CLI + class BubbleDomains < Base + desc 'list', 'List domains in the bubble' + def list + BubbleDomain.find_each do |entry| + say(entry.domain.to_s, :white) + end + end + + desc 'add [DOMAIN...]', 'Add domains to the bubble' + def add(*domains) + fail_with_message 'No domain(s) given' if domains.empty? + + domains = domains.map { |domain| TagManager.instance.normalize_domain(domain) } + + skipped = 0 + processed = 0 + + domains.each do |domain| + if BubbleDomain.exists?(domain: domain) + say("#{domain} is already in the bubble.", :yellow) + skipped += 1 + next + end + + bubble_domain = BubbleDomain.new(domain: domain) + bubble_domain.save! + processed += 1 + end + + say("Added #{processed}, skipped #{skipped}", color(processed, 0)) + end + + desc 'remove DOMAIN...', 'Remove domain from the bubble' + def remove(*domains) + fail_with_message 'No domain(s) given' if domains.empty? + + skipped = 0 + processed = 0 + failed = 0 + + domains.each do |domain| + entry = BubbleDomain.find_by(domain: domain) + + if entry.nil? + say("#{domain} is not in the bubble.", :yellow) + skipped += 1 + next + end + + result = entry.destroy + + if result + processed += 1 + else + say("#{domain} could not be removed.", :red) + failed += 1 + end + end + + say("Removed #{processed}, skipped #{skipped}, failed #{failed}", color(processed, failed)) + end + + private + + def color(processed, failed) + if !processed.zero? && failed.zero? + :green + elsif failed.zero? + :yellow + else + :red + end + end + end +end diff --git a/lib/mastodon/cli/main.rb b/lib/mastodon/cli/main.rb index ef40b81f33959c..7c49a6a2288493 100644 --- a/lib/mastodon/cli/main.rb +++ b/lib/mastodon/cli/main.rb @@ -3,6 +3,7 @@ require_relative 'base' require_relative 'accounts' +require_relative 'bubble_domains' require_relative 'cache' require_relative 'canonical_email_blocks' require_relative 'domains' @@ -63,6 +64,9 @@ class Main < Base desc 'canonical_email_blocks SUBCOMMAND ...ARGS', 'Manage canonical e-mail blocks' subcommand 'canonical_email_blocks', CanonicalEmailBlocks + desc 'bubble_domains SUBCOMMAND ...ARGS', 'Manage bubble domains' + subcommand 'bubble_domains', BubbleDomains + desc 'maintenance SUBCOMMAND ...ARGS', 'Various maintenance utilities' subcommand 'maintenance', Maintenance diff --git a/streaming/index.js b/streaming/index.js index 9c2b671c65e103..3853ff2e3dadbe 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -83,6 +83,8 @@ const PUBLIC_CHANNELS = [ 'public:media', 'public:local', 'public:local:media', + 'public:bubble', + 'public:bubble:media', 'public:remote', 'public:remote:media', 'hashtag', @@ -410,6 +412,8 @@ const startServer = async () => { return onlyMedia ? 'public:media' : 'public'; case '/api/v1/streaming/public/local': return onlyMedia ? 'public:local:media' : 'public:local'; + case '/api/v1/streaming/public/bubble': + return onlyMedia ? 'public:bubble:media' : 'public:bubble'; case '/api/v1/streaming/public/remote': return onlyMedia ? 'public:remote:media' : 'public:remote'; case '/api/v1/streaming/hashtag': @@ -1037,6 +1041,13 @@ const startServer = async () => { options: { needsFiltering: true, allowLocalOnly: true }, }); + break; + case 'public:bubble': + resolve({ + channelIds: ['timeline:public:bubble'], + options: { needsFiltering: true, allowLocalOnly: false }, + }); + break; case 'public:remote': resolve({ @@ -1065,6 +1076,13 @@ const startServer = async () => { options: { needsFiltering: true, allowLocalOnly: true }, }); + break; + case 'public:bubble:media': + resolve({ + channelIds: ['timeline:public:bubble:media'], + options: { needsFiltering: true, allowLocalOnly: false }, + }); + break; case 'public:remote:media': resolve({