diff options
Diffstat (limited to 'packages/backend/src/server/api')
24 files changed, 516 insertions, 505 deletions
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 15a2621da2..74b26af27a 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -205,7 +205,6 @@ 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'; @@ -337,7 +336,9 @@ 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_create_from_public from './endpoints/users/lists/create-from-public.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_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'; @@ -555,7 +556,6 @@ 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,7 +687,9 @@ 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_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.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_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 }; @@ -909,7 +911,6 @@ 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, @@ -1041,7 +1042,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_lists_update, $users_lists_favorite, $users_lists_unfavorite, - $users_lists_create_from_public, + $users_lists_createFromPublic, + $users_lists_updateMembership, + $users_lists_getMemberships, $users_notes, $users_pages, $users_flashs, @@ -1257,7 +1260,6 @@ 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, @@ -1386,7 +1388,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_lists_update, $users_lists_favorite, $users_lists_unfavorite, - $users_lists_create_from_public, + $users_lists_createFromPublic, + $users_lists_updateMembership, + $users_lists_getMemberships, $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 9acaa688c5..badcec1b33 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -14,6 +14,7 @@ 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'; @@ -37,6 +38,7 @@ export class StreamingApiServerService { private authenticateService: AuthenticateService, private channelsService: ChannelsService, private notificationService: NotificationService, + private usersService: UserService, ) { } @@ -130,14 +132,10 @@ export class StreamingApiServerService { this.#connections.set(connection, Date.now()); const userUpdateIntervalId = user ? setInterval(() => { - this.usersRepository.update(user.id, { - lastActiveDate: new Date(), - }); + this.usersService.updateLastActiveDate(user); }, 1000 * 60 * 5) : null; if (user) { - this.usersRepository.update(user.id, { - lastActiveDate: new Date(), - }); + this.usersService.updateLastActiveDate(user); } connection.once('close', () => { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 5ea4121918..279d56d1e5 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -205,7 +205,6 @@ 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,8 +335,10 @@ 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_create_from_public from './endpoints/users/lists/create-from-public.js'; +import * as ep___users_lists_createFromPublic 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'; @@ -553,7 +554,6 @@ 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,7 +685,9 @@ 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_create_from_public], + ['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/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 eaae7bff62..63e542cb62 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<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -86,7 +86,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- }); const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const noteIdsRes = await this.redisClient.xrevrange( + const noteIdsRes = await this.redisForTimelines.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 026b649537..56b8fc5c36 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<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -83,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- let noteIdsRes: [string, string[]][] = []; if (!ps.sinceId && !ps.sinceDate) { - noteIdsRes = await this.redisClient.xrevrange( + noteIdsRes = await this.redisForTimelines.xrevrange( `channelTimeline:${channel.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', '-', @@ -104,7 +104,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (me) { this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); } //#endregion @@ -129,7 +128,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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 25f393e517..db17d151df 100644 --- a/packages/backend/src/server/api/endpoints/following/update.ts +++ b/packages/backend/src/server/api/endpoints/following/update.ts @@ -57,8 +57,9 @@ export const paramDef = { properties: { userId: { type: 'string', format: 'misskey:id' }, notify: { type: 'string', enum: ['normal', 'none'] }, + withReplies: { type: 'boolean' }, }, - required: ['userId', 'notify'], + required: ['userId'], } as const; @Injectable() @@ -98,7 +99,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- await this.followingsRepository.update({ id: exist.id, }, { - notify: ps.notify === 'none' ? null : ps.notify, + notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined, + withReplies: ps.withReplies != null ? ps.withReplies : undefined, }); 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 deleted file mode 100644 index d62bfbb3ed..0000000000 --- a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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<typeof meta, typeof paramDef> { // 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 8784e86153..e5a86905d6 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -40,7 +40,6 @@ 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' }, @@ -68,49 +67,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.gtlDisabled); } - //#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); + // TODO? + return []; }); } } 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 9bde5dee21..d6ed3db6e3 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -5,14 +5,16 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; +import * as Redis from 'ioredis'; +import type { NotesRepository, FollowingsRepository, MiNote } 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 = { @@ -51,7 +53,6 @@ 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: [], @@ -60,17 +61,17 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // 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); @@ -78,79 +79,75 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.stlDisabled); } - //#region Construct query - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + 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 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.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)'); - })) + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .setParameters(followingQuery.getParameters()); + .leftJoinAndSelect('note.channel', 'channel'); - 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 = await query.getMany(); - 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)'); - })); - } - - 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)'); - })); - } + 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.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } + 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 + // TODO: フィルタした結果件数が足りなかった場合の対応 - const timeline = await query.limit(ps.limit).getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); 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 0fefddc51b..ed57ca1a30 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -5,14 +5,16 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import * as Redis from 'ioredis'; +import type { MiNote, 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 = { @@ -41,11 +43,7 @@ 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' }, @@ -59,14 +57,17 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // 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); @@ -74,56 +75,63 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.ltlDisabled); } - //#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)') + 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<string>(), new Set<string>(), new Set<string>()]; + + 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 }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - 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); + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } + timeline = await query.getMany(); - 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 }); + 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.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.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 + return true; + }); - const timeline = await query.limit(ps.limit).getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); 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 0d47cc1702..2f25d2d7ce 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,13 +5,16 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; +import * as Redis from 'ioredis'; +import type { NotesRepository, FollowingsRepository, MiNote } 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'], @@ -41,7 +44,6 @@ 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: [], @@ -50,96 +52,82 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // 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 followees = await this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }) - .getMany(); + 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), + ]); - //#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'); + let timeline: MiNote[] = []; - if (followees.length > 0) { - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let noteIdsRes: [string, string[]][] = []; - query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - } else { - query.andWhere('note.userId = :meId', { meId: me.id }); + 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); } - 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); + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); - 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)'); - })); + if (noteIds.length === 0) { + return []; } - 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)'); - })); - } + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - 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 = await query.getMany(); - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } + 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; + } - 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 + return true; + }); + + // TODO: フィルタした結果件数が足りなかった場合の対応 - const timeline = await query.limit(ps.limit).getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); 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 c20274b2ba..ae2401c4c0 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,12 +5,16 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js'; +import * as Redis from 'ioredis'; +import type { NotesRepository, UserListsRepository, UserListMembershipsRepository, MiNote } 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 = { @@ -49,7 +53,6 @@ export const paramDef = { includeMyRenotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, - withReplies: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', @@ -63,18 +66,19 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // 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({ @@ -86,72 +90,65 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.noSuchList); } - //#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') - .andWhere('userListJoining.userListId = :userListId', { userListId: list.id }); + 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), + ]); - 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); + let timeline: MiNote[] = []; - 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)'); - })); - } + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let noteIdsRes: [string, string[]][] = []; - 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.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); } - 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)'); - })); - } + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); - if (!ps.withReplies) { - query.andWhere('note.replyId IS NULL'); + if (noteIds.length === 0) { + return []; } - 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 != \'{}\''); - })); - })); - } + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - //#endregion + timeline = await query.getMany(); + + 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; + } + } + + return true; + }); + + // TODO: フィルタした結果件数が足りなかった場合の対応 - const timeline = await query.limit(ps.limit).getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); 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 6dc35907e1..f2533efa36 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<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -79,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- return []; } const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const noteIdsRes = await this.redisClient.xrevrange( + const noteIdsRes = await this.redisForTimelines.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 eae55905d3..f2f6c4303a 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, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js'; +import type { UserListsRepository, UserListMembershipsRepository, 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<typeof meta, typeof paramDef> { // eslint- @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -110,7 +110,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- name: ps.name, } as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); - const users = (await this.userListJoiningsRepository.findBy({ + const users = (await this.userListMembershipsRepository.findBy({ userListId: ps.listId, })).map(x => x.userId); @@ -132,7 +132,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } } - const exist = await this.userListJoiningsRepository.exist({ + const exist = await this.userListMembershipsRepository.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 new file mode 100644 index 0000000000..ae8b4e9b81 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts @@ -0,0 +1,79 @@ +/* + * 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<typeof meta, typeof paramDef> { + 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 72a6a7380d..c4ceec575b 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, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js'; +import type { UserListsRepository, UserListMembershipsRepository, 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<typeof meta, typeof paramDef> { // eslint- @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -115,7 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } } - const exist = await this.userListJoiningsRepository.exist({ + const exist = await this.userListMembershipsRepository.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 new file mode 100644 index 0000000000..b69465b940 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts @@ -0,0 +1,79 @@ +/* + * 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<typeof meta, typeof paramDef> { // 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 e660a0bb25..abcc02eace 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -5,12 +5,14 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import * as Redis from 'ioredis'; +import type { MiNote, 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 = { @@ -50,9 +52,6 @@ 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'], @@ -61,87 +60,63 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // 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) => { - // 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; - }); + let timeline: MiNote[] = []; - //#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'); + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let noteIdsRes: [string, string[]][] = []; - 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.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); } - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; } - 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 }); - } - })); + const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false; - 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)'); - } - } + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - if (!ps.withReplies) { - query.andWhere('note.replyId IS NULL'); - } + timeline = await query.getMany(); - 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 != \'{}\''); - })); - })); - } + timeline = timeline.filter(note => { + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (ps.withRenotes === false) return false; + } + } - 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)'); - })); - } + if (note.visibility === 'followers' && !isFollowing) return false; - //#endregion + return true; + }); - const timeline = await query.limit(ps.limit).getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); 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 a73071ea99..f981e63871 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 { MiUserProfile } from '@/models/_.js'; +import { MiFollowing, 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: Set<string> = new Set(); + public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {}; public followingChannels: Set<string> = new Set(); public userIdsWhoMeMuting: Set<string> = new Set(); public userIdsWhoBlockingMe: Set<string> = 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 fef52b6856..f0ac50349c 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -18,7 +18,6 @@ class GlobalTimelineChannel extends Channel { public readonly chName = 'globalTimeline'; public static shouldShare = true; public static requireCredential = false; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -38,7 +37,6 @@ 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 @@ -64,7 +62,7 @@ class GlobalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.withReplies) { + if (note.reply && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; @@ -82,13 +80,6 @@ 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 198c68e1c2..1c1b1c2ae4 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -16,7 +16,6 @@ class HomeTimelineChannel extends Channel { public readonly chName = 'homeTimeline'; public static shouldShare = true; public static requireCredential = true; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -31,7 +30,6 @@ 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); @@ -43,7 +41,7 @@ class HomeTimelineChannel extends Channel { if (!this.followingChannels.has(note.channelId)) return; } else { // その投稿のユーザーをフォローしていなかったら弾く - if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return; + if ((this.user!.id !== note.userId) && !Object.hasOwn(this.following, note.userId)) return; } // Ignore notes from instances the user has muted @@ -73,7 +71,7 @@ class HomeTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.withReplies) { + if (note.reply && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; @@ -88,13 +86,6 @@ 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 cde4297478..e2f4817bfa 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -18,7 +18,6 @@ class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; public static shouldShare = true; public static requireCredential = true; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -38,7 +37,6 @@ 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 @@ -53,7 +51,7 @@ class HybridTimelineChannel extends Channel { // フォローしているチャンネルの投稿 の場合だけ if (!( (note.channelId == null && this.user!.id === note.userId) || - (note.channelId == null && this.following.has(note.userId)) || + (note.channelId == null && Object.hasOwn(this.following, note.userId)) || (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || (note.channelId != null && this.followingChannels.has(note.channelId)) )) return; @@ -85,7 +83,7 @@ class HybridTimelineChannel extends Channel { if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return; // 関係ない返信は除外 - if (note.reply && !this.withReplies) { + if (note.reply && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; @@ -100,13 +98,6 @@ 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 ef708c4fee..ca563b5d19 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -17,7 +17,6 @@ class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; public static shouldShare = true; public static requireCredential = false; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -37,7 +36,6 @@ 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 @@ -64,7 +62,7 @@ class LocalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && this.user && !this.withReplies) { + if (note.reply && this.user && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; @@ -79,13 +77,6 @@ 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 8bbba0b6db..03f7760d8e 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 { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; +import type { MiUserListMembership, UserListMembershipsRepository, 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 listUsers: MiUser['id'][] = []; + public membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {}; private listUsersClock: NodeJS.Timeout; constructor( private userListsRepository: UserListsRepository, - private userListJoiningsRepository: UserListJoiningsRepository, + private userListMembershipsRepository: UserListMembershipsRepository, private noteEntityService: NoteEntityService, id: string, @@ -58,19 +58,25 @@ class UserListChannel extends Channel { @bindThis private async updateListUsers() { - const users = await this.userListJoiningsRepository.find({ + const memberships = await this.userListMembershipsRepository.find({ where: { userListId: this.listId, }, select: ['userId'], }); - this.listUsers = users.map(x => x.userId); + const membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {}; + for (const membership of memberships) { + membershipsMap[membership.userId] = { + withReplies: membership.withReplies, + }; + } + this.membershipsMap = membershipsMap; } @bindThis private async onNote(note: Packed<'Note'>) { - if (!this.listUsers.includes(note.userId)) return; + if (!Object.hasOwn(this.membershipsMap, note.userId)) return; if (['followers', 'specified'].includes(note.visibility)) { note = await this.noteEntityService.pack(note.id, this.user, { @@ -95,6 +101,13 @@ 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がブロックされているユーザーが関わるものだったら無視する @@ -124,8 +137,8 @@ export class UserListChannelService { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private noteEntityService: NoteEntityService, ) { @@ -135,7 +148,7 @@ export class UserListChannelService { public create(id: string, connection: Channel['connection']): UserListChannel { return new UserListChannel( this.userListsRepository, - this.userListJoiningsRepository, + this.userListMembershipsRepository, this.noteEntityService, id, connection, |