From 2f99c7e9dc2e5e3ca06c9672a6ab4887eb094310 Mon Sep 17 00:00:00 2001
From: Mar0xy <marie@kaifa.ch>
Date: Mon, 4 Dec 2023 02:10:51 +0100
Subject: [PATCH] add: Bubble timeline

Closes  transfem-org/Sharkey#154
---
 locales/en-US.yml                             |   1 +
 locales/index.d.ts                            |   1 +
 locales/ja-JP.yml                             |   1 +
 .../1701647674000-BubbleInstances.js          |  11 ++
 packages/backend/src/core/RoleService.ts      |   3 +
 packages/backend/src/models/Meta.ts           |   5 +
 .../src/server/NodeinfoServerService.ts       |   1 +
 packages/backend/src/server/ServerModule.ts   |   2 +
 .../backend/src/server/api/EndpointsModule.ts |   4 +
 packages/backend/src/server/api/endpoints.ts  |   2 +
 .../src/server/api/endpoints/admin/meta.ts    |   8 ++
 .../server/api/endpoints/admin/update-meta.ts |   5 +
 .../api/endpoints/notes/bubble-timeline.ts    | 130 ++++++++++++++++++
 .../src/server/api/stream/ChannelsService.ts  |   3 +
 .../api/stream/channels/bubble-timeline.ts    | 124 +++++++++++++++++
 .../frontend/src/components/MkTimeline.vue    |  13 ++
 packages/frontend/src/const.ts                |   1 +
 .../frontend/src/pages/admin/moderation.vue   |  10 ++
 .../frontend/src/pages/admin/roles.editor.vue |  20 +++
 packages/frontend/src/pages/admin/roles.vue   |  12 ++
 packages/frontend/src/pages/timeline.vue      |   6 +
 packages/frontend/src/ui/deck/deck-store.ts   |   2 +-
 packages/frontend/src/ui/deck/tl-column.vue   |   9 +-
 .../frontend/src/widgets/WidgetTimeline.vue   |   8 +-
 .../megalodon/src/misskey/entities/meta.ts    |   1 +
 packages/misskey-js/src/entities.ts           |   1 +
 packages/misskey-js/src/streaming.types.ts    |   7 +
 27 files changed, 387 insertions(+), 4 deletions(-)
 create mode 100644 packages/backend/migration/1701647674000-BubbleInstances.js
 create mode 100644 packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts
 create mode 100644 packages/backend/src/server/api/stream/channels/bubble-timeline.ts

diff --git a/locales/en-US.yml b/locales/en-US.yml
index 7f5239ae8d..6a8227511e 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1598,6 +1598,7 @@ _role:
     high: "High"
   _options:
     gtlAvailable: "Can view the global timeline"
+    btlAvailable: "Can view the bubble timeline"
     ltlAvailable: "Can view the local timeline"
     canPublicNote: "Can send public notes"
     canImportNotes: "Can import notes"
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 9aeaeb9e56..98ab44dd41 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1698,6 +1698,7 @@ export interface Locale {
         };
         "_options": {
             "gtlAvailable": string;
+            "btlAvailable": string;
             "ltlAvailable": string;
             "canPublicNote": string;
             "canImportNotes": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 2f81a25cba..0af37e041f 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1607,6 +1607,7 @@ _role:
     high: "高"
   _options:
     gtlAvailable: "グローバルタイムラインの閲覧"
+    btlAvailable: "バブルのタイムラインを見ることができる"
     ltlAvailable: "ローカルタイムラインの閲覧"
     canPublicNote: "パブリック投稿の許可"
     canImportNotes: "ノートのインポートが可能"
diff --git a/packages/backend/migration/1701647674000-BubbleInstances.js b/packages/backend/migration/1701647674000-BubbleInstances.js
new file mode 100644
index 0000000000..9928b4c36e
--- /dev/null
+++ b/packages/backend/migration/1701647674000-BubbleInstances.js
@@ -0,0 +1,11 @@
+export class BubbleInstances1701647674000 {
+    name = 'BubbleInstances1701647674000'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "meta" ADD "bubbleInstances" character varying(256) array NOT NULL DEFAULT '{}'::varchar[]`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "bubbleInstances"`);
+    }
+}
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 4c5f883351..79c1ecc76f 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -26,6 +26,7 @@ import type { OnApplicationShutdown } from '@nestjs/common';
 export type RolePolicies = {
 	gtlAvailable: boolean;
 	ltlAvailable: boolean;
+	btlAvailable: boolean;
 	canPublicNote: boolean;
 	canInvite: boolean;
 	inviteLimit: number;
@@ -53,6 +54,7 @@ export type RolePolicies = {
 export const DEFAULT_POLICIES: RolePolicies = {
 	gtlAvailable: true,
 	ltlAvailable: true,
+	btlAvailable: false,
 	canPublicNote: true,
 	canInvite: false,
 	inviteLimit: 0,
@@ -303,6 +305,7 @@ export class RoleService implements OnApplicationShutdown {
 
 		return {
 			gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
+			btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)),
 			ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
 			canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
 			canInvite: calc('canInvite', vs => vs.some(v => v === true)),
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index 97bec444d6..b70828f3dd 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -544,4 +544,9 @@ export class MiMeta {
 		nullable: true,
 	})
 	public defaultLike: string | null;
+
+	@Column('varchar', {
+		length: 256, array: true, default: '{}',
+	})
+	public bubbleInstances: string[];
 }
diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts
index 0b6a7dfe22..e308b5d3e4 100644
--- a/packages/backend/src/server/NodeinfoServerService.ts
+++ b/packages/backend/src/server/NodeinfoServerService.ts
@@ -109,6 +109,7 @@ export class NodeinfoServerService {
 					disableRegistration: meta.disableRegistration,
 					disableLocalTimeline: !basePolicies.ltlAvailable,
 					disableGlobalTimeline: !basePolicies.gtlAvailable,
+					disableBubbleTimeline: !basePolicies.btlAvailable,
 					emailRequiredForSignup: meta.emailRequiredForSignup,
 					enableHcaptcha: meta.enableHcaptcha,
 					enableRecaptcha: meta.enableRecaptcha,
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index fc5eece01f..52070b5157 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -32,6 +32,7 @@ import { AntennaChannelService } from './api/stream/channels/antenna.js';
 import { ChannelChannelService } from './api/stream/channels/channel.js';
 import { DriveChannelService } from './api/stream/channels/drive.js';
 import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js';
+import { BubbleTimelineChannelService } from './api/stream/channels/bubble-timeline.js';
 import { HashtagChannelService } from './api/stream/channels/hashtag.js';
 import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js';
 import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js';
@@ -77,6 +78,7 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
 		ChannelChannelService,
 		DriveChannelService,
 		GlobalTimelineChannelService,
+		BubbleTimelineChannelService,
 		HashtagChannelService,
 		RoleTimelineChannelService,
 		HomeTimelineChannelService,
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index e7014c1333..2037856797 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -277,6 +277,7 @@ import * as ep___notes_favorites_create from './endpoints/notes/favorites/create
 import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js';
 import * as ep___notes_featured from './endpoints/notes/featured.js';
 import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js';
+import * as ep___notes_bubbleTimeline from './endpoints/notes/bubble-timeline.js';
 import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js';
 import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js';
 import * as ep___notes_mentions from './endpoints/notes/mentions.js';
@@ -648,6 +649,7 @@ const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create'
 const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default };
 const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default };
 const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', useClass: ep___notes_globalTimeline.default };
+const $notes_bubbleTimeline: Provider = { provide: 'ep:notes/bubble-timeline', useClass: ep___notes_bubbleTimeline.default };
 const $notes_hybridTimeline: Provider = { provide: 'ep:notes/hybrid-timeline', useClass: ep___notes_hybridTimeline.default };
 const $notes_localTimeline: Provider = { provide: 'ep:notes/local-timeline', useClass: ep___notes_localTimeline.default };
 const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default };
@@ -1023,6 +1025,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de
 		$notes_favorites_delete,
 		$notes_featured,
 		$notes_globalTimeline,
+		$notes_bubbleTimeline,
 		$notes_hybridTimeline,
 		$notes_localTimeline,
 		$notes_mentions,
@@ -1392,6 +1395,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de
 		$notes_favorites_delete,
 		$notes_featured,
 		$notes_globalTimeline,
+		$notes_bubbleTimeline,
 		$notes_hybridTimeline,
 		$notes_localTimeline,
 		$notes_mentions,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 0d32b79900..bf299d6ef4 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -277,6 +277,7 @@ import * as ep___notes_favorites_create from './endpoints/notes/favorites/create
 import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js';
 import * as ep___notes_featured from './endpoints/notes/featured.js';
 import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js';
+import * as ep___notes_bubbleTimeline from './endpoints/notes/bubble-timeline.js';
 import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js';
 import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js';
 import * as ep___notes_mentions from './endpoints/notes/mentions.js';
@@ -646,6 +647,7 @@ const eps = [
 	['notes/favorites/delete', ep___notes_favorites_delete],
 	['notes/featured', ep___notes_featured],
 	['notes/global-timeline', ep___notes_globalTimeline],
+	['notes/bubble-timeline', ep___notes_bubbleTimeline],
 	['notes/hybrid-timeline', ep___notes_hybridTimeline],
 	['notes/local-timeline', ep___notes_localTimeline],
 	['notes/mentions', ep___notes_mentions],
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index b1ba1633c9..f10accaeac 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -154,6 +154,13 @@ export const meta = {
 					type: 'string',
 				},
 			},
+			bubbleInstances: {
+				type: 'array',
+				optional: false, nullable: false,
+				items: {
+					type: 'string',
+				},
+			},
 			hcaptchaSecretKey: {
 				type: 'string',
 				optional: false, nullable: true,
@@ -402,6 +409,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				silencedHosts: instance.silencedHosts,
 				sensitiveWords: instance.sensitiveWords,
 				preservedUsernames: instance.preservedUsernames,
+				bubbleInstances: instance.bubbleInstances,
 				hcaptchaSecretKey: instance.hcaptchaSecretKey,
 				recaptchaSecretKey: instance.recaptchaSecretKey,
 				turnstileSecretKey: instance.turnstileSecretKey,
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index e1a1f3acb3..47deeffe0c 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -123,6 +123,7 @@ export const paramDef = {
 		enableIdenticonGeneration: { type: 'boolean' },
 		serverRules: { type: 'array', items: { type: 'string' } },
 		preservedUsernames: { type: 'array', items: { type: 'string' } },
+		bubbleInstances: { type: 'array', items: { type: 'string' } },
 		manifestJsonOverride: { type: 'string' },
 		enableFanoutTimeline: { type: 'boolean' },
 		enableFanoutTimelineDbFallback: { type: 'boolean' },
@@ -482,6 +483,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				set.preservedUsernames = ps.preservedUsernames;
 			}
 
+			if (ps.bubbleInstances !== undefined) {
+				set.bubbleInstances = ps.bubbleInstances;
+			}
+
 			if (ps.manifestJsonOverride !== undefined) {
 				set.manifestJsonOverride = ps.manifestJsonOverride;
 			}
diff --git a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts
new file mode 100644
index 0000000000..0652c82a9d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts
@@ -0,0 +1,130 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
+import type { NotesRepository } from '@/models/_.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { QueryService } from '@/core/QueryService.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import ActiveUsersChart from '@/core/chart/charts/active-users.js';
+import { DI } from '@/di-symbols.js';
+import { RoleService } from '@/core/RoleService.js';
+import { ApiError } from '../../error.js';
+import { CacheService } from '@/core/CacheService.js';
+import { MetaService } from '@/core/MetaService.js';
+
+export const meta = {
+	tags: ['notes'],
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'Note',
+		},
+	},
+
+	errors: {
+		btlDisabled: {
+			message: 'Bubble timeline has been disabled.',
+			code: 'BTL_DISABLED',
+			id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6c',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		withFiles: { type: 'boolean', default: false },
+		withBots: { type: 'boolean', default: true },
+		withRenotes: { type: 'boolean', default: true },
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+		sinceDate: { type: 'integer' },
+		untilDate: { type: 'integer' },
+	},
+	required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		@Inject(DI.notesRepository)
+		private notesRepository: NotesRepository,
+
+		private noteEntityService: NoteEntityService,
+		private queryService: QueryService,
+		private roleService: RoleService,
+		private activeUsersChart: ActiveUsersChart,
+		private cacheService: CacheService,
+		private metaService: MetaService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const policies = await this.roleService.getUserPolicies(me ? me.id : null);
+			const instance = await this.metaService.fetch();
+			if (!policies.btlAvailable) {
+				throw new ApiError(meta.errors.btlDisabled);
+			}
+
+			const [
+				followings,
+			] = me ? await Promise.all([
+				this.cacheService.userFollowingsCache.fetch(me.id),
+			]) : [undefined];
+
+			//#region Construct query
+			const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
+				ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
+				.andWhere('note.visibility = \'public\'')
+				.andWhere('note.channelId IS NULL')
+				.andWhere(
+					`(note.userHost = ANY ('{"${instance.bubbleInstances.join('","')}"}'))`,
+				)		
+				.innerJoinAndSelect('note.user', 'user')
+				.leftJoinAndSelect('note.reply', 'reply')
+				.leftJoinAndSelect('note.renote', 'renote')
+				.leftJoinAndSelect('reply.user', 'replyUser')
+				.leftJoinAndSelect('renote.user', 'renoteUser');
+
+			if (me) {
+				this.queryService.generateMutedUserQuery(query, me);
+				this.queryService.generateBlockedUserQuery(query, me);
+				this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
+			}
+
+			if (ps.withFiles) {
+				query.andWhere('note.fileIds != \'{}\'');
+			}
+
+			if (!ps.withBots) query.andWhere('user.isBot = FALSE');
+			
+			if (ps.withRenotes === false) {
+				query.andWhere(new Brackets(qb => {
+					qb.where('note.renoteId IS NULL');
+					qb.orWhere(new Brackets(qb => {
+						qb.where('note.text IS NOT NULL');
+						qb.orWhere('note.fileIds != \'{}\'');
+					}));
+				}));
+			}
+			//#endregion
+
+			let timeline = await query.limit(ps.limit).getMany();
+
+			timeline = timeline.filter(note => {
+				if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
+				return true;
+			});
+
+			process.nextTick(() => {
+				if (me) {
+					this.activeUsersChart.read(me);
+				}
+			});
+
+			return await this.noteEntityService.packMany(timeline, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts
index 8fd106c10c..f9f2f15aff 100644
--- a/packages/backend/src/server/api/stream/ChannelsService.ts
+++ b/packages/backend/src/server/api/stream/ChannelsService.ts
@@ -8,6 +8,7 @@ import { bindThis } from '@/decorators.js';
 import { HybridTimelineChannelService } from './channels/hybrid-timeline.js';
 import { LocalTimelineChannelService } from './channels/local-timeline.js';
 import { HomeTimelineChannelService } from './channels/home-timeline.js';
+import { BubbleTimelineChannelService } from './channels/bubble-timeline.js';
 import { GlobalTimelineChannelService } from './channels/global-timeline.js';
 import { MainChannelService } from './channels/main.js';
 import { ChannelChannelService } from './channels/channel.js';
@@ -28,6 +29,7 @@ export class ChannelsService {
 		private localTimelineChannelService: LocalTimelineChannelService,
 		private hybridTimelineChannelService: HybridTimelineChannelService,
 		private globalTimelineChannelService: GlobalTimelineChannelService,
+		private bubbleTimelineChannelService: BubbleTimelineChannelService,
 		private userListChannelService: UserListChannelService,
 		private hashtagChannelService: HashtagChannelService,
 		private roleTimelineChannelService: RoleTimelineChannelService,
@@ -48,6 +50,7 @@ export class ChannelsService {
 			case 'localTimeline': return this.localTimelineChannelService;
 			case 'hybridTimeline': return this.hybridTimelineChannelService;
 			case 'globalTimeline': return this.globalTimelineChannelService;
+			case 'bubbleTimeline': return this.bubbleTimelineChannelService;
 			case 'userList': return this.userListChannelService;
 			case 'hashtag': return this.hashtagChannelService;
 			case 'roleTimeline': return this.roleTimelineChannelService;
diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts
new file mode 100644
index 0000000000..74d5c3ea4e
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts
@@ -0,0 +1,124 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { checkWordMute } from '@/misc/check-word-mute.js';
+import { isInstanceMuted } from '@/misc/is-instance-muted.js';
+import { isUserRelated } from '@/misc/is-user-related.js';
+import type { Packed } from '@/misc/json-schema.js';
+import { MetaService } from '@/core/MetaService.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { bindThis } from '@/decorators.js';
+import { RoleService } from '@/core/RoleService.js';
+import type { MiMeta } from '@/models/Meta.js';
+import Channel from '../channel.js';
+
+class BubbleTimelineChannel extends Channel {
+	public readonly chName = 'bubbleTimeline';
+	public static shouldShare = false;
+	public static requireCredential = false;
+	private withRenotes: boolean;
+	private withFiles: boolean;
+	private withBots: boolean;
+	private instance: MiMeta;
+
+	constructor(
+		private metaService: MetaService,
+		private roleService: RoleService,
+		private noteEntityService: NoteEntityService,
+
+		id: string,
+		connection: Channel['connection'],
+	) {
+		super(id, connection);
+		//this.onNote = this.onNote.bind(this);
+	}
+
+	@bindThis
+	public async init(params: any) {
+		const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
+		if (!policies.btlAvailable) return;
+
+		this.withRenotes = params.withRenotes ?? true;
+		this.withFiles = params.withFiles ?? false;
+		this.withBots = params.withBots ?? true;
+		this.instance = await this.metaService.fetch();
+
+		// Subscribe events
+		this.subscriber.on('notesStream', this.onNote);
+	}
+
+	@bindThis
+	private async onNote(note: Packed<'Note'>) {
+		if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
+		if (!this.withBots && note.user.isBot) return;
+		
+		if (!(note.user.host != null && this.instance.bubbleInstances.includes(note.user.host) && note.visibility === 'public' )) return;
+
+		if (note.channelId != null) return;
+
+		// 関係ない返信は除外
+		if (note.reply && !this.following[note.userId]?.withReplies) {
+			const reply = note.reply;
+			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
+			if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
+		}
+
+		if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
+
+		if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
+
+		// Ignore notes from instances the user has muted
+		if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
+
+		// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
+		if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
+		// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
+		if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
+
+		if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
+
+		if (this.user && note.renoteId && !note.text) {
+			if (note.renote && Object.keys(note.renote.reactions).length > 0) {
+				const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
+				note.renote.myReaction = myRenoteReaction;
+			}
+		}
+
+		this.connection.cacheNote(note);
+
+		this.send('note', note);
+	}
+
+	@bindThis
+	public dispose() {
+		// Unsubscribe events
+		this.subscriber.off('notesStream', this.onNote);
+	}
+}
+
+@Injectable()
+export class BubbleTimelineChannelService {
+	public readonly shouldShare = BubbleTimelineChannel.shouldShare;
+	public readonly requireCredential = BubbleTimelineChannel.requireCredential;
+
+	constructor(
+		private metaService: MetaService,
+		private roleService: RoleService,
+		private noteEntityService: NoteEntityService,
+	) {
+	}
+
+	@bindThis
+	public create(id: string, connection: Channel['connection']): BubbleTimelineChannel {
+		return new BubbleTimelineChannel(
+			this.metaService,
+			this.roleService,
+			this.noteEntityService,
+			id,
+			connection,
+		);
+	}
+}
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 466c534259..85096dc583 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -117,6 +117,12 @@ function connectChannel() {
 			withFiles: props.onlyFiles ? true : undefined,
 			withBots: props.withBots,
 		});
+	} else if (props.src === 'bubble') {
+		connection = stream.useChannel('bubbleTimeline', {
+			withRenotes: props.withRenotes,
+			withFiles: props.onlyFiles ? true : undefined,
+			withBots: props.withBots,
+		});
 	} else if (props.src === 'global') {
 		connection = stream.useChannel('globalTimeline', {
 			withRenotes: props.withRenotes,
@@ -188,6 +194,13 @@ function updatePaginationQuery() {
 			withFiles: props.onlyFiles ? true : undefined,
 			withBots: props.withBots,
 		};
+	} else if (props.src === 'bubble') {
+		endpoint = 'notes/bubble-timeline';
+		query = {
+			withRenotes: props.withRenotes,
+			withFiles: props.onlyFiles ? true : undefined,
+			withBots: props.withBots,
+		};
 	} else if (props.src === 'global') {
 		endpoint = 'notes/global-timeline';
 		query = {
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index 03be17de38..2f8d57c7f6 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -111,6 +111,7 @@ export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
 export const ROLE_POLICIES = [
 	'gtlAvailable',
 	'ltlAvailable',
+	'btlAvailable',
 	'canPublicNote',
 	'canImportNotes',
 	'canInvite',
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index fff4629766..cacb3254a8 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -34,6 +34,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 						<template #label>{{ i18n.ts.privacyPolicyUrl }}</template>
 					</MkInput>
 
+					<MkTextarea v-if="bubbleTimelineEnabled" v-model="bubbleTimeline">
+						<template #label>Bubble timeline</template>
+						<template #caption>Choose which instances should be displayed in the bubble.</template>
+					</MkTextarea>
+
 					<MkTextarea v-model="preservedUsernames">
 						<template #label>{{ i18n.ts.preservedUsernames }}</template>
 						<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
@@ -76,8 +81,10 @@ import FormLink from '@/components/form/link.vue';
 let enableRegistration: boolean = $ref(false);
 let emailRequiredForSignup: boolean = $ref(false);
 let approvalRequiredForSignup: boolean = $ref(false);
+let bubbleTimelineEnabled: boolean = $ref(false);
 let sensitiveWords: string = $ref('');
 let preservedUsernames: string = $ref('');
+let bubbleTimeline: string = $ref('');
 let tosUrl: string | null = $ref(null);
 let privacyPolicyUrl: string | null = $ref(null);
 
@@ -90,6 +97,8 @@ async function init() {
 	preservedUsernames = meta.preservedUsernames.join('\n');
 	tosUrl = meta.tosUrl;
 	privacyPolicyUrl = meta.privacyPolicyUrl;
+	bubbleTimeline = meta.bubbleInstances.join('\n');
+	bubbleTimelineEnabled = meta.policies.btlAvailable;
 }
 
 function save() {
@@ -101,6 +110,7 @@ function save() {
 		privacyPolicyUrl,
 		sensitiveWords: sensitiveWords.split('\n'),
 		preservedUsernames: preservedUsernames.split('\n'),
+		bubbleInstances: bubbleTimeline.split('\n'),
 	}).then(() => {
 		fetchInstance();
 	});
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 8c656e917b..efdf1ff4f8 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -120,6 +120,26 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 			</MkFolder>
 
+			<MkFolder v-if="matchQuery([i18n.ts._role._options.btlAvailable, 'btlAvailable'])">
+				<template #label>{{ i18n.ts._role._options.btlAvailable }}</template>
+				<template #suffix>
+					<span v-if="role.policies.btlAvailable.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+					<span v-else>{{ role.policies.btlAvailable.value ? i18n.ts.yes : i18n.ts.no }}</span>
+					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.btlAvailable)"></i></span>
+				</template>
+				<div class="_gaps">
+					<MkSwitch v-model="role.policies.btlAvailable.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts._role.useBaseValue }}</template>
+					</MkSwitch>
+					<MkSwitch v-model="role.policies.btlAvailable.value" :disabled="role.policies.btlAvailable.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts.enable }}</template>
+					</MkSwitch>
+					<MkRange v-model="role.policies.btlAvailable.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+						<template #label>{{ i18n.ts._role.priority }}</template>
+					</MkRange>
+				</div>
+			</MkFolder>
+
 			<MkFolder v-if="matchQuery([i18n.ts._role._options.ltlAvailable, 'ltlAvailable'])">
 				<template #label>{{ i18n.ts._role._options.ltlAvailable }}</template>
 				<template #suffix>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index a5e4369b38..7fedb87d41 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -32,6 +32,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 							</MkSwitch>
 						</MkFolder>
 
+						<MkFolder v-if="matchQuery([i18n.ts._role._options.btlAvailable, 'btlAvailable'])">
+							<template #label>{{ i18n.ts._role._options.btlAvailable }}</template>
+							<template #suffix>{{ policies.btlAvailable ? i18n.ts.yes : i18n.ts.no }}</template>
+							<div class="_gaps_s">
+								<MkInfo :warn="true">After enabling this option navigate to the Moderation section to configure which instances should be shown.</MkInfo>
+								<MkSwitch v-model="policies.btlAvailable">
+									<template #label>{{ i18n.ts.enable }}</template>
+								</MkSwitch>
+							</div>
+						</MkFolder>
+
 						<MkFolder v-if="matchQuery([i18n.ts._role._options.ltlAvailable, 'ltlAvailable'])">
 							<template #label>{{ i18n.ts._role._options.ltlAvailable }}</template>
 							<template #suffix>{{ policies.ltlAvailable ? i18n.ts.yes : i18n.ts.no }}</template>
@@ -232,6 +243,7 @@ import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkRange from '@/components/MkRange.vue';
+import MkInfo from '@/components/MkInfo.vue';
 import MkRolePreview from '@/components/MkRolePreview.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 4abffb72da..2f63ec9a38 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -54,6 +54,7 @@ provide('shouldOmitHeaderTitle', true);
 
 const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable);
 const isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable);
+const isBubbleTimelineAvailable = ($i == null && instance.policies.btlAvailable) || ($i != null && $i.policies.btlAvailable);
 const keymap = {
 	't': focus,
 };
@@ -207,6 +208,11 @@ const headerTabs = $computed(() => [...(defaultStore.reactiveState.pinnedUserLis
 	title: i18n.ts._timelines.social,
 	icon: 'ph-rocket-launch ph-bold ph-lg',
 	iconOnly: true,
+}] : []), ...(isBubbleTimelineAvailable ? [{
+	key: 'bubble',
+	title: 'Bubble',
+	icon: 'ph-drop ph-bold ph-lg',
+	iconOnly: true,
 }] : []), ...(isGlobalTimelineAvailable ? [{
 	key: 'global',
 	title: i18n.ts._timelines.global,
diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts
index 49fdf4d314..e68b7bba8c 100644
--- a/packages/frontend/src/ui/deck/deck-store.ts
+++ b/packages/frontend/src/ui/deck/deck-store.ts
@@ -29,7 +29,7 @@ export type Column = {
 	channelId?: string;
 	roleId?: string;
 	excludeTypes?: typeof notificationTypes[number][];
-	tl?: 'home' | 'local' | 'social' | 'global';
+	tl?: 'home' | 'local' | 'social' | 'global' | 'bubble';
 	withRenotes?: boolean;
 	withReplies?: boolean;
 	onlyFiles?: boolean;
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
index 1b04bcf0f3..b94adfb711 100644
--- a/packages/frontend/src/ui/deck/tl-column.vue
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -9,11 +9,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<i v-if="column.tl === 'home'" class="ph-house ph-bold ph-lg"></i>
 		<i v-else-if="column.tl === 'local'" class="ph-planet ph-bold ph-lg"></i>
 		<i v-else-if="column.tl === 'social'" class="ph-rocket-launch ph-bold ph-lg"></i>
+		<i v-else-if="column.tl === 'bubble'" class="ph-thumb-up ph-bold ph-lg"></i>
 		<i v-else-if="column.tl === 'global'" class="ph-globe-hemisphere-west ph-bold ph-lg"></i>
 		<span style="margin-left: 8px;">{{ column.name }}</span>
 	</template>
 
-	<div v-if="(((column.tl === 'local' || column.tl === 'social') && !isLocalTimelineAvailable) || (column.tl === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled">
+	<div v-if="(((column.tl === 'local' || column.tl === 'social') && !isLocalTimelineAvailable) || (column.tl === 'bubble' && !isBubbleTimelineAvailable)) || (column.tl === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled">
 		<p :class="$style.disabledTitle">
 			<i class="ph-minus-circle ph-bold ph-lg"></i>
 			{{ i18n.ts._disabledTimeline.title }}
@@ -52,6 +53,7 @@ let timeline = $shallowRef<InstanceType<typeof MkTimeline>>();
 
 const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
 const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
+const isBubbleTimelineAvailable = ($i == null && instance.policies.btlAvailable) || ($i != null && $i.policies.btlAvailable);
 const withRenotes = $ref(props.column.withRenotes ?? true);
 const withReplies = $ref(props.column.withReplies ?? false);
 const onlyFiles = $ref(props.column.onlyFiles ?? false);
@@ -80,7 +82,8 @@ onMounted(() => {
 	} else if ($i) {
 		disabled = (
 			(!((instance.policies.ltlAvailable) || ($i.policies.ltlAvailable)) && ['local', 'social'].includes(props.column.tl)) ||
-			(!((instance.policies.gtlAvailable) || ($i.policies.gtlAvailable)) && ['global'].includes(props.column.tl)));
+			(!((instance.policies.gtlAvailable) || ($i.policies.gtlAvailable)) && ['global'].includes(props.column.tl)) ||
+			(!((instance.policies.btlAvailable) || ($i.policies.btlAvailable)) && ['bubble'].includes(props.column.tl)));
 	}
 });
 
@@ -93,6 +96,8 @@ async function setType() {
 			value: 'local' as const, text: i18n.ts._timelines.local,
 		}, {
 			value: 'social' as const, text: i18n.ts._timelines.social,
+		}, {
+			value: 'bubble' as const, text: 'Bubble',
 		}, {
 			value: 'global' as const, text: i18n.ts._timelines.global,
 		}],
diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue
index 52b7068209..0ebffa105e 100644
--- a/packages/frontend/src/widgets/WidgetTimeline.vue
+++ b/packages/frontend/src/widgets/WidgetTimeline.vue
@@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<i v-if="widgetProps.src === 'home'" class="ph-house ph-bold ph-lg"></i>
 		<i v-else-if="widgetProps.src === 'local'" class="ph-planet ph-bold ph-lg"></i>
 		<i v-else-if="widgetProps.src === 'social'" class="ph-rocket-launch ph-bold ph-lg"></i>
+		<i v-else-if="widgetProps.src === 'bubble'" class="ph-drop ph-bold ph-lg"></i>
 		<i v-else-if="widgetProps.src === 'global'" class="ph-globe-hemisphere-west ph-bold ph-lg"></i>
 		<i v-else-if="widgetProps.src === 'list'" class="ph-list ph-bold ph-lg"></i>
 		<i v-else-if="widgetProps.src === 'antenna'" class="ph-flying-saucer ph-bold ph-lg"></i>
@@ -20,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</button>
 	</template>
 
-	<div v-if="(((widgetProps.src === 'local' || widgetProps.src === 'social') && !isLocalTimelineAvailable) || (widgetProps.src === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled">
+	<div v-if="(((widgetProps.src === 'local' || widgetProps.src === 'social') && !isLocalTimelineAvailable) || (widgetProps.src === 'bubble' && !isBubbleTimelineAvailable) || (widgetProps.src === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled">
 		<p :class="$style.disabledTitle">
 			<i class="ph-minus ph-bold ph-lg"></i>
 			{{ i18n.ts._disabledTimeline.title }}
@@ -47,6 +48,7 @@ import { instance } from '@/instance.js';
 const name = 'timeline';
 const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
 const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
+const isBubbleTimelineAvailable = (($i == null && instance.policies.btlAvailable) || ($i != null && $i.policies.btlAvailable));
 
 const widgetPropsDef = {
 	showHeader: {
@@ -126,6 +128,10 @@ const choose = async (ev) => {
 		text: i18n.ts._timelines.social,
 		icon: 'ph-rocket-launch ph-bold ph-lg',
 		action: () => { setSrc('social'); },
+	}, {
+		text: 'Bubble',
+		icon: 'ph-drop ph-bold ph-lg',
+		action: () => { setSrc('bubble'); },
 	}, {
 		text: i18n.ts._timelines.global,
 		icon: 'ph-globe-hemisphere-west ph-bold ph-lg',
diff --git a/packages/megalodon/src/misskey/entities/meta.ts b/packages/megalodon/src/misskey/entities/meta.ts
index b9b74b91d8..73a0104bdd 100644
--- a/packages/megalodon/src/misskey/entities/meta.ts
+++ b/packages/megalodon/src/misskey/entities/meta.ts
@@ -16,6 +16,7 @@ namespace MisskeyEntity {
     emojis: Array<Emoji>
     policies: {
       gtlAvailable: boolean
+      btlAvailable: boolean
       ltlAvailable: boolean
       canPublicNote: boolean
       canImportNotes: boolean
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index 10b9dd5eb4..05960a5719 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -361,6 +361,7 @@ export type LiteInstanceMetadata = {
 	privacyPolicyUrl: string | null;
 	disableRegistration: boolean;
 	disableLocalTimeline: boolean;
+	disableBubbleTimeline: boolean;
 	disableGlobalTimeline: boolean;
 	driveCapacityPerLocalUserMb: number;
 	driveCapacityPerRemoteUserMb: number;
diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts
index 124770bf1d..a6688e3866 100644
--- a/packages/misskey-js/src/streaming.types.ts
+++ b/packages/misskey-js/src/streaming.types.ts
@@ -70,6 +70,13 @@ export type Channels = {
 		};
 		receives: null;
 	};
+	bubbleTimeline: {
+		params: null;
+		events: {
+			note: (payload: Note) => void;
+		};
+		receives: null;
+	};
 	messaging: {
 		params: {
 			otherparty?: User['id'] | null;