diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts b/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts
new file mode 100644
index 00000000..1fc91b08
--- /dev/null
+++ b/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts
@@ -0,0 +1,108 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+
+import { route, sendMessage } from "@spacebar/api";
+import { Message, Channel, emitEvent, User, MessageUpdateEvent } from "@spacebar/util";
+import { MessageThreadCreationSchema, ChannelType, MessageType } from "@spacebar/schemas";
+
+import { Request, Response, Router } from "express";
+
+const router = Router({ mergeParams: true });
+
+// TODO: public read receipts & privacy scoping
+// TODO: send read state event to all channel members
+// TODO: advance-only notification cursor
+
+router.post(
+ "/",
+ route({
+ requestBody: "MessageThreadCreationSchema",
+ responses: {
+ 200: {},
+ 403: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+ const body = req.body as MessageThreadCreationSchema;
+ const message = await Message.findOneOrFail({
+ where: { id: message_id, channel_id },
+ relations: ["guild"],
+ });
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ const user = await User.findOneOrFail({ where: { id: req.user_id } });
+ const thread = await Channel.createChannel(
+ {
+ id: message.id,
+ owner: user,
+ parent: channel,
+ guild: channel.guild,
+ member_count: 1,
+ message_count: 1,
+ total_message_sent: 1,
+ name: body.name,
+ guild_id: channel.guild_id,
+ rate_limit_per_user: body.rate_limit_per_user,
+ type: ChannelType.GUILD_PUBLIC_THREAD,
+ thread_metadata: {
+ archived: false,
+ auto_archive_duration: body.auto_archive_duration || channel.default_auto_archive_duration || 4320,
+ archive_timestamp: new Date().toISOString(),
+ locked: false,
+ create_timestamp: new Date().toISOString(),
+ },
+ },
+ void 0,
+ { skipPermissionCheck: true, keepId: true, skipEventEmit: true },
+ );
+ message.thread = thread;
+ message.flags ||= 1 << 5;
+ await sendMessage({
+ channel_id: thread.id,
+ type: MessageType.THREAD_STARTER_MESSAGE,
+ message_reference: {
+ message_id: message.id,
+ channel_id: channel.id,
+ guild_id: channel.guild_id,
+ },
+ author_id: user.id,
+ });
+ await Promise.all([
+ emitEvent({
+ event: "THREAD_CREATE",
+ channel_id,
+ data: {
+ ...thread.toJSON(),
+ newly_created: true,
+ },
+ }),
+ message.save(),
+ emitEvent({
+ event: "MESSAGE_UPDATE",
+ channel_id: message.channel_id,
+ data: message.toJSON(),
+ } as MessageUpdateEvent),
+ ]);
+
+ return res.json(thread.toJSON());
+ },
+);
+
+export default router;
diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts
index 6b18e2c9..d8ff0f10 100644
--- a/src/api/routes/channels/#channel_id/messages/index.ts
+++ b/src/api/routes/channels/#channel_id/messages/index.ts
@@ -137,6 +137,7 @@ router.get(
sticker_items: true,
attachments: true,
},
+ thread: true,
},
};
diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts
index 766abd13..5d1b9fed 100644
--- a/src/api/util/handlers/Message.ts
+++ b/src/api/util/handlers/Message.ts
@@ -234,7 +234,8 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> {
if (opts.message_reference.type != 1) {
if (opts.message_reference.guild_id !== channel.guild_id) throw new HTTPError("You can only reference messages from this guild");
- if (opts.message_reference.channel_id !== opts.channel_id) throw new HTTPError("You can only reference messages from this channel");
+ if (opts.message_reference.channel_id !== opts.channel_id && opts.type !== MessageType.THREAD_STARTER_MESSAGE)
+ throw new HTTPError("You can only reference messages from this channel");
}
message.message_reference = opts.message_reference;
@@ -254,14 +255,22 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> {
},
});
- if (message.referenced_message.channel_id && message.referenced_message.channel_id !== opts.message_reference.channel_id)
+ if (
+ message.referenced_message.channel_id &&
+ message.referenced_message.channel_id !== opts.message_reference.channel_id &&
+ opts.type !== MessageType.THREAD_STARTER_MESSAGE
+ )
throw new HTTPError("Referenced message not found in the specified channel", 404);
- if (message.referenced_message.guild_id && message.referenced_message.guild_id !== opts.message_reference.guild_id)
+ if (
+ message.referenced_message.guild_id &&
+ message.referenced_message.guild_id !== opts.message_reference.guild_id &&
+ opts.type !== MessageType.THREAD_STARTER_MESSAGE
+ )
throw new HTTPError("Referenced message not found in the specified channel", 404);
}
/** Q: should be checked if the referenced message exists? ANSWER: NO
otherwise backfilling won't work **/
- message.type = MessageType.REPLY;
+ if (MessageType.THREAD_STARTER_MESSAGE !== message.type) message.type = MessageType.REPLY;
}
}
@@ -274,7 +283,8 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> {
!opts.sticker_ids?.length &&
!opts.poll &&
!opts.components?.length &&
- opts.message_reference?.type != 1
+ opts.message_reference?.type != 1 &&
+ opts.type !== MessageType.THREAD_STARTER_MESSAGE
) {
console.log("[Message] Rejecting empty message:", opts, message);
throw new HTTPError("Empty messages are not allowed", 50006);
|