diff --git a/locales/en-US.yml b/locales/en-US.yml index e77139036..fd1d2be04 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1101,6 +1101,10 @@ additionalEmojiDictionary: "Additional emoji dictionaries" installed: "Installed" branding: "Branding" enableServerMachineStats: "Publish server hardware stats" +enableAchievements: "Enable Achievements" +turnOffAchievements: "Turning this off will disable the achievement system" +enableBotTrending: "Populate Hashtags with Bots" +turnOffBotTrending: "Turning this off will stop Bots from populating Hashtags" enableIdenticonGeneration: "Enable user identicon generation" turnOffToImprovePerformance: "Turning this off can increase performance." createInviteCode: "Generate invite" @@ -1137,6 +1141,7 @@ loadConversation: "Show conversation" pinnedList: "Pinned list" keepScreenOn: "Keep screen on" clickToOpen: "Click to open notes" +showBots: "Show bots in timeline" verifiedLink: "Link ownership has been verified" notifyNotes: "Notify about new notes" unnotifyNotes: "Stop notifying about new notes" diff --git a/locales/index.d.ts b/locales/index.d.ts index 1d548979d..c8f38d1b3 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1099,6 +1099,10 @@ export interface Locale { "installed": string; "branding": string; "enableServerMachineStats": string; + "enableAchievements": string; + "turnOffAchievements": string; + "enableBotTrending": string; + "turnOffBotTrending": string; "enableIdenticonGeneration": string; "turnOffToImprovePerformance": string; "createInviteCode": string; @@ -1135,6 +1139,7 @@ export interface Locale { "pinnedList": string; "keepScreenOn": string; "clickToOpen": string; + "showBots": string; "verifiedLink": string; "notifyNotes": string; "unnotifyNotes": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1195ed435..ec50da79e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1096,6 +1096,10 @@ additionalEmojiDictionary: "絵文字の追加辞書" installed: "インストール済み" branding: "ブランディング" enableServerMachineStats: "サーバーのマシン情報を公開する" +enableAchievements: "実績を有効にする" +turnOffAchievements: "これをオフにすると、達成システムは無効になります。" +enableBotTrending: "ハッシュタグにボットを追加する" +turnOffBotTrending: "これをオフにすると、ボットがハッシュタグを入力しなくなります。" enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" turnOffToImprovePerformance: "オフにするとパフォーマンスが向上します。" createInviteCode: "招待コードを作成" @@ -1132,6 +1136,7 @@ loadConversation: "会話を見る" pinnedList: "ピン留めされたリスト" keepScreenOn: "デバイスの画面を常にオンにする" clickToOpen: "クリックしてノートを開く" +showBots: "ボットをタイムラインに表示" verifiedLink: "このリンク先の所有者であることが確認されました" notifyNotes: "投稿を通知" unnotifyNotes: "投稿の通知を解除" diff --git a/packages/backend/migration/1697603945000-BotTrending.js b/packages/backend/migration/1697603945000-BotTrending.js new file mode 100644 index 000000000..73f1f598e --- /dev/null +++ b/packages/backend/migration/1697603945000-BotTrending.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class BotTrending1697603945000 { + name = 'BotTrending1697603945000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableBotTrending" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableBotTrending"`); + } +} diff --git a/packages/backend/migration/1697624010000-isSilenced.js b/packages/backend/migration/1697624010000-isSilenced.js new file mode 100644 index 000000000..98b5e8c55 --- /dev/null +++ b/packages/backend/migration/1697624010000-isSilenced.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class IsSilenced1697624010000 { + name = 'IsSilenced1697624010000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "isSilenced" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isSilenced"`); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index da7e6b133..cc3dff850 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -511,7 +511,11 @@ export class NoteCreateService implements OnApplicationShutdown { // ハッシュタグ更新 if (data.visibility === 'public' || data.visibility === 'home') { - this.hashtagService.updateHashtags(user, tags); + if (user.isBot && meta.enableBotTrending) { + this.hashtagService.updateHashtags(user, tags); + } else if (!user.isBot) { + this.hashtagService.updateHashtags(user, tags); + } } // Increment notes count (user) diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 3de95129f..f59b0cbcc 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -368,6 +368,7 @@ export class UserEntityService implements OnModuleInit { createdAt: this.idService.parse(user.id).date.toISOString(), isBot: user.isBot ?? falsy, isCat: user.isCat ?? falsy, + isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), speakAsCat: user.speakAsCat ?? falsy, instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { name: instance.name, @@ -404,7 +405,6 @@ export class UserEntityService implements OnModuleInit { backgroundUrl: user.backgroundUrl, backgroundBlurhash: user.backgroundBlurhash, isLocked: user.isLocked, - isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSuspended: user.isSuspended ?? falsy, location: profile!.location, birthday: profile!.birthday, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index e4abe42de..c8a53d970 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -247,6 +247,11 @@ export class MiMeta { }) public enableSensitiveMediaDetectionForVideos: boolean; + @Column('boolean', { + default: true, + }) + public enableBotTrending: boolean; + @Column('varchar', { length: 1024, nullable: true, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 4704c607a..8f6cb9eba 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -173,6 +173,12 @@ export class MiUser { }) public isSuspended: boolean; + @Column('boolean', { + default: false, + comment: 'Whether the User is silenced.', + }) + public isSilenced: boolean; + @Column('boolean', { default: false, comment: 'Whether the User is locked.', diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index f1e933498..ca2436b73 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -47,6 +47,10 @@ export const packedUserLiteSchema = { nullable: false, optional: true, default: false, }, + isSilenced: { + type: 'boolean', + nullable: false, optional: false, + }, isBot: { type: 'boolean', nullable: false, optional: true, @@ -135,10 +139,6 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: false, }, - isSilenced: { - type: 'boolean', - nullable: false, optional: false, - }, isSuspended: { type: 'boolean', nullable: false, optional: false, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 4ffc1ba4b..cb2b4fe91 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -63,6 +63,8 @@ import * as ep___admin_showUser from './endpoints/admin/show-user.js'; import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; import * as ep___admin_nsfwUser from './endpoints/admin/nsfw-user.js'; import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js'; +import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js'; +import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; @@ -418,6 +420,8 @@ const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep_ const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default }; const $admin_nsfwUser: Provider = { provide: 'ep:admin/nsfw-user', useClass: ep___admin_nsfwUser.default }; const $admin_unnsfwUser: Provider = { provide: 'ep:admin/unnsfw-user', useClass: ep___admin_unnsfwUser.default }; +const $admin_silenceUser: Provider = { provide: 'ep:admin/silence-user', useClass: ep___admin_silenceUser.default }; +const $admin_unsilenceUser: Provider = { provide: 'ep:admin/unsilence-user', useClass: ep___admin_unsilenceUser.default }; const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default }; const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default }; const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default }; @@ -777,6 +781,8 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de $admin_showUsers, $admin_nsfwUser, $admin_unnsfwUser, + $admin_silenceUser, + $admin_unsilenceUser, $admin_suspendUser, $admin_unsuspendUser, $admin_updateMeta, @@ -1130,6 +1136,8 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de $admin_showUsers, $admin_nsfwUser, $admin_unnsfwUser, + $admin_silenceUser, + $admin_unsilenceUser, $admin_suspendUser, $admin_unsuspendUser, $admin_updateMeta, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index d602602e1..618180094 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -63,6 +63,8 @@ import * as ep___admin_showUser from './endpoints/admin/show-user.js'; import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; import * as ep___admin_nsfwUser from './endpoints/admin/nsfw-user.js'; import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js'; +import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js'; +import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; @@ -416,6 +418,8 @@ const eps = [ ['admin/show-users', ep___admin_showUsers], ['admin/nsfw-user', ep___admin_nsfwUser], ['admin/unnsfw-user', ep___admin_unnsfwUser], + ['admin/silence-user', ep___admin_silenceUser], + ['admin/unsilence-user', ep___admin_unsilenceUser], ['admin/suspend-user', ep___admin_suspendUser], ['admin/unsuspend-user', ep___admin_unsuspendUser], ['admin/update-meta', ep___admin_updateMeta], diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 88725ddbb..1d37c1012 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -178,6 +178,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + enableBotTrending: { + type: 'boolean', + optional: false, nullable: false, + }, proxyAccountId: { type: 'string', optional: false, nullable: true, @@ -391,6 +395,7 @@ export default class extends Endpoint { // eslint- sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, + enableBotTrending: instance.enableBotTrending, proxyAccountId: instance.proxyAccountId, summalyProxy: instance.summalyProxy, email: instance.email, diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index f550c4fd2..7fe70fa02 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -58,7 +58,7 @@ export default class extends Endpoint { // eslint- } const isModerator = await this.roleService.isModerator(user); - const isSilenced = !(await this.roleService.getUserPolicies(user.id)).canPublicNote; + const isSilenced = user.isSilenced || !(await this.roleService.getUserPolicies(user.id)).canPublicNote; const _me = await this.usersRepository.findOneByOrFail({ id: me.id }); if (!await this.roleService.isAdministrator(_me) && await this.roleService.isAdministrator(user)) { diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts new file mode 100644 index 000000000..ed1141da4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + if (await this.roleService.isModerator(user)) { + throw new Error('cannot silence moderator account'); + } + + await this.usersRepository.update(user.id, { + isSilenced: true, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts new file mode 100644 index 000000000..7cfedca7d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + await this.usersRepository.update(user.id, { + isSilenced: false, + }); + }); + } +} 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 40cc50fe7..96456101b 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -72,6 +72,7 @@ export const paramDef = { sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, enableSensitiveMediaDetectionForVideos: { type: 'boolean' }, + enableBotTrending: { type: 'boolean' }, proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true }, maintainerName: { type: 'string', nullable: true }, maintainerEmail: { type: 'string', nullable: true }, @@ -301,6 +302,10 @@ export default class extends Endpoint { // eslint- set.enableSensitiveMediaDetectionForVideos = ps.enableSensitiveMediaDetectionForVideos; } + if (ps.enableBotTrending !== undefined) { + set.enableBotTrending = ps.enableBotTrending; + } + if (ps.proxyAccountId !== undefined) { set.proxyAccountId = ps.proxyAccountId; } diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index be7557c21..582757754 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -13,6 +13,7 @@ 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'; export const meta = { tags: ['notes'], @@ -40,6 +41,7 @@ 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' }, @@ -60,6 +62,7 @@ export default class extends Endpoint { // eslint- 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); @@ -67,6 +70,12 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.gtlDisabled); } + 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) @@ -87,9 +96,16 @@ export default class extends Endpoint { // eslint- if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } + + if (!ps.withBots) query.andWhere('user.isBot = FALSE'); //#endregion - const timeline = await query.limit(ps.limit).getMany(); + 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) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 378529e30..941707401 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -56,6 +56,7 @@ export const paramDef = { withFiles: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, withReplies: { type: 'boolean', default: false }, + withBots: { type: 'boolean', default: true }, }, required: [], } as const; @@ -86,10 +87,12 @@ export default class extends Endpoint { // eslint- } const [ + followings, userIdsWhoMeMuting, userIdsWhoMeMutingRenotes, userIdsWhoBlockingMe, ] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), this.cacheService.userMutingsCache.fetch(me.id), this.cacheService.renoteMutingsCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id), @@ -134,6 +137,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel'); + if (!ps.withBots) query.andWhere('user.isBot = FALSE'); + let timeline = await query.getMany(); timeline = timeline.filter(note => { @@ -148,6 +153,7 @@ export default class extends Endpoint { // eslint- if (ps.withRenotes === false) return false; } } + if (note.user?.isSilenced && note.userId !== me.id && !followings[note.userId]) return false; return true; }); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index f69e60ab5..ea24d2643 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -46,6 +46,7 @@ export const paramDef = { withFiles: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, withReplies: { type: 'boolean', default: false }, + withBots: { type: 'boolean', default: true }, excludeNsfw: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, @@ -82,14 +83,16 @@ export default class extends Endpoint { // eslint- } const [ + followings, userIdsWhoMeMuting, userIdsWhoMeMutingRenotes, userIdsWhoBlockingMe, ] = me ? await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), this.cacheService.userMutingsCache.fetch(me.id), this.cacheService.renoteMutingsCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id), - ]) : [new Set(), new Set(), new Set()]; + ]) : [undefined, new Set(), new Set(), new Set()]; let noteIds: string[]; @@ -119,6 +122,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel'); + if (!ps.withBots) query.andWhere('user.isBot = FALSE'); + let timeline = await query.getMany(); timeline = timeline.filter(note => { @@ -134,6 +139,7 @@ export default class extends Endpoint { // eslint- if (ps.withRenotes === false) return false; } } + if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; return true; }); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index b00f5207d..bc33d6948 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -12,6 +12,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; +import { MetaService } from '@/core/MetaService.js'; export const meta = { tags: ['notes', 'hashtags'], @@ -71,6 +72,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -80,6 +82,10 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + const meta = await this.metaService.fetch(true); + + if (!meta.enableBotTrending) query.andWhere('user.isBot = FALSE'); + this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 8f13b3a4b..e8bae286e 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -46,6 +46,7 @@ export const paramDef = { includeLocalRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, + withBots: { type: 'boolean', default: true }, }, required: [], } as const; @@ -97,6 +98,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel'); + if (!ps.withBots) query.andWhere('user.isBot = FALSE'); + let timeline = await query.getMany(); timeline = timeline.filter(note => { @@ -114,6 +117,7 @@ export default class extends Endpoint { // eslint- if (note.reply && note.reply.visibility === 'followers') { if (!Object.hasOwn(followings, note.reply.userId)) return false; } + if (note.user?.isSilenced && note.userId !== me.id && !followings[note.userId]) return false; return true; }); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index c499d1787..b39afbe36 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -20,6 +20,7 @@ class GlobalTimelineChannel extends Channel { public static requireCredential = false; private withRenotes: boolean; private withFiles: boolean; + private withBots: boolean; constructor( private metaService: MetaService, @@ -40,6 +41,7 @@ class GlobalTimelineChannel extends Channel { this.withRenotes = params.withRenotes ?? true; this.withFiles = params.withFiles ?? false; + this.withBots = params.withBots ?? true; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -48,6 +50,7 @@ class GlobalTimelineChannel extends Channel { @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.visibility !== 'public') return; if (note.channelId != null) return; @@ -59,6 +62,8 @@ class GlobalTimelineChannel extends Channel { 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 diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index de755cccb..6da192146 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -64,6 +64,8 @@ class HomeTimelineChannel extends Channel { 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; // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 83f0bccd9..f7c2f0591 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -20,6 +20,7 @@ class HybridTimelineChannel extends Channel { public static requireCredential = true; private withRenotes: boolean; private withReplies: boolean; + private withBots: boolean; private withFiles: boolean; constructor( @@ -41,6 +42,7 @@ class HybridTimelineChannel extends Channel { this.withRenotes = params.withRenotes ?? true; this.withReplies = params.withReplies ?? false; + this.withBots = params.withBots ?? true; this.withFiles = params.withFiles ?? false; // Subscribe events @@ -50,6 +52,7 @@ class HybridTimelineChannel extends Channel { @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; // チャンネルの投稿ではなく、自分自身の投稿 または // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または @@ -78,6 +81,8 @@ class HybridTimelineChannel extends Channel { 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; // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index a21104113..738dbd80f 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -19,6 +19,7 @@ class LocalTimelineChannel extends Channel { public static requireCredential = false; private withRenotes: boolean; private withReplies: boolean; + private withBots: boolean; private withFiles: boolean; constructor( @@ -40,6 +41,7 @@ class LocalTimelineChannel extends Channel { this.withRenotes = params.withRenotes ?? true; this.withReplies = params.withReplies ?? false; + this.withBots = params.withBots ?? true; this.withFiles = params.withFiles ?? false; // Subscribe events @@ -49,6 +51,7 @@ class LocalTimelineChannel extends Channel { @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) return; if (note.visibility !== 'public') return; @@ -61,6 +64,8 @@ class LocalTimelineChannel extends Channel { 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; // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index cdd72febd..fa0438419 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -25,11 +25,13 @@ const props = withDefaults(defineProps<{ sound?: boolean; withRenotes?: boolean; withReplies?: boolean; + withBots?: boolean; onlyFiles?: boolean; }>(), { withRenotes: true, withReplies: false, onlyFiles: false, + withBots: true, }); const emit = defineEmits<{ @@ -93,11 +95,13 @@ if (props.src === 'antenna') { query = { withRenotes: props.withRenotes, withReplies: props.withReplies, + withBots: props.withBots, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('localTimeline', { withRenotes: props.withRenotes, withReplies: props.withReplies, + withBots: props.withBots, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); @@ -106,11 +110,13 @@ if (props.src === 'antenna') { query = { withRenotes: props.withRenotes, withReplies: props.withReplies, + withBots: props.withBots, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('hybridTimeline', { withRenotes: props.withRenotes, withReplies: props.withReplies, + withBots: props.withBots, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); @@ -118,10 +124,12 @@ if (props.src === 'antenna') { endpoint = 'notes/global-timeline'; query = { withRenotes: props.withRenotes, + withBots: props.withBots, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('globalTimeline', { withRenotes: props.withRenotes, + withBots: props.withBots, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index e7cdd34db..16d2894c6 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -76,6 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+ {{ i18n.ts.silence }} {{ i18n.ts.suspend }} {{ i18n.ts.markAsNSFW }} @@ -306,6 +307,19 @@ async function toggleNSFW(v) { } } +async function toggleSilence(v) { + const confirm = await os.confirm({ + type: 'warning', + text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm, + }); + if (confirm.canceled) { + silenced = !v; + } else { + await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id }); + await refreshUser(); + } +} + async function toggleSuspend(v) { const confirm = await os.confirm({ type: 'warning', diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue index ee24bf0fd..d1bc420db 100644 --- a/packages/frontend/src/pages/admin/other-settings.vue +++ b/packages/frontend/src/pages/admin/other-settings.vue @@ -18,8 +18,15 @@ SPDX-License-Identifier: AGPL-3.0-only
- - + + + +
+ +
+ + +
@@ -61,6 +68,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; let enableServerMachineStats: boolean = $ref(false); let enableAchievements: boolean = $ref(false); +let enableBotTrending: boolean = $ref(false); let enableIdenticonGeneration: boolean = $ref(false); let enableChartsForRemoteUser: boolean = $ref(false); let enableChartsForFederatedInstances: boolean = $ref(false); @@ -69,6 +77,7 @@ async function init() { const meta = await os.api('admin/meta'); enableServerMachineStats = meta.enableServerMachineStats; enableAchievements = meta.enableAchievements; + enableBotTrending = meta.enableBotTrending; enableIdenticonGeneration = meta.enableIdenticonGeneration; enableChartsForRemoteUser = meta.enableChartsForRemoteUser; enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances; @@ -78,6 +87,7 @@ function save() { os.apiWithDialog('admin/update-meta', { enableServerMachineStats, enableAchievements, + enableBotTrending, enableIdenticonGeneration, enableChartsForRemoteUser, enableChartsForFederatedInstances, diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 981216daa..753efafb3 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -152,6 +152,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.enableInfiniteScroll }} {{ i18n.ts.keepScreenOn }} {{ i18n.ts.clickToOpen }} + {{ i18n.ts.showBots }}
@@ -227,6 +228,7 @@ const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showC const reactionsDisplaySize = computed(defaultStore.makeGetterSetter('reactionsDisplaySize')); const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes')); const clickToOpen = computed(defaultStore.makeGetterSetter('clickToOpen')); +const showBots = computed(defaultStore.makeGetterSetter('tlWithBots')); const collapseFiles = computed(defaultStore.makeGetterSetter('collapseFiles')); const autoloadConversation = computed(defaultStore.makeGetterSetter('autoloadConversation')); const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index c375b3839..6d1e2dbd5 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -21,6 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only :withRenotes="withRenotes" :withReplies="withReplies" :onlyFiles="onlyFiles" + :withBots="withBots" :sound="true" @queue="queueUpdated" /> @@ -63,6 +64,7 @@ let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global'); const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) }); const withRenotes = $ref(true); const withReplies = $ref($i ? defaultStore.state.tlWithReplies : false); +const withBots = $ref($i ? defaultStore.state.tlWithBots : true); const onlyFiles = $ref(false); watch($$(src), () => queue = 0); diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index d34221b39..f7df90b2b 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -7,9 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
- - - + {{ i18n.ts.userSuspended }} + {{ i18n.ts.userSilenced }}
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index ceb5b9ca5..004f52ec5 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -373,6 +373,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + tlWithBots: { + where: 'device', + default: true, + }, })); // TODO: 他のタブと永続化されたstateを同期 diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index a93bda020..8451d870b 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -31,6 +31,8 @@ export type Endpoints = { 'admin/show-users': { req: TODO; res: TODO; }; 'admin/silence-user': { req: TODO; res: TODO; }; 'admin/suspend-user': { req: TODO; res: TODO; }; + 'admin/nsfw-user': { req: TODO; res: TODO; }; + 'admin/unnsfw-user': { req: TODO; res: TODO; }; 'admin/unsilence-user': { req: TODO; res: TODO; }; 'admin/unsuspend-user': { req: TODO; res: TODO; }; 'admin/update-meta': { req: TODO; res: TODO; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 5145b6a93..ce194f853 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -405,6 +405,7 @@ export type AdminInstanceMetadata = DetailedInstanceMetadata & { app192IconUrl: string | null; app512IconUrl: string | null; manifestJsonOverride: string; + enableBotTrending: boolean; }; export type ServerInfo = {