fix: `users/notes`でも返されるノート数がlimitよりも少ない事がある問題を修正
This commit is contained in:
parent
7f836df302
commit
743757007d
|
|
@ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
|
||||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
|
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
|
||||||
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
|
@ -53,6 +54,7 @@ export const paramDef = {
|
||||||
untilDate: { type: 'integer' },
|
untilDate: { type: 'integer' },
|
||||||
withFiles: { type: 'boolean', default: false },
|
withFiles: { type: 'boolean', default: false },
|
||||||
excludeNsfw: { type: 'boolean', default: false },
|
excludeNsfw: { type: 'boolean', default: false },
|
||||||
|
allowPartial: { type: 'boolean', default: false },
|
||||||
},
|
},
|
||||||
required: ['userId'],
|
required: ['userId'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -78,110 +80,166 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
const isRangeSpecified = untilId != null && sinceId != null;
|
const isRangeSpecified = untilId != null && sinceId != null;
|
||||||
const isSelf = me && (me.id === ps.userId);
|
const isSelf = me && (me.id === ps.userId);
|
||||||
|
|
||||||
if (isRangeSpecified || sinceId == null) {
|
if (!isRangeSpecified && sinceId != null) {
|
||||||
const [
|
return await this.getFromDb({
|
||||||
userIdsWhoMeMuting,
|
untilId,
|
||||||
] = me ? await Promise.all([
|
sinceId,
|
||||||
this.cacheService.userMutingsCache.fetch(me.id),
|
userId: ps.userId,
|
||||||
]) : [new Set<string>()];
|
withReplies: ps.withReplies,
|
||||||
|
withRenotes: ps.withRenotes,
|
||||||
|
withChannelNotes: ps.withChannelNotes,
|
||||||
|
limit: ps.limit,
|
||||||
|
withFiles: ps.withFiles,
|
||||||
|
excludeNsfw: ps.excludeNsfw,
|
||||||
|
}, me, isSelf);
|
||||||
|
}
|
||||||
|
|
||||||
const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
|
const [
|
||||||
this.funoutTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId),
|
userIdsWhoMeMuting,
|
||||||
ps.withReplies ? this.funoutTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
|
] = me ? await Promise.all([
|
||||||
ps.withChannelNotes ? this.funoutTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
|
this.cacheService.userMutingsCache.fetch(me.id),
|
||||||
]);
|
]) : [new Set<string>()];
|
||||||
|
|
||||||
let noteIds = Array.from(new Set([
|
const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
|
||||||
...noteIdsRes,
|
this.funoutTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId),
|
||||||
...repliesNoteIdsRes,
|
ps.withReplies ? this.funoutTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
|
||||||
...channelNoteIdsRes,
|
ps.withChannelNotes ? this.funoutTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
|
||||||
]));
|
]);
|
||||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
|
||||||
noteIds = noteIds.slice(0, ps.limit);
|
|
||||||
|
|
||||||
if (noteIds.length > 0) {
|
let noteIds = Array.from(new Set([
|
||||||
const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId);
|
...noteIdsRes,
|
||||||
|
...repliesNoteIdsRes,
|
||||||
|
...channelNoteIdsRes,
|
||||||
|
]));
|
||||||
|
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||||
|
noteIds = noteIds.slice(0, ps.limit);
|
||||||
|
|
||||||
const query = this.notesRepository.createQueryBuilder('note')
|
let redisTimeline: MiNote[] = [];
|
||||||
.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');
|
|
||||||
|
|
||||||
let timeline = await query.getMany();
|
if (noteIds.length > 0) {
|
||||||
|
const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId);
|
||||||
|
|
||||||
timeline = timeline.filter(note => {
|
const query = this.notesRepository.createQueryBuilder('note')
|
||||||
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
|
.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 (note.renoteId) {
|
redisTimeline = await query.getMany();
|
||||||
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
|
||||||
if (ps.withRenotes === false) return false;
|
redisTimeline = redisTimeline.filter(note => {
|
||||||
}
|
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
|
||||||
|
|
||||||
|
if (note.renoteId) {
|
||||||
|
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
||||||
|
if (ps.withRenotes === false) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.channel?.isSensitive && !isSelf) return false;
|
|
||||||
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
|
|
||||||
if (note.visibility === 'followers' && !isFollowing && !isSelf) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: フィルタで件数が減った場合の埋め合わせ処理
|
|
||||||
|
|
||||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
|
||||||
|
|
||||||
if (timeline.length > 0) {
|
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (note.channel?.isSensitive && !isSelf) return false;
|
||||||
|
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
|
||||||
|
if (note.visibility === 'followers' && !isFollowing && !isSelf) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
//#region fallback to database
|
if (redisTimeline.length === 0) {
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
// fallback to db
|
||||||
.andWhere('note.userId = :userId', { userId: ps.userId })
|
return await this.getFromDb({
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
untilId,
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
sinceId,
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
userId: ps.userId,
|
||||||
.leftJoinAndSelect('note.channel', 'channel')
|
withReplies: ps.withReplies,
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
withRenotes: ps.withRenotes,
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
withChannelNotes: ps.withChannelNotes,
|
||||||
|
limit: ps.limit,
|
||||||
if (ps.withChannelNotes) {
|
withFiles: ps.withFiles,
|
||||||
if (!isSelf) query.andWhere(new Brackets(qb => {
|
excludeNsfw: ps.excludeNsfw,
|
||||||
qb.orWhere('note.channelId IS NULL');
|
}, me, isSelf);
|
||||||
qb.orWhere('channel.isSensitive = false');
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
query.andWhere('note.channelId IS NULL');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
const packedNotes = await this.noteEntityService.packMany(redisTimeline, me);
|
||||||
if (me) {
|
|
||||||
this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
|
if (!ps.allowPartial && redisTimeline.length < ps.limit) {
|
||||||
this.queryService.generateBlockedUserQuery(query, me);
|
const notes = await this.getFromDb({
|
||||||
|
untilId: redisTimeline[redisTimeline.length - 1].id,
|
||||||
|
sinceId,
|
||||||
|
userId: ps.userId,
|
||||||
|
withReplies: ps.withReplies,
|
||||||
|
withRenotes: ps.withRenotes,
|
||||||
|
withChannelNotes: ps.withChannelNotes,
|
||||||
|
limit: ps.limit,
|
||||||
|
withFiles: ps.withFiles,
|
||||||
|
excludeNsfw: ps.excludeNsfw,
|
||||||
|
}, me, isSelf);
|
||||||
|
packedNotes.push(...notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ps.withFiles) {
|
return packedNotes;
|
||||||
query.andWhere('note.fileIds != \'{}\'');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ps.withRenotes === false) {
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb.orWhere('note.userId != :userId', { userId: ps.userId });
|
|
||||||
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 timeline = await query.limit(ps.limit).getMany();
|
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
|
||||||
//#endregion
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFromDb(ps: { untilId: string | null, sinceId: string | null, userId: string, limit: number, withReplies: boolean, withRenotes: boolean, withChannelNotes: boolean, withFiles: boolean, excludeNsfw: boolean }, me: MiLocalUser | null, isSelf: boolean | null) {
|
||||||
|
//#region Construct query
|
||||||
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
|
.andWhere('note.userId = :userId', { userId: ps.userId })
|
||||||
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
.leftJoinAndSelect('note.channel', 'channel')
|
||||||
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
|
if (ps.withChannelNotes) {
|
||||||
|
if (!isSelf) query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.channelId IS NULL');
|
||||||
|
qb.orWhere('channel.isSensitive = false');
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
query.andWhere('note.channelId IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ps.withReplies) {
|
||||||
|
query.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');
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
|
if (me) {
|
||||||
|
this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
|
||||||
|
this.queryService.generateBlockedUserQuery(query, me);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.withFiles) {
|
||||||
|
query.andWhere('note.fileIds != \'{}\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.withRenotes === false) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.userId != :userId', { userId: ps.userId });
|
||||||
|
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)');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
const timeline = await query.limit(ps.limit).getMany();
|
||||||
|
|
||||||
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ const prevPagination = {
|
||||||
params: computed(() => note ? ({
|
params: computed(() => note ? ({
|
||||||
userId: note.userId,
|
userId: note.userId,
|
||||||
untilId: note.id,
|
untilId: note.id,
|
||||||
|
allowPartial: true,
|
||||||
}) : null),
|
}) : null),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -83,6 +84,7 @@ const nextPagination = {
|
||||||
params: computed(() => note ? ({
|
params: computed(() => note ? ({
|
||||||
userId: note.userId,
|
userId: note.userId,
|
||||||
sinceId: note.id,
|
sinceId: note.id,
|
||||||
|
allowPartial: true,
|
||||||
}) : null),
|
}) : null),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ onMounted(() => {
|
||||||
withFiles: true,
|
withFiles: true,
|
||||||
excludeNsfw: defaultStore.state.nsfw !== 'ignore',
|
excludeNsfw: defaultStore.state.nsfw !== 'ignore',
|
||||||
limit: 15,
|
limit: 15,
|
||||||
|
allowPartial: true,
|
||||||
}).then(notes => {
|
}).then(notes => {
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
for (const file of note.files) {
|
for (const file of note.files) {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ const pagination = {
|
||||||
withReplies: include.value === 'all',
|
withReplies: include.value === 'all',
|
||||||
withChannelNotes: include.value === 'all',
|
withChannelNotes: include.value === 'all',
|
||||||
withFiles: include.value === 'files',
|
withFiles: include.value === 'files',
|
||||||
|
allowPartial: true,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue