import { sanitizeHtml } from "@sanitization"; import { config } from "config-manager"; import { type InferSelectModel, and, eq, inArray, isNotNull, isNull, or, sql, } from "drizzle-orm"; import { htmlToText } from "html-to-text"; import linkifyHtml from "linkify-html"; import linkifyStr from "linkify-string"; import type * as Lysand from "lysand-types"; import { parse } from "marked"; import { db } from "~drizzle/db"; import { type application, attachment, emojiToStatus, instance, type like, status, statusToMentions, user, } from "~drizzle/schema"; import type { APIAttachment } from "~types/entities/attachment"; import type { APIStatus } from "~types/entities/status"; import type { Note } from "~types/lysand/Object"; import { applicationToAPI } from "./Application"; import { attachmentFromLysand, attachmentToAPI, attachmentToLysand, } from "./Attachment"; import { type EmojiWithInstance, emojiToAPI, emojiToLysand, fetchEmoji, parseEmojis, } from "./Emoji"; import { objectToInboxRequest } from "./Federation"; import { type User, type UserWithRelations, type UserWithRelationsAndRelationships, findManyUsers, getUserUri, resolveUser, resolveWebFinger, transformOutputToUserWithRelations, userExtras, userExtrasTemplate, userRelations, userToAPI, } from "./User"; export type Status = InferSelectModel; export type StatusWithRelations = Status & { author: UserWithRelations; mentions: UserWithRelations[]; attachments: InferSelectModel[]; reblog: StatusWithoutRecursiveRelations | null; emojis: EmojiWithInstance[]; likes: InferSelectModel[]; inReplyTo: StatusWithoutRecursiveRelations | null; quoting: StatusWithoutRecursiveRelations | null; application: InferSelectModel | null; reblogCount: number; likeCount: number; replyCount: number; }; export type StatusWithoutRecursiveRelations = Omit< StatusWithRelations, | "inReplyTo" | "quoting" | "reblog" | "reblogCount" | "likeCount" | "replyCount" >; export const statusExtras = { reblogCount: sql`(SELECT COUNT(*) FROM "Status" "status" WHERE "status"."reblogId" = "status".id)`.as( "reblog_count", ), likeCount: sql`(SELECT COUNT(*) FROM "Like" "like" WHERE "like"."likedId" = "status".id)`.as( "like_count", ), replyCount: sql`(SELECT COUNT(*) FROM "Status" "status" WHERE "status"."inReplyToPostId" = "status".id)`.as( "reply_count", ), }; export const statusExtrasTemplate = (name: string) => ({ // @ts-ignore reblogCount: sql([ `(SELECT COUNT(*) FROM "Status" "status" WHERE "status"."reblogId" = ${name}.id)`, ]).as("reblog_count"), // @ts-ignore likeCount: sql([ `(SELECT COUNT(*) FROM "Like" "like" WHERE "like"."likedId" = ${name}.id)`, ]).as("like_count"), // @ts-ignore replyCount: sql([ `(SELECT COUNT(*) FROM "Status" "status" WHERE "status"."inReplyToPostId" = ${name}.id)`, ]).as("reply_count"), }); /** * Returns whether this status is viewable by a user. * @param user The user to check. * @returns Whether this status is viewable by the user. */ export const isViewableByUser = async ( status: StatusWithRelations, user: UserWithRelations | null, ) => { if (status.authorId === user?.id) return true; if (status.visibility === "public") return true; if (status.visibility === "unlisted") return true; if (status.visibility === "private") { return user ? await db.query.relationship.findFirst({ where: (relationship, { and, eq }) => and( eq(relationship.ownerId, user?.id), eq(relationship.subjectId, status.authorId), eq(relationship.following, true), ), }) : false; } return user && status.mentions.includes(user); }; export const findManyStatuses = async ( query: Parameters[0], ): Promise => { const output = await db.query.status.findMany({ ...query, with: { ...query?.with, attachments: { where: (attachment, { eq }) => eq(attachment.statusId, sql`"status"."id"`), }, emojis: { with: { emoji: { with: { instance: true, }, }, }, }, author: { with: { ...userRelations, }, extras: userExtrasTemplate("status_author"), }, mentions: { with: { user: { with: userRelations, extras: userExtrasTemplate("status_mentions_user"), }, }, }, reblog: { with: { attachments: true, emojis: { with: { emoji: { with: { instance: true, }, }, }, }, likes: true, application: true, mentions: { with: { user: { with: userRelations, extras: userExtrasTemplate( "status_reblog_mentions_user", ), }, }, }, author: { with: { ...userRelations, }, extras: userExtrasTemplate("status_reblog_author"), }, }, }, inReplyTo: { with: { attachments: true, emojis: { with: { emoji: { with: { instance: true, }, }, }, }, likes: true, application: true, mentions: { with: { user: { with: userRelations, extras: userExtrasTemplate( "status_inReplyTo_mentions_user", ), }, }, }, author: { with: { ...userRelations, }, extras: userExtrasTemplate("status_inReplyTo_author"), }, }, }, quoting: { with: { attachments: true, emojis: { with: { emoji: { with: { instance: true, }, }, }, }, likes: true, application: true, mentions: { with: { user: { with: userRelations, extras: userExtrasTemplate( "status_quoting_mentions_user", ), }, }, }, author: { with: { ...userRelations, }, extras: userExtrasTemplate("status_quoting_author"), }, }, }, }, extras: { ...statusExtras, ...query?.extras, }, }); return output.map((post) => ({ ...post, author: transformOutputToUserWithRelations(post.author), mentions: post.mentions.map( (mention) => mention.user && transformOutputToUserWithRelations(mention.user), ), reblog: post.reblog && { ...post.reblog, author: transformOutputToUserWithRelations(post.reblog.author), mentions: post.reblog.mentions.map( (mention) => mention.user && transformOutputToUserWithRelations(mention.user), ), emojis: post.reblog.emojis.map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), }, inReplyTo: post.inReplyTo && { ...post.inReplyTo, author: transformOutputToUserWithRelations(post.inReplyTo.author), mentions: post.inReplyTo.mentions.map( (mention) => mention.user && transformOutputToUserWithRelations(mention.user), ), emojis: post.inReplyTo.emojis.map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), }, quoting: post.quoting && { ...post.quoting, author: transformOutputToUserWithRelations(post.quoting.author), mentions: post.quoting.mentions.map( (mention) => mention.user && transformOutputToUserWithRelations(mention.user), ), emojis: post.quoting.emojis.map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), }, emojis: (post.emojis ?? []).map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), reblogCount: Number(post.reblogCount), likeCount: Number(post.likeCount), replyCount: Number(post.replyCount), })); }; export const findFirstStatuses = async ( query: Parameters[0], ): Promise => { const output = await db.query.status.findFirst({ ...query, with: { ...query?.with, attachments: { where: (attachment, { eq }) => eq(attachment.statusId, sql`"status"."id"`), }, emojis: { with: { emoji: { with: { instance: true, }, }, }, }, author: { with: { ...userRelations, }, extras: userExtrasTemplate("status_author"), }, mentions: { with: { user: { with: userRelations, extras: userExtrasTemplate("status_mentions_user"), }, }, }, reblog: { with: { attachments: true, emojis: { with: { emoji: { with: { instance: true, }, }, }, }, likes: true, application: true, mentions: { with: { user: { with: userRelations, extras: userExtrasTemplate( "status_reblog_mentions_user", ), }, }, }, author: { with: { ...userRelations, }, extras: userExtrasTemplate("status_reblog_author"), }, }, }, inReplyTo: { with: { attachments: true, emojis: { with: { emoji: { with: { instance: true, }, }, }, }, likes: true, application: true, mentions: { with: { user: { with: userRelations, extras: userExtrasTemplate( "status_inReplyTo_mentions_user", ), }, }, }, author: { with: { ...userRelations, }, extras: userExtrasTemplate("status_inReplyTo_author"), }, }, }, quoting: { with: { attachments: true, emojis: { with: { emoji: { with: { instance: true, }, }, }, }, likes: true, application: true, mentions: { with: { user: { with: userRelations, extras: userExtrasTemplate( "status_quoting_mentions_user", ), }, }, }, author: { with: { ...userRelations, }, extras: userExtrasTemplate("status_quoting_author"), }, }, }, }, extras: { ...statusExtras, ...query?.extras, }, }); if (!output) return null; return { ...output, author: transformOutputToUserWithRelations(output.author), mentions: output.mentions.map((mention) => transformOutputToUserWithRelations(mention.user), ), reblog: output.reblog && { ...output.reblog, author: transformOutputToUserWithRelations(output.reblog.author), mentions: output.reblog.mentions.map( (mention) => mention.user && transformOutputToUserWithRelations(mention.user), ), emojis: output.reblog.emojis.map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), }, inReplyTo: output.inReplyTo && { ...output.inReplyTo, author: transformOutputToUserWithRelations(output.inReplyTo.author), mentions: output.inReplyTo.mentions.map( (mention) => mention.user && transformOutputToUserWithRelations(mention.user), ), emojis: output.inReplyTo.emojis.map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), }, quoting: output.quoting && { ...output.quoting, author: transformOutputToUserWithRelations(output.quoting.author), mentions: output.quoting.mentions.map( (mention) => mention.user && transformOutputToUserWithRelations(mention.user), ), emojis: output.quoting.emojis.map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), }, emojis: (output.emojis ?? []).map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), reblogCount: Number(output.reblogCount), likeCount: Number(output.likeCount), replyCount: Number(output.replyCount), }; }; export const resolveStatus = async ( uri?: string, providedNote?: Lysand.Note, ): Promise => { if (!uri && !providedNote) { throw new Error("No URI or note provided"); } const foundStatus = await findFirstStatuses({ where: (status, { eq }) => eq(status.uri, uri ?? providedNote?.uri ?? ""), }); if (foundStatus) return foundStatus; let note: Lysand.Note | null = providedNote ?? null; if (uri) { if (!URL.canParse(uri)) { throw new Error(`Invalid URI to parse ${uri}`); } const response = await fetch(uri, { method: "GET", headers: { Accept: "application/json", }, }); note = (await response.json()) as Lysand.Note; } if (!note) { throw new Error("No note was able to be fetched"); } if (note.type !== "Note") { throw new Error("Invalid object type"); } if (!note.author) { throw new Error("Invalid object author"); } const author = await resolveUser(note.author); if (!author) { throw new Error("Invalid object author"); } const attachments = []; for (const attachment of note.attachments ?? []) { const resolvedAttachment = await attachmentFromLysand(attachment).catch( (e) => { console.error(e); return null; }, ); if (resolvedAttachment) { attachments.push(resolvedAttachment); } } const emojis = []; for (const emoji of note.extensions?.["org.lysand:custom_emojis"]?.emojis ?? []) { const resolvedEmoji = await fetchEmoji(emoji).catch((e) => { console.error(e); return null; }); if (resolvedEmoji) { emojis.push(resolvedEmoji); } } const createdStatus = await createNewStatus( author, note.content ?? { "text/plain": { content: "", }, }, note.visibility as APIStatus["visibility"], note.is_sensitive ?? false, note.subject ?? "", emojis, note.uri, await Promise.all( (note.mentions ?? []) .map((mention) => resolveUser(mention)) .filter( (mention) => mention !== null, ) as Promise[], ), attachments.map((a) => a.id), note.replies_to ? await resolveStatus(note.replies_to) : undefined, note.quotes ? await resolveStatus(note.quotes) : undefined, ); if (!createdStatus) { throw new Error("Failed to create status"); } return createdStatus; }; /** * Return all the ancestors of this post, */ export const getAncestors = async ( status: StatusWithRelations, fetcher: UserWithRelationsAndRelationships | null, ) => { const ancestors: StatusWithRelations[] = []; let currentStatus = status; while (currentStatus.inReplyToPostId) { const parent = await findFirstStatuses({ where: (status, { eq }) => eq(status.id, currentStatus.inReplyToPostId ?? ""), }); if (!parent) break; ancestors.push(parent); currentStatus = parent; } // Filter for posts that are viewable by the user const viewableAncestors = ancestors.filter((ancestor) => isViewableByUser(ancestor, fetcher), ); return viewableAncestors; }; /** * Return all the descendants of this post (recursive) * Temporary implementation, will be replaced with a recursive SQL query when Prisma adds support for it */ export const getDescendants = async ( status: StatusWithRelations, fetcher: UserWithRelationsAndRelationships | null, depth = 0, ) => { const descendants: StatusWithRelations[] = []; const currentStatus = status; // Fetch all children of children of children recursively calling getDescendants const children = await findManyStatuses({ where: (status, { eq }) => eq(status.inReplyToPostId, currentStatus.id), }); for (const child of children) { descendants.push(child); if (depth < 20) { const childDescendants = await getDescendants( child, fetcher, depth + 1, ); descendants.push(...childDescendants); } } // Filter for posts that are viewable by the user const viewableDescendants = descendants.filter((descendant) => isViewableByUser(descendant, fetcher), ); return viewableDescendants; }; /** * Get people mentioned in the content (match @username or @username@domain.com mentions) * @param text The text to parse mentions from. * @returns An array of users mentioned in the text. */ export const parseTextMentions = async ( text: string, ): Promise => { const mentionedPeople = text.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)?/g) ?? []; if (mentionedPeople.length === 0) return []; const remoteUsers = mentionedPeople.filter( (person) => person.split("@").length === 3 && person.split("@")[2] !== new URL(config.http.base_url).host, ); const localUsers = mentionedPeople.filter( (person) => person.split("@").length <= 2 || person.split("@")[2] === new URL(config.http.base_url).host, ); const foundRemote = remoteUsers.length > 0 ? await db .select({ id: user.id, username: user.username, baseUrl: instance.baseUrl, }) .from(user) .innerJoin(instance, eq(user.instanceId, instance.id)) .where( or( ...remoteUsers.map((person) => and( eq(user.username, person.split("@")[1]), eq(instance.baseUrl, person.split("@")[2]), ), ), ), ) : []; const foundLocal = localUsers.length > 0 ? await db .select({ id: user.id, }) .from(user) .where( and( inArray( user.username, localUsers.map((person) => person.split("@")[1]), ), isNull(user.instanceId), ), ) : []; const combinedFound = [ ...foundLocal.map((user) => user.id), ...foundRemote.map((user) => user.id), ]; const finalList = combinedFound.length > 0 ? await findManyUsers({ where: (user, { inArray }) => inArray(user.id, combinedFound), }) : []; const notFoundRemote = remoteUsers.filter( (person) => !foundRemote.find( (user) => user.username === person.split("@")[1] && user.baseUrl === person.split("@")[2], ), ); // Attempt to resolve mentions that were not found for (const person of notFoundRemote) { if (person.split("@").length < 2) continue; const user = await resolveWebFinger( person.split("@")[1], person.split("@")[2], ); if (user) { finalList.push(user); } } return finalList; }; export const replaceTextMentions = async ( text: string, mentions: UserWithRelations[], ) => { let finalText = text; for (const mention of mentions) { // Replace @username and @username@domain if (mention.instanceId) { finalText = finalText.replace( `@${mention.username}@${mention.instance?.baseUrl}`, `@${mention.username}@${mention.instance?.baseUrl}`, ); } else { finalText = finalText.replace( // Only replace @username if it doesn't have another @ right after new RegExp(`@${mention.username}(?![a-zA-Z0-9_@])`, "g"), `@${mention.username}`, ); finalText = finalText.replace( `@${mention.username}@${new URL(config.http.base_url).host}`, `@${mention.username}`, ); } } return finalText; }; /** * Creates a new status and saves it to the database. * @returns A promise that resolves with the new status. */ export const createNewStatus = async ( author: User, content: Lysand.ContentFormat, visibility: APIStatus["visibility"], is_sensitive: boolean, spoiler_text: string, emojis: EmojiWithInstance[], uri?: string, mentions?: UserWithRelations[], /** List of IDs of database Attachment objects */ media_attachments?: string[], inReplyTo?: StatusWithRelations, quoting?: StatusWithRelations, ): Promise => { let htmlContent: string; if (content["text/html"]) { htmlContent = content["text/html"].content; } else if (content["text/markdown"]) { htmlContent = await sanitizeHtml( await parse(content["text/markdown"].content), ); } else if (content["text/plain"]) { // Split by newline and add

tags htmlContent = content["text/plain"].content .split("\n") .map((line) => `

${line}

`) .join("\n"); } else { htmlContent = ""; } // Replace mentions text htmlContent = await replaceTextMentions(htmlContent, mentions ?? []); // Linkify htmlContent = linkifyHtml(htmlContent, { defaultProtocol: "https", validate: { email: () => false, }, target: "_blank", rel: "nofollow noopener noreferrer", }); // Parse emojis and fuse with existing emojis let foundEmojis = emojis; if (author.instanceId === null) { const parsedEmojis = await parseEmojis(htmlContent); // Fuse and deduplicate foundEmojis = [...emojis, ...parsedEmojis].filter( (emoji, index, self) => index === self.findIndex((t) => t.id === emoji.id), ); } const newStatus = ( await db .insert(status) .values({ authorId: author.id, content: htmlContent, contentSource: content["text/plain"]?.content || content["text/markdown"]?.content || "", contentType: "text/html", visibility, sensitive: is_sensitive, spoilerText: spoiler_text, instanceId: author.instanceId || null, uri: uri || null, inReplyToPostId: inReplyTo?.id, quotingPostId: quoting?.id, updatedAt: new Date().toISOString(), }) .returning() )[0]; // Connect emojis for (const emoji of foundEmojis) { await db .insert(emojiToStatus) .values({ emojiId: emoji.id, statusId: newStatus.id, }) .execute(); } // Connect mentions for (const mention of mentions ?? []) { await db .insert(statusToMentions) .values({ statusId: newStatus.id, userId: mention.id, }) .execute(); } // Set attachment parents if (media_attachments && media_attachments.length > 0) { await db .update(attachment) .set({ statusId: newStatus.id, }) .where(inArray(attachment.id, media_attachments)); } return ( (await findFirstStatuses({ where: (status, { eq }) => eq(status.id, newStatus.id), })) || null ); }; export const federateStatus = async (status: StatusWithRelations) => { const toFederateTo = await getUsersToFederateTo(status); for (const user of toFederateTo) { // TODO: Add queue system const request = await objectToInboxRequest( statusToLysand(status), status.author, user, ); // Send request const response = await fetch(request); if (!response.ok) { console.error(await response.text()); throw new Error( `Failed to federate status ${status.id} to ${user.uri}`, ); } } }; export const getUsersToFederateTo = async ( status: StatusWithRelations, ): Promise => { // Mentioned users const mentionedUsers = status.mentions.length > 0 ? await findManyUsers({ where: (user, { or, and, isNotNull, eq, inArray }) => and( isNotNull(user.instanceId), inArray( user.id, status.mentions.map((mention) => mention.id), ), ), with: { ...userRelations, }, }) : []; const usersThatCanSeePost = await findManyUsers({ where: (user, { isNotNull }) => isNotNull(user.instanceId), with: { ...userRelations, relationships: { where: (relationship, { eq, and }) => and( eq(relationship.subjectId, user.id), eq(relationship.following, true), ), }, }, }); const fusedUsers = [...mentionedUsers, ...usersThatCanSeePost]; const deduplicatedUsersById = fusedUsers.filter( (user, index, self) => index === self.findIndex((t) => t.id === user.id), ); return deduplicatedUsersById; }; export const editStatus = async ( statusToEdit: StatusWithRelations, data: { content: string; visibility?: APIStatus["visibility"]; sensitive: boolean; spoiler_text: string; emojis?: EmojiWithInstance[]; content_type?: string; uri?: string; mentions?: User[]; media_attachments?: string[]; }, ): Promise => { const mentions = await parseTextMentions(data.content); // Parse emojis const emojis = await parseEmojis(data.content); data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis; let formattedContent = ""; // Get HTML version of content if (data.content_type === "text/markdown") { formattedContent = linkifyHtml( await sanitizeHtml(await parse(data.content)), ); } else if (data.content_type === "text/x.misskeymarkdown") { // Parse as MFM } else { // Parse as plaintext formattedContent = linkifyStr(data.content); // Split by newline and add

tags formattedContent = formattedContent .split("\n") .map((line) => `

${line}

`) .join("\n"); } const updated = ( await db .update(status) .set({ content: formattedContent, contentSource: data.content, contentType: data.content_type, visibility: data.visibility, sensitive: data.sensitive, spoilerText: data.spoiler_text, }) .where(eq(status.id, statusToEdit.id)) .returning() )[0]; // Connect emojis for (const emoji of data.emojis) { await db .insert(emojiToStatus) .values({ emojiId: emoji.id, statusId: updated.id, }) .execute(); } // Connect mentions for (const mention of mentions) { await db .insert(statusToMentions) .values({ statusId: updated.id, userId: mention.id, }) .execute(); } // Set attachment parents await db .update(attachment) .set({ statusId: updated.id, }) .where(inArray(attachment.id, data.media_attachments ?? [])); return ( (await findFirstStatuses({ where: (status, { eq }) => eq(status.id, updated.id), })) || null ); }; export const isFavouritedBy = async (status: Status, user: User) => { return !!(await db.query.like.findFirst({ where: (like, { and, eq }) => and(eq(like.likerId, user.id), eq(like.likedId, status.id)), })); }; /** * Converts this status to an API status. * @returns A promise that resolves with the API status. */ export const statusToAPI = async ( statusToConvert: StatusWithRelations, userFetching?: UserWithRelations, ): Promise => { const wasPinnedByUser = userFetching ? !!(await db.query.userPinnedNotes.findFirst({ where: (relation, { and, eq }) => and( eq(relation.statusId, statusToConvert.id), eq(relation.userId, userFetching?.id), ), })) : false; const wasRebloggedByUser = userFetching ? !!(await db.query.status.findFirst({ where: (status, { eq, and }) => and( eq(status.authorId, userFetching?.id), eq(status.reblogId, statusToConvert.id), ), })) : false; const wasMutedByUser = userFetching ? !!(await db.query.relationship.findFirst({ where: (relationship, { and, eq }) => and( eq(relationship.ownerId, userFetching.id), eq(relationship.subjectId, statusToConvert.authorId), eq(relationship.muting, true), ), })) : false; // Convert mentions of local users from @username@host to @username const mentionedLocalUsers = statusToConvert.mentions.filter( (mention) => mention.instanceId === null, ); let replacedContent = statusToConvert.content; for (const mention of mentionedLocalUsers) { replacedContent = replacedContent.replace( new RegExp( `@${mention.username}@${new URL(config.http.base_url).host}`, "g", ), `@${mention.username}`, ); } return { id: statusToConvert.id, in_reply_to_id: statusToConvert.inReplyToPostId || null, in_reply_to_account_id: statusToConvert.inReplyTo?.authorId || null, account: userToAPI(statusToConvert.author), created_at: new Date(statusToConvert.createdAt).toISOString(), application: statusToConvert.application ? applicationToAPI(statusToConvert.application) : null, card: null, content: replacedContent, emojis: statusToConvert.emojis.map((emoji) => emojiToAPI(emoji)), favourited: !!(statusToConvert.likes ?? []).find( (like) => like.likerId === userFetching?.id, ), favourites_count: (statusToConvert.likes ?? []).length, media_attachments: (statusToConvert.attachments ?? []).map( (a) => attachmentToAPI(a) as APIAttachment, ), mentions: statusToConvert.mentions.map((mention) => userToAPI(mention)), language: null, muted: wasMutedByUser, pinned: wasPinnedByUser, // TODO: Add polls poll: null, reblog: statusToConvert.reblog ? await statusToAPI( statusToConvert.reblog as unknown as StatusWithRelations, userFetching, ) : null, reblogged: wasRebloggedByUser, reblogs_count: statusToConvert.reblogCount, replies_count: statusToConvert.replyCount, sensitive: statusToConvert.sensitive, spoiler_text: statusToConvert.spoilerText, tags: [], uri: statusToConvert.uri || new URL( `/@${statusToConvert.author.username}/${statusToConvert.id}`, config.http.base_url, ).toString(), visibility: statusToConvert.visibility as APIStatus["visibility"], url: statusToConvert.uri || new URL( `/@${statusToConvert.author.username}/${statusToConvert.id}`, config.http.base_url, ).toString(), bookmarked: false, quote: statusToConvert.quoting ? await statusToAPI( statusToConvert.quoting as unknown as StatusWithRelations, userFetching, ) : null, quote_id: statusToConvert.quotingPostId || undefined, }; }; export const getStatusUri = (status?: Status | null) => { if (!status) return undefined; return ( status.uri || new URL(`/objects/note/${status.id}`, config.http.base_url).toString() ); }; export const statusToLysand = (status: StatusWithRelations): Lysand.Note => { return { type: "Note", created_at: new Date(status.createdAt).toISOString(), id: status.id, author: getUserUri(status.author), uri: getStatusUri(status) ?? "", content: { "text/html": { content: status.content, }, "text/plain": { content: htmlToText(status.content), }, }, attachments: (status.attachments ?? []).map((attachment) => attachmentToLysand(attachment), ), is_sensitive: status.sensitive, mentions: status.mentions.map((mention) => mention.uri || ""), quotes: getStatusUri(status.quoting) ?? undefined, replies_to: getStatusUri(status.inReplyTo) ?? undefined, subject: status.spoilerText, visibility: status.visibility as Lysand.Visibility, extensions: { "org.lysand:custom_emojis": { emojis: status.emojis.map((emoji) => emojiToLysand(emoji)), }, // TODO: Add polls and reactions }, }; };