diff --git a/packages/backend/src/server/api/mastodon/converters/note.ts b/packages/backend/src/server/api/mastodon/converters/note.ts index 809aa29c6..1cae2d2ba 100644 --- a/packages/backend/src/server/api/mastodon/converters/note.ts +++ b/packages/backend/src/server/api/mastodon/converters/note.ts @@ -1,4 +1,4 @@ -import { ILocalUser } from "@/models/entities/user.js"; +import { ILocalUser, User } from "@/models/entities/user.js"; import { getNote } from "@/server/api/common/getters.js"; import { Note } from "@/models/entities/note.js"; import config from "@/config/index.js"; @@ -6,7 +6,7 @@ import mfm from "mfm-js"; import { UserConverter } from "@/server/api/mastodon/converters/user.js"; import { VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js"; import { escapeMFM } from "@/server/api/mastodon/converters/mfm.js"; -import { PopulatedEmoji, populateEmojis } from "@/misc/populate-emojis.js"; +import { aggregateNoteEmojis, PopulatedEmoji, populateEmojis, prefetchEmojis } from "@/misc/populate-emojis.js"; import { EmojiConverter } from "@/server/api/mastodon/converters/emoji.js"; import { DriveFiles, NoteFavorites, NoteReactions, Notes, NoteThreadMutings, UserNotePinings } from "@/models/index.js"; import { decodeReaction } from "@/misc/reaction-lib.js"; @@ -16,11 +16,13 @@ import { populatePoll } from "@/models/repositories/note.js"; import { FileConverter } from "@/server/api/mastodon/converters/file.js"; import { awaitAll } from "@/prelude/await-all.js"; import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; -import { IsNull } from "typeorm"; +import { In, IsNull } from "typeorm"; import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; import { getStubMastoContext, MastoContext } from "@/server/api/mastodon/index.js"; import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; import isQuote from "@/misc/is-quote.js"; +import { unique } from "@/prelude/array.js"; +import { NoteReaction } from "@/models/entities/note-reaction.js"; export class NoteConverter { public static async encode(note: Note, ctx: MastoContext, recurseCounter: number = 2): Promise { @@ -46,39 +48,46 @@ export class NoteConverter { .filter((e) => e.name.indexOf("@") === -1) .map((e) => EmojiConverter.encode(e))); - const reactionCount = NoteReactions.countBy({ noteId: note.id }); + const reactionCount = Object.values(note.reactions).reduce((a, b) => a + b, 0); - const reaction = user ? NoteReactions.findOneBy({ - userId: user.id, - noteId: note.id, - }) : null; + const aggregateReaction = (ctx.reactionAggregate as Map)?.get(note.id); + + const reaction = aggregateReaction !== undefined + ? aggregateReaction + : user ? NoteReactions.findOneBy({ + userId: user.id, + noteId: note.id, + }) : null; const isFavorited = Promise.resolve(reaction).then(p => !!p); - const isReblogged = user ? Notes.exist({ - where: { - userId: user.id, - renoteId: note.id, - text: IsNull(), - } - }) : null; + const isReblogged = (ctx.renoteAggregate as Map)?.get(note.id) + ?? (user ? Notes.exist({ + where: { + userId: user.id, + renoteId: note.id, + text: IsNull(), + } + }) : null); const renote = note.renote ?? (note.renoteId && recurseCounter > 0 ? getNote(note.renoteId, user) : null); - const isBookmarked = user ? NoteFavorites.exist({ - where: { - userId: user.id, - noteId: note.id, - }, - take: 1, - }) : false; + const isBookmarked = (ctx.bookmarkAggregate as Map)?.get(note.id) + ?? (user ? NoteFavorites.exist({ + where: { + userId: user.id, + noteId: note.id, + }, + take: 1, + }) : false); - const isMuted = user ? NoteThreadMutings.exist({ - where: { - userId: user.id, - threadId: note.threadId || note.id, - } - }) : false; + const isMuted = (ctx.mutingAggregate as Map)?.get(note.threadId ?? note.id) + ?? (user ? NoteThreadMutings.exist({ + where: { + userId: user.id, + threadId: note.threadId || note.id, + } + }) : false); const files = DriveFiles.packMany(note.fileIds); @@ -100,9 +109,10 @@ export class NoteConverter { .then(p => p ?? escapeMFM(text)) : ""); - const isPinned = user && note.userId === user.id - ? UserNotePinings.exist({ where: { userId: user.id, noteId: note.id } }) - : undefined; + const isPinned = (ctx.pinAggregate as Map)?.get(note.id) + ?? (user && note.userId === user.id + ? UserNotePinings.exist({ where: { userId: user.id, noteId: note.id } }) + : undefined); const tags = note.tags.map(tag => { return { @@ -152,10 +162,89 @@ export class NoteConverter { } public static async encodeMany(notes: Note[], ctx: MastoContext): Promise { + await this.aggregateData(notes, ctx); const encoded = notes.map(n => this.encode(n, ctx)); return Promise.all(encoded); } + private static async aggregateData(notes: Note[], ctx: MastoContext): Promise { + if (notes.length === 0) return; + + const user = ctx.user as ILocalUser | null; + const reactionAggregate = new Map(); + const renoteAggregate = new Map(); + const mutingAggregate = new Map(); + const bookmarkAggregate = new Map();; + const pinAggregate = new Map(); + + if (user?.id != null) { + const renoteIds = notes + .filter((n) => n.renoteId != null) + .map((n) => n.renoteId!); + + const noteIds = unique(notes.map((n) => n.id)); + const targets = unique([...noteIds, ...renoteIds]); + const mutingTargets = unique([...notes.map(n => n.threadId ?? n.id)]); + const pinTargets = unique([...notes.filter(n => n.userId === user.id).map(n => n.id)]); + + const reactions = await NoteReactions.findBy({ + userId: user.id, + noteId: In(targets), + }); + + const renotes = await Notes.createQueryBuilder('note') + .select('note.renoteId') + .where('note.userId = :meId', { meId: user.id }) + .andWhere('note.renoteId IN (:...targets)', { targets }) + .andWhere('note.text IS NULL') + .andWhere('note.hasPoll = FALSE') + .andWhere(`note.fileIds = '{}'`) + .getMany(); + + const mutings = await NoteThreadMutings.createQueryBuilder('muting') + .select('muting.threadId') + .where('muting.userId = :meId', { meId: user.id }) + .andWhere('muting.threadId IN (:...targets)', { targets: mutingTargets }) + .getMany(); + + const bookmarks = await NoteFavorites.createQueryBuilder('bookmark') + .select('bookmark.noteId') + .where('bookmark.userId = :meId', { meId: user.id }) + .andWhere('bookmark.noteId IN (:...targets)', { targets }) + .getMany(); + + const pins = pinTargets.length > 0 ? await UserNotePinings.createQueryBuilder('pin') + .select('pin.noteId') + .where('pin.userId = :meId', { meId: user.id }) + .andWhere('pin.noteId IN (:...targets)', { targets: pinTargets }) + .getMany() : []; + + for (const target of targets) { + reactionAggregate.set(target, reactions.find(r => r.noteId === target) ?? null); + renoteAggregate.set(target, !!renotes.find(n => n.renoteId === target)); + bookmarkAggregate.set(target, !!bookmarks.find(b => b.noteId === target)); + } + + for (const target of mutingTargets) { + mutingAggregate.set(target, !!mutings.find(m => m.threadId === target)); + } + + for (const target of pinTargets) { + mutingAggregate.set(target, !!pins.find(m => m.noteId === target)); + } + } + + ctx.reactionAggregate = reactionAggregate; + ctx.renoteAggregate = renoteAggregate; + ctx.mutingAggregate = mutingAggregate; + ctx.bookmarkAggregate = bookmarkAggregate; + ctx.pinAggregate = pinAggregate; + + const users = notes.filter(p => !!p.user).map(p => p.user as User); + await UserConverter.aggregateData([...users], ctx) + await prefetchEmojis(aggregateNoteEmojis(notes)); + } + private static encodeReactions(reactions: Record, myReaction: string | undefined, populated: PopulatedEmoji[]): MastodonEntity.Reaction[] { return Object.keys(reactions).map(key => { diff --git a/packages/backend/src/server/api/mastodon/converters/user.ts b/packages/backend/src/server/api/mastodon/converters/user.ts index 5ac37e805..aae85252d 100644 --- a/packages/backend/src/server/api/mastodon/converters/user.ts +++ b/packages/backend/src/server/api/mastodon/converters/user.ts @@ -10,6 +10,9 @@ import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; import { MastoContext } from "@/server/api/mastodon/index.js"; import { IMentionedRemoteUsers } from "@/models/entities/note.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import { In } from "typeorm"; +import { unique } from "@/prelude/array.js"; type Field = { name: string; @@ -32,8 +35,13 @@ export class UserConverter { acct = `${u.username}@${u.host}`; acctUrl = `https://${u.host}/@${u.username}`; } - const profile = UserProfiles.findOneBy({ userId: u.id }); - const bio = profile.then(profile => MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, u.host).then(p => p ?? escapeMFM(profile?.description ?? ""))); + + const aggregateProfile = (ctx.userProfileAggregate as Map)?.get(u.id); + + const profile = aggregateProfile !== undefined + ? aggregateProfile + : UserProfiles.findOneBy({ userId: u.id }); + const bio = Promise.resolve(profile).then(profile => MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, u.host).then(p => p ?? escapeMFM(profile?.description ?? ""))); const avatar = u.avatarId ? DriveFiles.getFinalUrlMaybe(u.avatarUrl) ?? (DriveFiles.findOneBy({ id: u.avatarId })) .then(p => p?.url ?? Users.getIdenticonUrl(u.id)) @@ -45,17 +53,18 @@ export class UserConverter { .then(p => DriveFiles.getFinalUrl(p)) : `${config.url}/static-assets/transparent.png`; - const isFollowedOrSelf = !!localUser && - (localUser.id === u.id || - Followings.exist({ - where: { - followeeId: u.id, - followerId: localUser.id, - }, - }) - ); + const isFollowedOrSelf = (ctx.followedOrSelfAggregate as Map)?.get(u.id) + ?? (!!localUser && + (localUser.id === u.id || + Followings.exist({ + where: { + followeeId: u.id, + followerId: localUser.id, + }, + }) + )); - const followersCount = profile.then(async profile => { + const followersCount = Promise.resolve(profile).then(async profile => { if (profile === null) return u.followersCount; switch (profile.ffVisibility) { case "public": @@ -66,7 +75,7 @@ export class UserConverter { return localUser?.id === profile.userId ? u.followersCount : 0; } }); - const followingCount = profile.then(async profile => { + const followingCount = Promise.resolve(profile).then(async profile => { if (profile === null) return u.followingCount; switch (profile.ffVisibility) { case "public": @@ -97,7 +106,7 @@ export class UserConverter { header_static: banner, emojis: populateEmojis(u.emojis, u.host).then(emoji => emoji.map((e) => EmojiConverter.encode(e))), moved: null, //FIXME - fields: profile.then(profile => Promise.all(profile?.fields.map(async p => this.encodeField(p, u.host, profile?.mentions)) ?? [])), + fields: Promise.resolve(profile).then(profile => Promise.all(profile?.fields.map(async p => this.encodeField(p, u.host, profile?.mentions)) ?? [])), bot: u.isBot, discoverable: u.isExplorable }).then(p => { @@ -109,7 +118,40 @@ export class UserConverter { }); } + public static async aggregateData(users: User[], ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser | null; + const targets = unique(users.map(u => u.id)); + + const followedOrSelfAggregate = new Map(); + const userProfileAggregate = new Map(); + + if (user) { + const followings = await Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :meId', { meId: user.id }) + .andWhere('following.followeeId IN (:...targets)', { targets: targets.filter(u => u !== user.id) }) + .getMany(); + + followedOrSelfAggregate.set(user.id, true); + + for (const userId of targets.filter(u => u !== user.id)) { + followedOrSelfAggregate.set(userId, !!followings.find(f => f.followerId === userId)); + } + } + + const profiles = await UserProfiles.findBy({ + userId: In(targets) + }); + + for (const userId of targets) { + userProfileAggregate.set(userId, profiles.find(p => p.userId === userId) ?? null); + } + + ctx.followedOrSelfAggregate = followedOrSelfAggregate; + } + public static async encodeMany(users: User[], ctx: MastoContext): Promise { + await this.aggregateData(users, ctx); const encoded = users.map(u => this.encode(u, ctx)); return Promise.all(encoded); } diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts index a97c12c44..e869aecab 100644 --- a/packages/backend/src/server/api/mastodon/helpers/timeline.ts +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -34,6 +34,7 @@ export class TimelineHelpers { maxId, minId ) + .leftJoinAndSelect("note.user", "user") .leftJoinAndSelect("note.renote", "renote"); await generateFollowingQuery(query, user);