[mastodon-client] Aggregate applicable fields in NoteConverter and UserConverter

This commit is contained in:
Laura Hausmann 2023-11-24 21:41:41 +01:00
parent 3ccfd0417b
commit b1d3e1d05f
No known key found for this signature in database
GPG Key ID: D044E84C5BE01605
3 changed files with 177 additions and 45 deletions

View File

@ -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 { getNote } from "@/server/api/common/getters.js";
import { Note } from "@/models/entities/note.js"; import { Note } from "@/models/entities/note.js";
import config from "@/config/index.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 { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js"; import { VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js";
import { escapeMFM } from "@/server/api/mastodon/converters/mfm.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 { EmojiConverter } from "@/server/api/mastodon/converters/emoji.js";
import { DriveFiles, NoteFavorites, NoteReactions, Notes, NoteThreadMutings, UserNotePinings } from "@/models/index.js"; import { DriveFiles, NoteFavorites, NoteReactions, Notes, NoteThreadMutings, UserNotePinings } from "@/models/index.js";
import { decodeReaction } from "@/misc/reaction-lib.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 { FileConverter } from "@/server/api/mastodon/converters/file.js";
import { awaitAll } from "@/prelude/await-all.js"; import { awaitAll } from "@/prelude/await-all.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.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 { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
import { getStubMastoContext, MastoContext } from "@/server/api/mastodon/index.js"; import { getStubMastoContext, MastoContext } from "@/server/api/mastodon/index.js";
import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js";
import isQuote from "@/misc/is-quote.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 { export class NoteConverter {
public static async encode(note: Note, ctx: MastoContext, recurseCounter: number = 2): Promise<MastodonEntity.Status> { public static async encode(note: Note, ctx: MastoContext, recurseCounter: number = 2): Promise<MastodonEntity.Status> {
@ -46,39 +48,46 @@ export class NoteConverter {
.filter((e) => e.name.indexOf("@") === -1) .filter((e) => e.name.indexOf("@") === -1)
.map((e) => EmojiConverter.encode(e))); .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({ const aggregateReaction = (ctx.reactionAggregate as Map<string, NoteReaction | null>)?.get(note.id);
userId: user.id,
noteId: note.id, const reaction = aggregateReaction !== undefined
}) : null; ? aggregateReaction
: user ? NoteReactions.findOneBy({
userId: user.id,
noteId: note.id,
}) : null;
const isFavorited = Promise.resolve(reaction).then(p => !!p); const isFavorited = Promise.resolve(reaction).then(p => !!p);
const isReblogged = user ? Notes.exist({ const isReblogged = (ctx.renoteAggregate as Map<string, boolean>)?.get(note.id)
where: { ?? (user ? Notes.exist({
userId: user.id, where: {
renoteId: note.id, userId: user.id,
text: IsNull(), renoteId: note.id,
} text: IsNull(),
}) : null; }
}) : null);
const renote = note.renote ?? (note.renoteId && recurseCounter > 0 ? getNote(note.renoteId, user) : null); const renote = note.renote ?? (note.renoteId && recurseCounter > 0 ? getNote(note.renoteId, user) : null);
const isBookmarked = user ? NoteFavorites.exist({ const isBookmarked = (ctx.bookmarkAggregate as Map<string, boolean>)?.get(note.id)
where: { ?? (user ? NoteFavorites.exist({
userId: user.id, where: {
noteId: note.id, userId: user.id,
}, noteId: note.id,
take: 1, },
}) : false; take: 1,
}) : false);
const isMuted = user ? NoteThreadMutings.exist({ const isMuted = (ctx.mutingAggregate as Map<string, boolean>)?.get(note.threadId ?? note.id)
where: { ?? (user ? NoteThreadMutings.exist({
userId: user.id, where: {
threadId: note.threadId || note.id, userId: user.id,
} threadId: note.threadId || note.id,
}) : false; }
}) : false);
const files = DriveFiles.packMany(note.fileIds); const files = DriveFiles.packMany(note.fileIds);
@ -100,9 +109,10 @@ export class NoteConverter {
.then(p => p ?? escapeMFM(text)) .then(p => p ?? escapeMFM(text))
: ""); : "");
const isPinned = user && note.userId === user.id const isPinned = (ctx.pinAggregate as Map<string, boolean>)?.get(note.id)
? UserNotePinings.exist({ where: { userId: user.id, noteId: note.id } }) ?? (user && note.userId === user.id
: undefined; ? UserNotePinings.exist({ where: { userId: user.id, noteId: note.id } })
: undefined);
const tags = note.tags.map(tag => { const tags = note.tags.map(tag => {
return { return {
@ -152,10 +162,89 @@ export class NoteConverter {
} }
public static async encodeMany(notes: Note[], ctx: MastoContext): Promise<MastodonEntity.Status[]> { public static async encodeMany(notes: Note[], ctx: MastoContext): Promise<MastodonEntity.Status[]> {
await this.aggregateData(notes, ctx);
const encoded = notes.map(n => this.encode(n, ctx)); const encoded = notes.map(n => this.encode(n, ctx));
return Promise.all(encoded); return Promise.all(encoded);
} }
private static async aggregateData(notes: Note[], ctx: MastoContext): Promise<void> {
if (notes.length === 0) return;
const user = ctx.user as ILocalUser | null;
const reactionAggregate = new Map<Note["id"], NoteReaction | null>();
const renoteAggregate = new Map<Note["id"], boolean>();
const mutingAggregate = new Map<Note["id"], boolean>();
const bookmarkAggregate = new Map<Note["id"], boolean>();;
const pinAggregate = new Map<Note["id"], boolean>();
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<string, number>, myReaction: string | undefined, populated: PopulatedEmoji[]): MastodonEntity.Reaction[] { private static encodeReactions(reactions: Record<string, number>, myReaction: string | undefined, populated: PopulatedEmoji[]): MastodonEntity.Reaction[] {
return Object.keys(reactions).map(key => { return Object.keys(reactions).map(key => {

View File

@ -10,6 +10,9 @@ import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js
import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
import { MastoContext } from "@/server/api/mastodon/index.js"; import { MastoContext } from "@/server/api/mastodon/index.js";
import { IMentionedRemoteUsers } from "@/models/entities/note.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 = { type Field = {
name: string; name: string;
@ -32,8 +35,13 @@ export class UserConverter {
acct = `${u.username}@${u.host}`; acct = `${u.username}@${u.host}`;
acctUrl = `https://${u.host}/@${u.username}`; 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<string, UserProfile | null>)?.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 const avatar = u.avatarId
? DriveFiles.getFinalUrlMaybe(u.avatarUrl) ?? (DriveFiles.findOneBy({ id: u.avatarId })) ? DriveFiles.getFinalUrlMaybe(u.avatarUrl) ?? (DriveFiles.findOneBy({ id: u.avatarId }))
.then(p => p?.url ?? Users.getIdenticonUrl(u.id)) .then(p => p?.url ?? Users.getIdenticonUrl(u.id))
@ -45,17 +53,18 @@ export class UserConverter {
.then(p => DriveFiles.getFinalUrl(p)) .then(p => DriveFiles.getFinalUrl(p))
: `${config.url}/static-assets/transparent.png`; : `${config.url}/static-assets/transparent.png`;
const isFollowedOrSelf = !!localUser && const isFollowedOrSelf = (ctx.followedOrSelfAggregate as Map<string, boolean>)?.get(u.id)
(localUser.id === u.id || ?? (!!localUser &&
Followings.exist({ (localUser.id === u.id ||
where: { Followings.exist({
followeeId: u.id, where: {
followerId: localUser.id, 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; if (profile === null) return u.followersCount;
switch (profile.ffVisibility) { switch (profile.ffVisibility) {
case "public": case "public":
@ -66,7 +75,7 @@ export class UserConverter {
return localUser?.id === profile.userId ? u.followersCount : 0; 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; if (profile === null) return u.followingCount;
switch (profile.ffVisibility) { switch (profile.ffVisibility) {
case "public": case "public":
@ -97,7 +106,7 @@ export class UserConverter {
header_static: banner, header_static: banner,
emojis: populateEmojis(u.emojis, u.host).then(emoji => emoji.map((e) => EmojiConverter.encode(e))), emojis: populateEmojis(u.emojis, u.host).then(emoji => emoji.map((e) => EmojiConverter.encode(e))),
moved: null, //FIXME 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, bot: u.isBot,
discoverable: u.isExplorable discoverable: u.isExplorable
}).then(p => { }).then(p => {
@ -109,7 +118,40 @@ export class UserConverter {
}); });
} }
public static async aggregateData(users: User[], ctx: MastoContext): Promise<void> {
const user = ctx.user as ILocalUser | null;
const targets = unique(users.map(u => u.id));
const followedOrSelfAggregate = new Map<User["id"], boolean>();
const userProfileAggregate = new Map<User["id"], UserProfile | null>();
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<MastodonEntity.Account[]> { public static async encodeMany(users: User[], ctx: MastoContext): Promise<MastodonEntity.Account[]> {
await this.aggregateData(users, ctx);
const encoded = users.map(u => this.encode(u, ctx)); const encoded = users.map(u => this.encode(u, ctx));
return Promise.all(encoded); return Promise.all(encoded);
} }

View File

@ -34,6 +34,7 @@ export class TimelineHelpers {
maxId, maxId,
minId minId
) )
.leftJoinAndSelect("note.user", "user")
.leftJoinAndSelect("note.renote", "renote"); .leftJoinAndSelect("note.renote", "renote");
await generateFollowingQuery(query, user); await generateFollowingQuery(query, user);