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; }; }