From bf3d493d049f30a93aecd8c39fbf433dcfbe959b Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Tue, 3 Oct 2023 20:21:26 +0200 Subject: [PATCH] Revert "feat: improve tl performance" --- .config/docker_example.yml | 8 - .config/example.yml | 10 - .devcontainer/devcontainer.yml | 8 - CHANGELOG.md | 14 - chart/files/default.yml | 8 - locales/index.d.ts | 7 +- locales/ja-JP.yml | 7 +- packages/backend/jest.config.cjs | 2 - .../migration/1696222183852-withReplies.js | 20 - .../1696323464251-user-list-membership.js | 11 - .../migration/1696331570827-hibernation.js | 17 - packages/backend/src/GlobalModule.ts | 14 +- packages/backend/src/config.ts | 3 - .../backend/src/core/AccountMoveService.ts | 30 +- packages/backend/src/core/AntennaService.ts | 14 +- packages/backend/src/core/CacheService.ts | 19 +- packages/backend/src/core/CoreModule.ts | 6 - .../backend/src/core/NoteCreateService.ts | 247 +----- packages/backend/src/core/NoteEditService.ts | 203 +---- .../backend/src/core/NotificationService.ts | 8 +- packages/backend/src/core/QueryService.ts | 64 +- .../backend/src/core/UserBlockingService.ts | 8 +- .../backend/src/core/UserFollowingService.ts | 6 +- packages/backend/src/core/UserListService.ts | 36 +- packages/backend/src/core/UserService.ts | 53 -- .../src/core/entities/NoteEntityService.ts | 6 +- .../src/core/entities/UserEntityService.ts | 1 - .../core/entities/UserListEntityService.ts | 24 +- packages/backend/src/di-symbols.ts | 4 +- packages/backend/src/models/Following.ts | 12 - packages/backend/src/models/MutedNote.ts | 53 ++ .../backend/src/models/RepositoryModule.ts | 20 +- packages/backend/src/models/User.ts | 5 - ...erListMembership.ts => UserListJoining.ts} | 12 +- packages/backend/src/models/_.ts | 9 +- .../backend/src/models/json-schema/user.ts | 4 - packages/backend/src/postgres.ts | 6 +- .../queue/processors/CleanProcessorService.ts | 15 +- .../ExportAntennasProcessorService.ts | 10 +- .../ExportUserListsProcessorService.ts | 10 +- .../ImportUserListsProcessorService.ts | 8 +- .../backend/src/server/api/EndpointsModule.ts | 20 +- .../server/api/StreamingApiServerService.ts | 10 +- packages/backend/src/server/api/endpoints.ts | 10 +- .../server/api/endpoints/antennas/notes.ts | 6 +- .../server/api/endpoints/channels/timeline.ts | 8 +- .../server/api/endpoints/following/update.ts | 6 +- .../endpoints/i/get-word-muted-notes-count.ts | 51 ++ .../api/endpoints/notes/global-timeline.ts | 46 +- .../api/endpoints/notes/hybrid-timeline.ts | 135 ++-- .../api/endpoints/notes/local-timeline.ts | 102 ++- .../server/api/endpoints/notes/timeline.ts | 136 ++-- .../api/endpoints/notes/user-list-timeline.ts | 120 +-- .../src/server/api/endpoints/roles/notes.ts | 6 +- .../users/lists/create-from-public.ts | 10 +- .../endpoints/users/lists/get-memberships.ts | 79 -- .../server/api/endpoints/users/lists/push.ts | 8 +- .../users/lists/update-membership.ts | 79 -- .../src/server/api/endpoints/users/notes.ts | 105 +-- .../src/server/api/stream/Connection.ts | 4 +- .../api/stream/channels/global-timeline.ts | 11 +- .../api/stream/channels/home-timeline.ts | 13 +- .../api/stream/channels/hybrid-timeline.ts | 13 +- .../api/stream/channels/local-timeline.ts | 11 +- .../server/api/stream/channels/user-list.ts | 31 +- packages/backend/test/e2e/renote-mute.ts | 8 +- packages/backend/test/e2e/timelines.ts | 701 ------------------ packages/backend/test/e2e/user-notes.ts | 17 +- packages/backend/test/e2e/users.ts | 1 - packages/backend/test/utils.ts | 10 +- packages/frontend/src/components/MkNote.vue | 4 +- .../src/components/MkNoteDetailed.vue | 4 +- .../frontend/src/components/MkNoteSub.vue | 2 +- .../frontend/src/components/MkTimeline.vue | 12 + packages/frontend/src/pages/my-lists/list.vue | 94 +-- .../frontend/src/pages/settings/word-mute.vue | 52 +- packages/frontend/src/pages/timeline.vue | 9 +- packages/frontend/src/pages/user/home.vue | 6 +- .../{index.files.vue => index.photos.vue} | 30 +- .../frontend/src/scripts/get-user-menu.ts | 13 - packages/frontend/src/store.ts | 6 +- packages/frontend/src/ui/deck/tl-column.vue | 14 +- packages/misskey-js/etc/misskey-js.api.md | 6 +- packages/misskey-js/src/api.types.ts | 1 + 84 files changed, 960 insertions(+), 2072 deletions(-) delete mode 100644 packages/backend/migration/1696222183852-withReplies.js delete mode 100644 packages/backend/migration/1696323464251-user-list-membership.js delete mode 100644 packages/backend/migration/1696331570827-hibernation.js delete mode 100644 packages/backend/src/core/UserService.ts create mode 100644 packages/backend/src/models/MutedNote.ts rename packages/backend/src/models/{UserListMembership.ts => UserListJoining.ts} (76%) create mode 100644 packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts delete mode 100644 packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts delete mode 100644 packages/backend/src/server/api/endpoints/users/lists/update-membership.ts delete mode 100644 packages/backend/test/e2e/timelines.ts rename packages/frontend/src/pages/user/{index.files.vue => index.photos.vue} (75%) diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 292174629..940b095fe 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -95,14 +95,6 @@ redis: # #prefix: example-prefix # #db: 1 -#redisForTimelines: -# host: redis -# port: 6379 -# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 -# #pass: example-pass -# #prefix: example-prefix -# #db: 1 - # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index 0e4f2f5a1..03864a329 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -105,16 +105,6 @@ redis: # # You can specify more ioredis options... # #username: example-username -#redisForTimelines: -# host: localhost -# port: 6379 -# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 -# #pass: example-pass -# #prefix: example-prefix -# #db: 1 -# # You can specify more ioredis options... -# #username: example-username - # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 3d57d1245..5dcd41599 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -95,14 +95,6 @@ redis: # #prefix: example-prefix # #db: 1 -#redisForTimelines: -# host: redis -# port: 6379 -# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 -# #pass: example-pass -# #prefix: example-prefix -# #db: 1 - # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index a761b79b8..a5b3f32c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,25 +13,11 @@ --> ## 2023.10.0 -### NOTE -- muted_noteテーブルは使われなくなったため手動で削除を行ってください。 - -### Changes -- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました -- API: notes/global-timeline は現在常に `[]` を返します - -### General -- Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました -- Feat: ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました -- Enhance: ソフトワードミュートとハードワードミュートは統合されました ### Client - Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に - Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正 -### Server -- Enhance: タイムライン取得時のパフォーマンスを改善 - ## 2023.9.3 ### General - Enhance: ノートの翻訳機能の利用可否をロールで設定可能に diff --git a/chart/files/default.yml b/chart/files/default.yml index 87b2f677e..90b574b99 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -116,14 +116,6 @@ redis: # #prefix: example-prefix # #db: 1 -#redisForTimelines: -# host: redis -# port: 6379 -# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 -# #pass: example-pass -# #prefix: example-prefix -# #db: 1 - # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/locales/index.d.ts b/locales/index.d.ts index 418e1c67f..a9de0ad96 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1129,8 +1129,6 @@ export interface Locale { "notificationRecieveConfig": string; "mutualFollow": string; "fileAttachedOnly": string; - "showRepliesToOthersInTimeline": string; - "hideRepliesToOthersInTimeline": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -1721,6 +1719,11 @@ export interface Locale { "muteWords": string; "muteWordsDescription": string; "muteWordsDescription2": string; + "softDescription": string; + "hardDescription": string; + "soft": string; + "hard": string; + "mutedNotes": string; }; "_instanceMute": { "instanceMuteDescription": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 80e4466a7..c45949872 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1126,8 +1126,6 @@ edited: "編集済み" notificationRecieveConfig: "通知の受信設定" mutualFollow: "相互フォロー" fileAttachedOnly: "ファイル付きのみ" -showRepliesToOthersInTimeline: "TLに他の人への返信を含める" -hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -1638,6 +1636,11 @@ _wordMute: muteWords: "ミュートするワード" muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。" + softDescription: "指定した条件のノートをタイムラインから隠します。" + hardDescription: "指定した条件のノートをタイムラインに追加しないようにします。追加されなかったノートは、条件を変更しても除外されたままになります。" + soft: "ソフト" + hard: "ハード" + mutedNotes: "ミュートされたノート" _instanceMute: instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全てのノートとRenoteをミュートします。" diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs index 97d777c86..6b1afec73 100644 --- a/packages/backend/jest.config.cjs +++ b/packages/backend/jest.config.cjs @@ -216,6 +216,4 @@ module.exports = { maxWorkers: 1, // Make it use worker (that can be killed and restarted) logHeapUsage: true, // To debug when out-of-memory happens on CI workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB) - - maxConcurrency: 32, }; diff --git a/packages/backend/migration/1696222183852-withReplies.js b/packages/backend/migration/1696222183852-withReplies.js deleted file mode 100644 index 9f65d5f6a..000000000 --- a/packages/backend/migration/1696222183852-withReplies.js +++ /dev/null @@ -1,20 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export class WithReplies1696222183852 { - name = 'WithReplies1696222183852' - - async up(queryRunner) { - await queryRunner.query(`ALTER TABLE "following" ADD "withReplies" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`ALTER TABLE "user_list_joining" ADD "withReplies" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `); - } - - async down(queryRunner) { - await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`); - await queryRunner.query(`ALTER TABLE "user_list_joining" DROP COLUMN "withReplies"`); - await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "withReplies"`); - } -} diff --git a/packages/backend/migration/1696323464251-user-list-membership.js b/packages/backend/migration/1696323464251-user-list-membership.js deleted file mode 100644 index 7534040c4..000000000 --- a/packages/backend/migration/1696323464251-user-list-membership.js +++ /dev/null @@ -1,11 +0,0 @@ -export class UserListMembership1696323464251 { - name = 'UserListMembership1696323464251' - - async up(queryRunner) { - await queryRunner.query(`ALTER TABLE "user_list_joining" RENAME TO "user_list_membership"`); - } - - async down(queryRunner) { - await queryRunner.query(`ALTER TABLE "user_list_membership" RENAME TO "user_list_joining"`); - } -} diff --git a/packages/backend/migration/1696331570827-hibernation.js b/packages/backend/migration/1696331570827-hibernation.js deleted file mode 100644 index 119d35913..000000000 --- a/packages/backend/migration/1696331570827-hibernation.js +++ /dev/null @@ -1,17 +0,0 @@ -export class Hibernation1696331570827 { - name = 'Hibernation1696331570827' - - async up(queryRunner) { - await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`); - await queryRunner.query(`ALTER TABLE "user" ADD "isHibernated" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`ALTER TABLE "following" ADD "isFollowerHibernated" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`CREATE INDEX "IDX_ce62b50d882d4e9dee10ad0d2f" ON "following" ("followeeId", "followerHost", "isFollowerHibernated") `); - } - - async down(queryRunner) { - await queryRunner.query(`DROP INDEX "public"."IDX_ce62b50d882d4e9dee10ad0d2f"`); - await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "isFollowerHibernated"`); - await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isHibernated"`); - await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `); - } -} diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 3e9d19f82..9f1ee9fca 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -70,19 +70,11 @@ const $redisForSub: Provider = { inject: [DI.config], }; -const $redisForTimelines: Provider = { - provide: DI.redisForTimelines, - useFactory: (config: Config) => { - return new Redis.Redis(config.redisForTimelines); - }, - inject: [DI.config], -}; - @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines], - exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule], + providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub], + exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( @@ -90,7 +82,6 @@ export class GlobalModule implements OnApplicationShutdown { @Inject(DI.redis) private redisClient: Redis.Redis, @Inject(DI.redisForPub) private redisForPub: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, - @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, ) {} public async dispose(): Promise { @@ -107,7 +98,6 @@ export class GlobalModule implements OnApplicationShutdown { this.redisClient.disconnect(), this.redisForPub.disconnect(), this.redisForSub.disconnect(), - this.redisForTimelines.disconnect(), ]); } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index ef59a8095..f89879d53 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -47,7 +47,6 @@ type Source = { redis: RedisOptionsSource; redisForPubsub?: RedisOptionsSource; redisForJobQueue?: RedisOptionsSource; - redisForTimelines?: RedisOptionsSource; meilisearch?: { host: string; port: string; @@ -162,7 +161,6 @@ export type Config = { redis: RedisOptions & RedisOptionsSource; redisForPubsub: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource; - redisForTimelines: RedisOptions & RedisOptionsSource; perChannelMaxNoteCacheCount: number; perUserNotificationsMaxCount: number; deactivateAntennaThreshold: number; @@ -229,7 +227,6 @@ export function loadConfig(): Config { redis, redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, - redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, id: config.id, proxy: config.proxy, proxySmtp: config.proxySmtp, diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index ba3413007..ec1d01392 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/_.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; import { IdService } from '@/core/IdService.js'; @@ -42,8 +42,8 @@ export class AccountMoveService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, @@ -215,40 +215,40 @@ export class AccountMoveService { @bindThis public async updateLists(src: ThinUser, dst: MiUser): Promise { // Return if there is no list to be updated. - const oldMemberships = await this.userListMembershipsRepository.find({ + const oldJoinings = await this.userListJoiningsRepository.find({ where: { userId: src.id, }, }); - if (oldMemberships.length === 0) return; + if (oldJoinings.length === 0) return; - const existingUserListIds = await this.userListMembershipsRepository.find({ + const existingUserListIds = await this.userListJoiningsRepository.find({ where: { userId: dst.id, }, - }).then(memberships => memberships.map(membership => membership.userListId)); + }).then(joinings => joinings.map(joining => joining.userListId)); - const newMemberships: Map = new Map(); + const newJoinings: Map = new Map(); // 重複しないようにIDを生成 const genId = (): string => { let id: string; do { id = this.idService.genId(); - } while (newMemberships.has(id)); + } while (newJoinings.has(id)); return id; }; - for (const membership of oldMemberships) { - if (existingUserListIds.includes(membership.userListId)) continue; // skip if dst exists in this user's list - newMemberships.set(genId(), { + for (const joining of oldJoinings) { + if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list + newJoinings.set(genId(), { createdAt: new Date(), userId: dst.id, - userListId: membership.userListId, + userListId: joining.userListId, }); } - const arrayToInsert = Array.from(newMemberships.entries()).map(entry => ({ ...entry[1], id: entry[0] })); - await this.userListMembershipsRepository.insert(arrayToInsert); + const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.userListJoiningsRepository.insert(arrayToInsert); // Have the proxy account follow the new account in the same way as UserListService.push if (this.userEntityService.isRemoteUser(dst)) { diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 95712b35b..d9f27b8c6 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -12,7 +12,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import * as Acct from '@/misc/acct.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; +import type { AntennasRepository, UserListJoiningsRepository } from '@/models/_.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; @@ -24,8 +24,8 @@ export class AntennaService implements OnApplicationShutdown { private antennas: MiAntenna[]; constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @@ -33,8 +33,8 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, private utilityService: UtilityService, private globalEventService: GlobalEventService, @@ -81,7 +81,7 @@ export class AntennaService implements OnApplicationShutdown { const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); - const redisPipeline = this.redisForTimelines.pipeline(); + const redisPipeline = this.redisClient.pipeline(); for (const antenna of matchedAntennas) { redisPipeline.xadd( @@ -108,7 +108,7 @@ export class AntennaService implements OnApplicationShutdown { if (antenna.src === 'home') { // TODO } else if (antenna.src === 'list') { - const listUsers = (await this.userListMembershipsRepository.findBy({ + const listUsers = (await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId!, })).map(x => x.userId); diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 22c510cc3..561979c4b 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; +import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; @@ -25,7 +25,7 @@ export class CacheService implements OnApplicationShutdown { public userBlockingCache: RedisKVCache>; public userBlockedCache: RedisKVCache>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: RedisKVCache>; - public userFollowingsCache: RedisKVCache | undefined>>; + public userFollowingsCache: RedisKVCache>; public userFollowingChannelsCache: RedisKVCache>; constructor( @@ -136,18 +136,12 @@ export class CacheService implements OnApplicationShutdown { fromRedisConverter: (value) => new Set(JSON.parse(value)), }); - this.userFollowingsCache = new RedisKVCache | undefined>>(this.redisClient, 'userFollowings', { + this.userFollowingsCache = new RedisKVCache>(this.redisClient, 'userFollowings', { lifetime: 1000 * 60 * 30, // 30m memoryCacheLifetime: 1000 * 60, // 1m - fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => { - const obj: Record | undefined> = {}; - for (const x of xs) { - obj[x.followeeId] = { withReplies: x.withReplies }; - } - return obj; - }), - toRedisConverter: (value) => JSON.stringify(value), - fromRedisConverter: (value) => JSON.parse(value), + fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), }); this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { @@ -194,7 +188,6 @@ export class CacheService implements OnApplicationShutdown { if (follower) follower.followingCount++; const followee = this.userByIdCache.get(body.followeeId); if (followee) followee.followersCount++; - this.userFollowingsCache.delete(body.followerId); break; } default: diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 955b9fdcf..7d6b76e9c 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -47,7 +47,6 @@ import { SignupService } from './SignupService.js'; import { WebAuthnService } from './WebAuthnService.js'; import { UserBlockingService } from './UserBlockingService.js'; import { CacheService } from './CacheService.js'; -import { UserService } from './UserService.js'; import { UserFollowingService } from './UserFollowingService.js'; import { UserKeypairService } from './UserKeypairService.js'; import { UserListService } from './UserListService.js'; @@ -176,7 +175,6 @@ const $SignupService: Provider = { provide: 'SignupService', useExisting: Signup const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; -const $UserService: Provider = { provide: 'UserService', useExisting: UserService }; const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; @@ -308,7 +306,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebAuthnService, UserBlockingService, CacheService, - UserService, UserFollowingService, UserKeypairService, UserListService, @@ -433,7 +430,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebAuthnService, $UserBlockingService, $CacheService, - $UserService, $UserFollowingService, $UserKeypairService, $UserListService, @@ -559,7 +555,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebAuthnService, UserBlockingService, CacheService, - UserService, UserFollowingService, UserKeypairService, UserListService, @@ -683,7 +678,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebAuthnService, $UserBlockingService, $CacheService, - $UserService, $UserFollowingService, $UserKeypairService, $UserListService, diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 8fb34fd63..f20727ce4 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -5,7 +5,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; -import { In, DataSource, IsNull, LessThan } from 'typeorm'; +import { In, DataSource } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import RE2 from 're2'; @@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { ChannelsRepository, FollowingsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -54,6 +54,8 @@ import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; +const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5); + type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; class NotificationManager { @@ -155,8 +157,8 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.db) private db: DataSource, - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -173,8 +175,8 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, @@ -185,9 +187,6 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private idService: IdService, @@ -335,7 +334,7 @@ export class NoteCreateService implements OnApplicationShutdown { const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); if (data.channel) { - this.redisForTimelines.xadd( + this.redisClient.xadd( `channelTimeline:${data.channel.id}`, 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), '*', @@ -481,13 +480,26 @@ export class NoteCreateService implements OnApplicationShutdown { // Increment notes count (user) this.incNotesCountOfUser(user); - if (data.visibility === 'public' || data.visibility === 'home') { - this.pushToTl(note, user); - } else if (data.visibility === 'followers') { - this.pushToTl(note, user); - } else if (data.visibility === 'specified') { - // TODO - } + // Word mute + mutedWordsCache.fetch(() => this.userProfilesRepository.find({ + where: { + enableWordMute: true, + }, + select: ['userId', 'mutedWords'], + })).then(us => { + for (const u of us) { + checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { + if (shouldMute) { + this.mutedNotesRepository.insert({ + id: this.idService.genId(), + userId: u.userId, + noteId: note.id, + reason: 'word', + }); + } + }); + } + }); this.antennaService.addNoteToAntennas(note, user); @@ -496,13 +508,11 @@ export class NoteCreateService implements OnApplicationShutdown { } if (data.reply == null) { - // TODO: キャッシュ this.followingsRepository.findBy({ followeeId: user.id, notify: 'normal', }).then(followings => { for (const following of followings) { - // TODO: ワードミュート考慮 this.notificationService.createNotification(following.followerId, 'note', { noteId: note.id, }, user.id); @@ -801,205 +811,6 @@ export class NoteCreateService implements OnApplicationShutdown { return mentionedUsers; } - @bindThis - private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { - const redisPipeline = this.redisForTimelines.pipeline(); - - if (note.channelId) { - const channelFollowings = await this.channelFollowingsRepository.find({ - where: { - followeeId: note.channelId, - }, - select: ['followerId'], - }); - - for (const channelFollowing of channelFollowings) { - redisPipeline.xadd( - `homeTimeline:${channelFollowing.followerId}`, - 'MAXLEN', '~', '200', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - `homeTimelineWithFiles:${channelFollowing.followerId}`, - 'MAXLEN', '~', '100', - '*', - 'note', note.id); - } - } - } else { - // TODO: キャッシュ? - const followings = await this.followingsRepository.find({ - where: { - followeeId: user.id, - followerHost: IsNull(), - isFollowerHibernated: false, - }, - select: ['followerId', 'withReplies'], - }); - - const userListMemberships = await this.userListMembershipsRepository.find({ - where: { - userId: user.id, - }, - select: ['userListId', 'withReplies'], - }); - - // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする - for (const following of followings) { - // 自分自身以外への返信 - if (note.replyId && note.replyUserId !== note.userId) { - if (!following.withReplies) continue; - } - - redisPipeline.xadd( - `homeTimeline:${following.followerId}`, - 'MAXLEN', '~', '200', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - `homeTimelineWithFiles:${following.followerId}`, - 'MAXLEN', '~', '100', - '*', - 'note', note.id); - } - } - - // TODO - //if (note.visibility === 'followers') { - // // TODO: 重そうだから何とかしたい Set 使う? - // userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId)); - //} - - for (const userListMembership of userListMemberships) { - // 自分自身以外への返信 - if (note.replyId && note.replyUserId !== note.userId) { - if (!userListMembership.withReplies) continue; - } - - redisPipeline.xadd( - `userListTimeline:${userListMembership.userListId}`, - 'MAXLEN', '~', '200', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - `userListTimelineWithFiles:${userListMembership.userListId}`, - 'MAXLEN', '~', '100', - '*', - 'note', note.id); - } - } - - { // 自分自身のHTL - redisPipeline.xadd( - `homeTimeline:${user.id}`, - 'MAXLEN', '~', '200', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - `homeTimelineWithFiles:${user.id}`, - 'MAXLEN', '~', '100', - '*', - 'note', note.id); - } - } - - if (note.visibility === 'public' || note.visibility === 'home') { - // 自分自身以外への返信 - if (note.replyId && note.replyUserId !== note.userId) { - redisPipeline.xadd( - `userTimelineWithReplies:${user.id}`, - 'MAXLEN', '~', '1000', - '*', - 'note', note.id); - } else { - redisPipeline.xadd( - `userTimeline:${user.id}`, - 'MAXLEN', '~', '1000', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - `userTimelineWithFiles:${user.id}`, - 'MAXLEN', '~', '500', - '*', - 'note', note.id); - } - - if (note.visibility === 'public' && note.userHost == null) { - redisPipeline.xadd( - 'localTimeline', - 'MAXLEN', '~', '1000', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - 'localTimelineWithFiles', - 'MAXLEN', '~', '500', - '*', - 'note', note.id); - } - } - } - } - - if (Math.random() < 0.1) { - process.nextTick(() => { - this.checkHibernation(followings); - }); - } - } - - redisPipeline.exec(); - } - - @bindThis - public async checkHibernation(followings: MiFollowing[]) { - if (followings.length === 0) return; - - const shuffle = (array: MiFollowing[]) => { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; - }; - - // ランダムに最大1000件サンプリング - const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); - - const hibernatedUsers = await this.usersRepository.find({ - where: { - id: In(samples.map(x => x.followerId)), - lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), - }, - select: ['id'], - }); - - if (hibernatedUsers.length > 0) { - this.usersRepository.update({ - id: In(hibernatedUsers.map(x => x.id)), - }, { - isHibernated: true, - }); - - this.followingsRepository.update({ - followerId: In(hibernatedUsers.map(x => x.id)), - }, { - isFollowerHibernated: true, - }); - } - } - @bindThis public dispose(): void { this.#shutdownController.abort(); diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index f331c8a9a..d38962fe3 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository, UserListMembershipsRepository, ChannelFollowingsRepository, MiFollowing } from '@/models/_.js'; +import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -49,6 +49,8 @@ import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; +const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5); + type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; class NotificationManager { @@ -149,8 +151,8 @@ export class NoteEditService implements OnApplicationShutdown { @Inject(DI.config) private config: Config, - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -170,11 +172,8 @@ export class NoteEditService implements OnApplicationShutdown { @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, @Inject(DI.noteThreadMutingsRepository) private noteThreadMutingsRepository: NoteThreadMutingsRepository, @@ -423,7 +422,7 @@ export class NoteEditService implements OnApplicationShutdown { await this.notesRepository.update(oldnote.id, note); if (data.channel) { - this.redisForTimelines.xadd( + this.redisClient.xadd( `channelTimeline:${data.channel.id}`, 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), '*', @@ -461,6 +460,27 @@ export class NoteEditService implements OnApplicationShutdown { this.hashtagService.updateHashtags(user, tags); } + // Word mute + mutedWordsCache.fetch(() => this.userProfilesRepository.find({ + where: { + enableWordMute: true, + }, + select: ['userId', 'mutedWords'], + })).then(us => { + for (const u of us) { + checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { + if (shouldMute) { + this.mutedNotesRepository.insert({ + id: this.idService.genId(), + userId: u.userId, + noteId: note.id, + reason: 'word', + }); + } + }); + } + }); + if (data.poll && data.poll.expiresAt) { const delay = data.poll.expiresAt.getTime() - Date.now(); this.queueService.endedPollNotificationQueue.add(note.id, { @@ -471,14 +491,6 @@ export class NoteEditService implements OnApplicationShutdown { }); } - if (data.visibility === 'public' || data.visibility === 'home') { - this.pushToTl(note, user); - } else if (data.visibility === 'followers') { - this.pushToTl(note, user); - } else if (data.visibility === 'specified') { - // TODO - } - if (!silent) { if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); @@ -639,163 +651,6 @@ export class NoteEditService implements OnApplicationShutdown { this.index(note); } - @bindThis - private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { - const redisPipeline = this.redisForTimelines.pipeline(); - - if (note.channelId) { - const channelFollowings = await this.channelFollowingsRepository.find({ - where: { - followeeId: note.channelId, - }, - select: ['followerId'], - }); - - for (const channelFollowing of channelFollowings) { - redisPipeline.xadd( - `homeTimeline:${channelFollowing.followerId}`, - 'MAXLEN', '~', '200', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - `homeTimelineWithFiles:${channelFollowing.followerId}`, - 'MAXLEN', '~', '100', - '*', - 'note', note.id); - } - } - } else { - // TODO: キャッシュ? - - const userListMemberships = await this.userListMembershipsRepository.find({ - where: { - userId: user.id, - }, - select: ['userListId', 'withReplies'], - }); - - // TODO - //if (note.visibility === 'followers') { - // // TODO: 重そうだから何とかしたい Set 使う? - // userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId)); - //} - - for (const userListMembership of userListMemberships) { - // 自分自身以外への返信 - if (note.replyId && note.replyUserId !== note.userId) { - if (!userListMembership.withReplies) continue; - } - - redisPipeline.xadd( - `userListTimeline:${userListMembership.userListId}`, - 'MAXLEN', '~', '200', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - `userListTimelineWithFiles:${userListMembership.userListId}`, - 'MAXLEN', '~', '100', - '*', - 'note', note.id); - } - } - - { // 自分自身のHTL - redisPipeline.xadd( - `homeTimeline:${user.id}`, - 'MAXLEN', '~', '200', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - `homeTimelineWithFiles:${user.id}`, - 'MAXLEN', '~', '100', - '*', - 'note', note.id); - } - } - - if (note.visibility === 'public' || note.visibility === 'home') { - // 自分自身以外への返信 - if (note.replyId && note.replyUserId !== note.userId) { - redisPipeline.xadd( - `userTimelineWithReplies:${user.id}`, - 'MAXLEN', '~', '1000', - '*', - 'note', note.id); - } else { - redisPipeline.xadd( - `userTimeline:${user.id}`, - 'MAXLEN', '~', '1000', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - `userTimelineWithFiles:${user.id}`, - 'MAXLEN', '~', '500', - '*', - 'note', note.id); - } - - if (note.visibility === 'public' && note.userHost == null) { - redisPipeline.xadd( - 'localTimeline', - 'MAXLEN', '~', '1000', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { - redisPipeline.xadd( - 'localTimelineWithFiles', - 'MAXLEN', '~', '500', - '*', - 'note', note.id); - } - } - } - } - } - - redisPipeline.exec(); - } - - @bindThis - public async checkHibernation(followings: MiFollowing[]) { - if (followings.length === 0) return; - - const shuffle = (array: MiFollowing[]) => { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; - }; - - // ランダムに最大1000件サンプリング - const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); - - const hibernatedUsers = await this.usersRepository.find({ - where: { - id: In(samples.map(x => x.followerId)), - lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), - }, - select: ['id'], - }); - - if (hibernatedUsers.length > 0) { - this.usersRepository.update({ - id: In(hibernatedUsers.map(x => x.id)), - }, { - isHibernated: true, - }); - } - } - @bindThis private isSensitive(note: Option, sensitiveWord: string[]): boolean { if (sensitiveWord.length > 0) { diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 32d54d257..ca05989a4 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -99,19 +99,19 @@ export class NotificationService implements OnApplicationShutdown { } if (recieveConfig?.type === 'following') { - const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)); if (!isFollowing) { return null; } } else if (recieveConfig?.type === 'follower') { - const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)); + const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)); if (!isFollower) { return null; } } else if (recieveConfig?.type === 'mutualFollow') { const [isFollowing, isFollower] = await Promise.all([ - this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)), - this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)), + this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)), + this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)), ]); if (!isFollowing && !isFollower) { return null; diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 18bd49286..9145726f8 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, ObjectLiteral } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import type { SelectQueryBuilder } from 'typeorm'; @@ -23,6 +23,9 @@ export class QueryService { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, + @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -105,6 +108,39 @@ export class QueryService { q.setParameters(blockedQuery.getParameters()); } + @bindThis + public generateChannelQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): void { + if (me == null) { + q.andWhere('note.channelId IS NULL'); + } else { + q.leftJoinAndSelect('note.channel', 'channel'); + + const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing') + .select('channelFollowing.followeeId') + .where('channelFollowing.followerId = :followerId', { followerId: me.id }); + + q.andWhere(new Brackets(qb => { qb + // チャンネルのノートではない + .where('note.channelId IS NULL') + // または自分がフォローしているチャンネルのノート + .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); + })); + + q.setParameters(channelFollowingQuery.getParameters()); + } + } + + @bindThis + public generateMutedNoteQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { + const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted') + .select('muted.noteId') + .where('muted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + + q.setParameters(mutedQuery.getParameters()); + } + @bindThis public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') @@ -176,6 +212,32 @@ export class QueryService { q.setParameters(mutingQuery.getParameters()); } + @bindThis + public generateRepliesQuery(q: SelectQueryBuilder, withReplies: boolean, me?: Pick | null): void { + if (me == null) { + q.andWhere(new Brackets(qb => { qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } else if (!withReplies) { + q.andWhere(new Brackets(qb => { qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 + .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.userId = :meId', { meId: me.id }); + })) + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } + } + @bindThis public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): void { // This code must always be synchronized with the checks in Notes.isVisibleForMe. diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index 087dfd921..37031e341 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -11,7 +11,7 @@ import type { MiBlocking } from '@/models/Blocking.js'; import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListMembershipsRepository } from '@/models/_.js'; +import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js'; import Logger from '@/logger.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @@ -38,8 +38,8 @@ export class UserBlockingService implements OnModuleInit { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, private cacheService: CacheService, private userEntityService: UserEntityService, @@ -149,7 +149,7 @@ export class UserBlockingService implements OnModuleInit { }); for (const userList of userLists) { - await this.userListMembershipsRepository.delete({ + await this.userListJoiningsRepository.delete({ userListId: userList.id, userId: user.id, }); diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index beffcc2e9..230f6ef26 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -123,11 +123,7 @@ export class UserFollowingService implements OnModuleInit { // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく - if ( - followee.isLocked || - (followeeProfile.carefulBot && follower.isBot) || - (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') - ) { + if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) { let autoAccept = false; // 鍵アカウントであっても、既にフォローされていた場合はスルー diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index bece1e442..93dc5edbb 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -5,10 +5,10 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { UserListMembershipsRepository } from '@/models/_.js'; +import type { UserListJoiningsRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import type { MiUserList } from '@/models/UserList.js'; -import type { MiUserListMembership } from '@/models/UserListMembership.js'; +import type { MiUserListJoining } from '@/models/UserListJoining.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -33,8 +33,8 @@ export class UserListService implements OnApplicationShutdown { @Inject(DI.redisForSub) private redisForSub: Redis.Redis, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, private userEntityService: UserEntityService, private idService: IdService, @@ -46,7 +46,7 @@ export class UserListService implements OnApplicationShutdown { this.membersCache = new RedisKVCache>(this.redisClient, 'userListMembers', { lifetime: 1000 * 60 * 30, // 30m memoryCacheLifetime: 1000 * 60, // 1m - fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))), + fetcher: (key) => this.userListJoiningsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))), toRedisConverter: (value) => JSON.stringify(Array.from(value)), fromRedisConverter: (value) => new Set(JSON.parse(value)), }); @@ -85,19 +85,19 @@ export class UserListService implements OnApplicationShutdown { @bindThis public async addMember(target: MiUser, list: MiUserList, me: MiUser) { - const currentCount = await this.userListMembershipsRepository.countBy({ + const currentCount = await this.userListJoiningsRepository.countBy({ userListId: list.id, }); if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { throw new UserListService.TooManyUsersError(); } - await this.userListMembershipsRepository.insert({ + await this.userListJoiningsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), userId: target.id, userListId: list.id, - } as MiUserListMembership); + } as MiUserListJoining); this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id }); this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); @@ -113,7 +113,7 @@ export class UserListService implements OnApplicationShutdown { @bindThis public async removeMember(target: MiUser, list: MiUserList) { - await this.userListMembershipsRepository.delete({ + await this.userListJoiningsRepository.delete({ userId: target.id, userListId: list.id, }); @@ -122,24 +122,6 @@ export class UserListService implements OnApplicationShutdown { this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target)); } - @bindThis - public async updateMembership(target: MiUser, list: MiUserList, options: { withReplies?: boolean }) { - const membership = await this.userListMembershipsRepository.findOneBy({ - userId: target.id, - userListId: list.id, - }); - - if (membership == null) { - throw new Error('User is not a member of the list'); - } - - await this.userListMembershipsRepository.update({ - id: membership.id, - }, { - withReplies: options.withReplies, - }); - } - @bindThis public dispose(): void { this.redisForSub.off('message', this.onMessage); diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts deleted file mode 100644 index d16e1be61..000000000 --- a/packages/backend/src/core/UserService.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { FollowingsRepository, UsersRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class UserService { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - ) { - } - - @bindThis - public async updateLastActiveDate(user: MiUser): Promise { - if (user.isHibernated) { - const result = await this.usersRepository.createQueryBuilder().update() - .set({ - lastActiveDate: new Date(), - }) - .where('id = :id', { id: user.id }) - .returning('*') - .execute() - .then((response) => { - return response.raw[0]; - }); - const wokeUp = result.isHibernated; - if (wokeUp) { - this.usersRepository.update(user.id, { - isHibernated: false, - }); - this.followingsRepository.update({ - followerId: user.id, - }, { - isFollowerHibernated: false, - }); - } - } else { - this.usersRepository.update(user.id, { - lastActiveDate: new Date(), - }); - } - } -} diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 4444e4118..026f84bb3 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -102,13 +102,13 @@ export class NoteEntityService implements OnModuleInit { } else if (meId === packedNote.userId) { hide = false; } else if (packedNote.reply && (meId === packedNote.reply.userId)) { - // 自分の投稿に対するリプライ + // 自分の投稿に対するリプライ hide = false; } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { - // 自分へのメンション + // 自分へのメンション hide = false; } else { - // フォロワーかどうか + // フォロワーかどうか const isFollowing = await this.followingsRepository.exist({ where: { followeeId: packedNote.userId, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index b8fb9f8db..cdd1182f6 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -493,7 +493,6 @@ export class UserEntityService implements OnModuleInit { isMuted: relation.isMuted, isRenoteMuted: relation.isRenoteMuted, notify: relation.following?.notify ?? 'none', - withReplies: relation.following?.withReplies ?? false, } : {}), } as Promiseable> as Promiseable>; diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index 06b6e852b..a7f288519 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -5,12 +5,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; +import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/Blocking.js'; import type { MiUserList } from '@/models/UserList.js'; import { bindThis } from '@/decorators.js'; -import { UserEntityService } from './UserEntityService.js'; @Injectable() export class UserListEntityService { @@ -18,10 +17,8 @@ export class UserListEntityService { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private userEntityService: UserEntityService, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, ) { } @@ -31,7 +28,7 @@ export class UserListEntityService { ): Promise> { const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src }); - const users = await this.userListMembershipsRepository.findBy({ + const users = await this.userListJoiningsRepository.findBy({ userListId: userList.id, }); @@ -43,18 +40,5 @@ export class UserListEntityService { isPublic: userList.isPublic, }; } - - @bindThis - public async packMembershipsMany( - memberships: MiUserListMembership[], - ) { - return Promise.all(memberships.map(async x => ({ - id: x.id, - createdAt: x.createdAt.toISOString(), - userId: x.userId, - user: await this.userEntityService.pack(x.userId), - withReplies: x.withReplies, - }))); - } } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index ccaa810f5..7034fff05 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -10,7 +10,6 @@ export const DI = { redis: Symbol('redis'), redisForPub: Symbol('redisForPub'), redisForSub: Symbol('redisForSub'), - redisForTimelines: Symbol('redisForTimelines'), //#region Repositories usersRepository: Symbol('usersRepository'), @@ -31,7 +30,7 @@ export const DI = { userPublickeysRepository: Symbol('userPublickeysRepository'), userListsRepository: Symbol('userListsRepository'), userListFavoritesRepository: Symbol('userListFavoritesRepository'), - userListMembershipsRepository: Symbol('userListMembershipsRepository'), + userListJoiningsRepository: Symbol('userListJoiningsRepository'), userNotePiningsRepository: Symbol('userNotePiningsRepository'), userIpsRepository: Symbol('userIpsRepository'), usedUsernamesRepository: Symbol('usedUsernamesRepository'), @@ -64,6 +63,7 @@ export const DI = { promoNotesRepository: Symbol('promoNotesRepository'), promoReadsRepository: Symbol('promoReadsRepository'), relaysRepository: Symbol('relaysRepository'), + mutedNotesRepository: Symbol('mutedNotesRepository'), channelsRepository: Symbol('channelsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'), channelFavoritesRepository: Symbol('channelFavoritesRepository'), diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 607538b1e..8c9f965fa 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -9,7 +9,6 @@ import { MiUser } from './User.js'; @Entity('following') @Index(['followerId', 'followeeId'], { unique: true }) -@Index(['followeeId', 'followerHost', 'isFollowerHibernated']) export class MiFollowing { @PrimaryColumn(id()) public id: string; @@ -46,17 +45,6 @@ export class MiFollowing { @JoinColumn() public follower: MiUser | null; - @Column('boolean', { - default: false, - }) - public isFollowerHibernated: boolean; - - // タイムラインにその人のリプライまで含めるかどうか - @Column('boolean', { - default: false, - }) - public withReplies: boolean; - @Index() @Column('varchar', { length: 32, diff --git a/packages/backend/src/models/MutedNote.ts b/packages/backend/src/models/MutedNote.ts new file mode 100644 index 000000000..89a678a2a --- /dev/null +++ b/packages/backend/src/models/MutedNote.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; +import { mutedNoteReasons } from '@/types.js'; +import { id } from './util/id.js'; +import { MiNote } from './Note.js'; +import { MiUser } from './User.js'; + +@Entity('muted_note') +@Index(['noteId', 'userId'], { unique: true }) +export class MiMutedNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The note ID.', + }) + public noteId: MiNote['id']; + + @ManyToOne(type => MiNote, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: MiNote | null; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.', + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + /** + * ミュートされた理由。 + */ + @Index() + @Column('enum', { + enum: mutedNoteReasons, + comment: 'The reason of the MutedNote.', + }) + public reason: typeof mutedNoteReasons[number]; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index bef053610..7e2bee8c4 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -5,7 +5,7 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, NoteEdit } from './_.js'; +import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, NoteEdit } from './_.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -117,9 +117,9 @@ const $userListFavoritesRepository: Provider = { inject: [DI.db], }; -const $userListMembershipsRepository: Provider = { - provide: DI.userListMembershipsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserListMembership), +const $userListJoiningsRepository: Provider = { + provide: DI.userListJoiningsRepository, + useFactory: (db: DataSource) => db.getRepository(MiUserListJoining), inject: [DI.db], }; @@ -315,6 +315,12 @@ const $relaysRepository: Provider = { inject: [DI.db], }; +const $mutedNotesRepository: Provider = { + provide: DI.mutedNotesRepository, + useFactory: (db: DataSource) => db.getRepository(MiMutedNote), + inject: [DI.db], +}; + const $channelsRepository: Provider = { provide: DI.channelsRepository, useFactory: (db: DataSource) => db.getRepository(MiChannel), @@ -421,7 +427,7 @@ const $noteEditRepository: Provider = { $userPublickeysRepository, $userListsRepository, $userListFavoritesRepository, - $userListMembershipsRepository, + $userListJoiningsRepository, $userNotePiningsRepository, $userIpsRepository, $usedUsernamesRepository, @@ -454,6 +460,7 @@ const $noteEditRepository: Provider = { $promoNotesRepository, $promoReadsRepository, $relaysRepository, + $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, $channelFavoritesRepository, @@ -488,7 +495,7 @@ const $noteEditRepository: Provider = { $userPublickeysRepository, $userListsRepository, $userListFavoritesRepository, - $userListMembershipsRepository, + $userListJoiningsRepository, $userNotePiningsRepository, $userIpsRepository, $usedUsernamesRepository, @@ -521,6 +528,7 @@ const $noteEditRepository: Provider = { $promoNotesRepository, $promoReadsRepository, $relaysRepository, + $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, $channelFavoritesRepository, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 9650622dd..8f0122a90 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -187,11 +187,6 @@ export class MiUser { }) public isExplorable: boolean; - @Column('boolean', { - default: false, - }) - public isHibernated: boolean; - // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ @Column('boolean', { default: false, diff --git a/packages/backend/src/models/UserListMembership.ts b/packages/backend/src/models/UserListJoining.ts similarity index 76% rename from packages/backend/src/models/UserListMembership.ts rename to packages/backend/src/models/UserListJoining.ts index f337f19a4..4918f2f70 100644 --- a/packages/backend/src/models/UserListMembership.ts +++ b/packages/backend/src/models/UserListJoining.ts @@ -8,14 +8,14 @@ import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiUserList } from './UserList.js'; -@Entity('user_list_membership') +@Entity('user_list_joining') @Index(['userId', 'userListId'], { unique: true }) -export class MiUserListMembership { +export class MiUserListJoining { @PrimaryColumn(id()) public id: string; @Column('timestamp with time zone', { - comment: 'The created date of the UserListMembership.', + comment: 'The created date of the UserListJoining.', }) public createdAt: Date; @@ -44,10 +44,4 @@ export class MiUserListMembership { }) @JoinColumn() public userList: MiUserList | null; - - // タイムラインにその人のリプライまで含めるかどうか - @Column('boolean', { - default: false, - }) - public withReplies: boolean; } diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index b76f6d542..ca047569c 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -28,6 +28,7 @@ import { MiHashtag } from '@/models/Hashtag.js'; import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; +import { MiMutedNote } from '@/models/MutedNote.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; @@ -52,7 +53,7 @@ import { MiUser } from '@/models/User.js'; import { MiUserIp } from '@/models/UserIp.js'; import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserList } from '@/models/UserList.js'; -import { MiUserListMembership } from '@/models/UserListMembership.js'; +import { MiUserListJoining } from '@/models/UserListJoining.js'; import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserPending } from '@/models/UserPending.js'; import { MiUserProfile } from '@/models/UserProfile.js'; @@ -96,6 +97,7 @@ export { MiInstance, MiMeta, MiModerationLog, + MiMutedNote, MiMuting, MiRenoteMuting, MiNote, @@ -121,7 +123,7 @@ export { MiUserKeypair, MiUserList, MiUserListFavorite, - MiUserListMembership, + MiUserListJoining, MiUserNotePining, MiUserPending, MiUserProfile, @@ -163,6 +165,7 @@ export type HashtagsRepository = Repository; export type InstancesRepository = Repository; export type MetasRepository = Repository; export type ModerationLogsRepository = Repository; +export type MutedNotesRepository = Repository; export type MutingsRepository = Repository; export type RenoteMutingsRepository = Repository; export type NotesRepository = Repository; @@ -188,7 +191,7 @@ export type UserIpsRepository = Repository; export type UserKeypairsRepository = Repository; export type UserListsRepository = Repository; export type UserListFavoritesRepository = Repository; -export type UserListMembershipsRepository = Repository; +export type UserListJoiningsRepository = Repository; export type UserNotePiningsRepository = Repository; export type UserPendingsRepository = Repository; export type UserProfilesRepository = Repository; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 25f054728..79b14bb65 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -283,10 +283,6 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'string', nullable: false, optional: true, }, - withReplies: { - type: 'boolean', - nullable: false, optional: true, - }, //#endregion }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 5cf9d7d1a..b12a84ac9 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -36,6 +36,7 @@ import { MiHashtag } from '@/models/Hashtag.js'; import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; +import { MiMutedNote } from '@/models/MutedNote.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; @@ -61,7 +62,7 @@ import { MiUserIp } from '@/models/UserIp.js'; import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserList } from '@/models/UserList.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; -import { MiUserListMembership } from '@/models/UserListMembership.js'; +import { MiUserListJoining } from '@/models/UserListJoining.js'; import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserPending } from '@/models/UserPending.js'; import { MiUserProfile } from '@/models/UserProfile.js'; @@ -138,7 +139,7 @@ export const entities = [ MiUserPublickey, MiUserList, MiUserListFavorite, - MiUserListMembership, + MiUserListJoining, MiUserNotePining, MiUserSecurityKey, MiUsedUsername, @@ -174,6 +175,7 @@ export const entities = [ MiPromoNote, MiPromoRead, MiRelay, + MiMutedNote, MiChannel, MiChannelFollowing, MiChannelFavorite, diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index e252c5d8a..f0453f705 100644 --- a/packages/backend/src/queue/processors/CleanProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanProcessorService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, LessThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js'; +import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; @@ -25,6 +25,9 @@ export class CleanProcessorService { @Inject(DI.userIpsRepository) private userIpsRepository: UserIpsRepository, + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, + @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, @@ -45,6 +48,16 @@ export class CleanProcessorService { createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), }); + this.mutedNotesRepository.delete({ + id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), + reason: 'word', + }); + + this.mutedNotesRepository.delete({ + id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), + reason: 'word', + }); + // 使われてないアンテナを停止 if (this.config.deactivateAntennaThreshold > 0) { this.antennasRepository.update({ diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index a0afbee3b..f941fb6e8 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { format as DateFormat } from 'date-fns'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, UsersRepository, UserListMembershipsRepository, MiUser } from '@/models/_.js'; +import type { AntennasRepository, UsersRepository, UserListJoiningsRepository, MiUser } from '@/models/_.js'; import Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { bindThis } from '@/decorators.js'; @@ -29,8 +29,8 @@ export class ExportAntennasProcessorService { @Inject(DI.antennasRepository) private antennsRepository: AntennasRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, private driveService: DriveService, private utilityService: UtilityService, @@ -65,9 +65,9 @@ export class ExportAntennasProcessorService { for (const [index, antenna] of antennas.entries()) { let users: MiUser[] | undefined; if (antenna.userListId !== null) { - const memberships = await this.userListMembershipsRepository.findBy({ userListId: antenna.userListId }); + const joinings = await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId }); users = await this.usersRepository.findBy({ - id: In(memberships.map(j => j.userId)), + id: In(joinings.map(j => j.userId)), }); } write(JSON.stringify({ diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index a3f9441dc..7baaa7081 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; import { format as dateFormat } from 'date-fns'; import { DI } from '@/di-symbols.js'; -import type { UserListMembershipsRepository, UserListsRepository, UsersRepository } from '@/models/_.js'; +import type { UserListJoiningsRepository, UserListsRepository, UsersRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; @@ -29,8 +29,8 @@ export class ExportUserListsProcessorService { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, private utilityService: UtilityService, private driveService: DriveService, @@ -61,9 +61,9 @@ export class ExportUserListsProcessorService { const stream = fs.createWriteStream(path, { flags: 'a' }); for (const list of lists) { - const memberships = await this.userListMembershipsRepository.findBy({ userListId: list.id }); + const joinings = await this.userListJoiningsRepository.findBy({ userListId: list.id }); const users = await this.usersRepository.findBy({ - id: In(memberships.map(j => j.userId)), + id: In(joinings.map(j => j.userId)), }); for (const u of users) { diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts index 9be36a9d0..60a0d1605 100644 --- a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, DriveFilesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; +import type { UsersRepository, DriveFilesRepository, UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; @@ -33,8 +33,8 @@ export class ImportUserListsProcessorService { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, private utilityService: UtilityService, private idService: IdService, @@ -99,7 +99,7 @@ export class ImportUserListsProcessorService { target = await this.remoteUserResolveService.resolveUser(username, host); } - if (await this.userListMembershipsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; + if (await this.userListJoiningsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; this.userListService.addMember(target, list!, user); } catch (e) { diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 74b26af27..15a2621da 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -205,6 +205,7 @@ import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; import * as ep___i_favorites from './endpoints/i/favorites.js'; import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; +import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js'; import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js'; @@ -336,9 +337,7 @@ import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js'; import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; -import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; -import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; +import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js'; import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_flashs from './endpoints/users/flashs.js'; @@ -556,6 +555,7 @@ const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default }; const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default }; const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default }; +const $i_getWordMutedNotesCount: Provider = { provide: 'ep:i/get-word-muted-notes-count', useClass: ep___i_getWordMutedNotesCount.default }; const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: ep___i_importBlocking.default }; const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default }; const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default }; @@ -687,9 +687,7 @@ const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default }; const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default }; -const $users_lists_createFromPublic: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_createFromPublic.default }; -const $users_lists_updateMembership: Provider = { provide: 'ep:users/lists/update-membership', useClass: ep___users_lists_updateMembership.default }; -const $users_lists_getMemberships: Provider = { provide: 'ep:users/lists/get-memberships', useClass: ep___users_lists_getMemberships.default }; +const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default }; const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default }; @@ -911,6 +909,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_favorites, $i_gallery_likes, $i_gallery_posts, + $i_getWordMutedNotesCount, $i_importBlocking, $i_importFollowing, $i_importMuting, @@ -1042,9 +1041,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_lists_update, $users_lists_favorite, $users_lists_unfavorite, - $users_lists_createFromPublic, - $users_lists_updateMembership, - $users_lists_getMemberships, + $users_lists_create_from_public, $users_notes, $users_pages, $users_flashs, @@ -1260,6 +1257,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_favorites, $i_gallery_likes, $i_gallery_posts, + $i_getWordMutedNotesCount, $i_importBlocking, $i_importFollowing, $i_importMuting, @@ -1388,9 +1386,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_lists_update, $users_lists_favorite, $users_lists_unfavorite, - $users_lists_createFromPublic, - $users_lists_updateMembership, - $users_lists_getMemberships, + $users_lists_create_from_public, $users_notes, $users_pages, $users_flashs, diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index badcec1b3..9acaa688c 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -14,7 +14,6 @@ import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { MiLocalUser } from '@/models/User.js'; -import { UserService } from '@/core/UserService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import MainStreamConnection from './stream/Connection.js'; import { ChannelsService } from './stream/ChannelsService.js'; @@ -38,7 +37,6 @@ export class StreamingApiServerService { private authenticateService: AuthenticateService, private channelsService: ChannelsService, private notificationService: NotificationService, - private usersService: UserService, ) { } @@ -132,10 +130,14 @@ export class StreamingApiServerService { this.#connections.set(connection, Date.now()); const userUpdateIntervalId = user ? setInterval(() => { - this.usersService.updateLastActiveDate(user); + this.usersRepository.update(user.id, { + lastActiveDate: new Date(), + }); }, 1000 * 60 * 5) : null; if (user) { - this.usersService.updateLastActiveDate(user); + this.usersRepository.update(user.id, { + lastActiveDate: new Date(), + }); } connection.once('close', () => { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 279d56d1e..5ea412191 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -205,6 +205,7 @@ import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; import * as ep___i_favorites from './endpoints/i/favorites.js'; import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; +import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js'; import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js'; @@ -335,10 +336,8 @@ import * as ep___users_lists_push from './endpoints/users/lists/push.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; +import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js'; -import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; -import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_flashs from './endpoints/users/flashs.js'; @@ -554,6 +553,7 @@ const eps = [ ['i/favorites', ep___i_favorites], ['i/gallery/likes', ep___i_gallery_likes], ['i/gallery/posts', ep___i_gallery_posts], + ['i/get-word-muted-notes-count', ep___i_getWordMutedNotesCount], ['i/import-blocking', ep___i_importBlocking], ['i/import-following', ep___i_importFollowing], ['i/import-muting', ep___i_importMuting], @@ -685,9 +685,7 @@ const eps = [ ['users/lists/favorite', ep___users_lists_favorite], ['users/lists/unfavorite', ep___users_lists_unfavorite], ['users/lists/update', ep___users_lists_update], - ['users/lists/create-from-public', ep___users_lists_createFromPublic], - ['users/lists/update-membership', ep___users_lists_updateMembership], - ['users/lists/get-memberships', ep___users_lists_getMemberships], + ['users/lists/create-from-public', ep___users_lists_create_from_public], ['users/notes', ep___users_notes], ['users/pages', ep___users_pages], ['users/flashs', ep___users_flashs], diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 63e542cb6..eaae7bff6 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -56,8 +56,8 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -86,7 +86,7 @@ export default class extends Endpoint { // eslint- }); const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const noteIdsRes = await this.redisForTimelines.xrevrange( + const noteIdsRes = await this.redisClient.xrevrange( `antennaTimeline:${antenna.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 56b8fc5c3..026b64953 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -54,8 +54,8 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -83,7 +83,7 @@ export default class extends Endpoint { // eslint- let noteIdsRes: [string, string[]][] = []; if (!ps.sinceId && !ps.sinceDate) { - noteIdsRes = await this.redisForTimelines.xrevrange( + noteIdsRes = await this.redisClient.xrevrange( `channelTimeline:${channel.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', '-', @@ -104,6 +104,7 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); } //#endregion @@ -128,6 +129,7 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); } //#endregion diff --git a/packages/backend/src/server/api/endpoints/following/update.ts b/packages/backend/src/server/api/endpoints/following/update.ts index db17d151d..25f393e51 100644 --- a/packages/backend/src/server/api/endpoints/following/update.ts +++ b/packages/backend/src/server/api/endpoints/following/update.ts @@ -57,9 +57,8 @@ export const paramDef = { properties: { userId: { type: 'string', format: 'misskey:id' }, notify: { type: 'string', enum: ['normal', 'none'] }, - withReplies: { type: 'boolean' }, }, - required: ['userId'], + required: ['userId', 'notify'], } as const; @Injectable() @@ -99,8 +98,7 @@ export default class extends Endpoint { // eslint- await this.followingsRepository.update({ id: exist.id, }, { - notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined, - withReplies: ps.withReplies != null ? ps.withReplies : undefined, + notify: ps.notify === 'none' ? null : ps.notify, }); return await this.userEntityService.pack(follower.id, me); diff --git a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts new file mode 100644 index 000000000..d62bfbb3e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { MutedNotesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'read:account', + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + count: { + type: 'number', + optional: false, nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + return { + count: await this.mutedNotesRepository.countBy({ + userId: me.id, + reason: 'word', + }), + }; + }); + } +} 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 e5a86905d..8784e8615 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -40,6 +40,7 @@ export const paramDef = { type: 'object', properties: { withFiles: { type: 'boolean', default: false }, + withReplies: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, @@ -67,8 +68,49 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.gtlDisabled); } - // TODO? - return []; + //#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') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + this.queryService.generateRepliesQuery(query, ps.withReplies, me); + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(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.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + //#endregion + + const timeline = await query.limit(ps.limit).getMany(); + + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); + } + }); + + return await this.noteEntityService.packMany(timeline, 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 d6ed3db6e..9bde5dee2 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -5,16 +5,14 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js'; +import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -53,6 +51,7 @@ export const paramDef = { includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, + withReplies: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, }, required: [], @@ -61,17 +60,17 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + private noteEntityService: NoteEntityService, + private queryService: QueryService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, private idService: IdService, - private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me.id); @@ -79,75 +78,79 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.stlDisabled); } - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]); + //#region Construct query + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); - let timeline: MiNote[] = []; - - const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - let htlNoteIdsRes: [string, string[]][] = []; - let ltlNoteIdsRes: [string, string[]][] = []; - - if (!ps.sinceId && !ps.sinceDate) { - htlNoteIdsRes = await this.redisForTimelines.xrevrange( - ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', - '-', - 'COUNT', limit); - ltlNoteIdsRes = await this.redisForTimelines.xrevrange( - ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', - '-', - 'COUNT', limit); - } - - const htlNoteIds = htlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); - const ltlNoteIds = ltlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); - let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); - noteIds.sort((a, b) => a > b ? -1 : 1); - noteIds = noteIds.slice(0, ps.limit); - - if (noteIds.length === 0) { - return []; - } - - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで + .andWhere(new Brackets(qb => { + qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }) + .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + })) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .setParameters(followingQuery.getParameters()); - timeline = await query.getMany(); + this.queryService.generateChannelQuery(query, me); + this.queryService.generateRepliesQuery(query, ps.withReplies, me); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - timeline = timeline.filter(note => { - if (note.userId === me.id) { - return true; - } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - return true; - }); + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - // TODO: フィルタした結果件数が足りなかった場合の対応 + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + //#endregion + + const timeline = await query.limit(ps.limit).getMany(); process.nextTick(() => { this.activeUsersChart.read(me); 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 ed57ca1a3..0fefddc51 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -5,16 +5,14 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { MiNote, NotesRepository } from '@/models/_.js'; +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 { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -43,7 +41,11 @@ export const paramDef = { type: 'object', properties: { withFiles: { type: 'boolean', default: false }, + withReplies: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, + fileType: { type: 'array', items: { + type: 'string', + } }, excludeNsfw: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, @@ -57,17 +59,14 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, private noteEntityService: NoteEntityService, + private queryService: QueryService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, private idService: IdService, - private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me ? me.id : null); @@ -75,63 +74,56 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.ltlDisabled); } - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]) : [new Set(), new Set(), new Set()]; - - let timeline: MiNote[] = []; - - const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - let noteIdsRes: [string, string[]][] = []; - - if (!ps.sinceId && !ps.sinceDate) { - noteIdsRes = await this.redisForTimelines.xrevrange( - ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', - '-', - 'COUNT', limit); - } - - const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); - - if (noteIds.length === 0) { - return []; - } - - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで + .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .leftJoinAndSelect('renote.user', 'renoteUser'); - timeline = await query.getMany(); + this.queryService.generateChannelQuery(query, me); + this.queryService.generateRepliesQuery(query, ps.withReplies, me); + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateMutedNoteQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - timeline = timeline.filter(note => { - if (me && (note.userId === me.id)) { - return true; - } - if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); } + } - return true; - }); + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + //#endregion - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + const timeline = await query.limit(ps.limit).getMany(); process.nextTick(() => { if (me) { diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 2f25d2d7c..0d47cc170 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,16 +5,13 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js'; +import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; export const meta = { tags: ['notes'], @@ -44,6 +41,7 @@ export const paramDef = { includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, + withReplies: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, }, required: [], @@ -52,82 +50,96 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + private noteEntityService: NoteEntityService, + private queryService: QueryService, private activeUsersChart: ActiveUsersChart, private idService: IdService, - private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { - 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), - ]); + const followees = await this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }) + .getMany(); - let timeline: MiNote[] = []; - - const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - let noteIdsRes: [string, string[]][] = []; - - if (!ps.sinceId && !ps.sinceDate) { - noteIdsRes = await this.redisForTimelines.xrevrange( - ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', - '-', - 'COUNT', limit); - } - - const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); - - if (noteIds.length === 0) { - return []; - } - - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + // パフォーマンス上の利点が無さそう? + //.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .leftJoinAndSelect('renote.user', 'renoteUser'); - timeline = await query.getMany(); + if (followees.length > 0) { + const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - timeline = timeline.filter(note => { - if (note.userId === me.id) { - return true; - } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } - if (note.reply && note.reply.visibility === 'followers') { - if (!Object.hasOwn(followings, note.reply.userId)) return false; - } + query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + } else { + query.andWhere('note.userId = :meId', { meId: me.id }); + } - return true; - }); + this.queryService.generateChannelQuery(query, me); + this.queryService.generateRepliesQuery(query, ps.withReplies, me); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - // TODO: フィルタした結果件数が足りなかった場合の対応 + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + //#endregion + + const timeline = await query.limit(ps.limit).getMany(); process.nextTick(() => { this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 8e943826d..c20274b2b 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -5,16 +5,12 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { NotesRepository, UserListsRepository, UserListMembershipsRepository, MiNote } from '@/models/_.js'; +import type { NotesRepository, UserListsRepository, UserListJoiningsRepository } 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 { CacheService } from '@/core/CacheService.js'; -import { IdService } from '@/core/IdService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -67,19 +63,18 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + private noteEntityService: NoteEntityService, + private queryService: QueryService, private activeUsersChart: ActiveUsersChart, - private cacheService: CacheService, - private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const list = await this.userListsRepository.findOneBy({ @@ -91,65 +86,72 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchList); } - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]); - - let timeline: MiNote[] = []; - - const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - let noteIdsRes: [string, string[]][] = []; - - if (!ps.sinceId && !ps.sinceDate) { - noteIdsRes = await this.redisForTimelines.xrevrange( - ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', - '-', - 'COUNT', limit); - } - - const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); - - if (noteIds.length === 0) { - return []; - } - - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .andWhere('userListJoining.userListId = :userListId', { userListId: list.id }); - timeline = await query.getMany(); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - timeline = timeline.filter(note => { - if (note.userId === me.id) { - return true; - } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - return true; - }); + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - // TODO: フィルタした結果件数が足りなかった場合の対応 + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + if (!ps.withReplies) { + query.andWhere('note.replyId IS NULL'); + } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.limit(ps.limit).getMany(); this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index f2533efa3..6dc35907e 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -53,8 +53,8 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -79,7 +79,7 @@ export default class extends Endpoint { // eslint- return []; } const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const noteIdsRes = await this.redisForTimelines.xrevrange( + const noteIdsRes = await this.redisClient.xrevrange( `roleTimeline:${role.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts index f2f6c4303..eae55905d 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js'; +import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import type { MiUserList } from '@/models/UserList.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -76,8 +76,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -110,7 +110,7 @@ export default class extends Endpoint { // eslint- name: ps.name, } as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); - const users = (await this.userListMembershipsRepository.findBy({ + const users = (await this.userListJoiningsRepository.findBy({ userListId: ps.listId, })).map(x => x.userId); @@ -132,7 +132,7 @@ export default class extends Endpoint { // eslint- } } - const exist = await this.userListMembershipsRepository.exist({ + const exist = await this.userListJoiningsRepository.exist({ where: { userListId: userList.id, userId: currentUser.id, diff --git a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts deleted file mode 100644 index ae8b4e9b8..000000000 --- a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository, UserListFavoritesRepository, UserListMembershipsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { QueryService } from '@/core/QueryService.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - tags: ['lists', 'account'], - - requireCredential: false, - - kind: 'read:account', - - errors: { - noSuchList: { - message: 'No such list.', - code: 'NO_SUCH_LIST', - id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - listId: { type: 'string', format: 'misskey:id' }, - forPublic: { type: 'boolean', default: false }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - }, - required: ['listId'], -} as const; - -@Injectable() // eslint-disable-next-line import/no-default-export -export default class extends Endpoint { - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private userListEntityService: UserListEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch the list - const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? { - id: ps.listId, - userId: me.id, - } : { - id: ps.listId, - isPublic: true, - }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); - } - - const query = this.queryService.makePaginationQuery(this.userListMembershipsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId) - .andWhere('membership.userListId = :userListId', { userListId: userList.id }) - .innerJoinAndSelect('membership.user', 'user'); - - const memberships = await query - .limit(ps.limit) - .getMany(); - - return this.userListEntityService.packMembershipsMany(memberships); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index c4ceec575..72a6a7380 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js'; +import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { UserListService } from '@/core/UserListService.js'; @@ -76,8 +76,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -115,7 +115,7 @@ export default class extends Endpoint { // eslint- } } - const exist = await this.userListMembershipsRepository.exist({ + const exist = await this.userListJoiningsRepository.exist({ where: { userListId: userList.id, userId: user.id, diff --git a/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts deleted file mode 100644 index b69465b94..000000000 --- a/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { DI } from '@/di-symbols.js'; -import { UserListService } from '@/core/UserListService.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - tags: ['lists', 'users'], - - requireCredential: true, - - prohibitMoved: true, - - kind: 'write:account', - - errors: { - noSuchList: { - message: 'No such list.', - code: 'NO_SUCH_LIST', - id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02', - }, - - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: '588e7f72-c744-4a61-b180-d354e912bda2', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - listId: { type: 'string', format: 'misskey:id' }, - userId: { type: 'string', format: 'misskey:id' }, - withReplies: { type: 'boolean' }, - }, - required: ['listId', 'userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - private userListService: UserListService, - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch the list - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); - } - - // Fetch the user - const user = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - await this.userListService.updateMembership(user, userList, { - withReplies: ps.withReplies, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 9321a41d5..e660a0bb2 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -5,14 +5,12 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { MiNote, NotesRepository } from '@/models/_.js'; +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 { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -52,6 +50,9 @@ export const paramDef = { untilDate: { type: 'integer' }, includeMyRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, + fileType: { type: 'array', items: { + type: 'string', + } }, excludeNsfw: { type: 'boolean', default: false }, }, required: ['userId'], @@ -60,52 +61,64 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, private noteEntityService: NoteEntityService, + private queryService: QueryService, private getterService: GetterService, - private cacheService: CacheService, - private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - let timeline: MiNote[] = []; + // Lookup user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); - const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - let noteIdsRes: [string, string[]][] = []; - - if (!ps.sinceId && !ps.sinceDate) { - noteIdsRes = await this.redisForTimelines.xrevrange( - ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : ps.withReplies ? `userTimelineWithReplies:${ps.userId}` : `userTimeline:${ps.userId}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', - '-', - 'COUNT', limit); - } - - const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); - - if (noteIds.length === 0) { - return []; - } - - const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false; - - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.userId = :userId', { userId: user.id }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('note.channel', 'channel') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .leftJoinAndSelect('renote.user', 'renoteUser'); + + query.andWhere(new Brackets(qb => { + qb.orWhere('note.channelId IS NULL'); + qb.orWhere('channel.isSensitive = false'); + })); + + this.queryService.generateVisibilityQuery(query, me); + if (me) { + this.queryService.generateMutedUserQuery(query, me, user); + this.queryService.generateBlockedUserQuery(query, me); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + } + } if (!ps.withReplies) { query.andWhere('note.replyId IS NULL'); } - + if (ps.withRenotes === false) { query.andWhere(new Brackets(qb => { qb.orWhere('note.renoteId IS NULL'); @@ -116,21 +129,19 @@ export default class extends Endpoint { // eslint- })); } - timeline = await query.getMany(); + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :userId', { userId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - timeline = timeline.filter(note => { - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (ps.withRenotes === false) return false; - } - } + //#endregion - if (note.visibility === 'followers' && !isFollowing) return false; - - return true; - }); - - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + const timeline = await query.limit(ps.limit).getMany(); return await this.noteEntityService.packMany(timeline, me); }); diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index f981e6387..a73071ea9 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -11,7 +11,7 @@ import type { NoteReadService } from '@/core/NoteReadService.js'; import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; -import { MiFollowing, MiUserProfile } from '@/models/_.js'; +import { MiUserProfile } from '@/models/_.js'; import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; @@ -30,7 +30,7 @@ export default class Connection { private subscribingNotes: any = {}; private cachedNotes: Packed<'Note'>[] = []; public userProfile: MiUserProfile | null = null; - public following: Record | undefined> = {}; + public following: Set = new Set(); public followingChannels: Set = new Set(); public userIdsWhoMeMuting: Set = new Set(); public userIdsWhoBlockingMe: Set = new Set(); 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 f0ac50349..fef52b685 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -18,6 +18,7 @@ class GlobalTimelineChannel extends Channel { public readonly chName = 'globalTimeline'; public static shouldShare = true; public static requireCredential = false; + private withReplies: boolean; private withRenotes: boolean; constructor( @@ -37,6 +38,7 @@ class GlobalTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.gtlAvailable) return; + this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; // Subscribe events @@ -62,7 +64,7 @@ class GlobalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies) { + if (note.reply && !this.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; @@ -80,6 +82,13 @@ class GlobalTimelineChannel extends Channel { if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + this.connection.cacheNote(note); this.send('note', note); 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 1c1b1c2ae..198c68e1c 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -16,6 +16,7 @@ class HomeTimelineChannel extends Channel { public readonly chName = 'homeTimeline'; public static shouldShare = true; public static requireCredential = true; + private withReplies: boolean; private withRenotes: boolean; constructor( @@ -30,6 +31,7 @@ class HomeTimelineChannel extends Channel { @bindThis public async init(params: any) { + this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; this.subscriber.on('notesStream', this.onNote); @@ -41,7 +43,7 @@ class HomeTimelineChannel extends Channel { if (!this.followingChannels.has(note.channelId)) return; } else { // その投稿のユーザーをフォローしていなかったら弾く - if ((this.user!.id !== note.userId) && !Object.hasOwn(this.following, note.userId)) return; + if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return; } // Ignore notes from instances the user has muted @@ -71,7 +73,7 @@ class HomeTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies) { + if (note.reply && !this.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; @@ -86,6 +88,13 @@ class HomeTimelineChannel extends Channel { if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (await checkWordMute(note, this.user, this.userProfile!.mutedWords)) return; + this.connection.cacheNote(note); this.send('note', 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 e2f4817bf..cde429747 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -18,6 +18,7 @@ class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; public static shouldShare = true; public static requireCredential = true; + private withReplies: boolean; private withRenotes: boolean; constructor( @@ -37,6 +38,7 @@ class HybridTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.ltlAvailable) return; + this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; // Subscribe events @@ -51,7 +53,7 @@ class HybridTimelineChannel extends Channel { // フォローしているチャンネルの投稿 の場合だけ if (!( (note.channelId == null && this.user!.id === note.userId) || - (note.channelId == null && Object.hasOwn(this.following, note.userId)) || + (note.channelId == null && this.following.has(note.userId)) || (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || (note.channelId != null && this.followingChannels.has(note.channelId)) )) return; @@ -83,7 +85,7 @@ class HybridTimelineChannel extends Channel { if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances ?? []))) return; // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies) { + if (note.reply && !this.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; @@ -98,6 +100,13 @@ class HybridTimelineChannel extends Channel { if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + this.connection.cacheNote(note); this.send('note', 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 ca563b5d1..ef708c4fe 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -17,6 +17,7 @@ class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; public static shouldShare = true; public static requireCredential = false; + private withReplies: boolean; private withRenotes: boolean; constructor( @@ -36,6 +37,7 @@ class LocalTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.ltlAvailable) return; + this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; // Subscribe events @@ -62,7 +64,7 @@ class LocalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && this.user && !this.following[note.userId]?.withReplies) { + if (note.reply && this.user && !this.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; @@ -77,6 +79,13 @@ class LocalTimelineChannel extends Channel { if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 03f7760d8..8bbba0b6d 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; +import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -18,12 +18,12 @@ class UserListChannel extends Channel { public static shouldShare = false; public static requireCredential = false; private listId: string; - public membershipsMap: Record | undefined> = {}; + public listUsers: MiUser['id'][] = []; private listUsersClock: NodeJS.Timeout; constructor( private userListsRepository: UserListsRepository, - private userListMembershipsRepository: UserListMembershipsRepository, + private userListJoiningsRepository: UserListJoiningsRepository, private noteEntityService: NoteEntityService, id: string, @@ -58,25 +58,19 @@ class UserListChannel extends Channel { @bindThis private async updateListUsers() { - const memberships = await this.userListMembershipsRepository.find({ + const users = await this.userListJoiningsRepository.find({ where: { userListId: this.listId, }, select: ['userId'], }); - const membershipsMap: Record | undefined> = {}; - for (const membership of memberships) { - membershipsMap[membership.userId] = { - withReplies: membership.withReplies, - }; - } - this.membershipsMap = membershipsMap; + this.listUsers = users.map(x => x.userId); } @bindThis private async onNote(note: Packed<'Note'>) { - if (!Object.hasOwn(this.membershipsMap, note.userId)) return; + if (!this.listUsers.includes(note.userId)) return; if (['followers', 'specified'].includes(note.visibility)) { note = await this.noteEntityService.pack(note.id, this.user, { @@ -101,13 +95,6 @@ class UserListChannel extends Channel { } } - // 関係ない返信は除外 - if (note.reply && !this.membershipsMap[note.userId]?.withReplies) { - const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; - } - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する @@ -137,8 +124,8 @@ export class UserListChannelService { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, private noteEntityService: NoteEntityService, ) { @@ -148,7 +135,7 @@ export class UserListChannelService { public create(id: string, connection: Channel['connection']): UserListChannel { return new UserListChannel( this.userListsRepository, - this.userListMembershipsRepository, + this.userListJoiningsRepository, this.noteEntityService, id, connection, diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts index 7d57ba17b..c9e1ccc30 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -6,7 +6,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { signup, api, post, react, startServer, waitFire, sleep } from '../utils.js'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; import type * as misskey from 'misskey-js'; @@ -42,9 +42,6 @@ describe('Renote Mute', () => { const carolRenote = await post(carol, { renoteId: bobNote.id }); const carolNote = await post(carol, { text: 'hi' }); - // redisに追加されるのを待つ - await sleep(100); - const res = await api('/notes/local-timeline', {}, alice); assert.strictEqual(res.status, 200); @@ -59,9 +56,6 @@ describe('Renote Mute', () => { const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); const carolNote = await post(carol, { text: 'hi' }); - // redisに追加されるのを待つ - await sleep(100); - const res = await api('/notes/local-timeline', {}, alice); assert.strictEqual(res.status, 200); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts deleted file mode 100644 index f4c7ffc82..000000000 --- a/packages/backend/test/e2e/timelines.ts +++ /dev/null @@ -1,701 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; -process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true'; - -import * as assert from 'assert'; -import { signup, api, post, react, startServer, waitFire, sleep, uploadUrl } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -let app: INestApplicationContext; - -beforeAll(async () => { - app = await startServer(); -}, 1000 * 60 * 2); - -afterAll(async () => { - await app.close(); -}); - -describe('Timelines', () => { - describe('Home TL', () => { - test.concurrent('自分の visibility: followers なノートが含まれる', async () => { - const [alice] = await Promise.all([signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); - }); - - test.concurrent('フォローしているユーザーのノートが含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi' }); - const carolNote = await post(carol, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - const carolNote = await post(carol, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/create', { userId: carol.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi'); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/create', { userId: carol.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - }); - - test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }); - - test.concurrent('自分の他人への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi' }); - const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - }); - - test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { renoteId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { renoteId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', { - withRenotes: false, - }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', { - withRenotes: false, - }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/mute/create', { userId: carol.id }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - await api('/mute/create', { userId: carol.id }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); - - await api('/following/create', { userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); - - await api('/following/create', { userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const [bobFile, carolFile] = await Promise.all([ - uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'), - uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'), - ]); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [bobFile.id] }); - const carolNote1 = await post(carol, { text: 'hi' }); - const carolNote2 = await post(carol, { fileIds: [carolFile.id] }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/timeline', { withFiles: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false); - }, 1000 * 10); - }); - - describe('Local TL', () => { - test.concurrent('visibility: home なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); - const bobNote = await post(bob, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('リモートユーザーのノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); - - const bobNote = await post(bob, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - // 含まれても良いと思うけど実装が面倒なので含まれない - test.concurrent('フォローしているユーザーの visibility: home なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { - userId: carol.id, - }, alice); - const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); - const bobNote = await post(bob, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('ミュートしているユーザーのノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/mute/create', { userId: carol.id }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/mute/create', { userId: carol.id }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - await api('/mute/create', { userId: carol.id }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/local-timeline', { withFiles: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); - }); - - describe('Social TL', () => { - test.concurrent('ローカルユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/hybrid-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/hybrid-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/hybrid-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('リモートユーザーのノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); - - const bobNote = await post(bob, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); - - await api('/following/create', { userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/hybrid-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); - - await api('/following/create', { userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/hybrid-timeline', {}, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/hybrid-timeline', { withFiles: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); - }); - - describe('User List TL', () => { - test.concurrent('リスインしているフォローしていないユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - /* 未実装 - test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - */ - - test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれるが隠される', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, null); - }); - - test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }); - - test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await api('/users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: true }, alice); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); - }); - - test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await sleep(100); // redisに追加されるのを待つ - - const res = await api('/notes/user-list-timeline', { listId: list.id, withFiles: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); - }); - - // TODO: リノートミュート済みユーザーのテスト - // TODO: ページネーションのテスト -}); diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts index b5f00a632..121070787 100644 --- a/packages/backend/test/e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -38,10 +38,23 @@ describe('users/notes', () => { await app.close(); }); - test('withFiles', async () => { + test('ファイルタイプ指定 (jpg)', async () => { const res = await api('/users/notes', { userId: alice.id, - withFiles: true, + fileType: ['image/jpeg'], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); + }); + + test('ファイルタイプ指定 (jpg or png)', async () => { + const res = await api('/users/notes', { + userId: alice.id, + fileType: ['image/jpeg', 'image/png'], }, alice); assert.strictEqual(res.status, 200); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 53db1ac28..0f5d5f734 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -133,7 +133,6 @@ describe('ユーザー', () => { isMuted: user.isMuted ?? false, isRenoteMuted: user.isRenoteMuted ?? false, notify: user.notify ?? 'none', - withReplies: user.withReplies ?? false, }); }; diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index fae901842..adc532bbe 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -99,17 +99,9 @@ export const relativeFetch = async (path: string, init?: RequestInit | undefined return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); }; -function randomString(chars = 'abcdefghijklmnopqrstuvwxyz0123456789', length = 16) { - let randomString = ''; - for (let i = 0; i < length; i++) { - randomString += chars[Math.floor(Math.random() * chars.length)]; - } - return randomString; -} - export const signup = async (params?: Partial): Promise> => { const q = Object.assign({ - username: randomString(), + username: 'test', password: 'test', }, params); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index a844dcec1..599985439 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -169,7 +169,7 @@ import { deepClone } from '@/scripts/clone.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { getNoteSummary } from '@/scripts/get-note-summary.js'; -import { MenuItem } from '@/types/menu.js'; +import { MenuItem } from '@/types/menu'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; @@ -226,7 +226,7 @@ const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).fil const isLong = shouldCollapsed(appearNote); const collapsed = ref(appearNote.cw == null && isLong); const isDeleted = ref(false); -const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); +const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); const translation = ref(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 0acc60922..2d9566bce 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -218,7 +218,7 @@ import { useNoteCapture } from '@/scripts/use-note-capture.js'; import { deepClone } from '@/scripts/clone.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import { MenuItem } from '@/types/menu.js'; +import { MenuItem } from '@/types/menu'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -266,7 +266,7 @@ const renoteUri = appearNote.renote ? appearNote.renote.uri : null; const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); const isDeleted = ref(false); -const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); +const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); const translation = ref(null); const translating = ref(false); const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null; diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 23cff2a03..a5213e1cc 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -108,7 +108,7 @@ const props = withDefaults(defineProps<{ }); const el = shallowRef(); -const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false); +const muted = ref(checkWordMute(props.note, $i, defaultStore.state.mutedWords)); const translation = ref(null); const translating = ref(false); const isDeleted = ref(false); diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index c4a34667e..1dcafd6be 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -23,9 +23,11 @@ const props = withDefaults(defineProps<{ role?: string; sound?: boolean; withRenotes?: boolean; + withReplies?: boolean; onlyFiles?: boolean; }>(), { withRenotes: true, + withReplies: false, onlyFiles: false, }); @@ -68,10 +70,12 @@ if (props.src === 'antenna') { endpoint = 'notes/timeline'; query = { withRenotes: props.withRenotes, + withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('homeTimeline', { withRenotes: props.withRenotes, + withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); @@ -81,10 +85,12 @@ if (props.src === 'antenna') { endpoint = 'notes/local-timeline'; query = { withRenotes: props.withRenotes, + withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('localTimeline', { withRenotes: props.withRenotes, + withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); @@ -92,10 +98,12 @@ if (props.src === 'antenna') { endpoint = 'notes/hybrid-timeline'; query = { withRenotes: props.withRenotes, + withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('hybridTimeline', { withRenotes: props.withRenotes, + withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); @@ -103,10 +111,12 @@ if (props.src === 'antenna') { endpoint = 'notes/global-timeline'; query = { withRenotes: props.withRenotes, + withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('globalTimeline', { withRenotes: props.withRenotes, + withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); @@ -130,11 +140,13 @@ if (props.src === 'antenna') { endpoint = 'notes/user-list-timeline'; query = { withRenotes: props.withRenotes, + withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }; connection = stream.useChannel('userList', { withRenotes: props.withRenotes, + withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }); diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index cadc9f6d1..41f3a9c23 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -29,22 +29,16 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.addUser }} - - - - +
+ + + + +
+ + {{ i18n.ts.loadMore }} + +
@@ -65,11 +59,9 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/MkInput.vue'; -import { userListsCache } from '@/cache.js'; +import { userListsCache } from '@/cache'; import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; - const { enableInfiniteScroll, } = defaultStore.reactiveState; @@ -78,25 +70,40 @@ const props = defineProps<{ listId: string; }>(); -const paginationEl = ref>(); +const FETCH_USERS_LIMIT = 20; + let list = $ref(null); +let users = $ref([]); +let queueUserIds = $ref([]); +let fetching = $ref(true); const isPublic = ref(false); const name = ref(''); -const membershipsPagination = { - endpoint: 'users/lists/get-memberships' as const, - limit: 30, - params: computed(() => ({ - listId: props.listId, - })), -}; function fetchList() { + fetching = true; os.api('users/lists/show', { listId: props.listId, }).then(_list => { list = _list; name.value = list.name; isPublic.value = list.isPublic; + queueUserIds = list.userIds; + + return fetchMoreUsers(); + }); +} + +function fetchMoreUsers() { + if (!list) return; + if (fetching && users.length !== 0) return; // fetchingがtrueならやめるが、usersが空なら続行 + fetching = true; + os.api('users/show', { + userIds: queueUserIds.slice(0, FETCH_USERS_LIMIT), + }).then(_users => { + users = users.concat(_users); + queueUserIds = queueUserIds.slice(FETCH_USERS_LIMIT); + }).finally(() => { + fetching = false; }); } @@ -107,12 +114,12 @@ function addUser() { listId: list.id, userId: user.id, }).then(() => { - paginationEl.value.reload(); + users.push(user); }); }); } -async function removeUser(item, ev) { +async function removeUser(user, ev) { os.popupMenu([{ text: i18n.ts.remove, icon: 'ph-x ph-bold ph-lg', @@ -121,28 +128,9 @@ async function removeUser(item, ev) { if (!list) return; os.api('users/lists/pull', { listId: list.id, - userId: item.userId, + userId: user.id, }).then(() => { - paginationEl.value.removeItem(item.id); - }); - }, - }], ev.currentTarget ?? ev.target); -} - -async function showMembershipMenu(item, ev) { - os.popupMenu([{ - text: item.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline, - icon: item.withReplies ? 'ti ti-messages-off' : 'ti ti-messages', - action: async () => { - os.api('users/lists/update-membership', { - listId: list.id, - userId: item.userId, - withReplies: !item.withReplies, - }).then(() => { - paginationEl.value.updateItem(item.id, (old) => ({ - ...old, - withReplies: !item.withReplies, - })); + users = users.filter(x => x.id !== user.id); }); }, }], ev.currentTarget ?? ev.target); @@ -214,12 +202,6 @@ definePageMetadata(computed(() => list ? { align-self: center; } -.menu { - width: 32px; - height: 32px; - align-self: center; -} - .more { margin-left: auto; margin-right: auto; diff --git a/packages/frontend/src/pages/settings/word-mute.vue b/packages/frontend/src/pages/settings/word-mute.vue index bbd6fdfac..cfac5a4fd 100644 --- a/packages/frontend/src/pages/settings/word-mute.vue +++ b/packages/frontend/src/pages/settings/word-mute.vue @@ -5,11 +5,29 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -41,7 +40,7 @@ const props = defineProps<{ }>(); let fetching = $ref(true); -let files = $ref<{ +let images = $ref<{ note: Misskey.entities.Note; file: Misskey.entities.DriveFile; }[]>([]); @@ -53,15 +52,24 @@ function thumbnail(image: Misskey.entities.DriveFile): string { } onMounted(() => { + const image = [ + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/png', + 'image/gif', + 'image/apng', + 'image/vnd.mozilla.apng', + ]; os.api('users/notes', { userId: props.user.id, - withFiles: true, + fileType: image, excludeNsfw: defaultStore.state.nsfw !== 'ignore', - limit: 15, + limit: 10, }).then(notes => { for (const note of notes) { for (const file of note.files) { - files.push({ + images.push({ note, file, }); diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 79b952689..24ca93495 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -80,15 +80,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router }); } - async function toggleWithReplies() { - os.apiWithDialog('following/update', { - userId: user.id, - withReplies: !user.withReplies, - }).then(() => { - user.withReplies = !user.withReplies; - }); - } - async function toggleNotify() { os.apiWithDialog('following/update', { userId: user.id, @@ -291,10 +282,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router // フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため //if (user.isFollowing) { menu = menu.concat([{ - icon: user.withReplies ? 'ph-envelope ph-bold pg-lg-off' : 'ph-envelope ph-bold', - text: user.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline, - action: toggleWithReplies, - }, { icon: user.notify === 'none' ? 'ph-bell ph-bold pg-lg' : 'ph-bell ph-bold pg-lg-off', text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes, action: toggleNotify, diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index bd6e1f7f7..2bf518b2f 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -5,7 +5,7 @@ import { markRaw, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { miLocalStorage } from './local-storage.js'; +import { miLocalStorage } from './local-storage'; import { Storage } from '@/pizzax.js'; interface PostFormAction { @@ -105,6 +105,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, }, + mutedWords: { + where: 'account', + default: [], + }, mutedAds: { where: 'account', default: [] as string[], diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index a91fa3c5b..554f067df 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -23,9 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -51,6 +52,7 @@ let disabled = $ref(false); 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 withRenotes = $ref(props.column.withRenotes ?? true); +const withReplies = $ref(props.column.withReplies ?? false); const onlyFiles = $ref(props.column.onlyFiles ?? false); watch($$(withRenotes), v => { @@ -59,6 +61,12 @@ watch($$(withRenotes), v => { }); }); +watch($$(withReplies), v => { + updateColumn(props.column.id, { + withReplies: v, + }); +}); + watch($$(onlyFiles), v => { updateColumn(props.column.id, { onlyFiles: v, @@ -107,6 +115,10 @@ const menu = [{ type: 'switch', text: i18n.ts.showRenotes, ref: $$(withRenotes), +}, { + type: 'switch', + text: i18n.ts.withReplies, + ref: $$(withReplies), }, { type: 'switch', text: i18n.ts.fileAttachedOnly, diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 4df7e8332..f0fc47c20 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1381,6 +1381,10 @@ export type Endpoints = { req: TODO; res: TODO; }; + 'i/get-word-muted-notes-count': { + req: TODO; + res: TODO; + }; 'i/import-following': { req: TODO; res: TODO; @@ -2977,7 +2981,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts -// src/api.types.ts:630:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts +// src/api.types.ts:631:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts // src/entities.ts:107:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts // src/entities.ts:595:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index 1c423610d..7a8b6872d 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -371,6 +371,7 @@ export type Endpoints = { 'i/favorites': { req: { limit?: number; sinceId?: NoteFavorite['id']; untilId?: NoteFavorite['id']; }; res: NoteFavorite[]; }; 'i/gallery/likes': { req: TODO; res: TODO; }; 'i/gallery/posts': { req: TODO; res: TODO; }; + 'i/get-word-muted-notes-count': { req: TODO; res: TODO; }; 'i/import-following': { req: TODO; res: TODO; }; 'i/import-user-lists': { req: TODO; res: TODO; }; 'i/move': { req: TODO; res: TODO; };