Fixed 267U.pre2
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { MfmHelpers } from "../helpers/mfm.js";
|
||||
import mfm from "mfm-js";
|
||||
export class AnnouncementConverter {
|
||||
static async encode(announcement, isRead) {
|
||||
return {
|
||||
id: announcement.id,
|
||||
content: `<h1>${await MfmHelpers.toHtml(mfm.parse(announcement.title), [], null) ?? 'Announcement'}</h1>${await MfmHelpers.toHtml(mfm.parse(announcement.text), [], null) ?? ''}`,
|
||||
starts_at: null,
|
||||
ends_at: null,
|
||||
published: true,
|
||||
all_day: false,
|
||||
published_at: announcement.createdAt.toISOString(),
|
||||
updated_at: announcement.updatedAt?.toISOString() ?? announcement.createdAt.toISOString(),
|
||||
read: isRead,
|
||||
mentions: [],
|
||||
statuses: [],
|
||||
tags: [],
|
||||
emojis: [],
|
||||
reactions: []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class EmojiConverter {
|
||||
static encode(e) {
|
||||
return {
|
||||
shortcode: e.name,
|
||||
static_url: e.url,
|
||||
url: e.url,
|
||||
visible_in_picker: true,
|
||||
category: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export class FileConverter {
|
||||
static encode(f) {
|
||||
return {
|
||||
id: f.id,
|
||||
type: this.encodefileType(f.type),
|
||||
url: f.url ?? "",
|
||||
remote_url: f.url,
|
||||
preview_url: f.thumbnailUrl ?? f.url ?? "",
|
||||
text_url: f.url,
|
||||
meta: {
|
||||
width: f.properties.width,
|
||||
height: f.properties.height
|
||||
},
|
||||
description: f.comment,
|
||||
blurhash: f.blurhash
|
||||
};
|
||||
}
|
||||
static encodefileType(s) {
|
||||
if (s === "image/gif") {
|
||||
return "gifv";
|
||||
}
|
||||
if (s.includes("image")) {
|
||||
return "image";
|
||||
}
|
||||
if (s.includes("video")) {
|
||||
return "video";
|
||||
}
|
||||
if (s.includes("audio")) {
|
||||
return "audio";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import config from "../../../../config/index.js";
|
||||
export class MentionConverter {
|
||||
static encode(u, m) {
|
||||
let acct = u.username;
|
||||
let acctUrl = `https://${u.host || config.host}/@${u.username}`;
|
||||
let url = null;
|
||||
if (u.host) {
|
||||
const info = m.find((r)=>r.username === u.username && r.host === u.host);
|
||||
acct = `${u.username}@${u.host}`;
|
||||
acctUrl = `https://${u.host}/@${u.username}`;
|
||||
if (info) url = info.url ?? info.uri;
|
||||
}
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
acct: acct,
|
||||
url: url ?? acctUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const escapeMFM = (text)=>text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'").replace(/`/g, "`").replace(/\r?\n/g, "<br>");
|
||||
@@ -0,0 +1,326 @@
|
||||
import { getNote } from "../../common/getters.js";
|
||||
import config from "../../../../config/index.js";
|
||||
import mfm from "mfm-js";
|
||||
import { UserConverter } from "./user.js";
|
||||
import { VisibilityConverter } from "./visibility.js";
|
||||
import { escapeMFM } from "./mfm.js";
|
||||
import { aggregateNoteEmojis, populateEmojis, prefetchEmojis } from "../../../../misc/populate-emojis.js";
|
||||
import { EmojiConverter } from "./emoji.js";
|
||||
import { DriveFiles, HtmlNoteCacheEntries, NoteFavorites, NoteReactions, Notes, NoteThreadMutings, UserNotePinings } from "../../../../models/index.js";
|
||||
import { decodeReaction } from "../../../../misc/reaction-lib.js";
|
||||
import { MentionConverter } from "./mention.js";
|
||||
import { PollConverter } from "./poll.js";
|
||||
import { populatePoll } from "../../../../models/repositories/note.js";
|
||||
import { FileConverter } from "./file.js";
|
||||
import { awaitAll } from "../../../../prelude/await-all.js";
|
||||
import { UserHelpers } from "../helpers/user.js";
|
||||
import { In, IsNull } from "typeorm";
|
||||
import { MfmHelpers } from "../helpers/mfm.js";
|
||||
import { getStubMastoContext } from "../index.js";
|
||||
import { NoteHelpers } from "../helpers/note.js";
|
||||
import isQuote from "../../../../misc/is-quote.js";
|
||||
import { unique } from "../../../../prelude/array.js";
|
||||
import { Cache } from "../../../../misc/cache.js";
|
||||
import { isFiltered } from "../../../../misc/is-filtered.js";
|
||||
export class NoteConverter {
|
||||
static noteContentHtmlCache = new Cache('html:note:content', config.htmlCache?.ttlSeconds ?? 60 * 60);
|
||||
static async encode(note, ctx, recurseCounter = 2) {
|
||||
const user = ctx.user;
|
||||
const noteUser = note.user ?? UserHelpers.getUserCached(note.userId, ctx);
|
||||
if (!await Notes.isVisibleForMe(note, user?.id ?? null)) throw new Error('Cannot encode note not visible for user');
|
||||
const host = Promise.resolve(noteUser).then((noteUser)=>noteUser.host ?? null);
|
||||
const reactionEmojiNames = Object.keys(note.reactions).filter((x)=>x?.startsWith(":")).map((x)=>decodeReaction(x).reaction).map((x)=>x.replace(/:/g, ""));
|
||||
const populated = host.then(async (host)=>populateEmojis(note.emojis.concat(reactionEmojiNames), host));
|
||||
const noteEmoji = populated.then((noteEmoji)=>noteEmoji.filter((e)=>e.name.indexOf("@") === -1).map((e)=>EmojiConverter.encode(e)));
|
||||
const reactionCount = Object.values(note.reactions).reduce((a, b)=>a + b, 0);
|
||||
const aggregateReaction = ctx.reactionAggregate?.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 = ctx.renoteAggregate?.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 = ctx.bookmarkAggregate?.get(note.id) ?? (user ? NoteFavorites.exist({
|
||||
where: {
|
||||
userId: user.id,
|
||||
noteId: note.id
|
||||
},
|
||||
take: 1
|
||||
}) : false);
|
||||
const isMuted = ctx.mutingAggregate?.get(note.threadId ?? note.id) ?? (user ? NoteThreadMutings.exist({
|
||||
where: {
|
||||
userId: user.id,
|
||||
threadId: note.threadId || note.id
|
||||
}
|
||||
}) : false);
|
||||
const files = DriveFiles.packMany(note.fileIds);
|
||||
const mentions = Promise.all(note.mentions.map((p)=>UserHelpers.getUserCached(p, ctx).then((u)=>MentionConverter.encode(u, JSON.parse(note.mentionedRemoteUsers))).catch(()=>null))).then((p)=>p.filter((m)=>m));
|
||||
const quoteUri = Promise.resolve(renote).then((renote)=>{
|
||||
if (!renote || !isQuote(note)) return null;
|
||||
return renote.url ?? renote.uri ?? `${config.url}/notes/${renote.id}`;
|
||||
});
|
||||
const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`;
|
||||
const text = quoteUri.then((quoteUri)=>note.text !== null ? quoteUri !== null ? note.text.replaceAll(`RE: ${quoteUri}`, '').replaceAll(quoteUri, '').trimEnd() : note.text : null);
|
||||
const content = this.noteContentHtmlCache.fetch(identifier, async ()=>Promise.resolve(await this.fetchFromCacheWithFallback(note, ctx) ?? text.then((text)=>text !== null ? quoteUri.then((quoteUri)=>MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), note.userHost, false, quoteUri)).then((p)=>p ?? escapeMFM(text)) : "")), true).then((p)=>p ?? '');
|
||||
const isPinned = ctx.pinAggregate?.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 {
|
||||
name: tag,
|
||||
url: `${config.url}/tags/${tag}`
|
||||
};
|
||||
});
|
||||
const reblog = Promise.resolve(renote).then((renote)=>recurseCounter > 0 && renote ? this.encode(renote, ctx, isQuote(renote) && !isQuote(note) ? --recurseCounter : 0) : null);
|
||||
const filtered = isFiltered(note, user).then((res)=>{
|
||||
if (!res || ctx.filterContext == null || ![
|
||||
'home',
|
||||
'public'
|
||||
].includes(ctx.filterContext)) return null;
|
||||
return [
|
||||
{
|
||||
filter: {
|
||||
id: '0',
|
||||
title: 'Hard word mutes',
|
||||
context: [
|
||||
'home',
|
||||
'public'
|
||||
],
|
||||
expires_at: null,
|
||||
filter_action: 'hide',
|
||||
keywords: [],
|
||||
statuses: []
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
// noinspection ES6MissingAwait
|
||||
return await awaitAll({
|
||||
id: note.id,
|
||||
uri: note.uri ?? `https://${config.host}/notes/${note.id}`,
|
||||
url: note.url ?? note.uri ?? `https://${config.host}/notes/${note.id}`,
|
||||
account: Promise.resolve(noteUser).then((p)=>UserConverter.encode(p, ctx)),
|
||||
in_reply_to_id: note.replyId,
|
||||
in_reply_to_account_id: note.replyUserId,
|
||||
reblog: reblog.then((reblog)=>!isQuote(note) ? reblog : null),
|
||||
content: content,
|
||||
content_type: 'text/x.misskeymarkdown',
|
||||
text: text,
|
||||
created_at: note.createdAt.toISOString(),
|
||||
emojis: noteEmoji,
|
||||
replies_count: note.repliesCount,
|
||||
reblogs_count: note.renoteCount,
|
||||
favourites_count: reactionCount,
|
||||
reblogged: isReblogged,
|
||||
favourited: isFavorited,
|
||||
muted: isMuted,
|
||||
sensitive: files.then((files)=>files.length > 0 ? files.some((f)=>f.isSensitive) : false),
|
||||
spoiler_text: note.cw ? note.cw : "",
|
||||
visibility: VisibilityConverter.encode(note.visibility),
|
||||
media_attachments: files.then((files)=>files.length > 0 ? files.map((f)=>FileConverter.encode(f)) : []),
|
||||
mentions: mentions,
|
||||
tags: tags,
|
||||
card: null,
|
||||
poll: note.hasPoll ? populatePoll(note, user?.id ?? null).then((p)=>noteEmoji.then((emojis)=>PollConverter.encode(p, note.id, emojis))) : null,
|
||||
application: null,
|
||||
language: null,
|
||||
pinned: isPinned,
|
||||
reactions: populated.then((populated)=>Promise.resolve(reaction).then((reaction)=>this.encodeReactions(note.reactions, reaction?.reaction, populated))),
|
||||
bookmarked: isBookmarked,
|
||||
quote: reblog.then((reblog)=>reblog !== null && isQuote(note) ? {
|
||||
state: "accepted",
|
||||
quoted_status: reblog,
|
||||
...reblog
|
||||
} : null),
|
||||
quote_id: isQuote(note) ? note.renoteId : null,
|
||||
edited_at: note.updatedAt?.toISOString() ?? null,
|
||||
filtered: filtered,
|
||||
quote_approval: {
|
||||
automatic: [
|
||||
"public"
|
||||
],
|
||||
manual: [],
|
||||
current_user: "automatic"
|
||||
}
|
||||
});
|
||||
}
|
||||
static async encodeMany(notes, ctx) {
|
||||
await this.aggregateData(notes, ctx);
|
||||
const encoded = notes.map((n)=>this.encode(n, ctx));
|
||||
return Promise.all(encoded);
|
||||
}
|
||||
static async aggregateData(notes, ctx) {
|
||||
if (notes.length === 0) return;
|
||||
const user = ctx.user;
|
||||
const reactionAggregate = new Map();
|
||||
const renoteAggregate = new Map();
|
||||
const mutingAggregate = new Map();
|
||||
const bookmarkAggregate = new Map();
|
||||
const pinAggregate = new Map();
|
||||
const htmlNoteCacheAggregate = new Map();
|
||||
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
|
||||
]);
|
||||
if (config.htmlCache?.dbFallback) {
|
||||
const htmlNoteCacheEntries = await HtmlNoteCacheEntries.findBy({
|
||||
noteId: In(targets)
|
||||
});
|
||||
for (const target of targets){
|
||||
htmlNoteCacheAggregate.set(target, htmlNoteCacheEntries.find((n)=>n.noteId === target) ?? null);
|
||||
}
|
||||
}
|
||||
if (user?.id != null) {
|
||||
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;
|
||||
ctx.htmlNoteCacheAggregate = htmlNoteCacheAggregate;
|
||||
const users = notes.filter((p)=>!!p.user).map((p)=>p.user);
|
||||
const renoteUserIds = notes.filter((p)=>p.renoteUserId !== null).map((p)=>p.renoteUserId);
|
||||
await UserConverter.aggregateData([
|
||||
...users
|
||||
], ctx);
|
||||
await UserConverter.aggregateDataByIds(renoteUserIds, ctx);
|
||||
await prefetchEmojis(aggregateNoteEmojis(notes));
|
||||
}
|
||||
static encodeReactions(reactions, myReaction, populated) {
|
||||
return Object.keys(reactions).map((key)=>{
|
||||
const isCustom = key.startsWith(':') && key.endsWith(':');
|
||||
const name = isCustom ? key.substring(1, key.length - 1) : key;
|
||||
const populatedName = isCustom && name.indexOf('@') === -1 ? `${name}@.` : name;
|
||||
const url = isCustom ? populated.find((p)=>p.name === populatedName)?.url : undefined;
|
||||
return {
|
||||
count: reactions[key],
|
||||
me: key === myReaction,
|
||||
name: name,
|
||||
url: url,
|
||||
static_url: url
|
||||
};
|
||||
}).filter((r)=>r.count > 0);
|
||||
}
|
||||
static async encodeEvent(note, user, filterContext) {
|
||||
const ctx = getStubMastoContext(user, filterContext);
|
||||
NoteHelpers.fixupEventNote(note);
|
||||
return NoteConverter.encode(note, ctx);
|
||||
}
|
||||
static async fetchFromCacheWithFallback(note, ctx) {
|
||||
if (!config.htmlCache?.dbFallback) return null;
|
||||
let dbHit = ctx.htmlNoteCacheAggregate?.get(note.id);
|
||||
if (dbHit === undefined) dbHit = HtmlNoteCacheEntries.findOneBy({
|
||||
noteId: note.id
|
||||
});
|
||||
return Promise.resolve(dbHit).then((res)=>{
|
||||
if (res === null || res.updatedAt?.getTime() !== note.updatedAt?.getTime()) {
|
||||
return this.dbCacheMiss(note, ctx);
|
||||
}
|
||||
return res;
|
||||
}).then((hit)=>hit?.updatedAt === note.updatedAt ? hit?.content ?? null : null);
|
||||
}
|
||||
static async dbCacheMiss(note, ctx) {
|
||||
const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`;
|
||||
const cache = ctx.cache;
|
||||
return cache.locks.acquire(identifier, async ()=>{
|
||||
const cachedContent = await this.noteContentHtmlCache.get(identifier);
|
||||
if (cachedContent !== undefined) {
|
||||
return {
|
||||
content: cachedContent
|
||||
};
|
||||
}
|
||||
const quoteUri = note.renote ? isQuote(note) ? note.renote.url ?? note.renote.uri ?? `${config.url}/notes/${note.renote.id}` : null : null;
|
||||
const text = note.text !== null ? quoteUri !== null ? note.text.replaceAll(`RE: ${quoteUri}`, '').replaceAll(quoteUri, '').trimEnd() : note.text : null;
|
||||
const content = text !== null ? MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), note.userHost, false, quoteUri).then((p)=>p ?? escapeMFM(text)) : null;
|
||||
HtmlNoteCacheEntries.upsert({
|
||||
noteId: note.id,
|
||||
updatedAt: note.updatedAt ?? note.createdAt,
|
||||
content: await content
|
||||
}, [
|
||||
"noteId"
|
||||
]);
|
||||
await this.noteContentHtmlCache.set(identifier, await content);
|
||||
return {
|
||||
content
|
||||
};
|
||||
});
|
||||
}
|
||||
static async prewarmCache(note) {
|
||||
if (!config.htmlCache?.prewarm) return;
|
||||
const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`;
|
||||
if (await this.noteContentHtmlCache.get(identifier) !== undefined) return;
|
||||
if (note.renoteId !== null && !note.renote) {
|
||||
note.renote = await Notes.findOneBy({
|
||||
id: note.renoteId
|
||||
});
|
||||
}
|
||||
const quoteUri = note.renote ? isQuote(note) ? note.renote.url ?? note.renote.uri ?? `${config.url}/notes/${note.renote.id}` : null : null;
|
||||
const text = note.text !== null ? quoteUri !== null ? note.text.replaceAll(`RE: ${quoteUri}`, '').replaceAll(quoteUri, '').trimEnd() : note.text : null;
|
||||
const content = text !== null ? MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), note.userHost, false, quoteUri).then((p)=>p ?? escapeMFM(text)) : null;
|
||||
if (note.user) UserConverter.prewarmCache(note.user);
|
||||
else if (note.userId) UserConverter.prewarmCacheById(note.userId);
|
||||
if (note.replyUserId) UserConverter.prewarmCacheById(note.replyUserId);
|
||||
if (note.renoteUserId) UserConverter.prewarmCacheById(note.renoteUserId);
|
||||
this.noteContentHtmlCache.set(identifier, await content);
|
||||
if (config.htmlCache?.dbFallback) HtmlNoteCacheEntries.upsert({
|
||||
noteId: note.id,
|
||||
updatedAt: note.updatedAt ?? note.createdAt,
|
||||
content: await content
|
||||
}, [
|
||||
"noteId"
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { UserConverter } from "./user.js";
|
||||
import { UserHelpers } from "../helpers/user.js";
|
||||
import { awaitAll } from "../../../../prelude/await-all.js";
|
||||
import { NoteConverter } from "./note.js";
|
||||
import { getNote } from "../../common/getters.js";
|
||||
import { getStubMastoContext } from "../index.js";
|
||||
import { Notifications } from "../../../../models/index.js";
|
||||
import isQuote from "../../../../misc/is-quote.js";
|
||||
import { unique } from "../../../../prelude/array.js";
|
||||
export class NotificationConverter {
|
||||
static async encode(notification, ctx) {
|
||||
const localUser = ctx.user;
|
||||
if (notification.notifieeId !== localUser.id) throw new Error('User is not recipient of notification');
|
||||
const account = notification.notifierId ? UserHelpers.getUserCached(notification.notifierId, ctx).then((p)=>UserConverter.encode(p, ctx)) : UserConverter.encode(localUser, ctx);
|
||||
let result = {
|
||||
id: notification.id,
|
||||
account: account,
|
||||
created_at: notification.createdAt.toISOString(),
|
||||
type: this.encodeNotificationType(notification.type)
|
||||
};
|
||||
const note = notification.note ?? (notification.noteId ? await getNote(notification.noteId, localUser) : null);
|
||||
if (note) {
|
||||
const isPureRenote = note.renoteId !== null && !isQuote(note);
|
||||
const encodedNote = isPureRenote ? getNote(note.renoteId, localUser).then((note)=>NoteConverter.encode(note, ctx)) : NoteConverter.encode(note, ctx);
|
||||
result = Object.assign(result, {
|
||||
status: encodedNote
|
||||
});
|
||||
if (result.type === 'poll') {
|
||||
result = Object.assign(result, {
|
||||
account: encodedNote.then((p)=>p.account)
|
||||
});
|
||||
}
|
||||
if (notification.reaction) {
|
||||
//FIXME: Implement reactions;
|
||||
}
|
||||
}
|
||||
return awaitAll(result);
|
||||
}
|
||||
static async encodeMany(notifications, ctx) {
|
||||
await this.aggregateData(notifications, ctx);
|
||||
const encoded = notifications.map((u)=>this.encode(u, ctx));
|
||||
return Promise.all(encoded).then((p)=>p.filter((n)=>n !== null));
|
||||
}
|
||||
static async aggregateData(notifications, ctx) {
|
||||
if (notifications.length === 0) return;
|
||||
const notes = unique(notifications.filter((p)=>p.note != null).map((n)=>n.note));
|
||||
const users = unique(notifications.filter((p)=>p.notifier != null).map((n)=>n.notifier).concat(notifications.filter((p)=>p.notifiee != null).map((n)=>n.notifiee)));
|
||||
await NoteConverter.aggregateData(notes, ctx);
|
||||
await UserConverter.aggregateData(users, ctx);
|
||||
}
|
||||
static encodeNotificationType(t) {
|
||||
//FIXME: Implement custom notification for followRequestAccepted
|
||||
//FIXME: Implement mastodon notification type 'update' on misskey side
|
||||
switch(t){
|
||||
case "follow":
|
||||
return 'follow';
|
||||
case "mention":
|
||||
case "reply":
|
||||
return 'mention';
|
||||
case "renote":
|
||||
return 'reblog';
|
||||
case "quote":
|
||||
return 'reblog';
|
||||
case "reaction":
|
||||
return 'favourite';
|
||||
case "pollEnded":
|
||||
return 'poll';
|
||||
case "receiveFollowRequest":
|
||||
return 'follow_request';
|
||||
case "followRequestAccepted":
|
||||
case "pollVote":
|
||||
case "groupInvited":
|
||||
case "app":
|
||||
throw new Error(`Notification type ${t} not supported`);
|
||||
}
|
||||
}
|
||||
static async encodeEvent(target, user, filterContext) {
|
||||
const ctx = getStubMastoContext(user, filterContext);
|
||||
const notification = await Notifications.findOneByOrFail({
|
||||
id: target
|
||||
});
|
||||
return this.encode(notification, ctx).catch((_)=>null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export class PollConverter {
|
||||
static encode(p, noteId, emojis) {
|
||||
const now = new Date();
|
||||
const count = p.choices.reduce((sum, choice)=>sum + choice.votes, 0);
|
||||
return {
|
||||
id: noteId,
|
||||
expires_at: p.expiresAt?.toISOString() ?? null,
|
||||
expired: p.expiresAt == null ? false : now > p.expiresAt,
|
||||
multiple: p.multiple,
|
||||
votes_count: count,
|
||||
options: p.choices.map((c)=>this.encodeChoice(c)),
|
||||
emojis: emojis,
|
||||
voted: p.choices.some((c)=>c.isVoted),
|
||||
own_votes: p.choices.filter((c)=>c.isVoted).map((c)=>p.choices.indexOf(c))
|
||||
};
|
||||
}
|
||||
static encodeChoice(c) {
|
||||
return {
|
||||
title: c.text,
|
||||
votes_count: c.votes
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import config from "../../../../config/index.js";
|
||||
import { DriveFiles, Followings, HtmlUserCacheEntries, UserProfiles, Users } from "../../../../models/index.js";
|
||||
import { EmojiConverter } from "./emoji.js";
|
||||
import { populateEmojis } from "../../../../misc/populate-emojis.js";
|
||||
import { escapeMFM } from "./mfm.js";
|
||||
import mfm from "mfm-js";
|
||||
import { awaitAll } from "../../../../prelude/await-all.js";
|
||||
import { UserHelpers } from "../helpers/user.js";
|
||||
import { MfmHelpers } from "../helpers/mfm.js";
|
||||
import { In } from "typeorm";
|
||||
import { unique } from "../../../../prelude/array.js";
|
||||
import { Cache } from "../../../../misc/cache.js";
|
||||
import { getUser } from "../../common/getters.js";
|
||||
import AsyncLock from "async-lock";
|
||||
export class UserConverter {
|
||||
static userBioHtmlCache = new Cache('html:user:bio', config.htmlCache?.ttlSeconds ?? 60 * 60);
|
||||
static userFieldsHtmlCache = new Cache('html:user:fields', config.htmlCache?.ttlSeconds ?? 60 * 60);
|
||||
static async encode(u, ctx) {
|
||||
const localUser = ctx.user;
|
||||
const cache = ctx.cache;
|
||||
return cache.locks.acquire(u.id, async ()=>{
|
||||
const cacheHit = cache.accounts.find((p)=>p.id == u.id);
|
||||
if (cacheHit) return cacheHit;
|
||||
const identifier = `${u.id}:${(u.lastFetchedAt ?? u.updatedAt ?? u.createdAt).getTime()}`;
|
||||
let fqn = `${u.username}@${u.host ?? config.domain}`;
|
||||
let acct = u.username;
|
||||
let acctUrl = `https://${u.host || config.host}/@${u.username}`;
|
||||
if (u.host) {
|
||||
acct = `${u.username}@${u.host}`;
|
||||
acctUrl = `https://${u.host}/@${u.username}`;
|
||||
}
|
||||
const aggregateProfile = ctx.userProfileAggregate?.get(u.id);
|
||||
let htmlCacheEntry = undefined;
|
||||
const htmlCacheEntryLock = new AsyncLock();
|
||||
const profile = aggregateProfile !== undefined ? aggregateProfile : UserProfiles.findOneBy({
|
||||
userId: u.id
|
||||
});
|
||||
const bio = this.userBioHtmlCache.fetch(identifier, async ()=>{
|
||||
return htmlCacheEntryLock.acquire(u.id, async ()=>{
|
||||
if (htmlCacheEntry === undefined) htmlCacheEntry = await this.fetchFromCacheWithFallback(u, await profile, ctx);
|
||||
if (htmlCacheEntry === null) {
|
||||
return Promise.resolve(profile).then(async (profile)=>{
|
||||
return MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, u.host).then((p)=>p ?? escapeMFM(profile?.description ?? "")).then((p)=>p !== '<p></p>' ? p : null);
|
||||
});
|
||||
}
|
||||
return htmlCacheEntry?.bio ?? null;
|
||||
});
|
||||
}, true).then((p)=>p ?? '<p></p>');
|
||||
const avatar = u.avatarId ? DriveFiles.getFinalUrlMaybe(u.avatarUrl) ?? DriveFiles.findOneBy({
|
||||
id: u.avatarId
|
||||
}).then((p)=>p?.url ?? Users.getIdenticonUrl(u.id)).then((p)=>DriveFiles.getFinalUrl(p)) : Users.getIdenticonUrl(u.id);
|
||||
const banner = u.bannerId ? DriveFiles.getFinalUrlMaybe(u.bannerUrl) ?? DriveFiles.findOneBy({
|
||||
id: u.bannerId
|
||||
}).then((p)=>p?.url ?? `${config.url}/static-assets/transparent.png`).then((p)=>DriveFiles.getFinalUrl(p)) : `${config.url}/static-assets/transparent.png`;
|
||||
const isFollowedOrSelf = ctx.followedOrSelfAggregate?.get(u.id) ?? (!!localUser && (localUser.id === u.id || Followings.exist({
|
||||
where: {
|
||||
followeeId: u.id,
|
||||
followerId: localUser.id
|
||||
}
|
||||
})));
|
||||
const followersCount = Promise.resolve(profile).then(async (profile)=>{
|
||||
if (profile === null) return u.followersCount;
|
||||
switch(profile.ffVisibility){
|
||||
case "public":
|
||||
return u.followersCount;
|
||||
case "followers":
|
||||
return Promise.resolve(isFollowedOrSelf).then((isFollowedOrSelf)=>isFollowedOrSelf ? u.followersCount : 0);
|
||||
case "private":
|
||||
return localUser?.id === profile.userId ? u.followersCount : 0;
|
||||
}
|
||||
});
|
||||
const followingCount = Promise.resolve(profile).then(async (profile)=>{
|
||||
if (profile === null) return u.followingCount;
|
||||
switch(profile.ffVisibility){
|
||||
case "public":
|
||||
return u.followingCount;
|
||||
case "followers":
|
||||
return Promise.resolve(isFollowedOrSelf).then((isFollowedOrSelf)=>isFollowedOrSelf ? u.followingCount : 0);
|
||||
case "private":
|
||||
return localUser?.id === profile.userId ? u.followingCount : 0;
|
||||
}
|
||||
});
|
||||
const fields = this.userFieldsHtmlCache.fetch(identifier, async ()=>{
|
||||
return htmlCacheEntryLock.acquire(u.id, async ()=>{
|
||||
if (htmlCacheEntry === undefined) htmlCacheEntry = await this.fetchFromCacheWithFallback(u, await profile, ctx);
|
||||
if (htmlCacheEntry === null) {
|
||||
return Promise.resolve(profile).then((profile)=>Promise.all(profile?.fields.map(async (p)=>this.encodeField(p, u.host, profile?.mentions)) ?? []));
|
||||
}
|
||||
return htmlCacheEntry?.fields ?? [];
|
||||
});
|
||||
}, true);
|
||||
return awaitAll({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
acct: acct,
|
||||
fqn: fqn,
|
||||
display_name: u.name || u.username,
|
||||
locked: u.isLocked,
|
||||
created_at: u.createdAt.toISOString(),
|
||||
followers_count: followersCount,
|
||||
following_count: followingCount,
|
||||
statuses_count: u.notesCount,
|
||||
note: bio,
|
||||
url: u.uri ?? acctUrl,
|
||||
avatar: avatar,
|
||||
avatar_static: avatar,
|
||||
header: banner,
|
||||
header_static: banner,
|
||||
emojis: populateEmojis(u.emojis, u.host).then((emoji)=>emoji.map((e)=>EmojiConverter.encode(e))),
|
||||
moved: null,
|
||||
fields: fields,
|
||||
bot: u.isBot,
|
||||
discoverable: u.isExplorable
|
||||
}).then((p)=>{
|
||||
// noinspection ES6MissingAwait
|
||||
UserHelpers.updateUserInBackground(u);
|
||||
cache.accounts.push(p);
|
||||
return p;
|
||||
});
|
||||
});
|
||||
}
|
||||
static async aggregateData(users, ctx) {
|
||||
const user = ctx.user;
|
||||
const targets = unique(users.map((u)=>u.id));
|
||||
const followedOrSelfAggregate = new Map();
|
||||
const userProfileAggregate = new Map();
|
||||
const htmlUserCacheAggregate = ctx.htmlUserCacheAggregate ?? new Map();
|
||||
if (config.htmlCache?.dbFallback) {
|
||||
const htmlUserCacheEntries = await HtmlUserCacheEntries.findBy({
|
||||
userId: In(targets)
|
||||
});
|
||||
for (const target of targets){
|
||||
htmlUserCacheAggregate.set(target, htmlUserCacheEntries.find((n)=>n.userId === target) ?? null);
|
||||
}
|
||||
}
|
||||
if (user) {
|
||||
const targetsWithoutSelf = targets.filter((u)=>u !== user.id);
|
||||
if (targetsWithoutSelf.length > 0) {
|
||||
const followings = await Followings.createQueryBuilder('following').select('following.followeeId').where('following.followerId = :meId', {
|
||||
meId: user.id
|
||||
}).andWhere('following.followeeId IN (:...targets)', {
|
||||
targets: targetsWithoutSelf
|
||||
}).getMany();
|
||||
for (const userId of targetsWithoutSelf){
|
||||
followedOrSelfAggregate.set(userId, !!followings.find((f)=>f.followerId === userId));
|
||||
}
|
||||
}
|
||||
followedOrSelfAggregate.set(user.id, true);
|
||||
}
|
||||
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;
|
||||
ctx.htmlUserCacheAggregate = htmlUserCacheAggregate;
|
||||
}
|
||||
static async aggregateDataByIds(userIds, ctx) {
|
||||
const targets = unique(userIds);
|
||||
const htmlUserCacheAggregate = ctx.htmlUserCacheAggregate ?? new Map();
|
||||
if (config.htmlCache?.dbFallback) {
|
||||
const htmlUserCacheEntries = await HtmlUserCacheEntries.findBy({
|
||||
userId: In(targets)
|
||||
});
|
||||
for (const target of targets){
|
||||
htmlUserCacheAggregate.set(target, htmlUserCacheEntries.find((n)=>n.userId === target) ?? null);
|
||||
}
|
||||
}
|
||||
ctx.htmlUserCacheAggregate = htmlUserCacheAggregate;
|
||||
}
|
||||
static async encodeMany(users, ctx) {
|
||||
await this.aggregateData(users, ctx);
|
||||
const encoded = users.map((u)=>this.encode(u, ctx));
|
||||
return Promise.all(encoded);
|
||||
}
|
||||
static async encodeField(f, host, mentions) {
|
||||
return {
|
||||
name: f.name,
|
||||
value: await MfmHelpers.toHtml(mfm.parse(f.value), mentions, host, true) ?? escapeMFM(f.value),
|
||||
verified_at: f.verified ? new Date().toISOString() : null
|
||||
};
|
||||
}
|
||||
static async fetchFromCacheWithFallback(user, profile, ctx) {
|
||||
if (!config.htmlCache?.dbFallback) return null;
|
||||
let dbHit = ctx.htmlUserCacheAggregate?.get(user.id);
|
||||
if (dbHit === undefined) dbHit = HtmlUserCacheEntries.findOneBy({
|
||||
userId: user.id
|
||||
});
|
||||
return Promise.resolve(dbHit).then((res)=>{
|
||||
if (res === null || res.updatedAt.getTime() !== (user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()) {
|
||||
return this.dbCacheMiss(user, profile, ctx);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
}
|
||||
static async dbCacheMiss(user, profile, ctx) {
|
||||
const identifier = `${user.id}:${(user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()}`;
|
||||
const cache = ctx.cache;
|
||||
return cache.locks.acquire(identifier, async ()=>{
|
||||
const cachedBio = await this.userBioHtmlCache.get(identifier);
|
||||
const cachedFields = await this.userFieldsHtmlCache.get(identifier);
|
||||
if (cachedBio !== undefined && cachedFields !== undefined) {
|
||||
return {
|
||||
bio: cachedBio,
|
||||
fields: cachedFields
|
||||
};
|
||||
}
|
||||
if (profile === undefined) {
|
||||
profile = await UserProfiles.findOneBy({
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
let bio = cachedBio;
|
||||
let fields = cachedFields;
|
||||
if (bio === undefined) {
|
||||
bio = MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, user.host).then((p)=>p ?? escapeMFM(profile?.description ?? "")).then((p)=>p !== '<p></p>' ? p : null);
|
||||
}
|
||||
if (fields === undefined) {
|
||||
fields = Promise.all(profile.fields.map(async (p)=>this.encodeField(p, user.host, profile.mentions)) ?? []);
|
||||
}
|
||||
HtmlUserCacheEntries.upsert({
|
||||
userId: user.id,
|
||||
updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt,
|
||||
bio: await bio,
|
||||
fields: await fields
|
||||
}, [
|
||||
"userId"
|
||||
]);
|
||||
await this.userBioHtmlCache.set(identifier, await bio);
|
||||
await this.userFieldsHtmlCache.set(identifier, await fields);
|
||||
return {
|
||||
bio,
|
||||
fields
|
||||
};
|
||||
});
|
||||
}
|
||||
static async prewarmCache(user, profile, oldProfile) {
|
||||
const identifier = `${user.id}:${(user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()}`;
|
||||
if (profile !== null) {
|
||||
if (config.htmlCache?.dbFallback) {
|
||||
if (profile === undefined) {
|
||||
profile = await UserProfiles.findOneBy({
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
if (oldProfile !== undefined && profile?.fields === oldProfile?.fields && profile?.description === oldProfile?.description) {
|
||||
HtmlUserCacheEntries.update({
|
||||
userId: user.id
|
||||
}, {
|
||||
updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!config.htmlCache?.prewarm) return;
|
||||
if (profile === undefined) {
|
||||
profile = await UserProfiles.findOneBy({
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
if (await this.userBioHtmlCache.get(identifier) === undefined) {
|
||||
const bio = MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, user.host).then((p)=>p ?? escapeMFM(profile?.description ?? "")).then((p)=>p !== '<p></p>' ? p : null);
|
||||
this.userBioHtmlCache.set(identifier, await bio);
|
||||
if (config.htmlCache?.dbFallback) HtmlUserCacheEntries.upsert({
|
||||
userId: user.id,
|
||||
updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt,
|
||||
bio: await bio
|
||||
}, [
|
||||
"userId"
|
||||
]);
|
||||
}
|
||||
if (await this.userFieldsHtmlCache.get(identifier) === undefined) {
|
||||
const fields = await Promise.all(profile.fields.map(async (p)=>this.encodeField(p, user.host, profile.mentions)) ?? []);
|
||||
this.userFieldsHtmlCache.set(identifier, fields);
|
||||
if (config.htmlCache?.dbFallback) HtmlUserCacheEntries.upsert({
|
||||
userId: user.id,
|
||||
updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt,
|
||||
fields: fields
|
||||
}, [
|
||||
"userId"
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
static async prewarmCacheById(userId, oldProfile) {
|
||||
await this.prewarmCache(await getUser(userId), undefined, oldProfile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export class VisibilityConverter {
|
||||
static encode(v) {
|
||||
switch(v){
|
||||
case "public":
|
||||
return v;
|
||||
case "home":
|
||||
return "unlisted";
|
||||
case "followers":
|
||||
return "private";
|
||||
case "specified":
|
||||
return "direct";
|
||||
case "hidden":
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
static decode(v) {
|
||||
switch(v){
|
||||
case "public":
|
||||
return v;
|
||||
case "unlisted":
|
||||
return "home";
|
||||
case "private":
|
||||
return "followers";
|
||||
case "direct":
|
||||
return "specified";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user