server/database/entities/Status.ts

537 lines
12 KiB
TypeScript
Raw Normal View History

/* eslint-disable @typescript-eslint/no-unsafe-member-access */
2023-09-12 22:48:10 +02:00
import { getConfig } from "@config";
import {
UserWithRelations,
fetchRemoteUser,
parseMentionsUris,
userRelations,
userToAPI,
} from "./User";
import { client } from "~database/datasource";
import { LysandPublication, Note } from "~types/lysand/Object";
import { htmlToText } from "html-to-text";
import { getBestContentType } from "@content_types";
import {
Application,
Emoji,
Instance,
Like,
Relationship,
Status,
User,
} from "@prisma/client";
import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji";
import { APIStatus } from "~types/entities/status";
import { applicationToAPI } from "./Application";
2023-09-12 22:48:10 +02:00
const config = getConfig();
export const statusAndUserRelations = {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: true,
},
},
instance: true,
mentions: true,
pinnedBy: true,
_count: {
select: {
replies: true,
},
},
},
},
instance: true,
mentions: true,
pinnedBy: true,
_count: {
select: {
replies: true,
likes: true,
2023-11-12 09:28:06 +01:00
reblogs: true,
},
},
reblog: {
include: {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: true,
},
},
instance: true,
mentions: true,
pinnedBy: true,
_count: {
select: {
replies: true,
},
},
},
},
quotingPost: {
include: {
author: {
include: userRelations,
},
application: true,
emojis: true,
inReplyToPost: {
include: {
author: true,
},
},
instance: true,
mentions: true,
pinnedBy: true,
_count: {
select: {
replies: true,
},
},
},
},
likes: {
include: {
liker: true,
},
},
};
export type StatusWithRelations = Status & {
author: UserWithRelations;
application: Application | null;
emojis: Emoji[];
inReplyToPost:
| (Status & {
author: UserWithRelations;
application: Application | null;
emojis: Emoji[];
inReplyToPost: Status | null;
instance: Instance | null;
mentions: User[];
pinnedBy: User[];
_count: {
replies: number;
};
})
| null;
instance: Instance | null;
mentions: User[];
pinnedBy: User[];
_count: {
replies: number;
likes: number;
2023-11-12 09:28:06 +01:00
reblogs: number;
};
reblog:
| (Status & {
author: UserWithRelations;
application: Application | null;
emojis: Emoji[];
inReplyToPost: Status | null;
instance: Instance | null;
mentions: User[];
pinnedBy: User[];
_count: {
replies: number;
};
})
| null;
quotingPost:
| (Status & {
author: UserWithRelations;
application: Application | null;
emojis: Emoji[];
inReplyToPost: Status | null;
instance: Instance | null;
mentions: User[];
pinnedBy: User[];
_count: {
replies: number;
};
})
| null;
likes: (Like & {
liker: User;
})[];
};
2023-09-12 22:48:10 +02:00
/**
2023-09-28 20:19:21 +02:00
* Represents a status (i.e. a post)
2023-09-12 22:48:10 +02:00
*/
/**
* 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 = (status: Status, user: User | null) => {
if (status.authorId === user?.id) return true;
if (status.visibility === "public") return true;
else if (status.visibility === "unlisted") return true;
else if (status.visibility === "private") {
// @ts-expect-error Prisma TypeScript types dont include relations
return !!(user?.relationships as Relationship[]).find(
rel => rel.id === status.authorId
);
} else {
// @ts-expect-error Prisma TypeScript types dont include relations
return user && (status.mentions as User[]).includes(user);
}
};
export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
// Check if already in database
const existingStatus: StatusWithRelations | null =
await client.status.findFirst({
where: {
uri: uri,
},
include: statusAndUserRelations,
});
if (existingStatus) return existingStatus;
const status = await fetch(uri);
if (status.status === 404) return null;
const body = (await status.json()) as LysandPublication;
const content = getBestContentType(body.contents);
const emojis = await parseEmojis(content?.content || "");
const author = await fetchRemoteUser(body.author);
let replyStatus: Status | null = null;
let quotingStatus: Status | null = null;
if (body.replies_to.length > 0) {
replyStatus = await fetchFromRemote(body.replies_to[0]);
}
if (body.quotes.length > 0) {
quotingStatus = await fetchFromRemote(body.quotes[0]);
}
return await createNewStatus({
account: author,
content: content?.content || "",
content_type: content?.content_type,
application: null,
// TODO: Add visibility
visibility: "public",
spoiler_text: body.subject || "",
uri: body.uri,
sensitive: body.is_sensitive,
emojis: emojis,
mentions: await parseMentionsUris(body.mentions),
reply: replyStatus
? {
status: replyStatus,
user: (replyStatus as any).author,
}
: undefined,
quote: quotingStatus || undefined,
});
};
/**
* Return all the ancestors of this post,
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
export const getAncestors = async (
status: StatusWithRelations,
fetcher: UserWithRelations | null
) => {
const ancestors: StatusWithRelations[] = [];
let currentStatus = status;
while (currentStatus.inReplyToPostId) {
const parent = await client.status.findFirst({
where: {
id: currentStatus.inReplyToPostId,
},
include: statusAndUserRelations,
});
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;
};
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
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
export const getDescendants = async (
status: StatusWithRelations,
fetcher: UserWithRelations | null,
depth = 0
) => {
const descendants: StatusWithRelations[] = [];
const currentStatus = status;
// Fetch all children of children of children recursively calling getDescendants
const children = await client.status.findMany({
where: {
inReplyToPostId: currentStatus.id,
},
include: statusAndUserRelations,
});
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
/**
* Creates a new status and saves it to the database.
* @param data The data for the new status.
* @returns A promise that resolves with the new status.
*/
export const createNewStatus = async (data: {
account: User;
application: Application | null;
content: string;
visibility: APIStatus["visibility"];
sensitive: boolean;
spoiler_text: string;
emojis: Emoji[];
content_type?: string;
uri?: string;
mentions?: User[];
reply?: {
status: Status;
user: User;
};
quote?: Status;
}) => {
// Get people mentioned in the content
const mentionedPeople = [...data.content.matchAll(/@([a-zA-Z0-9_]+)/g)].map(
match => {
2023-10-16 08:04:03 +02:00
return `${config.http.base_url}/users/${match[1]}`;
}
);
let mentions = data.mentions || [];
2023-09-22 03:09:14 +02:00
// Get list of mentioned users
if (mentions.length === 0) {
mentions = await client.user.findMany({
2023-10-28 22:21:04 +02:00
where: {
OR: mentionedPeople.map(person => ({
username: person.split("@")[1],
instance: {
base_url: person.split("@")[2],
},
})),
2023-10-28 22:21:04 +02:00
},
include: userRelations,
2023-10-28 22:21:04 +02:00
});
}
let status = await client.status.create({
data: {
authorId: data.account.id,
applicationId: data.application?.id,
content: data.content,
contentType: data.content_type,
visibility: data.visibility,
sensitive: data.sensitive,
spoilerText: data.spoiler_text,
emojis: {
connect: data.emojis.map(emoji => {
return {
id: emoji.id,
};
}),
},
inReplyToPostId: data.reply?.status.id,
quotingPostId: data.quote?.id,
instanceId: data.account.instanceId || undefined,
isReblog: false,
uri:
data.uri ||
`${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`,
mentions: {
connect: mentions.map(mention => {
return {
id: mention.id,
};
}),
},
},
include: statusAndUserRelations,
});
// Update URI
status = await client.status.update({
where: {
id: status.id,
},
data: {
uri: data.uri || `${config.http.base_url}/statuses/${status.id}`,
},
include: statusAndUserRelations,
});
2023-10-28 22:21:04 +02:00
return status;
};
2023-10-28 22:21:04 +02:00
export const isFavouritedBy = async (status: Status, user: User) => {
return !!(await client.like.findFirst({
where: {
likerId: user.id,
likedId: status.id,
},
}));
};
/**
* Converts this status to an API status.
* @returns A promise that resolves with the API status.
*/
export const statusToAPI = async (
status: StatusWithRelations,
user?: UserWithRelations
): Promise<APIStatus> => {
return {
id: status.id,
in_reply_to_id: status.inReplyToPostId || null,
in_reply_to_account_id: status.inReplyToPost?.authorId || null,
2023-11-20 03:42:40 +01:00
account: userToAPI(status.author),
created_at: new Date(status.createdAt).toISOString(),
application: status.application
2023-11-20 03:42:40 +01:00
? applicationToAPI(status.application)
: null,
card: null,
content: status.content,
2023-11-20 03:42:40 +01:00
emojis: status.emojis.map(emoji => emojiToAPI(emoji)),
2023-11-12 09:28:06 +01:00
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
favourited: !!(status.likes ?? []).find(
like => like.likerId === user?.id
),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
favourites_count: (status.likes ?? []).length,
media_attachments: [],
mentions: [],
language: null,
muted: user
? user.relationships.find(r => r.subjectId == status.authorId)
?.muting || false
: false,
pinned: status.author.pinnedNotes.some(note => note.id === status.id),
// TODO: Add pols
poll: null,
reblog: status.reblog
? await statusToAPI(status.reblog as unknown as StatusWithRelations)
: null,
reblogged: !!(await client.status.findFirst({
where: {
authorId: user?.id,
reblogId: status.id,
},
})),
reblogs_count: await client.status.count({
where: {
reblogId: status.id,
},
}),
replies_count: status._count.replies,
sensitive: status.sensitive,
spoiler_text: status.spoilerText,
tags: [],
uri: `${config.http.base_url}/statuses/${status.id}`,
visibility: "public",
url: `${config.http.base_url}/statuses/${status.id}`,
bookmarked: false,
quote: null,
quote_id: undefined,
};
};
export const statusToLysand = (status: StatusWithRelations): Note => {
return {
type: "Note",
created_at: new Date(status.createdAt).toISOString(),
id: status.id,
author: status.authorId,
uri: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`,
contents: [
{
content: status.content,
content_type: "text/html",
},
{
// Content converted to plaintext
content: htmlToText(status.content),
content_type: "text/plain",
},
],
// TODO: Add attachments
attachments: [],
is_sensitive: status.sensitive,
mentions: status.mentions.map(mention => mention.uri),
quotes: status.quotingPost ? [status.quotingPost.uri] : [],
replies_to: status.inReplyToPostId ? [status.inReplyToPostId] : [],
subject: status.spoilerText,
extensions: {
"org.lysand:custom_emojis": {
emojis: status.emojis.map(emoji => emojiToLysand(emoji)),
},
// TODO: Add polls and reactions
},
};
};