diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index 5bc8de8334..9556963b74 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 :require_user!, only: [:show], if: :require_auth?
- 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!
@@ -38,6 +38,7 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
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 d8341a5c16..c46e9ea5ba 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -22,6 +22,7 @@ import {
fillPublicTimelineGaps,
fillCommunityTimelineGaps,
fillListTimelineGaps,
+ fillBubbleTimelineGaps,
} from './timelines';
/**
@@ -150,6 +151,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 fa69bca985..2d7e0449f1 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -156,6 +156,7 @@ export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) {
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => 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 }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
+export const expandBubbleTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`bubble${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { bubble: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
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 });
@@ -174,6 +175,7 @@ export const expandHashtagTimeline = (hashtag, { maxId, tags, local } =
export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home', '/api/v1/timelines/home', {}, done);
export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia, allow_local_only: !!allowLocalOnly }, done);
export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done);
+export const fillBubbleTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`bubble${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { bubble: true, only_media: !!onlyMedia }, done);
export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done);
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 c01c767b86..9e02748f3e 100644
--- a/app/javascript/flavours/glitch/features/firehose/index.jsx
+++ b/app/javascript/flavours/glitch/features/firehose/index.jsx
@@ -8,8 +8,8 @@ import { NavLink } from 'react-router-dom';
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 initialState, { domain } from 'flavours/glitch/initial_state';
@@ -91,6 +91,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;
@@ -105,6 +108,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;
@@ -128,6 +134,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) {
@@ -145,35 +157,58 @@ const Firehose = ({ feedType, multiColumn }) => {
return () => disconnect?.();
}, [dispatch, signedIn, feedType, onlyMedia, allowLocalOnly]);
- const prependBanner = feedType === 'community' ? (
-
- ) : (
-
-
-
- );
+ let prependBanner;
+ let emptyMessage;
- const emptyMessage = feedType === 'community' ? (
-
- ) : (
-
- );
+ if (feedType === 'community') {
+ prependBanner = (
+
+ );
+ emptyMessage = (
+
+ );
+ } else if (feedType === 'bubble') {
+ prependBanner = (
+
+
+
+ );
+ emptyMessage = (
+
+ );
+ } else {
+ prependBanner = (
+
+
+
+ );
+ emptyMessage = (
+
+ );
+ }
return (
@@ -193,6 +228,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 fa943f579f..c4ced17099 100644
--- a/app/javascript/flavours/glitch/features/ui/index.jsx
+++ b/app/javascript/flavours/glitch/features/ui/index.jsx
@@ -208,6 +208,7 @@ class SwitchingColumnsArea extends PureComponent {
+
diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json
index 4b2418b5ed..8fce6777e4 100644
--- a/app/javascript/flavours/glitch/locales/en.json
+++ b/app/javascript/flavours/glitch/locales/en.json
@@ -43,8 +43,11 @@
"confirmations.unfilter.filters": "Matching {count, plural, one {filter} other {filters}}",
"content-type.change": "Content type",
"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!",
"endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
"favourite_modal.combo": "You can press {combo} to skip this next time",
+ "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/models/bubble_domain.rb b/app/models/bubble_domain.rb
new file mode 100644
index 0000000000..ba47e6dd7b
--- /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
+ select(: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 8a7c3a4512..1d835ad1f9 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 @@ class PublicFeed
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 @@ class PublicFeed
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 @@ class PublicFeed
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 653bea1d35..7796d08815 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -127,6 +127,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 fbbdbaae27..1b6c5707f3 100644
--- a/app/models/tag_feed.rb
+++ b/app/models/tag_feed.rb
@@ -30,6 +30,7 @@ class TagFeed < PublicFeed
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/config/routes.rb b/config/routes.rb
index d4cd27a491..4c2ee3ddaf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -25,6 +25,7 @@ Rails.application.routes.draw do
/home
/public
/public/local
+ /public/bubble
/public/remote
/conversations
/lists/(*any)
diff --git a/db/migrate/20240114042123_create_bubble_domains.rb b/db/migrate/20240114042123_create_bubble_domains.rb
new file mode 100644
index 0000000000..83c87956bf
--- /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 da2be71318..a704e50640 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.1].define(version: 2024_01_09_103012) do
+ActiveRecord::Schema[7.1].define(version: 2024_01_14_042123) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -296,6 +296,13 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_09_103012) do
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 0000000000..63a38bc372
--- /dev/null
+++ b/lib/mastodon/cli/bubble_domains.rb
@@ -0,0 +1,87 @@
+# 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)
+ if domains.empty?
+ say('No domain(s) given', :red)
+ exit(1)
+ end
+
+ 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)
+ if domains.empty?
+ say('No domain(s) given', :red)
+ exit(1)
+ end
+
+ 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 ef40b81f33..7c49a6a228 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 @@ module Mastodon::CLI
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 9ed24dc02d..c62ab1e12f 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -164,6 +164,8 @@ const PUBLIC_CHANNELS = [
'public:media',
'public:local',
'public:local:media',
+ 'public:bubble',
+ 'public:bubble:media',
'public:remote',
'public:remote:media',
'hashtag',
@@ -438,6 +440,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':
@@ -1096,6 +1100,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({
@@ -1124,6 +1135,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({