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 1678c37d76f111..f2f178eaec3898 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/bubble_timeline/components/column_settings.jsx b/app/javascript/flavours/glitch/features/bubble_timeline/components/column_settings.jsx
new file mode 100644
index 00000000000000..1252eba3193608
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/bubble_timeline/components/column_settings.jsx
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import { PureComponent } from 'react';
+
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+import SettingText from 'flavours/glitch/components/setting_text';
+import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle';
+
+const messages = defineMessages({
+ filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+ settings: { id: 'home.settings', defaultMessage: 'Column settings' },
+});
+
+class ColumnSettings extends PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ };
+
+ render () {
+ const { settings, onChange, intl } = this.props;
+
+ return (
+
+ );
+ }
+
+}
+
+export default injectIntl(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/bubble_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/bubble_timeline/containers/column_settings_container.js
new file mode 100644
index 00000000000000..95e589df8dc23f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/bubble_timeline/containers/column_settings_container.js
@@ -0,0 +1,29 @@
+import { connect } from 'react-redux';
+
+import { changeColumnParams } from '../../../actions/columns';
+import { changeSetting } from '../../../actions/settings';
+import ColumnSettings from '../components/column_settings';
+
+const mapStateToProps = (state, { columnId }) => {
+ const uuid = columnId;
+ const columns = state.getIn(['settings', 'columns']);
+ const index = columns.findIndex(c => c.get('uuid') === uuid);
+
+ return {
+ settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'bubble']),
+ };
+};
+
+const mapDispatchToProps = (dispatch, { columnId }) => {
+ return {
+ onChange (key, checked) {
+ if (columnId) {
+ dispatch(changeColumnParams(columnId, key, checked));
+ } else {
+ dispatch(changeSetting(['bubble', ...key], checked));
+ }
+ },
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/bubble_timeline/index.jsx b/app/javascript/flavours/glitch/features/bubble_timeline/index.jsx
new file mode 100644
index 00000000000000..725612553b9452
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/bubble_timeline/index.jsx
@@ -0,0 +1,165 @@
+import PropTypes from 'prop-types';
+import { PureComponent } from 'react';
+
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+import { Helmet } from 'react-helmet';
+
+import { connect } from 'react-redux';
+
+import BubbleChartIcon from '@/material-icons/400-24px/bubble_chart.svg?react';
+import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
+import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
+import { domain } from 'flavours/glitch/initial_state';
+
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import { connectBubbleStream } from '../../actions/streaming';
+import { expandBubbleTimeline } from '../../actions/timelines';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import StatusListContainer from '../ui/containers/status_list_container';
+
+import ColumnSettingsContainer from './containers/column_settings_container';
+
+const messages = defineMessages({
+ title: { id: 'column.bubble', defaultMessage: 'Bubble timeline' },
+});
+
+const mapStateToProps = (state, { columnId }) => {
+ const uuid = columnId;
+ const columns = state.getIn(['settings', 'columns']);
+ const index = columns.findIndex(c => c.get('uuid') === uuid);
+ const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'bubble', 'other', 'onlyMedia']);
+ const regex = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'regex', 'body']) : state.getIn(['settings', 'bubble', 'regex', 'body']);
+ const timelineState = state.getIn(['timelines', `bubble${onlyMedia ? ':media' : ''}`]);
+
+ return {
+ hasUnread: !!timelineState && timelineState.get('unread') > 0,
+ onlyMedia,
+ regex,
+ };
+};
+
+class BubbleTimeline extends PureComponent {
+ static defaultProps = {
+ onlyMedia: false,
+ };
+
+ static propTypes = {
+ identity: identityContextPropShape,
+ dispatch: PropTypes.func.isRequired,
+ columnId: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ hasUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ onlyMedia: PropTypes.bool,
+ regex: PropTypes.string,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch, onlyMedia } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('BUBBLE', { other: { onlyMedia } }));
+ }
+ };
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ };
+
+ componentDidMount () {
+ const { dispatch, onlyMedia } = this.props;
+ const { signedIn } = this.props.identity;
+
+ dispatch(expandBubbleTimeline({ onlyMedia }));
+
+ if (signedIn) {
+ this.disconnect = dispatch(connectBubbleStream({ onlyMedia }));
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ const { signedIn } = this.props.identity;
+
+ if (prevProps.onlyMedia !== this.props.onlyMedia) {
+ const { dispatch, onlyMedia } = this.props;
+
+ if (this.disconnect) {
+ this.disconnect();
+ }
+
+ dispatch(expandBubbleTimeline({ onlyMedia }));
+
+ if (signedIn) {
+ this.disconnect = dispatch(connectBubbleStream({ onlyMedia }));
+ }
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ };
+
+ handleLoadMore = maxId => {
+ const { dispatch, onlyMedia } = this.props;
+
+ dispatch(expandBubbleTimeline({ maxId, onlyMedia }));
+ };
+
+ render () {
+ const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+ }
+ trackScroll={!pinned}
+ scrollKey={`bubble_timeline-${columnId}`}
+ timelineId={`bubble${onlyMedia ? ':media' : ''}`}
+ onLoadMore={this.handleLoadMore}
+ emptyMessage={}
+ bindToDocument={!multiColumn}
+ regex={this.props.regex}
+ />
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ );
+ }
+
+}
+
+export default withIdentity(connect(mapStateToProps)(injectIntl(BubbleTimeline)));
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' ? (
-
- ) : (
-
+ );
+ } 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/getting_started/index.jsx b/app/javascript/flavours/glitch/features/getting_started/index.jsx
index af6dc313ad74dc..01bef0e7981bf7 100644
--- a/app/javascript/flavours/glitch/features/getting_started/index.jsx
+++ b/app/javascript/flavours/glitch/features/getting_started/index.jsx
@@ -11,6 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
+import BubbleChartIcon from '@/material-icons/400-24px/bubble_chart.svg?react';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
@@ -48,6 +49,7 @@ const messages = defineMessages({
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+ bubble_timeline: { id: 'navigation_bar.bubble_timeline', defaultMessage: 'Bubble timeline' },
explore: { id: 'navigation_bar.explore', defaultMessage: 'Explore' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
@@ -152,6 +154,10 @@ class GettingStarted extends ImmutablePureComponent {
navItems.push();
}
+ if (!columns.find(item => item.get('id') === 'BUBBLE')) {
+ navItems.push();
+ }
+
if (!columns.find(item => item.get('id') === 'PUBLIC')) {
navItems.push();
}
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx
index ca24596aa33be2..dda66f7643772d 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx
@@ -11,6 +11,7 @@ import {
Notifications,
HomeTimeline,
CommunityTimeline,
+ BubbleTimeline,
PublicTimeline,
HashtagTimeline,
DirectTimeline,
@@ -34,6 +35,7 @@ const componentMap = {
'PUBLIC': PublicTimeline,
'REMOTE': PublicTimeline,
'COMMUNITY': CommunityTimeline,
+ 'BUBBLE': BubbleTimeline,
'HASHTAG': HashtagTimeline,
'DIRECT': DirectTimeline,
'FAVOURITES': FavouritedStatuses,
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/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js
index 8530dc95c1c294..a3b2a081364a63 100644
--- a/app/javascript/flavours/glitch/features/ui/util/async-components.js
+++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js
@@ -22,6 +22,10 @@ export function CommunityTimeline () {
return import(/* webpackChunkName: "flavours/glitch/async/community_timeline" */'../../community_timeline');
}
+export function BubbleTimeline () {
+ return import(/* webpackChunkName: "flavours/glitch/async/bubble_timeline" */'../../bubble_timeline');
+}
+
export function Firehose () {
return import(/* webpackChunkName: "flavours/glitch/async/firehose" */'../../firehose');
}
diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json
index dbf1f5b21996a6..d05af4bc018d3e 100644
--- a/app/javascript/flavours/glitch/locales/en.json
+++ b/app/javascript/flavours/glitch/locales/en.json
@@ -36,11 +36,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 fediverse 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 6c8e11b9cf99c9..7fe57a3de1eff0 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -139,6 +139,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
@@ -221,6 +223,10 @@ def local?
attributes['local'] || uri.nil?
end
+ def bubble?
+ BubbleDomain.in_bubble?(account.domain)
+ end
+
def in_reply_to_local_account?
reply? && thread&.account&.local?
end
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/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index f55458455f573c..b7ef5d7afc2f3a 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -86,10 +86,12 @@ def unpush_from_public_timelines(status, pipeline)
pipeline.publish('timeline:public', payload)
pipeline.publish(status.local? ? 'timeline:public:local' : 'timeline:public:remote', payload)
+ pipeline.publish('timeline:public:bubble', payload) if status.bubble?
if status.media_attachments.any?
pipeline.publish('timeline:public:media', payload)
pipeline.publish(status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', payload)
+ pipeline.publish('timeline:public:bubble:media', payload) if status.bubble?
end
status.tags.map { |tag| tag.name.mb_chars.downcase }.each do |hashtag|
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 895f4264f24128..d235cc221445ed 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -154,6 +154,8 @@ def broadcast_to_public_streams!
elsif !is_reply || Setting.show_replies_in_federated_timelines
broadcast_to.call('timeline:public:remote')
end
+
+ broadcast_to.call('timeline:public:bubble') if @status.bubble?
end
def deliver_to_conversation!
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 3b7a1b8580f93c..e93d5ab8bd9d9d 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -137,6 +137,7 @@ def remove_from_public
redis.publish('timeline:public', @payload)
redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload)
+ redis.publish('timeline:public:bubble', @payload) if @status.bubble?
end
def remove_from_media
@@ -146,6 +147,7 @@ def remove_from_media
redis.publish('timeline:public:media', @payload)
redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload)
+ redis.publish('timeline:public:bubble:media', @payload) if @status.bubble?
end
def remove_from_direct
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 3d89afae8b4c19..5a8c9ee0d90a2e 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 68f549383f4832..1d6e90b00ddf84 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({