use more bulk hints in NoteEntityService / UserEntityService, and run the packMany queries in parallel

This commit is contained in:
Hazelnoot 2025-06-06 02:33:38 -04:00
parent 5e7d0e9acc
commit bd8cd8c4e4
7 changed files with 363 additions and 218 deletions

View file

@ -122,7 +122,7 @@ export class ReactionService {
} }
// check visibility // check visibility
if (!await this.noteEntityService.isVisibleForMe(note, user.id)) { if (!await this.noteEntityService.isVisibleForMe(note, user.id, { me: user })) {
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
} }

View file

@ -365,7 +365,7 @@ export class ApInboxService {
const renote = await this.apNoteService.resolveNote(target, { resolver, sentFrom: getApId(target) }); const renote = await this.apNoteService.resolveNote(target, { resolver, sentFrom: getApId(target) });
if (renote == null) return 'announce target is null'; if (renote == null) return 'announce target is null';
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) { if (!await this.noteEntityService.isVisibleForMe(renote, actor.id, { me: actor })) {
return 'skip: invalid actor for this activity'; return 'skip: invalid actor for this activity';
} }

View file

@ -11,7 +11,7 @@ import type { Packed } from '@/misc/json-schema.js';
import { awaitAll } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta } from '@/models/_.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js'; import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
@ -26,13 +26,13 @@ import type { UserEntityService } from './UserEntityService.js';
import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js';
// is-renote.tsとよしなにリンク // is-renote.tsとよしなにリンク
function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } { function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id'] } {
return ( return (
note.renote != null && note.renoteId != null &&
note.reply == null && note.replyId == null &&
note.text == null && note.text == null &&
note.cw == null && note.cw == null &&
(note.fileIds == null || note.fileIds.length === 0) && note.fileIds.length === 0 &&
!note.hasPoll !note.hasPoll
); );
} }
@ -132,7 +132,10 @@ export class NoteEntityService implements OnModuleInit {
} }
@bindThis @bindThis
public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> { public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: {
myFollowing?: ReadonlySet<string>,
myBlockers?: ReadonlySet<string>,
}): Promise<void> {
if (meId === packedNote.userId) return; if (meId === packedNote.userId) return;
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
@ -188,14 +191,9 @@ export class NoteEntityService implements OnModuleInit {
} else if (packedNote.renote && (meId === packedNote.renote.userId)) { } else if (packedNote.renote && (meId === packedNote.renote.userId)) {
hide = false; hide = false;
} else { } else {
// フォロワーかどうか const isFollowing = hint?.myFollowing
// TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする ? hint.myFollowing.has(packedNote.userId)
const isFollowing = await this.followingsRepository.exists({ : (await this.cacheService.userFollowingsCache.fetch(meId))[packedNote.userId] != null;
where: {
followeeId: packedNote.userId,
followerId: meId,
},
});
hide = !isFollowing; hide = !isFollowing;
} }
@ -211,7 +209,8 @@ export class NoteEntityService implements OnModuleInit {
} }
if (!hide && meId && packedNote.userId !== meId) { if (!hide && meId && packedNote.userId !== meId) {
const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(packedNote.userId); const blockers = hint?.myBlockers ?? await this.cacheService.userBlockedCache.fetch(meId);
const isBlocked = blockers.has(packedNote.userId);
if (isBlocked) hide = true; if (isBlocked) hide = true;
} }
@ -235,8 +234,11 @@ export class NoteEntityService implements OnModuleInit {
} }
@bindThis @bindThis
private async populatePoll(note: MiNote, meId: MiUser['id'] | null) { private async populatePoll(note: MiNote, meId: MiUser['id'] | null, hint?: {
const poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); poll?: MiPoll,
myVotes?: MiPollVote[],
}) {
const poll = hint?.poll ?? await this.pollsRepository.findOneByOrFail({ noteId: note.id });
const choices = poll.choices.map(c => ({ const choices = poll.choices.map(c => ({
text: c, text: c,
votes: poll.votes[poll.choices.indexOf(c)], votes: poll.votes[poll.choices.indexOf(c)],
@ -245,7 +247,7 @@ export class NoteEntityService implements OnModuleInit {
if (meId) { if (meId) {
if (poll.multiple) { if (poll.multiple) {
const votes = await this.pollVotesRepository.findBy({ const votes = hint?.myVotes ?? await this.pollVotesRepository.findBy({
userId: meId, userId: meId,
noteId: note.id, noteId: note.id,
}); });
@ -255,7 +257,7 @@ export class NoteEntityService implements OnModuleInit {
choices[myChoice].isVoted = true; choices[myChoice].isVoted = true;
} }
} else { } else {
const vote = await this.pollVotesRepository.findOneBy({ const vote = hint?.myVotes ? hint.myVotes[0] : await this.pollVotesRepository.findOneBy({
userId: meId, userId: meId,
noteId: note.id, noteId: note.id,
}); });
@ -317,7 +319,12 @@ export class NoteEntityService implements OnModuleInit {
} }
@bindThis @bindThis
public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null): Promise<boolean> { public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null, hint?: {
myFollowing?: ReadonlySet<string>,
myBlocking?: ReadonlySet<string>,
myBlockers?: ReadonlySet<string>,
me?: Pick<MiUser, 'host'> | null,
}): Promise<boolean> {
// This code must always be synchronized with the checks in generateVisibilityQuery. // This code must always be synchronized with the checks in generateVisibilityQuery.
// visibility が specified かつ自分が指定されていなかったら非表示 // visibility が specified かつ自分が指定されていなかったら非表示
if (note.visibility === 'specified') { if (note.visibility === 'specified') {
@ -345,16 +352,20 @@ export class NoteEntityService implements OnModuleInit {
return true; return true;
} else { } else {
// フォロワーかどうか // フォロワーかどうか
const [blocked, following, user] = await Promise.all([ const [blocked, following, userHost] = await Promise.all([
this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)), hint?.myBlocking
this.followingsRepository.count({ ? hint.myBlocking.has(note.userId)
where: { : this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)),
hint?.myFollowing
? hint.myFollowing.has(note.userId)
: this.followingsRepository.existsBy({
followeeId: note.userId, followeeId: note.userId,
followerId: meId, followerId: meId,
}, }),
take: 1, hint?.me !== undefined
}), ? (hint.me?.host ?? null)
this.usersRepository.findOneByOrFail({ id: meId }), : this.cacheService.userByIdCache.fetch(meId, () => this.usersRepository.findOneByOrFail({ id: meId }))
.then(me => me.host),
]); ]);
if (blocked) return false; if (blocked) return false;
@ -366,12 +377,13 @@ export class NoteEntityService implements OnModuleInit {
in which case we can never know the following. Instead we have in which case we can never know the following. Instead we have
to assume that the users are following each other. to assume that the users are following each other.
*/ */
return following > 0 || (note.userHost != null && user.host != null); return following || (note.userHost != null && userHost != null);
} }
} }
if (meId != null) { if (meId != null) {
const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(note.userId); const blockers = hint?.myBlockers ?? await this.cacheService.userBlockedCache.fetch(meId);
const isBlocked = blockers.has(note.userId);
if (isBlocked) return false; if (isBlocked) return false;
} }
@ -408,6 +420,11 @@ export class NoteEntityService implements OnModuleInit {
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>; packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>; packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
mentionHandles: Record<string, string | undefined>; mentionHandles: Record<string, string | undefined>;
userFollowings: Map<string, Set<string>>;
userBlockers: Map<string, Set<string>>;
polls: Map<string, MiPoll>;
pollVotes: Map<string, Map<string, MiPollVote[]>>;
channels: Map<string, MiChannel>;
}; };
}, },
): Promise<Packed<'Note'>> { ): Promise<Packed<'Note'>> {
@ -437,9 +454,7 @@ export class NoteEntityService implements OnModuleInit {
} }
const channel = note.channelId const channel = note.channelId
? note.channel ? (opts._hint_?.channels.get(note.channelId) ?? note.channel ?? await this.channelsRepository.findOneBy({ id: note.channelId }))
? note.channel
: await this.channelsRepository.findOneBy({ id: note.channelId })
: null; : null;
const reactionEmojiNames = Object.keys(reactions) const reactionEmojiNames = Object.keys(reactions)
@ -485,7 +500,10 @@ export class NoteEntityService implements OnModuleInit {
mentionHandles: note.mentions.length > 0 ? this.getUserHandles(note.mentions, options?._hint_?.mentionHandles) : undefined, mentionHandles: note.mentions.length > 0 ? this.getUserHandles(note.mentions, options?._hint_?.mentionHandles) : undefined,
uri: note.uri ?? undefined, uri: note.uri ?? undefined,
url: note.url ?? undefined, url: note.url ?? undefined,
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, poll: note.hasPoll ? this.populatePoll(note, meId, {
poll: opts._hint_?.polls.get(note.id),
myVotes: opts._hint_?.pollVotes.get(note.id)?.get(note.userId),
}) : undefined,
...(meId && Object.keys(reactions).length > 0 ? { ...(meId && Object.keys(reactions).length > 0 ? {
myReaction: this.populateMyReaction({ myReaction: this.populateMyReaction({
@ -518,7 +536,10 @@ export class NoteEntityService implements OnModuleInit {
this.treatVisibility(packed); this.treatVisibility(packed);
if (!opts.skipHide) { if (!opts.skipHide) {
await this.hideNote(packed, meId); await this.hideNote(packed, meId, meId == null ? undefined : {
myFollowing: opts._hint_?.userFollowings.get(meId),
myBlockers: opts._hint_?.userBlockers.get(meId),
});
} }
return packed; return packed;
@ -536,69 +557,53 @@ export class NoteEntityService implements OnModuleInit {
if (notes.length === 0) return []; if (notes.length === 0) return [];
const targetNotes: MiNote[] = []; const targetNotes: MiNote[] = [];
const targetNotesToFetch: string[] = [];
for (const note of notes) { for (const note of notes) {
if (isPureRenote(note)) { if (isPureRenote(note)) {
// we may need to fetch 'my reaction' for renote target. // we may need to fetch 'my reaction' for renote target.
targetNotes.push(note.renote); if (note.renote) {
if (note.renote.reply) { targetNotes.push(note.renote);
// idem if the renote is also a reply. if (note.renote.reply) {
targetNotes.push(note.renote.reply); // idem if the renote is also a reply.
targetNotes.push(note.renote.reply);
}
} else if (options?.detail) {
targetNotesToFetch.push(note.renoteId);
} }
} else { } else {
if (note.reply) { if (note.reply) {
// idem for OP of a regular reply. // idem for OP of a regular reply.
targetNotes.push(note.reply); targetNotes.push(note.reply);
} else if (note.replyId && options?.detail) {
targetNotesToFetch.push(note.replyId);
} }
targetNotes.push(note); targetNotes.push(note);
} }
} }
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null; // Populate any relations that weren't included in the source
if (targetNotesToFetch.length > 0) {
const meId = me ? me.id : null; const newNotes = await this.notesRepository.find({
const myReactionsMap = new Map<MiNote['id'], string | null>(); where: {
if (meId) { id: In(targetNotesToFetch),
const idsNeedFetchMyReaction = new Set<MiNote['id']>(); },
relations: {
for (const note of targetNotes) { user: true,
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); reply: true,
if (reactionsCount === 0) { renote: true,
myReactionsMap.set(note.id, null); channel: true,
} else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) { },
const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId); });
if (pairInBuffer) { targetNotes.push(...newNotes);
myReactionsMap.set(note.id, pairInBuffer[1]);
} else {
const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId));
myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null);
}
} else {
idsNeedFetchMyReaction.add(note.id);
}
}
const myReactions = idsNeedFetchMyReaction.size > 0 ? await this.noteReactionsRepository.findBy({
userId: meId,
noteId: In(Array.from(idsNeedFetchMyReaction)),
}) : [];
for (const id of idsNeedFetchMyReaction) {
myReactionsMap.set(id, myReactions.find(reaction => reaction.noteId === id)?.reaction ?? null);
}
} }
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes));
// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null); const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null);
const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map();
const users = [ const users = [
...notes.map(({ user, userId }) => user ?? userId), ...notes.map(({ user, userId }) => user ?? userId),
...notes.map(({ replyUserId }) => replyUserId).filter(x => x != null), ...notes.map(({ reply, replyUserId }) => reply?.user ?? replyUserId).filter(x => x != null),
...notes.map(({ renoteUserId }) => renoteUserId).filter(x => x != null), ...notes.map(({ renote, renoteUserId }) => renote?.user ?? renoteUserId).filter(x => x != null),
]; ];
const packedUsers = await this.userEntityService.packMany(users, me)
.then(users => new Map(users.map(u => [u.id, u])));
// Recursively add all mentioned users from all notes + replies + renotes // Recursively add all mentioned users from all notes + replies + renotes
const allMentionedUsers = targetNotes.reduce((users, note) => { const allMentionedUsers = targetNotes.reduce((users, note) => {
@ -607,7 +612,47 @@ export class NoteEntityService implements OnModuleInit {
} }
return users; return users;
}, new Set<string>()); }, new Set<string>());
const mentionHandles = await this.getUserHandles(Array.from(allMentionedUsers));
const userIds = Array.from(new Set(users.map(u => typeof(u) === 'string' ? u : u.id)));
const noteIds = Array.from(new Set(targetNotes.map(n => n.id)));
const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels] = await Promise.all([
// bufferedReactions & myReactionsMap
this.getReactions(targetNotes, me),
// packedFiles
this.driveFileEntityService.packManyByIdsMap(fileIds),
// packedUsers
this.userEntityService.packMany(users, me)
.then(users => new Map(users.map(u => [u.id, u]))),
// mentionHandles
this.getUserHandles(Array.from(allMentionedUsers)),
// userFollowings
this.cacheService.getUserFollowings(userIds),
// userBlockers
this.cacheService.getUserBlockers(userIds),
// polls
this.pollsRepository.findBy({ noteId: In(noteIds) })
.then(polls => new Map(polls.map(p => [p.noteId, p]))),
// pollVotes
this.pollVotesRepository.findBy({ noteId: In(noteIds), userId: In(userIds) })
.then(votes => votes.reduce((noteMap, vote) => {
let userMap = noteMap.get(vote.noteId);
if (!userMap) {
userMap = new Map<string, MiPollVote[]>();
noteMap.set(vote.noteId, userMap);
}
let voteList = userMap.get(vote.userId);
if (!voteList) {
voteList = [];
userMap.set(vote.userId, voteList);
}
voteList.push(vote);
return noteMap;
}, new Map<string, Map<string, MiPollVote[]>>)),
// channels
this.getChannels(targetNotes),
// (not returned)
this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)),
]);
return await Promise.all(notes.map(n => this.pack(n, me, { return await Promise.all(notes.map(n => this.pack(n, me, {
...options, ...options,
@ -617,6 +662,11 @@ export class NoteEntityService implements OnModuleInit {
packedFiles, packedFiles,
packedUsers, packedUsers,
mentionHandles, mentionHandles,
userFollowings,
userBlockers,
polls,
pollVotes,
channels,
}, },
}))); })));
} }
@ -685,6 +735,68 @@ export class NoteEntityService implements OnModuleInit {
}, {} as Record<string, string | undefined>); }, {} as Record<string, string | undefined>);
} }
private async getChannels(notes: MiNote[]): Promise<Map<string, MiChannel>> {
const channels = new Map<string, MiChannel>();
const channelsToFetch = new Set<string>();
for (const note of notes) {
if (note.channel) {
channels.set(note.channel.id, note.channel);
} else if (note.channelId) {
channelsToFetch.add(note.channelId);
}
}
if (channelsToFetch.size > 0) {
const newChannels = await this.channelsRepository.findBy({
id: In(Array.from(channelsToFetch)),
});
for (const channel of newChannels) {
channels.set(channel.id, channel);
}
}
return channels;
}
private async getReactions(notes: MiNote[], me: { id: string } | null | undefined) {
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
const meId = me ? me.id : null;
const myReactionsMap = new Map<MiNote['id'], string | null>();
if (meId) {
const idsNeedFetchMyReaction = new Set<MiNote['id']>();
for (const note of notes) {
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) {
myReactionsMap.set(note.id, null);
} else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) {
const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId);
if (pairInBuffer) {
myReactionsMap.set(note.id, pairInBuffer[1]);
} else {
const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId));
myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null);
}
} else {
idsNeedFetchMyReaction.add(note.id);
}
}
const myReactions = idsNeedFetchMyReaction.size > 0 ? await this.noteReactionsRepository.findBy({
userId: meId,
noteId: In(Array.from(idsNeedFetchMyReaction)),
}) : [];
for (const id of idsNeedFetchMyReaction) {
myReactionsMap.set(id, myReactions.find(reaction => reaction.noteId === id)?.reaction ?? null);
}
}
return { bufferedReactions, myReactionsMap };
}
@bindThis @bindThis
public genLocalNoteUri(noteId: string): string { public genLocalNoteUri(noteId: string): string {
return `${this.config.url}/notes/${noteId}`; return `${this.config.url}/notes/${noteId}`;

View file

@ -30,6 +30,7 @@ import type {
FollowingsRepository, FollowingsRepository,
FollowRequestsRepository, FollowRequestsRepository,
MiFollowing, MiFollowing,
MiInstance,
MiMeta, MiMeta,
MiUserNotePining, MiUserNotePining,
MiUserProfile, MiUserProfile,
@ -42,7 +43,7 @@ import type {
UsersRepository, UsersRepository,
} from '@/models/_.js'; } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RolePolicies, RoleService } from '@/core/RoleService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
@ -52,6 +53,7 @@ import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
import { ChatService } from '@/core/ChatService.js'; import { ChatService } from '@/core/ChatService.js';
import { isSystemAccount } from '@/misc/is-system-account.js'; import { isSystemAccount } from '@/misc/is-system-account.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { CacheService } from '@/core/CacheService.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { NoteEntityService } from './NoteEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js';
import type { PageEntityService } from './PageEntityService.js'; import type { PageEntityService } from './PageEntityService.js';
@ -103,6 +105,7 @@ export class UserEntityService implements OnModuleInit {
private idService: IdService; private idService: IdService;
private avatarDecorationService: AvatarDecorationService; private avatarDecorationService: AvatarDecorationService;
private chatService: ChatService; private chatService: ChatService;
private cacheService: CacheService;
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@ -163,6 +166,7 @@ export class UserEntityService implements OnModuleInit {
this.idService = this.moduleRef.get('IdService'); this.idService = this.moduleRef.get('IdService');
this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService'); this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService');
this.chatService = this.moduleRef.get('ChatService'); this.chatService = this.moduleRef.get('ChatService');
this.cacheService = this.moduleRef.get('CacheService');
} }
//#region Validators //#region Validators
@ -215,45 +219,23 @@ export class UserEntityService implements OnModuleInit {
followeeId: me, followeeId: me,
}, },
}), }),
this.blockingsRepository.exists({ this.cacheService.userBlockedCache.fetch(me)
where: { .then(blockers => blockers.size > 0),
blockerId: me, this.cacheService.userBlockingCache.fetch(me)
blockeeId: target, .then(blockees => blockees.size > 0),
}, this.cacheService.userMutingsCache.fetch(me)
}), .then(mutings => mutings.size > 0),
this.blockingsRepository.exists({ this.cacheService.renoteMutingsCache.fetch(me)
where: { .then(mutings => mutings.size > 0),
blockerId: target, this.cacheService.userByIdCache.fetch(target, () => this.usersRepository.findOneByOrFail({ id: target }))
blockeeId: me, .then(user => user.host),
},
}),
this.mutingsRepository.exists({
where: {
muterId: me,
muteeId: target,
},
}),
this.renoteMutingsRepository.exists({
where: {
muterId: me,
muteeId: target,
},
}),
this.usersRepository.createQueryBuilder('u')
.select('u.host')
.where({ id: target })
.getRawOne<{ u_host: string }>()
.then(it => it?.u_host ?? null),
this.userMemosRepository.createQueryBuilder('m') this.userMemosRepository.createQueryBuilder('m')
.select('m.memo') .select('m.memo')
.where({ userId: me, targetUserId: target }) .where({ userId: me, targetUserId: target })
.getRawOne<{ m_memo: string | null }>() .getRawOne<{ m_memo: string | null }>()
.then(it => it?.m_memo ?? null), .then(it => it?.m_memo ?? null),
this.userProfilesRepository.createQueryBuilder('p') this.cacheService.userProfileCache.fetch(me)
.select('p.mutedInstances') .then(profile => profile.mutedInstances),
.where({ userId: me })
.getRawOne<{ p_mutedInstances: string[] }>()
.then(it => it?.p_mutedInstances ?? []),
]); ]);
const isInstanceMuted = !!host && mutedInstances.includes(host); const isInstanceMuted = !!host && mutedInstances.includes(host);
@ -306,34 +288,18 @@ export class UserEntityService implements OnModuleInit {
.where('f.followeeId = :me', { me }) .where('f.followeeId = :me', { me })
.getRawMany<{ f_followerId: string }>() .getRawMany<{ f_followerId: string }>()
.then(it => it.map(it => it.f_followerId)), .then(it => it.map(it => it.f_followerId)),
this.blockingsRepository.createQueryBuilder('b') this.cacheService.userBlockedCache.fetch(me),
.select('b.blockeeId') this.cacheService.userBlockingCache.fetch(me),
.where('b.blockerId = :me', { me }) this.cacheService.userMutingsCache.fetch(me),
.getRawMany<{ b_blockeeId: string }>() this.cacheService.renoteMutingsCache.fetch(me),
.then(it => it.map(it => it.b_blockeeId)), this.cacheService.getUsers(targets)
this.blockingsRepository.createQueryBuilder('b') .then(users => {
.select('b.blockerId') const record: Record<string, string | null> = {};
.where('b.blockeeId = :me', { me }) for (const [id, user] of users) {
.getRawMany<{ b_blockerId: string }>() record[id] = user.host;
.then(it => it.map(it => it.b_blockerId)), }
this.mutingsRepository.createQueryBuilder('m') return record;
.select('m.muteeId') }),
.where('m.muterId = :me', { me })
.getRawMany<{ m_muteeId: string }>()
.then(it => it.map(it => it.m_muteeId)),
this.renoteMutingsRepository.createQueryBuilder('m')
.select('m.muteeId')
.where('m.muterId = :me', { me })
.getRawMany<{ m_muteeId: string }>()
.then(it => it.map(it => it.m_muteeId)),
this.usersRepository.createQueryBuilder('u')
.select(['u.id', 'u.host'])
.where({ id: In(targets) } )
.getRawMany<{ m_id: string, m_host: string }>()
.then(it => it.reduce((map, it) => {
map[it.m_id] = it.m_host;
return map;
}, {} as Record<string, string>)),
this.userMemosRepository.createQueryBuilder('m') this.userMemosRepository.createQueryBuilder('m')
.select(['m.targetUserId', 'm.memo']) .select(['m.targetUserId', 'm.memo'])
.where({ userId: me, targetUserId: In(targets) }) .where({ userId: me, targetUserId: In(targets) })
@ -342,11 +308,8 @@ export class UserEntityService implements OnModuleInit {
map[it.m_targetUserId] = it.m_memo; map[it.m_targetUserId] = it.m_memo;
return map; return map;
}, {} as Record<string, string | null>)), }, {} as Record<string, string | null>)),
this.userProfilesRepository.createQueryBuilder('p') this.cacheService.userProfileCache.fetch(me)
.select('p.mutedInstances') .then(p => p.mutedInstances),
.where({ userId: me })
.getRawOne<{ p_mutedInstances: string[] }>()
.then(it => it?.p_mutedInstances ?? []),
]); ]);
return new Map( return new Map(
@ -362,11 +325,11 @@ export class UserEntityService implements OnModuleInit {
isFollowed: followees.includes(target), isFollowed: followees.includes(target),
hasPendingFollowRequestFromYou: followersRequests.includes(target), hasPendingFollowRequestFromYou: followersRequests.includes(target),
hasPendingFollowRequestToYou: followeesRequests.includes(target), hasPendingFollowRequestToYou: followeesRequests.includes(target),
isBlocking: blockers.includes(target), isBlocking: blockers.has(target),
isBlocked: blockees.includes(target), isBlocked: blockees.has(target),
isMuted: muters.includes(target), isMuted: muters.has(target),
isRenoteMuted: renoteMuters.includes(target), isRenoteMuted: renoteMuters.has(target),
isInstanceMuted: mutedInstances.includes(hosts[target]), isInstanceMuted: hosts[target] != null && mutedInstances.includes(hosts[target]),
memo: memos[target] ?? null, memo: memos[target] ?? null,
}, },
]; ];
@ -391,6 +354,7 @@ export class UserEntityService implements OnModuleInit {
return false; // TODO return false; // TODO
} }
// TODO make redis calls in MULTI?
@bindThis @bindThis
public async getNotificationsInfo(userId: MiUser['id']): Promise<{ public async getNotificationsInfo(userId: MiUser['id']): Promise<{
hasUnread: boolean; hasUnread: boolean;
@ -424,16 +388,14 @@ export class UserEntityService implements OnModuleInit {
@bindThis @bindThis
public async getHasPendingReceivedFollowRequest(userId: MiUser['id']): Promise<boolean> { public async getHasPendingReceivedFollowRequest(userId: MiUser['id']): Promise<boolean> {
const count = await this.followRequestsRepository.countBy({ return await this.followRequestsRepository.existsBy({
followeeId: userId, followeeId: userId,
}); });
return count > 0;
} }
@bindThis @bindThis
public async getHasPendingSentFollowRequest(userId: MiUser['id']): Promise<boolean> { public async getHasPendingSentFollowRequest(userId: MiUser['id']): Promise<boolean> {
return this.followRequestsRepository.existsBy({ return await this.followRequestsRepository.existsBy({
followerId: userId, followerId: userId,
}); });
} }
@ -480,6 +442,12 @@ export class UserEntityService implements OnModuleInit {
userRelations?: Map<MiUser['id'], UserRelation>, userRelations?: Map<MiUser['id'], UserRelation>,
userMemos?: Map<MiUser['id'], string | null>, userMemos?: Map<MiUser['id'], string | null>,
pinNotes?: Map<MiUser['id'], MiUserNotePining[]>, pinNotes?: Map<MiUser['id'], MiUserNotePining[]>,
iAmModerator?: boolean,
userIdsByUri?: Map<string, string>,
instances?: Map<string, MiInstance | null>,
securityKeyCounts?: Map<string, number>,
pendingReceivedFollows?: Set<string>,
pendingSentFollows?: Set<string>,
}, },
): Promise<Packed<S>> { ): Promise<Packed<S>> {
const opts = Object.assign({ const opts = Object.assign({
@ -521,7 +489,7 @@ export class UserEntityService implements OnModuleInit {
const isDetailed = opts.schema !== 'UserLite'; const isDetailed = opts.schema !== 'UserLite';
const meId = me ? me.id : null; const meId = me ? me.id : null;
const isMe = meId === user.id; const isMe = meId === user.id;
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me as MiUser) : false);
const profile = isDetailed const profile = isDetailed
? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) ? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
@ -582,6 +550,9 @@ export class UserEntityService implements OnModuleInit {
const checkHost = user.host == null ? this.config.host : user.host; const checkHost = user.host == null ? this.config.host : user.host;
const notificationsInfo = isMe && isDetailed ? await this.getNotificationsInfo(user.id) : null; const notificationsInfo = isMe && isDetailed ? await this.getNotificationsInfo(user.id) : null;
let fetchPoliciesPromise: Promise<RolePolicies> | null = null;
const fetchPolicies = () => fetchPoliciesPromise ??= this.roleService.getUserPolicies(user);
const packed = { const packed = {
id: user.id, id: user.id,
name: user.name, name: user.name,
@ -607,13 +578,13 @@ export class UserEntityService implements OnModuleInit {
mandatoryCW: user.mandatoryCW, mandatoryCW: user.mandatoryCW,
rejectQuotes: user.rejectQuotes, rejectQuotes: user.rejectQuotes,
attributionDomains: user.attributionDomains, attributionDomains: user.attributionDomains,
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSilenced: user.isSilenced || fetchPolicies().then(r => !r.canPublicNote),
speakAsCat: user.speakAsCat ?? false, speakAsCat: user.speakAsCat ?? false,
approved: user.approved, approved: user.approved,
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true, requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined, makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined,
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined, makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined,
instance: user.host ? this.federatedInstanceService.fetch(user.host).then(instance => instance ? { instance: user.host ? Promise.resolve(opts.instances?.has(user.host) ? opts.instances.get(user.host) : this.federatedInstanceService.fetch(user.host)).then(instance => instance ? {
name: instance.name, name: instance.name,
softwareName: instance.softwareName, softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion, softwareVersion: instance.softwareVersion,
@ -628,7 +599,7 @@ export class UserEntityService implements OnModuleInit {
emojis: this.customEmojiService.populateEmojis(user.emojis, checkHost), emojis: this.customEmojiService.populateEmojis(user.emojis, checkHost),
onlineStatus: this.getOnlineStatus(user), onlineStatus: this.getOnlineStatus(user),
// パフォーマンス上の理由でローカルユーザーのみ // パフォーマンス上の理由でローカルユーザーのみ
badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user).then((rs) => rs
.filter((r) => r.isPublic || iAmModerator) .filter((r) => r.isPublic || iAmModerator)
.sort((a, b) => b.displayOrder - a.displayOrder) .sort((a, b) => b.displayOrder - a.displayOrder)
.map((r) => ({ .map((r) => ({
@ -641,9 +612,9 @@ export class UserEntityService implements OnModuleInit {
...(isDetailed ? { ...(isDetailed ? {
url: profile!.url, url: profile!.url,
uri: user.uri, uri: user.uri,
movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null, movedTo: user.movedToUri ? Promise.resolve(opts.userIdsByUri?.get(user.movedToUri) ?? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null)) : null,
alsoKnownAs: user.alsoKnownAs alsoKnownAs: user.alsoKnownAs
? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))) ? Promise.all(user.alsoKnownAs.map(uri => Promise.resolve(opts.userIdsByUri?.get(uri) ?? this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))))
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null)) .then(xs => xs.length === 0 ? null : xs.filter(x => x != null))
: null, : null,
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
@ -670,8 +641,8 @@ export class UserEntityService implements OnModuleInit {
followersVisibility: profile!.followersVisibility, followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility, followingVisibility: profile!.followingVisibility,
chatScope: user.chatScope, chatScope: user.chatScope,
canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'), canChat: fetchPolicies().then(r => r.chatAvailability === 'available'),
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ roles: this.roleService.getUserRoles(user).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
id: role.id, id: role.id,
name: role.name, name: role.name,
color: role.color, color: role.color,
@ -689,7 +660,7 @@ export class UserEntityService implements OnModuleInit {
twoFactorEnabled: profile!.twoFactorEnabled, twoFactorEnabled: profile!.twoFactorEnabled,
usePasswordLessLogin: profile!.usePasswordLessLogin, usePasswordLessLogin: profile!.usePasswordLessLogin,
securityKeys: profile!.twoFactorEnabled securityKeys: profile!.twoFactorEnabled
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) ? Promise.resolve(opts.securityKeyCounts?.get(user.id) ?? this.userSecurityKeysRepository.countBy({ userId: user.id })).then(result => result >= 1)
: false, : false,
} : {}), } : {}),
@ -722,8 +693,8 @@ export class UserEntityService implements OnModuleInit {
hasUnreadAntenna: this.getHasUnreadAntenna(user.id), hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
hasUnreadChannel: false, // 後方互換性のため hasUnreadChannel: false, // 後方互換性のため
hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), hasPendingReceivedFollowRequest: opts.pendingReceivedFollows?.has(user.id) ?? this.getHasPendingReceivedFollowRequest(user.id),
hasPendingSentFollowRequest: this.getHasPendingSentFollowRequest(user.id), hasPendingSentFollowRequest: opts.pendingSentFollows?.has(user.id) ?? this.getHasPendingSentFollowRequest(user.id),
unreadNotificationsCount: notificationsInfo?.unreadCount, unreadNotificationsCount: notificationsInfo?.unreadCount,
mutedWords: profile!.mutedWords, mutedWords: profile!.mutedWords,
hardMutedWords: profile!.hardMutedWords, hardMutedWords: profile!.hardMutedWords,
@ -733,7 +704,7 @@ export class UserEntityService implements OnModuleInit {
emailNotificationTypes: profile!.emailNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes,
achievements: profile!.achievements, achievements: profile!.achievements,
loggedInDays: profile!.loggedInDates.length, loggedInDays: profile!.loggedInDates.length,
policies: this.roleService.getUserPolicies(user.id), policies: fetchPolicies(),
defaultCW: profile!.defaultCW, defaultCW: profile!.defaultCW,
defaultCWPriority: profile!.defaultCWPriority, defaultCWPriority: profile!.defaultCWPriority,
allowUnsignedFetch: user.allowUnsignedFetch, allowUnsignedFetch: user.allowUnsignedFetch,
@ -783,6 +754,8 @@ export class UserEntityService implements OnModuleInit {
includeSecrets?: boolean, includeSecrets?: boolean,
}, },
): Promise<Packed<S>[]> { ): Promise<Packed<S>[]> {
if (users.length === 0) return [];
// -- IDのみの要素を補完して完全なエンティティ一覧を作る // -- IDのみの要素を補完して完全なエンティティ一覧を作る
const _users = users.filter((user): user is MiUser => typeof user !== 'string'); const _users = users.filter((user): user is MiUser => typeof user !== 'string');
@ -800,57 +773,111 @@ export class UserEntityService implements OnModuleInit {
} }
const _userIds = _users.map(u => u.id); const _userIds = _users.map(u => u.id);
// -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得 const iAmModerator = await this.roleService.isModerator(me as MiUser);
const meId = me ? me.id : null;
const isMe = meId && _userIds.includes(meId);
const isDetailed = options && options.schema !== 'UserLite';
const isDetailedAndMe = isDetailed && isMe;
const isDetailedAndMeOrMod = isDetailed && (isMe || iAmModerator);
const isDetailedAndNotMe = isDetailed && !isMe;
let profilesMap: Map<MiUser['id'], MiUserProfile> = new Map(); const userUris = new Set(_users
let userRelations: Map<MiUser['id'], UserRelation> = new Map(); .flatMap(user => [user.uri, user.movedToUri])
let userMemos: Map<MiUser['id'], string | null> = new Map(); .filter((uri): uri is string => uri != null));
let pinNotes: Map<MiUser['id'], MiUserNotePining[]> = new Map();
if (options?.schema !== 'UserLite') { const userHosts = new Set(_users
const _profiles: MiUserProfile[] = []; .map(user => user.host)
const _profilesToFetch: string[] = []; .filter((host): host is string => host != null));
for (const user of _users) {
if (user.userProfile) {
_profiles.push(user.userProfile);
} else {
_profilesToFetch.push(user.id);
}
}
if (_profilesToFetch.length > 0) {
const fetched = await this.userProfilesRepository.findBy({ userId: In(_profilesToFetch) });
_profiles.push(...fetched);
}
profilesMap = new Map(_profiles.map(p => [p.userId, p]));
const meId = me ? me.id : null; const _profiles: MiUserProfile[] = [];
if (meId) { const _profilesToFetch: string[] = [];
userMemos = await this.userMemosRepository.findBy({ userId: meId }) for (const user of _users) {
.then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))); if (user.userProfile) {
_profiles.push(user.userProfile);
if (_userIds.length > 0) { } else {
userRelations = await this.getRelations(meId, _userIds); _profilesToFetch.push(user.id);
pinNotes = await this.userNotePiningsRepository.createQueryBuilder('pin')
.where('pin.userId IN (:...userIds)', { userIds: _userIds })
.innerJoinAndSelect('pin.note', 'note')
.getMany()
.then(pinsNotes => {
const map = new Map<MiUser['id'], MiUserNotePining[]>();
for (const note of pinsNotes) {
const notes = map.get(note.userId) ?? [];
notes.push(note);
map.set(note.userId, notes);
}
for (const [, notes] of map.entries()) {
// pack側ではDESCで取得しているので、それに合わせて降順に並び替えておく
notes.sort((a, b) => b.id.localeCompare(a.id));
}
return map;
});
}
} }
} }
// -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得
const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts, pendingReceivedFollows, pendingSentFollows] = await Promise.all([
// profilesMap
this.cacheService.getUserProfiles(_profilesToFetch)
.then(profiles => {
for (const profile of _profiles) {
profiles.set(profile.userId, profile);
}
return profiles;
}),
// userMemos
isDetailed && meId ? this.userMemosRepository.findBy({ userId: meId })
.then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))) : new Map(),
// userRelations
isDetailedAndNotMe && meId ? this.getRelations(meId, _userIds) : new Map(),
// pinNotes
isDetailed ? this.userNotePiningsRepository.createQueryBuilder('pin')
.where('pin.userId IN (:...userIds)', { userIds: _userIds })
.innerJoinAndSelect('pin.note', 'note')
.getMany()
.then(pinsNotes => {
const map = new Map<MiUser['id'], MiUserNotePining[]>();
for (const note of pinsNotes) {
const notes = map.get(note.userId) ?? [];
notes.push(note);
map.set(note.userId, notes);
}
for (const [, notes] of map.entries()) {
// pack側ではDESCで取得しているので、それに合わせて降順に並び替えておく
notes.sort((a, b) => b.id.localeCompare(a.id));
}
return map;
}) : new Map(),
// userIdsByUrl
isDetailed ? this.usersRepository.createQueryBuilder('user')
.select([
'user.id',
'user.uri',
])
.where({
uri: In(Array.from(userUris)),
})
.getRawMany<{ user_uri: string, user_id: string }>()
.then(users => new Map(users.map(u => [u.user_uri, u.user_id]))) : new Map(),
// instances
Promise.all(Array.from(userHosts).map(async host => [host, await this.federatedInstanceService.fetch(host)] as const))
.then(hosts => new Map(hosts)),
// securityKeyCounts
isDetailedAndMeOrMod ? this.userSecurityKeysRepository.createQueryBuilder('key')
.select('key.userId', 'userId')
.addSelect('count(key.id)', 'userCount')
.where({
userId: In(_userIds),
})
.groupBy('key.userId')
.getRawMany<{ userId: string, userCount: number }>()
.then(counts => new Map(counts.map(c => [c.userId, c.userCount]))) : new Map(),
// TODO check query performance
// pendingReceivedFollows
isDetailedAndMe ? this.followRequestsRepository.createQueryBuilder('req')
.select('req.followeeId', 'followeeId')
.where({
followeeId: In(_userIds),
})
.groupBy('req.followeeId')
.getRawMany<{ followeeId: string }>()
.then(reqs => new Set(reqs.map(r => r.followeeId))) : new Set<string>(),
// pendingSentFollows
isDetailedAndMe ? this.followRequestsRepository.createQueryBuilder('req')
.select('req.followerId', 'followerId')
.where({
followerId: In(_userIds),
})
.groupBy('req.followerId')
.getRawMany<{ followerId: string }>()
.then(reqs => new Set(reqs.map(r => r.followerId))) : new Set<string>(),
]);
return Promise.all( return Promise.all(
_users.map(u => this.pack( _users.map(u => this.pack(
u, u,
@ -861,6 +888,12 @@ export class UserEntityService implements OnModuleInit {
userRelations: userRelations, userRelations: userRelations,
userMemos: userMemos, userMemos: userMemos,
pinNotes: pinNotes, pinNotes: pinNotes,
iAmModerator,
userIdsByUri,
instances,
securityKeyCounts,
pendingReceivedFollows,
pendingSentFollows,
}, },
)), )),
); );

View file

@ -348,7 +348,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchReplyTarget); throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isRenote(reply) && !isQuote(reply)) { } else if (isRenote(reply) && !isQuote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote); throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id, { me })) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote); throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') { } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);

View file

@ -402,7 +402,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchReplyTarget); throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isRenote(reply) && !isQuote(reply)) { } else if (isRenote(reply) && !isQuote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote); throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id, { me })) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote); throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') { } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);

View file

@ -91,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err; throw err;
}); });
if (!(await this.noteEntityService.isVisibleForMe(note, me?.id ?? null))) { if (!(await this.noteEntityService.isVisibleForMe(note, me?.id ?? null, { me }))) {
throw new ApiError(meta.errors.cannotTranslateInvisibleNote); throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
} }