=> {
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
},
};
};