diff --git a/src/api/routes/channels/#channel_id/post-data.ts b/src/api/routes/channels/#channel_id/post-data.ts
new file mode 100644
index 000000000..6f1f530e1
--- /dev/null
+++ b/src/api/routes/channels/#channel_id/post-data.ts
@@ -0,0 +1,84 @@
+/*
+ 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 .
+*/
+
+import { handleMessage, postHandleMessage, route, sendMessage } from "@spacebar/api";
+import { Channel, emitEvent, User, uploadFile, Attachment, Member, ReadState, MessageCreateEvent, FieldErrors, getPermission, ThreadMember, Message } from "@spacebar/util";
+import { ChannelType, MessageType, ThreadCreationSchema, MessageCreateAttachment, MessageCreateCloudAttachment, PostDataSchema } from "@spacebar/schemas";
+
+import { Request, Response, Router } from "express";
+import { messageUpload } from "./messages";
+import { HTTPError } from "#util/util/lambert-server";
+import { FindManyOptions, FindOptionsOrder, In, Like } from "typeorm";
+
+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(
+ "/",
+ messageUpload.any(),
+ (req, res, next) => {
+ if (req.body.payload_json) {
+ req.body = JSON.parse(req.body.payload_json);
+ }
+
+ next();
+ },
+ route({
+ requestBody: "PostDataSchema",
+ permission: "VIEW_CHANNEL",
+ responses: {
+ 200: {},
+ 403: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const body = (req.body as PostDataSchema).thread_ids;
+ const threads = await Channel.find({
+ where: {
+ id: In(body),
+ },
+ });
+ const [messages, members] = await Promise.all([
+ Message.find({
+ where: {
+ id: In(threads.map(({ id }) => id)),
+ },
+ }),
+ Member.find({
+ where: {
+ id: In(threads.map(({ owner_id }) => owner_id)),
+ },
+ }),
+ ]);
+ const objRet: { threads: Record } = { threads: {} };
+ for (const thread of threads) {
+ const owner = members.find(({ id }) => id === thread.owner_id)?.toJSON() || null;
+ const first_message = messages.find(({ channel_id }) => channel_id === thread.id)?.toJSON() || null;
+ objRet.threads[thread.id] = {
+ owner,
+ first_message,
+ };
+ }
+ return res.json(objRet);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/channels/#channel_id/threads.ts b/src/api/routes/channels/#channel_id/threads.ts
index 129da9a74..6149d3891 100644
--- a/src/api/routes/channels/#channel_id/threads.ts
+++ b/src/api/routes/channels/#channel_id/threads.ts
@@ -17,11 +17,13 @@
*/
import { handleMessage, postHandleMessage, route, sendMessage } from "@spacebar/api";
-import { Channel, emitEvent, User, uploadFile, Attachment, Member, ReadState, MessageCreateEvent } from "@spacebar/util";
+import { Channel, emitEvent, User, uploadFile, Attachment, Member, ReadState, MessageCreateEvent, FieldErrors, getPermission, ThreadMember, Message } from "@spacebar/util";
import { ChannelType, MessageType, ThreadCreationSchema, MessageCreateAttachment, MessageCreateCloudAttachment } from "@spacebar/schemas";
import { Request, Response, Router } from "express";
import { messageUpload } from "./messages";
+import { HTTPError } from "#util/util/lambert-server";
+import { FindManyOptions, FindOptionsOrder, In, Like } from "typeorm";
const router = Router({ mergeParams: true });
@@ -168,4 +170,101 @@ router.post(
},
);
+router.get(
+ "/search",
+ route({
+ responses: {
+ 200: {
+ body: "GuildMessagesSearchResponse",
+ },
+ 403: {
+ body: "APIErrorResponse",
+ },
+ 422: {
+ body: "APIErrorResponse",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { name, slop, tag, tag_setting, archived, sort_by, sort_order, limit, offset, max_id, min_id } = req.query as Record;
+ const { channel_id } = req.params as Record;
+
+ const parsedLimit = Number(limit) || 25;
+ if (parsedLimit < 1 || parsedLimit > 25) throw new HTTPError("limit must be between 1 and 25", 422);
+
+ let order: FindOptionsOrder;
+ switch (sort_by) {
+ case undefined:
+ case "creation_time":
+ order = {
+ created_at: sort_order === "asc" ? "ASC" : "DESC",
+ };
+ break;
+ case "last_message_time":
+ order = {
+ last_message_id: sort_order === "asc" ? "ASC" : "DESC",
+ };
+ break;
+ default:
+ throw FieldErrors({
+ sort_by: {
+ message: "Value must be one of ('last_message_time', 'archive_time', 'relevance', 'creation_time').",
+ code: "BASE_TYPE_CHOICES",
+ },
+ }); // todo this is wrong
+ }
+ const channel = await Channel.findOneOrFail({
+ where: {
+ id: channel_id,
+ },
+ });
+
+ const permissions = await getPermission(req.user_id, channel.guild_id, channel);
+ permissions.hasThrow("VIEW_CHANNEL");
+ if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json({ threads: [], total_results: 0, members: [], has_more: false, first_messages: [] });
+ const member = await Member.findOneOrFail({ where: { guild_id: channel.guild_id, id: req.user_id } });
+
+ const query: FindManyOptions = {
+ order,
+ where: {
+ parent_id: channel_id,
+ ...(name ? { name: Like(`%${name}%`) } : {}),
+ ...(archived
+ ? {
+ thread_metadata: {
+ archived: archived === "true" ? true : false,
+ },
+ }
+ : {}),
+ },
+ relations: {},
+ };
+
+ const threads: Channel[] = await Channel.find({ ...query, take: parsedLimit || 0, skip: offset ? Number(offset) : 0 });
+ const total_results = await Channel.count(query);
+
+ const members = ThreadMember.find({
+ where: {
+ member_idx: member.index,
+ id: In(threads.map(({ id }) => id)),
+ },
+ });
+
+ const messages = Message.find({
+ where: {
+ id: In(threads.map(({ id }) => id)),
+ },
+ });
+
+ const left = total_results - threads.length - +offset;
+ return res.json({
+ threads: threads.map((_) => _.toJSON()),
+ members: (await members).map((_) => _.toJSON()),
+ messages: (await messages).map((_) => _.toJSON()),
+ total_results,
+ has_more: left > 0,
+ });
+ },
+);
+
export default router;
diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts
index dacc35ccd..efed94fc6 100644
--- a/src/gateway/opcodes/Identify.ts
+++ b/src/gateway/opcodes/Identify.ts
@@ -419,8 +419,8 @@ export async function onIdentify(this: WebSocket, data: Payload) {
const threadMembers = await ThreadMember.find({
where: { member_idx: In(member_idx) },
- relations: { channel: { thread_members: { member: true } } },
});
+ const threadMemberMap = new Map(threadMembers.map((member) => [member.id, member] as const));
const threadMemberTime = taskSw.getElapsedAndReset();
// Populated with guilds 'unavailable' currently
@@ -428,6 +428,15 @@ export async function onIdentify(this: WebSocket, data: Payload) {
const pending_guilds: Guild[] = [];
+ const allThreads = (
+ await Channel.find({
+ where: {
+ type: In([ChannelType.GUILD_NEWS_THREAD, ChannelType.GUILD_PUBLIC_THREAD]),
+ guild_id: In(members.map(({ guild }) => guild.id)),
+ },
+ })
+ ).filter(({ thread_metadata }) => thread_metadata?.archived === false);
+
// Generate guilds list ( make them unavailable if user is bot )
const guilds: GuildOrUnavailable[] = members.map((member) => {
member.guild.channels = member.guild.channels
@@ -456,23 +465,20 @@ export async function onIdentify(this: WebSocket, data: Payload) {
pending_guilds.push(member.guild);
return { id: member.guild.id, unavailable: true };
}
- const threads = threadMembers
- .filter(({ channel }) => channel.guild_id === member.guild.id)
- .map((_) => {
- return {
- member: {
- ..._.toJSON(),
- channel: undefined,
- },
- ..._.channel.toJSON(),
- };
- });
+
+ const threads: Channel[] = allThreads.filter((_) => _.guild_id === member.guild_id);
return {
...member.guild.toJSON(),
joined_at: member.joined_at,
- threads,
+ threads: threads.map((thread) => {
+ const member = threadMemberMap.get(thread.id)?.toJSON();
+ return {
+ ...thread.toJSON(),
+ member,
+ };
+ }),
};
});
const generateGuildsListTime = taskSw.getElapsedAndReset();
diff --git a/src/schemas/uncategorised/PostDataSchema.ts b/src/schemas/uncategorised/PostDataSchema.ts
new file mode 100644
index 000000000..b380610d3
--- /dev/null
+++ b/src/schemas/uncategorised/PostDataSchema.ts
@@ -0,0 +1,21 @@
+/*
+ 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 .
+*/
+
+export interface PostDataSchema {
+ thread_ids: string[];
+}
diff --git a/src/schemas/uncategorised/index.ts b/src/schemas/uncategorised/index.ts
index 5a0d9ebda..9083fd2ab 100644
--- a/src/schemas/uncategorised/index.ts
+++ b/src/schemas/uncategorised/index.ts
@@ -1,17 +1,17 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2025 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 .
*/
@@ -94,3 +94,4 @@ export * from "./WidgetModifySchema";
export * from "./MessageThreadCreationSchema";
export * from "./ThreadCreationSchema";
export * from "./MessageActivity";
+export * from "./PostDataSchema";