diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs
index aa1b9bea69..7fc5b11e39 100644
--- a/.lintstagedrc.mjs
+++ b/.lintstagedrc.mjs
@@ -1,4 +1,9 @@
export default {
"*.{j,t}s": [() => "npm run build:src:tsgo", "eslint --concurrency 4" /* sweet spot it seems */, "prettier --write"],
- "src/schemas/{*,**/*}.ts": [() => "npm run build:src:tsgo", () => "node scripts/schema.js", () => "node scripts/openapi.js", () => "git add assets/schemas.json assets/openapi.json"],
+ "src/schemas/{*,**/*}.ts": [
+ () => "npm run build:src:tsgo",
+ () => "node scripts/schema.js",
+ () => "node scripts/openapi.js",
+ () => "git add assets/schemas.json assets/openapi.json",
+ ],
};
diff --git a/assets/openapi.json b/assets/openapi.json
index aa965def46..6d491d525c 100644
--- a/assets/openapi.json
+++ b/assets/openapi.json
@@ -10710,7 +10710,7 @@
"reactions": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/Reaction"
+ "$ref": "#/components/schemas/StoredReaction"
}
},
"nonce": {
@@ -11472,12 +11472,15 @@
}
}
},
- "Reaction": {
+ "StoredReaction": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
+ "count_details": {
+ "$ref": "#/components/schemas/ReactionCountDetails"
+ },
"emoji": {
"$ref": "#/components/schemas/PartialEmoji"
},
@@ -11486,6 +11489,18 @@
"items": {
"type": "string"
}
+ },
+ "burst_user_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "burst_colors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
}
},
"required": [
@@ -11494,6 +11509,21 @@
"user_ids"
]
},
+ "ReactionCountDetails": {
+ "type": "object",
+ "properties": {
+ "normal": {
+ "type": "integer"
+ },
+ "burst": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "burst",
+ "normal"
+ ]
+ },
"PartialEmoji": {
"anyOf": [
{
@@ -25451,6 +25481,15 @@
"type": "string"
},
"description": "emoji"
+ },
+ {
+ "name": "type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "number"
+ },
+ "description": "The type of reaction to return users for."
}
],
"tags": [
@@ -25600,7 +25639,87 @@
]
}
},
- "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/{burst}/{user_id}": {
+ "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/{type}/{user_id}": {
+ "put": {
+ "x-right-required": "SELF_ADD_REACTIONS",
+ "x-permission-required": "READ_MESSAGE_HISTORY",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No description available"
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "No description available"
+ },
+ "404": {
+ "description": "No description available"
+ }
+ },
+ "parameters": [
+ {
+ "name": "channel_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "channel_id"
+ },
+ {
+ "name": "message_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "message_id"
+ },
+ {
+ "name": "emoji",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "emoji"
+ },
+ {
+ "name": "type",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "type"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "user_id"
+ }
+ ],
+ "tags": [
+ "channels"
+ ]
+ },
"delete": {
"security": [
{
@@ -25657,13 +25776,13 @@
"description": "emoji"
},
{
- "name": "burst",
+ "name": "type",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
- "description": "burst"
+ "description": "type"
},
{
"name": "user_id",
diff --git a/assets/schemas.json b/assets/schemas.json
index e28447eb84..746618ae59 100644
--- a/assets/schemas.json
+++ b/assets/schemas.json
@@ -11389,7 +11389,7 @@
"reactions": {
"type": "array",
"items": {
- "$ref": "#/definitions/Reaction"
+ "$ref": "#/definitions/StoredReaction"
}
},
"nonce": {
@@ -12176,12 +12176,15 @@
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
- "Reaction": {
+ "StoredReaction": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
+ "count_details": {
+ "$ref": "#/definitions/ReactionCountDetails"
+ },
"emoji": {
"$ref": "#/definitions/PartialEmoji"
},
@@ -12190,6 +12193,18 @@
"items": {
"type": "string"
}
+ },
+ "burst_user_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "burst_colors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
}
},
"additionalProperties": false,
@@ -12200,6 +12215,23 @@
],
"$schema": "http://json-schema.org/draft-07/schema#"
},
+ "ReactionCountDetails": {
+ "type": "object",
+ "properties": {
+ "normal": {
+ "type": "integer"
+ },
+ "burst": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "burst",
+ "normal"
+ ],
+ "$schema": "http://json-schema.org/draft-07/schema#"
+ },
"PartialEmoji": {
"anyOf": [
{
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 83ff1e783e..cd8f785e43 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -66,7 +66,7 @@ export default defineConfig([
// "sort-imports": ["error", {}],
"default-case": "error",
"default-case-last": "error",
- "yoda": "error",
+ yoda: "error",
// unsure what the defaults are here, but we want them to error
"for-direction": "error",
"constructor-super": "error",
diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts
index eed36691d0..84b5f24097 100644
--- a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts
+++ b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts
@@ -34,7 +34,7 @@ import {
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import multer from "multer";
-import { handleMessage, postHandleMessage, route } from "@spacebar/api";
+import { handleMessage, postHandleMessage, route, toPublicReactions } from "@spacebar/api";
import { MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageEditSchema, ChannelType } from "@spacebar/schemas";
const router = Router({ mergeParams: true });
@@ -122,6 +122,7 @@ router.patch(
author: new_message.author?.toPublicUser(),
attachments: new_message.attachments,
embeds: new_message.embeds,
+ reactions: toPublicReactions(new_message.reactions, req.user_id),
mentions: new_message.embeds,
mention_roles: new_message.mention_roles,
mention_everyone: new_message.mention_everyone,
@@ -273,7 +274,10 @@ router.get(
if (message.author_id !== req.user_id) permissions.hasThrow("READ_MESSAGE_HISTORY");
- return res.json(message);
+ const publicMessage = message.toJSON();
+ publicMessage.reactions = toPublicReactions(message.reactions, req.user_id);
+
+ return res.json(publicMessage);
},
);
diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts b/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts
index 305f81fcc8..255faff72d 100644
--- a/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts
+++ b/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts
@@ -36,6 +36,16 @@ import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import { In } from "typeorm";
import { PartialEmoji, PublicMemberProjection, PublicUserProjection } from "@spacebar/schemas";
+import {
+ addReactionUser,
+ findReaction,
+ getReactionUserIds,
+ parseOptionalReactionTypeParam,
+ parseReactionTypeParam,
+ reactionEventTypeData,
+ reactionRemoveEventUserData,
+ removeReactionUser,
+} from "@spacebar/api/util/utility/ReactionTypes";
const router = Router({ mergeParams: true });
// TODO: check if emoji is really an unicode emoji or a properly encoded external emoji
@@ -55,6 +65,12 @@ function getEmoji(emoji: string): PartialEmoji {
};
}
+function parseRouteReactionType(value: string): ReactionType {
+ const type = parseReactionTypeParam(value);
+ if (type === null) throw new HTTPError("Invalid reaction type", 400);
+ return type;
+}
+
router.delete(
"/",
route({
@@ -112,7 +128,7 @@ router.delete(
where: { id: message_id, channel_id },
});
- const already_added = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name);
+ const already_added = findReaction(message.reactions, emoji);
if (!already_added) throw new HTTPError("Reaction not found", 404);
arrayRemove(message.reactions, already_added);
@@ -138,6 +154,14 @@ router.get(
"/:emoji",
route({
permission: "VIEW_CHANNEL",
+ query: {
+ type: {
+ type: "number",
+ required: false,
+ values: ["0", "1"],
+ description: "The type of reaction to return users for.",
+ },
+ },
responses: {
200: {
body: "PublicUser",
@@ -152,18 +176,22 @@ router.get(
async (req: Request, res: Response) => {
const { message_id, channel_id } = req.params as { [key: string]: string };
const limit = req.query.limit ? Number(req.query.limit) : 25;
+ const type = parseOptionalReactionTypeParam(req.query.type);
+ if (type === null) throw new HTTPError("Invalid reaction type", 400);
const emoji = getEmoji(req.params.emoji as string);
const message = await Message.findOneOrFail({
where: { id: message_id, channel_id },
});
- const reaction = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name);
+ const reaction = findReaction(message.reactions, emoji);
if (!reaction) throw new HTTPError("Reaction not found", 404);
+ const userIds = getReactionUserIds(reaction, type);
+ if (!userIds.length) return res.json([]);
const users = (
await User.find({
where: {
- id: In(reaction.user_ids),
+ id: In(userIds),
},
select: PublicUserProjection,
take: limit,
@@ -174,6 +202,70 @@ router.get(
},
);
+async function addReaction(req: Request, res: Response, type: ReactionType) {
+ const { message_id, channel_id, user_id } = req.params as { [key: string]: string };
+ if (user_id !== "@me") throw new HTTPError("Invalid user");
+ const emoji = getEmoji(req.params.emoji as string);
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ const message = await Message.findOneOrFail({
+ where: { id: message_id, channel_id },
+ });
+ const already_added = findReaction(message.reactions, emoji);
+
+ if (!already_added) req.permission?.hasThrow("ADD_REACTIONS");
+
+ if (emoji.id) {
+ const external_emoji = await Emoji.findOneOrFail({
+ where: { id: emoji.id },
+ });
+ if (!already_added && channel.guild_id != external_emoji.guild_id) req.permission?.hasThrow("USE_EXTERNAL_EMOJIS");
+ emoji.animated = external_emoji.animated;
+ emoji.name = external_emoji.name;
+ }
+
+ const result = addReactionUser(message.reactions, emoji, req.user_id, type);
+ if (!result.changed) return res.sendStatus(204); // Do not throw an error ¯\_(ツ)_/¯ as discord also doesn't throw any error
+
+ await message.save();
+
+ const member = channel.guild_id
+ ? (
+ await Member.findOneOrFail({
+ where: { id: req.user_id },
+ relations: { roles: true, user: true },
+ select: {
+ index: true,
+ ...Object.fromEntries(PublicMemberProjection.map((x) => [x, true])),
+ user: Object.fromEntries(PublicUserProjection.map((x) => [x, true])),
+ roles: {
+ id: true,
+ },
+ },
+ })
+ ).toPublicMember()
+ : undefined;
+
+ await emitEvent({
+ event: "MESSAGE_REACTION_ADD",
+ channel_id,
+ data: {
+ user_id: req.user_id,
+ channel_id,
+ message_id,
+ guild_id: channel.guild_id,
+ emoji,
+ member,
+ ...reactionEventTypeData(type),
+ burst_colors: type === ReactionType.burst ? [] : undefined,
+ },
+ } satisfies MessageReactionAddEvent);
+
+ res.sendStatus(204);
+}
+
router.put(
"/:emoji/:user_id",
route({
@@ -188,81 +280,14 @@ router.put(
403: {},
},
}),
- async (req: Request, res: Response) => {
- const { message_id, channel_id, user_id } = req.params as { [key: string]: string };
- if (user_id !== "@me") throw new HTTPError("Invalid user");
- const emoji = getEmoji(req.params.emoji as string);
-
- const channel = await Channel.findOneOrFail({
- where: { id: channel_id },
- });
- const message = await Message.findOneOrFail({
- where: { id: message_id, channel_id },
- });
- const already_added = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name);
-
- if (!already_added) req.permission?.hasThrow("ADD_REACTIONS");
-
- if (emoji.id) {
- const external_emoji = await Emoji.findOneOrFail({
- where: { id: emoji.id },
- });
- if (!already_added && channel.guild_id != external_emoji.guild_id) req.permission?.hasThrow("USE_EXTERNAL_EMOJIS");
- emoji.animated = external_emoji.animated;
- emoji.name = external_emoji.name;
- }
-
- if (already_added) {
- if (already_added.user_ids.includes(req.user_id)) return res.sendStatus(204); // Do not throw an error ¯\_(ツ)_/¯ as discord also doesn't throw any error
- already_added.count++;
- already_added.user_ids.push(req.user_id);
- } else
- message.reactions.push({
- count: 1,
- emoji,
- user_ids: [req.user_id],
- });
-
- await message.save();
-
- const member = channel.guild_id
- ? (
- await Member.findOneOrFail({
- where: { id: req.user_id },
- relations: { roles: true, user: true },
- select: {
- index: true,
- ...Object.fromEntries(PublicMemberProjection.map((x) => [x, true])),
- user: Object.fromEntries(PublicUserProjection.map((x) => [x, true])),
- roles: {
- id: true,
- },
- },
- })
- ).toPublicMember()
- : undefined;
-
- await emitEvent({
- event: "MESSAGE_REACTION_ADD",
- channel_id,
- data: {
- user_id: req.user_id,
- channel_id,
- message_id,
- guild_id: channel.guild_id,
- emoji,
- member,
- type: ReactionType.normal,
- },
- } satisfies MessageReactionAddEvent);
-
- res.sendStatus(204);
- },
+ async (req: Request, res: Response) => addReaction(req, res, ReactionType.normal),
);
-router.delete(
- "/:emoji/:user_id",
+router.put(
+ "/:emoji/:type/:user_id",
route({
+ permission: "READ_MESSAGE_HISTORY",
+ right: "SELF_ADD_REACTIONS",
responses: {
204: {},
400: {
@@ -272,54 +297,52 @@ router.delete(
403: {},
},
}),
- async (req: Request, res: Response) => {
- let { user_id } = req.params as { [key: string]: string };
- const { message_id, channel_id } = req.params as { [key: string]: string };
+ async (req: Request, res: Response) => addReaction(req, res, parseRouteReactionType(req.params.type as string)),
+);
- const emoji = getEmoji(req.params.emoji as string);
+async function removeReaction(req: Request, res: Response, type: ReactionType) {
+ let { user_id } = req.params as { [key: string]: string };
+ const { message_id, channel_id } = req.params as { [key: string]: string };
- const channel = await Channel.findOneOrFail({
- where: { id: channel_id },
- });
- const message = await Message.findOneOrFail({
- where: { id: message_id, channel_id },
- });
+ const emoji = getEmoji(req.params.emoji as string);
- if (user_id === "@me") user_id = req.user_id;
- else {
- const permissions = await getPermission(req.user_id, undefined, channel_id);
- permissions.hasThrow("MANAGE_MESSAGES");
- }
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ const message = await Message.findOneOrFail({
+ where: { id: message_id, channel_id },
+ });
- const already_added = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name);
- if (!already_added || !already_added.user_ids.includes(user_id)) throw new HTTPError("Reaction not found", 404);
+ if (user_id === "@me") user_id = req.user_id;
+ else {
+ const permissions = await getPermission(req.user_id, undefined, channel_id);
+ permissions.hasThrow("MANAGE_MESSAGES");
+ }
- already_added.count--;
+ const already_added = findReaction(message.reactions, emoji);
+ if (!already_added || !removeReactionUser(already_added, user_id, type)) throw new HTTPError("Reaction not found", 404);
- if (already_added.count <= 0) arrayRemove(message.reactions, already_added);
- else already_added.user_ids.splice(already_added.user_ids.indexOf(user_id), 1);
+ if (already_added.count <= 0) arrayRemove(message.reactions, already_added);
- await message.save();
+ await message.save();
- await emitEvent({
- event: "MESSAGE_REACTION_REMOVE",
+ await emitEvent({
+ event: "MESSAGE_REACTION_REMOVE",
+ channel_id,
+ data: {
+ ...reactionRemoveEventUserData(user_id, type),
channel_id,
- data: {
- user_id: req.user_id,
- channel_id,
- message_id,
- guild_id: channel.guild_id,
- emoji,
- type: ReactionType.normal,
- },
- } satisfies MessageReactionRemoveEvent);
+ message_id,
+ guild_id: channel.guild_id,
+ emoji,
+ },
+ } satisfies MessageReactionRemoveEvent);
- res.sendStatus(204);
- },
-);
+ res.sendStatus(204);
+}
router.delete(
- "/:emoji/:burst/:user_id",
+ "/:emoji/:user_id",
route({
responses: {
204: {},
@@ -330,50 +353,22 @@ router.delete(
403: {},
},
}),
- async (req: Request, res: Response) => {
- let { user_id } = req.params as { [key: string]: string };
- const { message_id, channel_id } = req.params as { [key: string]: string };
-
- const emoji = getEmoji(req.params.emoji as string);
-
- const channel = await Channel.findOneOrFail({
- where: { id: channel_id },
- });
- const message = await Message.findOneOrFail({
- where: { id: message_id, channel_id },
- });
-
- if (user_id === "@me") user_id = req.user_id;
- else {
- const permissions = await getPermission(req.user_id, undefined, channel_id);
- permissions.hasThrow("MANAGE_MESSAGES");
- }
-
- const already_added = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name);
- if (!already_added || !already_added.user_ids.includes(user_id)) throw new HTTPError("Reaction not found", 404);
-
- already_added.count--;
-
- if (already_added.count <= 0) arrayRemove(message.reactions, already_added);
- else already_added.user_ids.splice(already_added.user_ids.indexOf(user_id), 1);
-
- await message.save();
+ async (req: Request, res: Response) => removeReaction(req, res, ReactionType.normal),
+);
- await emitEvent({
- event: "MESSAGE_REACTION_REMOVE",
- channel_id,
- data: {
- user_id: req.user_id,
- channel_id,
- message_id,
- guild_id: channel.guild_id,
- emoji,
- type: ReactionType.normal,
+router.delete(
+ "/:emoji/:type/:user_id",
+ route({
+ responses: {
+ 204: {},
+ 400: {
+ body: "APIErrorResponse",
},
- } satisfies MessageReactionRemoveEvent);
-
- res.sendStatus(204);
- },
+ 404: {},
+ 403: {},
+ },
+ }),
+ async (req: Request, res: Response) => removeReaction(req, res, parseRouteReactionType(req.params.type as string)),
);
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 b117e3aca9..9a0f618ec7 100644
--- a/src/api/routes/channels/#channel_id/messages/index.ts
+++ b/src/api/routes/channels/#channel_id/messages/index.ts
@@ -16,7 +16,7 @@
along with this program. If not, see .
*/
-import { handleMessage, postHandleMessage, route } from "@spacebar/api";
+import { handleMessage, postHandleMessage, route, toPublicReactions } from "@spacebar/api";
import {
Attachment,
Channel,
@@ -56,7 +56,6 @@ import {
MessageCreateSchema,
PartialUser,
PublicMessage,
- Reaction,
ReadStateType,
RelationshipType,
} from "@spacebar/schemas";
@@ -181,12 +180,7 @@ router.get(
const ret = messages.map((msg) => {
const x = msg.toJSON();
- (x.reactions || []).forEach((y: Partial) => {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore
- if ((y.user_ids || []).includes(req.user_id)) y.me = true;
- delete y.user_ids;
- });
+ x.reactions = toPublicReactions(msg.reactions, req.user_id);
if (!x.author)
x.author = {
id: "4",
diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts
index 32f1e3d78d..3d085defbc 100644
--- a/src/api/util/handlers/Message.ts
+++ b/src/api/util/handlers/Message.ts
@@ -61,8 +61,8 @@ import {
MessageCreateCloudAttachment,
MessageCreateSchema,
MessageType,
- Reaction,
ReadStateType,
+ StoredReaction,
UnfurledMediaItem,
BaseMessageComponents,
v1CompTypes,
@@ -715,7 +715,7 @@ interface MessageOptions extends MessageCreateSchema {
webhook_id?: string;
application_id?: string;
embeds?: Embed[] | null;
- reactions?: Reaction[];
+ reactions?: StoredReaction[];
channel_id?: string;
attachments?: (MessageCreateAttachment | MessageCreateCloudAttachment | Attachment)[]; // why are we masking this?
edited_timestamp?: Date;
diff --git a/src/api/util/index.ts b/src/api/util/index.ts
index cb26d4f545..3d9b0a9713 100644
--- a/src/api/util/index.ts
+++ b/src/api/util/index.ts
@@ -21,6 +21,7 @@ export * from "./utility/ipAddress";
export * from "./handlers/Message";
export * from "./utility/passwordStrength";
export * from "./utility/RandomInviteID";
+export * from "./utility/ReactionTypes";
export * from "./handlers/route";
export * from "./utility/String";
export * from "./handlers/Voice";
diff --git a/src/api/util/utility/ReactionTypes.test.ts b/src/api/util/utility/ReactionTypes.test.ts
new file mode 100644
index 0000000000..80b714d1b9
--- /dev/null
+++ b/src/api/util/utility/ReactionTypes.test.ts
@@ -0,0 +1,153 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { describe, it } from "node:test";
+import { ReactionType } from "@spacebar/util";
+import { PartialEmoji, StoredReaction } from "@spacebar/schemas";
+import {
+ addReactionUser,
+ getReactionUserIds,
+ normalizeStoredReaction,
+ parseOptionalReactionTypeParam,
+ parseReactionTypeParam,
+ reactionEventTypeData,
+ reactionRemoveEventUserData,
+ removeReactionUser,
+ toPublicReaction,
+ toPublicReactions,
+} from "./ReactionTypes";
+
+describe("parseReactionTypeParam", () => {
+ it("parses normal and burst reaction types", () => {
+ assert.equal(parseReactionTypeParam("0"), ReactionType.normal);
+ assert.equal(parseReactionTypeParam("1"), ReactionType.burst);
+ });
+
+ it("rejects unsupported route values", () => {
+ assert.equal(parseReactionTypeParam("2"), null);
+ assert.equal(parseReactionTypeParam("normal"), null);
+ assert.equal(parseReactionTypeParam(""), null);
+ });
+
+ it("defaults missing optional route values to normal reactions", () => {
+ assert.equal(parseOptionalReactionTypeParam(undefined), ReactionType.normal);
+ assert.equal(parseOptionalReactionTypeParam("1"), ReactionType.burst);
+ assert.equal(parseOptionalReactionTypeParam(["1"]), null);
+ });
+
+ it("tracks normal and burst reaction users independently for the same emoji", () => {
+ const reactions: StoredReaction[] = [];
+ const emoji: PartialEmoji = { name: "🔥" };
+
+ assert.deepEqual(addReactionUser(reactions, emoji, "user-a", ReactionType.normal), {
+ reaction: reactions[0],
+ created: true,
+ changed: true,
+ });
+ assert.deepEqual(addReactionUser(reactions, emoji, "user-a", ReactionType.normal), {
+ reaction: reactions[0],
+ created: false,
+ changed: false,
+ });
+ assert.deepEqual(addReactionUser(reactions, emoji, "user-a", ReactionType.burst), {
+ reaction: reactions[0],
+ created: false,
+ changed: true,
+ });
+
+ assert.equal(reactions.length, 1);
+ assert.equal(reactions[0].count, 2);
+ assert.deepEqual(reactions[0].count_details, { normal: 1, burst: 1 });
+ assert.deepEqual(getReactionUserIds(reactions[0], ReactionType.normal), ["user-a"]);
+ assert.deepEqual(getReactionUserIds(reactions[0], ReactionType.burst), ["user-a"]);
+ });
+
+ it("removes only the selected reaction type", () => {
+ const reaction = normalizeStoredReaction({
+ count: 2,
+ count_details: { normal: 1, burst: 1 },
+ emoji: { name: "🔥" },
+ user_ids: ["user-a"],
+ burst_user_ids: ["user-a"],
+ burst_colors: [],
+ });
+
+ assert.equal(removeReactionUser(reaction, "user-a", ReactionType.burst), true);
+ assert.equal(removeReactionUser(reaction, "user-a", ReactionType.burst), false);
+ assert.equal(reaction.count, 1);
+ assert.deepEqual(reaction.count_details, { normal: 1, burst: 0 });
+ assert.deepEqual(getReactionUserIds(reaction, ReactionType.normal), ["user-a"]);
+ assert.deepEqual(getReactionUserIds(reaction, ReactionType.burst), []);
+ });
+
+ it("normalizes legacy stored reactions as normal reactions", () => {
+ const reaction = normalizeStoredReaction({
+ count: 2,
+ emoji: { name: "✅" },
+ user_ids: ["user-a", "user-b", "user-a"],
+ });
+
+ assert.equal(reaction.count, 2);
+ assert.deepEqual(reaction.count_details, { normal: 2, burst: 0 });
+ assert.deepEqual(reaction.user_ids, ["user-a", "user-b"]);
+ assert.deepEqual(reaction.burst_user_ids, []);
+ assert.deepEqual(reaction.burst_colors, []);
+ });
+
+ it("serializes public reaction state without internal user id arrays", () => {
+ const publicReaction = toPublicReaction(
+ {
+ count: 2,
+ emoji: { name: "🔥" },
+ user_ids: ["normal-user"],
+ burst_user_ids: ["burst-user"],
+ burst_colors: ["#ff0000"],
+ },
+ "burst-user",
+ );
+
+ assert.deepEqual(publicReaction, {
+ count: 2,
+ count_details: { normal: 1, burst: 1 },
+ me: false,
+ me_burst: true,
+ emoji: { name: "🔥" },
+ burst_colors: ["#ff0000"],
+ });
+ assert.equal("user_ids" in publicReaction, false);
+ assert.equal("burst_user_ids" in publicReaction, false);
+ });
+
+ it("serializes absent public reactions as an empty array", () => {
+ assert.deepEqual(toPublicReactions(undefined, "user-a"), []);
+ });
+
+ it("builds Discord-compatible event type flags", () => {
+ assert.deepEqual(reactionEventTypeData(ReactionType.normal), {
+ type: ReactionType.normal,
+ burst: false,
+ });
+ assert.deepEqual(reactionEventTypeData(ReactionType.burst), {
+ type: ReactionType.burst,
+ burst: true,
+ });
+ });
+
+ it("uses the removed reaction owner in remove event data", () => {
+ assert.deepEqual(reactionRemoveEventUserData("target-user", ReactionType.burst), {
+ user_id: "target-user",
+ type: ReactionType.burst,
+ burst: true,
+ });
+ });
+
+ it("declares typed reaction mutation routes", () => {
+ const routePath = path.resolve(process.cwd(), "src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts");
+ const source = fs.readFileSync(routePath, "utf8");
+
+ assert.match(source, /router\.put\(\s*"\/:emoji\/:user_id"/);
+ assert.match(source, /router\.put\(\s*"\/:emoji\/:type\/:user_id"/);
+ assert.match(source, /router\.delete\(\s*"\/:emoji\/:user_id"/);
+ assert.match(source, /router\.delete\(\s*"\/:emoji\/:type\/:user_id"/);
+ });
+});
diff --git a/src/api/util/utility/ReactionTypes.ts b/src/api/util/utility/ReactionTypes.ts
new file mode 100644
index 0000000000..9cfe415dd3
--- /dev/null
+++ b/src/api/util/utility/ReactionTypes.ts
@@ -0,0 +1,119 @@
+import { ReactionType } from "@spacebar/util";
+import { PartialEmoji, Reaction, StoredReaction } from "@spacebar/schemas";
+
+export function parseReactionTypeParam(value: unknown): ReactionType | null {
+ if (value === String(ReactionType.normal)) return ReactionType.normal;
+ if (value === String(ReactionType.burst)) return ReactionType.burst;
+ return null;
+}
+
+export function parseOptionalReactionTypeParam(value: unknown): ReactionType | null {
+ if (value === undefined) return ReactionType.normal;
+ return parseReactionTypeParam(value);
+}
+
+export function reactionEmojiEquals(left: PartialEmoji, right: PartialEmoji): boolean {
+ return Boolean((left.id === right.id && right.id) || left.name === right.name);
+}
+
+export function findReaction(reactions: StoredReaction[], emoji: PartialEmoji): StoredReaction | undefined {
+ return reactions.find((reaction) => reactionEmojiEquals(reaction.emoji, emoji));
+}
+
+export function normalizeStoredReaction(reaction: StoredReaction): StoredReaction {
+ reaction.user_ids = [...new Set(reaction.user_ids ?? [])];
+ reaction.burst_user_ids = [...new Set(reaction.burst_user_ids ?? [])];
+ reaction.burst_colors ??= [];
+ updateReactionCounts(reaction);
+ return reaction;
+}
+
+export function getReactionUserIds(reaction: StoredReaction, type: ReactionType): string[] {
+ normalizeStoredReaction(reaction);
+ return [...getMutableReactionUserIds(reaction, type)];
+}
+
+export function addReactionUser(
+ reactions: StoredReaction[],
+ emoji: PartialEmoji,
+ userId: string,
+ type: ReactionType,
+): { reaction: StoredReaction; created: boolean; changed: boolean } {
+ let reaction = findReaction(reactions, emoji);
+ const created = !reaction;
+
+ if (!reaction) {
+ reaction = {
+ count: 0,
+ count_details: { normal: 0, burst: 0 },
+ emoji,
+ user_ids: [],
+ burst_user_ids: [],
+ burst_colors: [],
+ };
+ reactions.push(reaction);
+ }
+
+ const users = getMutableReactionUserIds(reaction, type);
+ if (users.includes(userId)) return { reaction, created, changed: false };
+
+ users.push(userId);
+ updateReactionCounts(reaction);
+
+ return { reaction, created, changed: true };
+}
+
+export function removeReactionUser(reaction: StoredReaction, userId: string, type: ReactionType): boolean {
+ const users = getMutableReactionUserIds(reaction, type);
+ const index = users.indexOf(userId);
+ if (index === -1) return false;
+
+ users.splice(index, 1);
+ updateReactionCounts(reaction);
+
+ return true;
+}
+
+export function toPublicReaction(reaction: StoredReaction, userId: string): Reaction {
+ normalizeStoredReaction(reaction);
+
+ return {
+ count: reaction.count,
+ count_details: { ...reaction.count_details! },
+ me: reaction.user_ids.includes(userId),
+ me_burst: reaction.burst_user_ids!.includes(userId),
+ emoji: reaction.emoji,
+ burst_colors: [...reaction.burst_colors!],
+ };
+}
+
+export function toPublicReactions(reactions: StoredReaction[] | undefined, userId: string): Reaction[] {
+ return (reactions ?? []).map((reaction) => toPublicReaction(reaction, userId));
+}
+
+export function reactionEventTypeData(type: ReactionType): { type: ReactionType; burst: boolean } {
+ return {
+ type,
+ burst: type === ReactionType.burst,
+ };
+}
+
+export function reactionRemoveEventUserData(userId: string, type: ReactionType): { user_id: string; type: ReactionType; burst: boolean } {
+ return {
+ user_id: userId,
+ ...reactionEventTypeData(type),
+ };
+}
+
+function getMutableReactionUserIds(reaction: StoredReaction, type: ReactionType): string[] {
+ normalizeStoredReaction(reaction);
+ return type === ReactionType.burst ? reaction.burst_user_ids! : reaction.user_ids;
+}
+
+function updateReactionCounts(reaction: StoredReaction) {
+ const normal = reaction.user_ids.length;
+ const burst = reaction.burst_user_ids?.length ?? 0;
+
+ reaction.count_details = { normal, burst };
+ reaction.count = normal + burst;
+}
diff --git a/src/cdn/util/Storage.ts b/src/cdn/util/Storage.ts
index 7579e2d610..3245f65572 100644
--- a/src/cdn/util/Storage.ts
+++ b/src/cdn/util/Storage.ts
@@ -86,7 +86,9 @@ if (process.env.STORAGE_PROVIDER === "file" || !process.env.STORAGE_PROVIDER) {
const forcePathStyle = process.env.STORAGE_FORCE_PATH_STYLE === "true";
if (process.env.STORAGE_FORCE_PATH_STYLE === undefined) {
- console.warn(`[CDN] STORAGE_FORCE_PATH_STYLE is not set for S3 provider; defaulting to virtual-hosted style. Set STORAGE_FORCE_PATH_STYLE=true to enable path-style addressing.`);
+ console.warn(
+ `[CDN] STORAGE_FORCE_PATH_STYLE is not set for S3 provider; defaulting to virtual-hosted style. Set STORAGE_FORCE_PATH_STYLE=true to enable path-style addressing.`,
+ );
}
const { S3Storage } = require("./S3Storage");
diff --git a/src/schemas/api/messages/Message.ts b/src/schemas/api/messages/Message.ts
index b7eed17cc5..4d1039efe5 100644
--- a/src/schemas/api/messages/Message.ts
+++ b/src/schemas/api/messages/Message.ts
@@ -120,9 +120,25 @@ export interface PartialMessage {
export interface Reaction {
count: number;
- //// not saved in the database // me: boolean; // whether the current user reacted using this emoji
+ count_details?: ReactionCountDetails;
+ me?: boolean;
+ me_burst?: boolean;
+ emoji: PartialEmoji;
+ burst_colors?: string[];
+}
+
+export interface ReactionCountDetails {
+ normal: number;
+ burst: number;
+}
+
+export interface StoredReaction {
+ count: number;
+ count_details?: ReactionCountDetails;
emoji: PartialEmoji;
user_ids: Snowflake[];
+ burst_user_ids?: Snowflake[];
+ burst_colors?: string[];
}
// aka { animated } & OneOf<{id},{name}>
diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts
index 153e086d49..3338e97246 100644
--- a/src/util/entities/Message.ts
+++ b/src/util/entities/Message.ts
@@ -38,7 +38,7 @@ import {
PartialMessage,
Poll,
PublicMessage,
- Reaction,
+ StoredReaction,
UnfurledMediaItem,
PartialUser,
InteractionType,
@@ -168,7 +168,7 @@ export class Message extends BaseClass {
@Column({ type: "jsonb" })
@JsonRemoveEmpty
- reactions: Reaction[];
+ reactions: StoredReaction[];
@Column({ type: "text", nullable: true })
@JsonRemoveEmpty
diff --git a/src/util/interfaces/Event.ts b/src/util/interfaces/Event.ts
index 96d04a556c..da8a93f601 100644
--- a/src/util/interfaces/Event.ts
+++ b/src/util/interfaces/Event.ts
@@ -395,6 +395,8 @@ export interface MessageReactionAddEvent extends Event {
guild_id?: string;
member?: PublicMember;
emoji: PartialEmoji;
+ burst: boolean;
+ burst_colors?: string[];
type: ReactionType;
};
}
@@ -407,6 +409,7 @@ export interface MessageReactionRemoveEvent extends Event {
message_id: string;
guild_id?: string;
emoji: PartialEmoji;
+ burst: boolean;
type: ReactionType;
};
}