server/database/entities/Status.ts

1309 lines
40 KiB
TypeScript
Raw Normal View History

2024-04-07 07:30:49 +02:00
import { sanitizeHtml } from "@sanitization";
import { config } from "config-manager";
import { htmlToText } from "html-to-text";
import linkifyHtml from "linkify-html";
import linkifyStr from "linkify-string";
import { parse } from "marked";
import type { APIAttachment } from "~types/entities/attachment";
import type { APIStatus } from "~types/entities/status";
2024-04-10 08:57:29 +02:00
import type { Note } from "~types/lysand/Object";
import type * as Lysand from "lysand-types";
import { applicationToAPI } from "./Application";
import {
attachmentFromLysand,
attachmentToAPI,
attachmentToLysand,
} from "./Attachment";
import {
emojiToAPI,
emojiToLysand,
fetchEmoji,
parseEmojis,
type EmojiWithInstance,
} from "./Emoji";
import {
getUserUri,
resolveUser,
resolveWebFinger,
userToAPI,
userExtras,
userRelations,
userExtrasTemplate,
type User,
type UserWithRelations,
type UserWithRelationsAndRelationships,
transformOutputToUserWithRelations,
findManyUsers,
} from "./User";
2024-04-10 07:51:00 +02:00
import { objectToInboxRequest } from "./Federation";
import {
and,
eq,
or,
type InferSelectModel,
sql,
isNotNull,
inArray,
isNull,
} from "drizzle-orm";
import {
status,
type application,
attachment,
type like,
user,
statusToUser,
emojiToStatus,
instance,
} from "~drizzle/schema";
import { db } from "~drizzle/db";
export type Status = InferSelectModel<typeof status>;
export type StatusWithRelations = Status & {
author: UserWithRelations;
mentions: UserWithRelations[];
attachments: InferSelectModel<typeof attachment>[];
reblog: StatusWithoutRecursiveRelations | null;
emojis: EmojiWithInstance[];
likes: InferSelectModel<typeof like>[];
inReplyTo: StatusWithoutRecursiveRelations | null;
quoting: StatusWithoutRecursiveRelations | null;
application: InferSelectModel<typeof application> | null;
reblogCount: number;
likeCount: number;
replyCount: number;
};
2023-09-12 22:48:10 +02:00
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",
),
};
2024-04-11 14:12:16 +02:00
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,
) => {
2024-04-07 07:30:49 +02:00
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;
2024-04-07 07:30:49 +02:00
}
return user && status.mentions.includes(user);
};
export const findManyStatuses = async (
query: Parameters<typeof db.query.status.findMany>[0],
): Promise<StatusWithRelations[]> => {
const output = await db.query.status.findMany({
...query,
with: {
...query?.with,
attachments: {
where: (attachment, { eq }) =>
eq(attachment.statusId, sql`"status"."id"`),
},
2024-04-11 14:12:16 +02:00
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<string, object>)
.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<string, object>)
.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<string, object>)
.emoji as EmojiWithInstance,
),
},
emojis: (post.emojis ?? []).map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as EmojiWithInstance,
),
reblogCount: Number(post.reblogCount),
likeCount: Number(post.likeCount),
replyCount: Number(post.replyCount),
}));
};
export const findFirstStatuses = async (
query: Parameters<typeof db.query.status.findFirst>[0],
): Promise<StatusWithRelations | null> => {
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<string, object>)
.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<string, object>)
.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<string, object>)
.emoji as EmojiWithInstance,
),
},
emojis: (output.emojis ?? []).map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.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<StatusWithRelations> => {
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<UserWithRelations>[],
),
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 (
2024-04-07 07:30:49 +02:00
status: StatusWithRelations,
fetcher: UserWithRelationsAndRelationships | null,
) => {
2024-04-07 07:30:49 +02:00
const ancestors: StatusWithRelations[] = [];
2024-04-07 07:30:49 +02:00
let currentStatus = status;
2024-04-07 07:30:49 +02:00
while (currentStatus.inReplyToPostId) {
const parent = await findFirstStatuses({
where: (status, { eq }) =>
eq(status.id, currentStatus.inReplyToPostId ?? ""),
2024-04-07 07:30:49 +02:00
});
2024-04-07 07:30:49 +02:00
if (!parent) break;
2024-04-07 07:30:49 +02:00
ancestors.push(parent);
2024-04-07 07:30:49 +02:00
currentStatus = parent;
}
2024-04-07 07:30:49 +02:00
// Filter for posts that are viewable by the user
const viewableAncestors = ancestors.filter((ancestor) =>
isViewableByUser(ancestor, fetcher),
);
return viewableAncestors;
};
2023-09-22 03:09:14 +02:00
/**
* 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 (
2024-04-07 07:30:49 +02:00
status: StatusWithRelations,
fetcher: UserWithRelationsAndRelationships | null,
2024-04-07 07:30:49 +02:00
depth = 0,
) => {
2024-04-07 07:30:49 +02:00
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),
2024-04-07 07:30:49 +02:00
});
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;
};
2023-09-22 03:09:14 +02:00
2024-04-10 04:05:02 +02:00
/**
* 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<UserWithRelations[]> => {
2024-04-10 04:05:02 +02:00
const mentionedPeople =
2024-04-11 00:21:42 +02:00
text.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)?/g) ?? [];
2024-04-10 04:05:02 +02:00
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) {
2024-04-11 00:47:44 +02:00
if (person.split("@").length < 2) continue;
const user = await resolveWebFinger(
person.split("@")[1],
person.split("@")[2],
);
if (user) {
finalList.push(user);
}
}
return finalList;
2024-04-10 04:05:02 +02:00
};
2024-04-10 11:33:21 +02:00
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}`,
2024-04-10 11:38:48 +02:00
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri(
mention,
)}">@${mention.username}@${mention.instance?.baseUrl}</a>`,
2024-04-10 11:33:21 +02:00
);
} else {
finalText = finalText.replace(
// Only replace @username if it doesn't have another @ right after
new RegExp(`@${mention.username}(?![a-zA-Z0-9_@])`, "g"),
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri(
mention,
)}">@${mention.username}</a>`,
);
finalText = finalText.replace(
`@${mention.username}@${new URL(config.http.base_url).host}`,
2024-04-10 11:38:48 +02:00
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${getUserUri(
mention,
)}">@${mention.username}</a>`,
2024-04-10 11:33:21 +02:00
);
}
}
return finalText;
};
2024-04-10 07:24:07 +02:00
/**
* Creates a new status and saves it to the database.
* @returns A promise that resolves with the new status.
*/
2024-04-10 04:05:02 +02:00
export const createNewStatus = async (
author: User,
content: Lysand.ContentFormat,
visibility: APIStatus["visibility"],
is_sensitive: boolean,
spoiler_text: string,
emojis: EmojiWithInstance[],
2024-04-10 04:05:02 +02:00
uri?: string,
mentions?: UserWithRelations[],
/** List of IDs of database Attachment objects */
media_attachments?: string[],
inReplyTo?: StatusWithRelations,
quoting?: StatusWithRelations,
): Promise<StatusWithRelations | null> => {
2024-04-10 04:05:02 +02:00
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),
2024-04-10 04:05:02 +02:00
);
} else if (content["text/plain"]) {
// Split by newline and add <p> tags
2024-04-10 11:47:11 +02:00
htmlContent = content["text/plain"].content
2024-04-10 04:05:02 +02:00
.split("\n")
.map((line) => `<p>${line}</p>`)
.join("\n");
} else {
htmlContent = "";
}
2024-04-10 11:33:21 +02:00
// Replace mentions text
htmlContent = await replaceTextMentions(htmlContent, mentions ?? []);
// Linkify
htmlContent = linkifyHtml(htmlContent, {
defaultProtocol: "https",
validate: {
email: () => false,
},
target: "_blank",
rel: "nofollow noopener noreferrer",
});
2024-04-10 04:05:02 +02:00
// 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",
2024-04-12 01:12:23 +02:00
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({
a: emoji.id,
b: newStatus.id,
})
.execute();
}
2024-04-10 04:05:02 +02:00
// Connect mentions
for (const mention of mentions ?? []) {
await db
.insert(statusToUser)
.values({
a: newStatus.id,
b: 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
);
2024-04-10 04:05:02 +02:00
};
2024-04-10 07:24:07 +02:00
export const federateStatus = async (status: StatusWithRelations) => {
const toFederateTo = await getUsersToFederateTo(status);
2024-04-07 07:30:49 +02:00
2024-04-10 07:24:07 +02:00
for (const user of toFederateTo) {
// TODO: Add queue system
2024-04-10 07:51:00 +02:00
const request = await objectToInboxRequest(
statusToLysand(status),
status.author,
user,
);
2024-04-07 07:30:49 +02:00
2024-04-10 07:24:07 +02:00
// Send request
const response = await fetch(request);
2024-04-07 07:30:49 +02:00
2024-04-10 07:24:07 +02:00
if (!response.ok) {
2024-04-10 08:14:33 +02:00
console.error(await response.text());
2024-04-10 07:24:07 +02:00
throw new Error(
`Failed to federate status ${status.id} to ${user.uri}`,
);
}
2024-04-07 07:30:49 +02:00
}
2024-04-10 07:24:07 +02:00
};
2024-04-07 07:30:49 +02:00
export const getUsersToFederateTo = async (
status: StatusWithRelations,
): Promise<UserWithRelations[]> => {
// 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),
),
},
2024-04-10 07:24:07 +02:00
},
});
const fusedUsers = [...mentionedUsers, ...usersThatCanSeePost];
const deduplicatedUsersById = fusedUsers.filter(
(user, index, self) =>
index === self.findIndex((t) => t.id === user.id),
);
return deduplicatedUsersById;
};
2023-10-28 22:21:04 +02:00
export const editStatus = async (
statusToEdit: StatusWithRelations,
2024-04-07 07:30:49 +02:00
data: {
content: string;
visibility?: APIStatus["visibility"];
sensitive: boolean;
spoiler_text: string;
emojis?: EmojiWithInstance[];
2024-04-07 07:30:49 +02:00
content_type?: string;
uri?: string;
mentions?: User[];
media_attachments?: string[];
},
): Promise<StatusWithRelations | null> => {
const mentions = await parseTextMentions(data.content);
2024-04-07 07:30:49 +02:00
// 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 <p> tags
formattedContent = formattedContent
.split("\n")
.map((line) => `<p>${line}</p>`)
.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({
a: emoji.id,
b: updated.id,
})
.execute();
}
// Connect mentions
for (const mention of mentions) {
await db
.insert(statusToUser)
.values({
a: updated.id,
b: mention.id,
})
.execute();
}
// Set attachment parents
await db
.update(attachment)
.set({
statusId: updated.id,
})
.where(inArray(attachment.id, data.media_attachments ?? []));
2024-04-07 07:30:49 +02:00
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)),
2024-04-07 07:30:49 +02:00
}));
};
/**
* 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<APIStatus> => {
const wasPinnedByUser = userFetching
? !!(await db.query.statusToUser.findFirst({
where: (relation, { and, eq }) =>
and(
eq(relation.a, statusToConvert.id),
eq(relation.b, 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}`,
);
}
2024-04-07 07:30:49 +02:00
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)
2024-04-07 07:30:49 +02:00
: null,
card: null,
content: replacedContent,
emojis: statusToConvert.emojis.map((emoji) => emojiToAPI(emoji)),
favourited: !!(statusToConvert.likes ?? []).find(
(like) => like.likerId === userFetching?.id,
2024-04-07 07:30:49 +02:00
),
favourites_count: (statusToConvert.likes ?? []).length,
media_attachments: (statusToConvert.attachments ?? []).map(
2024-04-07 07:30:49 +02:00
(a) => attachmentToAPI(a) as APIAttachment,
),
mentions: statusToConvert.mentions.map((mention) => userToAPI(mention)),
2024-04-07 07:30:49 +02:00
language: null,
muted: wasMutedByUser,
pinned: wasPinnedByUser,
2024-04-08 05:28:18 +02:00
// TODO: Add polls
2024-04-07 07:30:49 +02:00
poll: null,
reblog: statusToConvert.reblog
? await statusToAPI(
statusToConvert.reblog as unknown as StatusWithRelations,
userFetching,
)
2024-04-07 07:30:49 +02:00
: null,
reblogged: wasRebloggedByUser,
reblogs_count: statusToConvert.reblogCount,
replies_count: statusToConvert.replyCount,
sensitive: statusToConvert.sensitive,
spoiler_text: statusToConvert.spoilerText,
2024-04-07 07:30:49 +02:00
tags: [],
uri:
statusToConvert.uri ||
new URL(
`/@${statusToConvert.author.username}/${statusToConvert.id}`,
config.http.base_url,
).toString(),
2024-04-12 01:12:23 +02:00
visibility: statusToConvert.visibility as APIStatus["visibility"],
url:
statusToConvert.uri ||
new URL(
`/@${statusToConvert.author.username}/${statusToConvert.id}`,
config.http.base_url,
).toString(),
2024-04-07 07:30:49 +02:00
bookmarked: false,
quote: statusToConvert.quoting
2024-04-07 07:30:49 +02:00
? await statusToAPI(
statusToConvert.quoting as unknown as StatusWithRelations,
userFetching,
2024-04-07 07:30:49 +02:00
)
: null,
quote_id: statusToConvert.quotingPostId || undefined,
2024-04-07 07:30:49 +02:00
};
};
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 => {
2024-04-07 07:30:49 +02:00
return {
type: "Note",
created_at: new Date(status.createdAt).toISOString(),
id: status.id,
author: getUserUri(status.author),
uri: getStatusUri(status) ?? "",
content: {
"text/html": {
2024-04-07 07:30:49 +02:00
content: status.content,
},
"text/plain": {
2024-04-07 07:30:49 +02:00
content: htmlToText(status.content),
},
},
attachments: (status.attachments ?? []).map((attachment) =>
attachmentToLysand(attachment),
),
2024-04-07 07:30:49 +02:00
is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) => mention.uri || ""),
quotes: getStatusUri(status.quoting) ?? undefined,
replies_to: getStatusUri(status.inReplyTo) ?? undefined,
2024-04-07 07:30:49 +02:00
subject: status.spoilerText,
visibility: status.visibility as Lysand.Visibility,
2024-04-07 07:30:49 +02:00
extensions: {
"org.lysand:custom_emojis": {
emojis: status.emojis.map((emoji) => emojiToLysand(emoji)),
},
// TODO: Add polls and reactions
},
};
};