diff --git a/locales/index.d.ts b/locales/index.d.ts index 014a98b7b3..e0af67d622 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1147,7 +1147,6 @@ export interface Locale { "edited": string; "notificationRecieveConfig": string; "mutualFollow": string; - "fileAttachedOnly": string; "showRepliesToOthersInTimeline": string; "hideRepliesToOthersInTimeline": string; "externalServices": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index c6f25764dd..fb195b862c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1144,7 +1144,6 @@ showRenotes: "リノートを表示" edited: "編集済み" notificationRecieveConfig: "通知の受信設定" mutualFollow: "相互フォロー" -fileAttachedOnly: "ファイル付きのみ" showRepliesToOthersInTimeline: "TLに他の人への返信を含める" hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" externalServices: "外部サービス" diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 18bd49286e..a80fa5bdf2 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -235,4 +235,51 @@ export class QueryService { q.setParameters(mutingQuery.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 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'); + })); + })); + } + } + } 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 e5a86905d6..c1949caf67 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -50,6 +50,7 @@ export const paramDef = { required: [], } as const; + @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( @@ -67,8 +68,47 @@ 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'); + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.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 8e7f2a2a98..25aa03ff12 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -16,6 +16,7 @@ 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'; +import {QueryService} from "@/core/QueryService.js"; export const meta = { tags: ['notes'], @@ -67,7 +68,11 @@ export default class extends Endpoint { // eslint- @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, @@ -115,7 +120,69 @@ export default class extends Endpoint { // eslint- 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 < limit) { + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + 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') + .setParameters(followingQuery.getParameters()); + this.queryService.generateChannelQuery(query, me); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + 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)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + const ids = await query.limit(limit - noteIds.length).getMany(); + noteIds = noteIds.concat(ids.map(note => note.id)); + + } + if (noteIds.length === 0) { return []; } 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 ac8f8d610e..c5b2059176 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -16,6 +16,7 @@ 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'; +import {QueryService} from "@/core/QueryService.js"; export const meta = { tags: ['notes'], @@ -65,6 +66,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private roleService: RoleService, + private queryService: QueryService, private activeUsersChart: ActiveUsersChart, private idService: IdService, private cacheService: CacheService, @@ -98,7 +100,43 @@ export default class extends Endpoint { // eslint- 'COUNT', limit); } - const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId); + let noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId); + + if (noteIds.length < limit) { + //#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'); + + this.queryService.generateChannelQuery(query, me); + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + if (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 != \'{}\''); + })); + })); + } + const ids = await query.limit(limit - noteIds.length).getMany(); + noteIds = noteIds.concat(ids.map(note => note.id)); + } if (noteIds.length === 0) { return []; diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 7442356978..c26a378c9e 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -59,6 +59,7 @@ export default class extends Endpoint { // eslint- private notesRepository: NotesRepository, private noteEntityService: NoteEntityService, + private queryService: QueryService, private activeUsersChart: ActiveUsersChart, private idService: IdService, private cacheService: CacheService, @@ -89,7 +90,26 @@ export default class extends Endpoint { // eslint- 'COUNT', limit); } - const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId); + let noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId); + if (noteIds.length < limit) { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate); + const followingIds = Object.keys(followings); + if (followingIds.length > 0) { + const meOrFolloweeIds = [me.id, ...followingIds]; + query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + } else { + query.andWhere('note.userId = :meId', { meId: me.id }); + } + + this.queryService.generateChannelQuery(query, me); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + const ids = await query.limit(limit - noteIds.length).getMany(); + noteIds = noteIds.concat(ids.map(note => note.id)); + } if (noteIds.length === 0) { return []; diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 23c2811fb9..e9a67eb2e2 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -15,6 +15,7 @@ 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'; +import {QueryService} from "@/core/QueryService.js"; export const meta = { tags: ['users', 'notes'], @@ -66,6 +67,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, + private queryService: QueryService, private noteEntityService: NoteEntityService, private getterService: GetterService, private cacheService: CacheService, @@ -117,6 +119,66 @@ export default class extends Endpoint { // eslint- noteIds.sort((a, b) => a > b ? -1 : 1); noteIds = noteIds.slice(0, ps.limit); + if (noteIds.length < limit) { + // 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; + }); + + //#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'); + + 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.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.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)'); + })); + } + + const ids = await query.limit(limit - noteIds.length).getMany(); + noteIds = noteIds.concat(ids.map(note => note.id)); + } + if (noteIds.length === 0) { return []; }