mirror of
https://iceshrimp.dev/crimekillz/trashposs
synced 2024-11-22 00:43:49 +01:00
[mastodon-client] Aggregate applicable fields in NoteConverter and UserConverter
This commit is contained in:
parent
3ccfd0417b
commit
b1d3e1d05f
@ -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<MastodonEntity.Status> {
|
||||
@ -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({
|
||||
const aggregateReaction = (ctx.reactionAggregate as Map<string, NoteReaction | null>)?.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({
|
||||
const isReblogged = (ctx.renoteAggregate as Map<string, boolean>)?.get(note.id)
|
||||
?? (user ? Notes.exist({
|
||||
where: {
|
||||
userId: user.id,
|
||||
renoteId: note.id,
|
||||
text: IsNull(),
|
||||
}
|
||||
}) : null;
|
||||
}) : 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)
|
||||
?? (user ? NoteFavorites.exist({
|
||||
where: {
|
||||
userId: user.id,
|
||||
noteId: note.id,
|
||||
},
|
||||
take: 1,
|
||||
}) : false;
|
||||
}) : false);
|
||||
|
||||
const isMuted = user ? NoteThreadMutings.exist({
|
||||
const isMuted = (ctx.mutingAggregate as Map<string, boolean>)?.get(note.threadId ?? note.id)
|
||||
?? (user ? NoteThreadMutings.exist({
|
||||
where: {
|
||||
userId: user.id,
|
||||
threadId: note.threadId || note.id,
|
||||
}
|
||||
}) : false;
|
||||
}) : 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
|
||||
const isPinned = (ctx.pinAggregate as Map<string, boolean>)?.get(note.id)
|
||||
?? (user && note.userId === user.id
|
||||
? UserNotePinings.exist({ where: { userId: user.id, noteId: note.id } })
|
||||
: undefined;
|
||||
: undefined);
|
||||
|
||||
const tags = note.tags.map(tag => {
|
||||
return {
|
||||
@ -152,10 +162,89 @@ export class NoteConverter {
|
||||
}
|
||||
|
||||
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));
|
||||
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[] {
|
||||
return Object.keys(reactions).map(key => {
|
||||
|
||||
|
@ -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<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
|
||||
? DriveFiles.getFinalUrlMaybe(u.avatarUrl) ?? (DriveFiles.findOneBy({ id: u.avatarId }))
|
||||
.then(p => p?.url ?? Users.getIdenticonUrl(u.id))
|
||||
@ -45,7 +53,8 @@ export class UserConverter {
|
||||
.then(p => DriveFiles.getFinalUrl(p))
|
||||
: `${config.url}/static-assets/transparent.png`;
|
||||
|
||||
const isFollowedOrSelf = !!localUser &&
|
||||
const isFollowedOrSelf = (ctx.followedOrSelfAggregate as Map<string, boolean>)?.get(u.id)
|
||||
?? (!!localUser &&
|
||||
(localUser.id === u.id ||
|
||||
Followings.exist({
|
||||
where: {
|
||||
@ -53,9 +62,9 @@ export class UserConverter {
|
||||
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<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[]> {
|
||||
await this.aggregateData(users, ctx);
|
||||
const encoded = users.map(u => this.encode(u, ctx));
|
||||
return Promise.all(encoded);
|
||||
}
|
||||
|
@ -34,6 +34,7 @@ export class TimelineHelpers {
|
||||
maxId,
|
||||
minId
|
||||
)
|
||||
.leftJoinAndSelect("note.user", "user")
|
||||
.leftJoinAndSelect("note.renote", "renote");
|
||||
|
||||
await generateFollowingQuery(query, user);
|
||||
|
Loading…
Reference in New Issue
Block a user