diff --git a/CHANGELOG_CHERRYPICK.md b/CHANGELOG_CHERRYPICK.md index 7b5dbc942c..b4237dc6f0 100644 --- a/CHANGELOG_CHERRYPICK.md +++ b/CHANGELOG_CHERRYPICK.md @@ -28,6 +28,10 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2024xx](CHANGE 기반 Misskey 버전: 2024.x.x
Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2024xx](CHANGELOG.md#2024xx) 문서를 참고하십시오. +### General +- Feat: 버블 타임라인 (kokonect-link/cherrypick#512, [TransFem-org/Sharkey#154](https://activitypub.software/TransFem-org/Sharkey/-/issues/154), [TransFem-org/Sharkey@2f99c7e9](https://activitypub.software/TransFem-org/Sharkey/-/commit/2f99c7e9dc2e5e3ca06c9672a6ab4887eb094310)) + - 관리자가 설정한 서버의 게시글만 볼 수 있는 타임라인으로, 글로벌 타임라인의 무분별한 내용을 포함하는 게시글을 제한하는 목적으로 사용할 수 있습니다. + ### Client - Enhance: 미디어 그리드 레이아옷 조정 - 여러 장의 이미지가 있을 때 표시되는 아이콘을 보다 명확하게 볼 수 있도록 개선됨 diff --git a/locales/en-US.yml b/locales/en-US.yml index da68ba48c1..da5532ad7f 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1,5 +1,8 @@ --- _lang_: "English" +bubbleTimeline: "Bubble Timeline" +bubbleTimelineDescription: "After enabling this option navigate to the Moderation section to configure which servers should be shown." +bubbleInstancesDescription: "Set the host names of servers to be displayed in the bubble timeline, separated by line breaks." selectReaction: "Select reactions to use with the Like button" disableRegistrationWhenInactive: "Disable new user registration when moderator is inactivated" disablePublicNoteWhenInactive: "Disable 'Can send public notes' when moderator is inactivated" @@ -1667,6 +1670,7 @@ _timelineDescription: local: "In the Local timeline, you can see notes from all users on this server." social: "The Social timeline displays notes from both the Home and Local timelines." global: "In the Global timeline, you can see notes from all connected servers." + bubble: "In the Bubble timeline, you can see notes from servers set up by the administrator." _serverRules: description: "A set of rules to be displayed before registration. Setting a summary of the Terms of Service is recommended." _event: @@ -2020,6 +2024,7 @@ _role: _options: gtlAvailable: "Can view the global timeline" ltlAvailable: "Can view the local timeline" + btlAvailable: "Can view the bubble timeline" canPublicNote: "Can send public notes" canEditNote: "Note editing" scheduleNoteMax: "Maximum number of scheduled notes" @@ -2695,6 +2700,7 @@ _timelines: local: "Local" social: "Social" global: "Global" + bubble: "Bubble" _play: new: "Create Play" edit: "Edit Play" diff --git a/locales/index.d.ts b/locales/index.d.ts index 7dcbe8268b..c7fd75250c 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13,6 +13,18 @@ export interface Locale extends ILocale { * 日本語 */ "_lang_": string; + /** + * バブルタイムライン + */ + "bubbleTimeline": string; + /** + * このオプションを有効にし、モデレーションに移動して、表示するサーバを設定します。 + */ + "bubbleTimelineDescription": string; + /** + * バブルタイムラインに表示するサーバのホスト名を改行で区切って設定します。 + */ + "bubbleInstancesDescription": string; /** * いいねボタンで使うリアクションを選択 */ @@ -6639,6 +6651,10 @@ export interface Locale extends ILocale { * グローバルタイムラインでは、接続している他のすべてのサーバーからの投稿を見られます。 */ "global": string; + /** + * バブルタイムラインでは、管理者が設定したサーバーの投稿を見ることができます。 + */ + "bubble": string; }; "_serverRules": { /** @@ -7898,6 +7914,10 @@ export interface Locale extends ILocale { * ローカルタイムラインの閲覧 */ "ltlAvailable": string; + /** + * バブルタイムラインの閲覧 + */ + "btlAvailable": string; /** * パブリック投稿の許可 */ @@ -10503,6 +10523,10 @@ export interface Locale extends ILocale { * グローバル */ "global": string; + /** + * バブル + */ + "bubble": string; }; "_play": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b71d95abca..c8f47eab82 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1,5 +1,8 @@ _lang_: "日本語" +bubbleTimeline: "バブルタイムライン" +bubbleTimelineDescription: "このオプションを有効にし、モデレーションに移動して、表示するサーバを設定します。" +bubbleInstancesDescription: "バブルタイムラインに表示するサーバのホスト名を改行で区切って設定します。" selectReaction: "いいねボタンで使うリアクションを選択" disableRegistrationWhenInactive: "モデレーターが一定期間非アクティブになったとき、新規登録を無効化" disablePublicNoteWhenInactive: "モデレーターが一定期間非アクティブになったとき、「パブリック投稿の許可」を無効化" @@ -1684,6 +1687,7 @@ _timelineDescription: local: "ローカルタイムラインでは、このサーバーにいるユーザー全員の投稿を見られます。" social: "ソーシャルタイムラインには、ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" global: "グローバルタイムラインでは、接続している他のすべてのサーバーからの投稿を見られます。" + bubble: "バブルタイムラインでは、管理者が設定したサーバーの投稿を見ることができます。" _serverRules: description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" @@ -2045,6 +2049,7 @@ _role: _options: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" + btlAvailable: "バブルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" canEditNote: "ノートの編集" scheduleNoteMax: "予約投稿の最大数" @@ -2764,6 +2769,7 @@ _timelines: local: "ローカル" social: "ソーシャル" global: "グローバル" + bubble: "バブル" _play: new: "Playの作成" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 4577f64055..29cc59c744 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1,5 +1,8 @@ --- _lang_: "한국어" +bubbleTimeline: "버블 타임라인" +bubbleTimelineDescription: "이 옵션을 활성화하고 모더레이션으로 이동해 버블 타임라인에 표시할 서버를 구성해 주세요." +bubbleInstancesDescription: "버블 타임라인에 표시할 인스턴스의 호스트 이름을 줄바꿈으로 구분하여 설정해요." selectReaction: "'좋아요!' 버튼으로 사용할 리액션 선택" disableRegistrationWhenInactive: "모더레이터 부재 시 신규 회원가입 비활성화" disablePublicNoteWhenInactive: "모더레이터 부재 시 '공개 노트 허용' 비활성화" @@ -1668,6 +1671,7 @@ _timelineDescription: local: "로컬 타임라인에서는, 이 서버의 모든 사용자의 게시물을 볼 수 있어요." social: "소셜 타임라인에서는, 홈 타임라인과 로컬 타임라인의 게시물을 모두 볼 수 있어요." global: "글로벌 타임라인에서는, 여기와 연결된 다른 모든 서버의 게시물을 볼 수 있어요." + bubble: "버블 타임라인에서는, 관리자가 설정한 서버의 게시물을 볼 수 있어요." _serverRules: description: "회원 가입 이전에 간단하게 표시할 서버 규칙이에요. 이용 약관의 요약으로 구성하는 것을 추천해요." _event: @@ -2023,6 +2027,7 @@ _role: _options: gtlAvailable: "글로벌 타임라인 보이기" ltlAvailable: "로컬 타임라인 보이기" + btlAvailable: "버블 타임라인 보이기" canPublicNote: "공개 노트 허용" canEditNote: "노트 편집 허용" scheduleNoteMax: "게시를 예약한 노트의 최대 수" @@ -2698,6 +2703,7 @@ _timelines: local: "로컬" social: "소셜" global: "글로벌" + bubble: "버블" _play: new: "Play 만들기" edit: "Play 편집하기" diff --git a/packages/backend/migration/1701647674000-BubbleInstances.js b/packages/backend/migration/1701647674000-BubbleInstances.js new file mode 100644 index 0000000000..f048f521b0 --- /dev/null +++ b/packages/backend/migration/1701647674000-BubbleInstances.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 7e58ce60e2..e7fa9e677e 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -34,6 +34,7 @@ import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; + btlAvailable: boolean; canPublicNote: boolean; canEditNote: boolean; scheduleNoteMax: number; @@ -71,6 +72,7 @@ export type RolePolicies = { export const DEFAULT_POLICIES: RolePolicies = { gtlAvailable: true, ltlAvailable: true, + btlAvailable: false, canPublicNote: true, canEditNote: true, scheduleNoteMax: 5, @@ -381,6 +383,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { return { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), + btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canEditNote: calc('canEditNote', vs => vs.some(v => v === true)), scheduleNoteMax: calc('scheduleNoteMax', vs => Math.max(...vs)), diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 7b1e15ba4d..33f7cdf53e 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -823,4 +823,11 @@ export class MiMeta { default: false, }) public disablePublicNoteWhenInactive: boolean; + + @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 aa84e82db5..7c0d46efc7 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -120,6 +120,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 43b597bb06..cfb1b0386e 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -49,6 +49,7 @@ import { RoleTimelineChannelService } from './api/stream/channels/role-timeline. import { ReversiChannelService } from './api/stream/channels/reversi.js'; import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; +import { BubbleTimelineChannelService } from './api/stream/channels/bubble-timeline.js'; @Module({ imports: [ @@ -98,6 +99,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j UserListChannelService, OpenApiServerService, OAuth2ProviderService, + BubbleTimelineChannelService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 880cdadb3d..837658772a 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -303,6 +303,7 @@ import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; 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_bubbleTimeline from './endpoints/notes/bubble-timeline.js'; import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; @@ -726,6 +727,7 @@ const $notes_update: Provider = { provide: 'ep:notes/update', useClass: ep___not const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; 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_bubbleTimeline: Provider = { provide: 'ep:notes/bubble-timeline', useClass: ep___notes_bubbleTimeline.default }; const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', useClass: ep___notes_globalTimeline.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 }; @@ -1154,6 +1156,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_favorites_create, $notes_favorites_delete, $notes_featured, + $notes_bubbleTimeline, $notes_globalTimeline, $notes_hybridTimeline, $notes_localTimeline, @@ -1573,6 +1576,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_favorites_create, $notes_favorites_delete, $notes_featured, + $notes_bubbleTimeline, $notes_globalTimeline, $notes_hybridTimeline, $notes_localTimeline, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index b72ed843ea..1ec54b7b36 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -308,6 +308,7 @@ import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; 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_bubbleTimeline from './endpoints/notes/bubble-timeline.js'; import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; @@ -729,6 +730,7 @@ const eps = [ ['notes/favorites/create', ep___notes_favorites_create], ['notes/favorites/delete', ep___notes_favorites_delete], ['notes/featured', ep___notes_featured], + ['notes/bubble-timeline', ep___notes_bubbleTimeline], ['notes/global-timeline', ep___notes_globalTimeline], ['notes/hybrid-timeline', ep___notes_hybridTimeline], ['notes/local-timeline', ep___notes_localTimeline], diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 6777f39880..9881e021c9 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -625,6 +625,13 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + bubbleInstances: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + }, + }, }, }, } as const; @@ -798,6 +805,7 @@ export default class extends Endpoint { // eslint- customSplashText: instance.customSplashText, disableRegistrationWhenInactive: instance.disableRegistrationWhenInactive, disablePublicNoteWhenInactive: instance.disablePublicNoteWhenInactive, + bubbleInstances: instance.bubbleInstances, }; }); } 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 87976ddf69..0ac4619b83 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -224,6 +224,7 @@ export const paramDef = { }, disableRegistrationWhenInactive: { type: 'boolean', nullable: true }, disablePublicNoteWhenInactive: { type: 'boolean', nullable: true }, + bubbleInstances: { type: 'array', items: { type: 'string' } }, }, required: [], } as const; @@ -833,6 +834,10 @@ export default class extends Endpoint { // eslint- set.disablePublicNoteWhenInactive = ps.disablePublicNoteWhenInactive; } + if (ps.bubbleInstances !== undefined) { + set.bubbleInstances = ps.bubbleInstances; + } + const before = await this.metaService.fetch(true); await this.metaService.update(set); 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..bb396d88a8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -0,0 +1,134 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import type { NotesRepository, MiMeta } 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 { CacheService } from '@/core/CacheService.js'; +import { ApiError } from '../../error.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 }, + withCats: { 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 { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private roleService: RoleService, + private activeUsersChart: ActiveUsersChart, + private cacheService: CacheService, + ) { + super(meta, paramDef, async (ps, me) => { + const policies = await this.roleService.getUserPolicies(me ? me.id : null); + 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 IN (:...hosts)', { hosts: this.serverSettings.bubbleInstances }) + .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.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 != \'{}\''); + })); + })); + } + + if (ps.withCats) { + query.andWhere('(select "isCat" from "user" where id = note."userId")'); + } + + if (!ps.withBots) { + query.andWhere('user.isBot = FALSE'); + } + //#endregion + + let timeline = await query.limit(ps.limit).getMany(); + + timeline = timeline.filter(note => { + return !(note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]); + }); + + 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 e6ca455510..715d6d73ed 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -9,6 +9,7 @@ import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; import { LocalTimelineChannelService } from './channels/local-timeline.js'; import { HomeTimelineChannelService } from './channels/home-timeline.js'; import { GlobalTimelineChannelService } from './channels/global-timeline.js'; +import { BubbleTimelineChannelService } from './channels/bubble-timeline.js'; import { MainChannelService } from './channels/main.js'; import { ChannelChannelService } from './channels/channel.js'; import { AdminChannelService } from './channels/admin.js'; @@ -33,6 +34,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, @@ -57,6 +59,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..06ac5528ab --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +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 { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import type { JsonObject } from '@/misc/json-value.js'; +import Channel, { MiChannelService } from '../channel.js'; + +class BubbleTimelineChannel extends Channel { + public readonly chName = 'bubbleTimeline'; + public static shouldShare = false; + public static requireCredential = false as const; + private withRenotes: boolean; + private withFiles: boolean; + private withCats: 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: JsonObject) { + 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.withCats = !!(params.withCats ?? 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.withCats && (note.user.isCat == null || note.user.isCat === false)) 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 (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; + + if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; + + if (this.isNoteMutedOrBlocked(note)) return; + + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { + 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 implements MiChannelService { + public readonly shouldShare = BubbleTimelineChannel.shouldShare; + public readonly requireCredential = BubbleTimelineChannel.requireCredential; + public readonly kind = BubbleTimelineChannel.kind; + + 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/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index c6c730dc61..cca82bfd38 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -1726,6 +1726,8 @@ declare namespace entities { NotesFavoritesDeleteRequest, NotesFeaturedRequest, NotesFeaturedResponse, + NotesBubbleTimelineRequest, + NotesBubbleTimelineResponse, NotesGlobalTimelineRequest, NotesGlobalTimelineResponse, NotesHybridTimelineRequest, @@ -2806,6 +2808,12 @@ type NoteFavorite = components['schemas']['NoteFavorite']; // @public (undocumented) type NoteReaction = components['schemas']['NoteReaction']; +// @public (undocumented) +type NotesBubbleTimelineRequest = operations['notes___bubble-timeline']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesBubbleTimelineResponse = operations['notes___bubble-timeline']['responses']['200']['content']['application/json']; + // @public (undocumented) type NotesChildrenRequest = operations['notes___children']['requestBody']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts index 7fa1069b04..8217693c03 100644 --- a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts +++ b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts @@ -3310,6 +3310,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *No* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/cherrypick-js/src/autogen/endpoint.ts b/packages/cherrypick-js/src/autogen/endpoint.ts index 5a7bcba8f0..d4a9840c99 100644 --- a/packages/cherrypick-js/src/autogen/endpoint.ts +++ b/packages/cherrypick-js/src/autogen/endpoint.ts @@ -441,6 +441,8 @@ import type { NotesFavoritesDeleteRequest, NotesFeaturedRequest, NotesFeaturedResponse, + NotesBubbleTimelineRequest, + NotesBubbleTimelineResponse, NotesGlobalTimelineRequest, NotesGlobalTimelineResponse, NotesHybridTimelineRequest, @@ -929,6 +931,7 @@ export type Endpoints = { 'notes/favorites/create': { req: NotesFavoritesCreateRequest; res: EmptyResponse }; 'notes/favorites/delete': { req: NotesFavoritesDeleteRequest; res: EmptyResponse }; 'notes/featured': { req: NotesFeaturedRequest; res: NotesFeaturedResponse }; + 'notes/bubble-timeline': { req: NotesBubbleTimelineRequest; res: NotesBubbleTimelineResponse }; 'notes/global-timeline': { req: NotesGlobalTimelineRequest; res: NotesGlobalTimelineResponse }; 'notes/hybrid-timeline': { req: NotesHybridTimelineRequest; res: NotesHybridTimelineResponse }; 'notes/local-timeline': { req: NotesLocalTimelineRequest; res: NotesLocalTimelineResponse }; diff --git a/packages/cherrypick-js/src/autogen/entities.ts b/packages/cherrypick-js/src/autogen/entities.ts index 3aa2058cc8..d51cd141c8 100644 --- a/packages/cherrypick-js/src/autogen/entities.ts +++ b/packages/cherrypick-js/src/autogen/entities.ts @@ -444,6 +444,8 @@ export type NotesFavoritesCreateRequest = operations['notes___favorites___create export type NotesFavoritesDeleteRequest = operations['notes___favorites___delete']['requestBody']['content']['application/json']; export type NotesFeaturedRequest = operations['notes___featured']['requestBody']['content']['application/json']; export type NotesFeaturedResponse = operations['notes___featured']['responses']['200']['content']['application/json']; +export type NotesBubbleTimelineRequest = operations['notes___bubble-timeline']['requestBody']['content']['application/json']; +export type NotesBubbleTimelineResponse = operations['notes___bubble-timeline']['responses']['200']['content']['application/json']; export type NotesGlobalTimelineRequest = operations['notes___global-timeline']['requestBody']['content']['application/json']; export type NotesGlobalTimelineResponse = operations['notes___global-timeline']['responses']['200']['content']['application/json']; export type NotesHybridTimelineRequest = operations['notes___hybrid-timeline']['requestBody']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index c4cbaaefff..7b73768569 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -2860,6 +2860,15 @@ export type paths = { */ post: operations['notes___featured']; }; + '/notes/bubble-timeline': { + /** + * notes/bubble-timeline + * @description No description provided. + * + * **Credential required**: *No* + */ + post: operations['notes___bubble-timeline']; + }; '/notes/global-timeline': { /** * notes/global-timeline @@ -5644,6 +5653,7 @@ export type operations = { customSplashText: string[]; disableRegistrationWhenInactive: boolean; disablePublicNoteWhenInactive: boolean; + bubbleInstances: string[]; }; }; }; @@ -10469,6 +10479,7 @@ export type operations = { customSplashText?: string[] | null; disableRegistrationWhenInactive?: boolean | null; disablePublicNoteWhenInactive?: boolean | null; + bubbleInstances?: string[]; }; }; }; @@ -23305,6 +23316,74 @@ export type operations = { }; }; }; + /** + * notes/bubble-timeline + * @description No description provided. + * + * **Credential required**: *No* + */ + 'notes___bubble-timeline': { + requestBody: { + content: { + 'application/json': { + /** @default false */ + withFiles?: boolean; + /** @default false */ + withCats?: boolean; + /** @default true */ + withBots?: boolean; + /** @default true */ + withRenotes?: boolean; + /** @default 10 */ + limit?: number; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + sinceDate?: number; + untilDate?: number; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['Note'][]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * notes/global-timeline * @description No description provided. diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 5e381096e6..af3d886727 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -78,6 +78,7 @@ export const obsoleteNotificationTypes = ['pollVote'/*, 'groupInvited'*/] as con export const ROLE_POLICIES = [ 'gtlAvailable', 'ltlAvailable', + 'btlAvailable', 'canPublicNote', 'canEditNote', 'scheduleNoteMax', diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 91cd9b6e67..2e096d9639 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -135,6 +135,12 @@ function connectChannel() { withFiles: props.onlyFiles ? true : undefined, withCats: props.onlyCats, }); + } else if (props.src === 'bubble') { + connection = stream.useChannel('bubbleTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withCats: props.onlyCats, + }); } else if (props.src === 'mentions') { connection = stream.useChannel('main'); connection.on('mention', prepend); @@ -212,6 +218,13 @@ function updatePaginationQuery() { withFiles: props.onlyFiles ? true : undefined, withCats: props.onlyCats, }; + } else if (props.src === 'bubble') { + endpoint = 'notes/bubble-timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withCats: props.onlyCats, + }; } else if (props.src === 'mentions') { endpoint = 'notes/mentions'; query = null; diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 8cb53e3a1f..6ab37fe1f3 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -139,6 +139,18 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.save }} + + + + + +
+ + + + {{ i18n.ts.save }} +
+
@@ -175,6 +187,8 @@ const blockedHosts = ref(''); const silencedHosts = ref(''); const mediaSilencedHosts = ref(''); const trustedLinkUrlPatterns = ref(''); +const bubbleTimelineEnabled = ref(false); +const bubbleTimeline = ref(''); async function init() { const meta = await misskeyApi('admin/meta'); @@ -191,6 +205,8 @@ async function init() { silencedHosts.value = meta.silencedHosts?.join('\n') ?? ''; mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n'); trustedLinkUrlPatterns.value = meta.trustedLinkUrlPatterns.join('\n'); + bubbleTimelineEnabled.value = meta.policies.btlAvailable; + bubbleTimeline.value = meta.bubbleInstances.join('\n'); } async function onChange_enableRegistration(value: boolean) { @@ -307,6 +323,14 @@ function save_mediaSilencedHosts() { }); } +function save_bubbleTimeline() { + os.apiWithDialog('admin/update-meta', { + bubbleInstances: bubbleTimeline.value.split('\n') || [], + }).then(() => { + fetchInstance(true); + }); +} + const headerTabs = computed(() => []); definePageMetadata(() => ({ diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 88939a1b4e..ad1f8ea6c7 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -140,6 +140,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + + +
+
+