From fb9f4bb991b3eb307c43c15d48a162ee8e4e5131 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 10 Apr 2026 18:51:59 -0400 Subject: [PATCH] feat: Allow replacing custom emoji image (#11998) * feat: Allow replacing custom emoji image --- app/actions/definitions/emojis.tsx | 2 +- app/components/EmojiCreateDialog.tsx | 233 ------------------ app/components/EmojiDialog/Components.tsx | 161 ++++++++++++ .../EmojiDialog/EmojiCreateDialog.tsx | 132 ++++++++++ .../EmojiDialog/EmojiReplaceDialog.tsx | 86 +++++++ .../IconPicker/components/EmojiPanel.tsx | 2 +- app/hooks/useEmojiMenuActions.tsx | 82 ++++-- .../Settings/components/EmojisTable.tsx | 7 +- app/stores/EmojiStore.ts | 1 + server/policies/emoji.ts | 2 +- server/routes/api/emojis/emojis.test.ts | 170 +++++++++++++ server/routes/api/emojis/emojis.ts | 50 ++++ server/routes/api/emojis/schema.ts | 11 + shared/components/CustomEmoji.tsx | 18 +- shared/i18n/locales/en_US/translation.json | 14 +- 15 files changed, 707 insertions(+), 264 deletions(-) delete mode 100644 app/components/EmojiCreateDialog.tsx create mode 100644 app/components/EmojiDialog/Components.tsx create mode 100644 app/components/EmojiDialog/EmojiCreateDialog.tsx create mode 100644 app/components/EmojiDialog/EmojiReplaceDialog.tsx create mode 100644 server/routes/api/emojis/emojis.test.ts diff --git a/app/actions/definitions/emojis.tsx b/app/actions/definitions/emojis.tsx index 0b18d6fa96..7ebcdaf5ad 100644 --- a/app/actions/definitions/emojis.tsx +++ b/app/actions/definitions/emojis.tsx @@ -2,7 +2,7 @@ import { PlusIcon } from "outline-icons"; import { createAction } from "~/actions"; import { TeamSection } from "../sections"; import stores from "~/stores"; -import { EmojiCreateDialog } from "~/components/EmojiCreateDialog"; +import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog"; export const createEmoji = createAction({ name: ({ t }) => `${t("New emoji")}…`, diff --git a/app/components/EmojiCreateDialog.tsx b/app/components/EmojiCreateDialog.tsx deleted file mode 100644 index a2a1deb6c3..0000000000 --- a/app/components/EmojiCreateDialog.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import * as React from "react"; -import { useDropzone } from "react-dropzone"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import styled from "styled-components"; -import { s } from "@shared/styles"; -import { AttachmentPreset } from "@shared/types"; -import { getDataTransferFiles } from "@shared/utils/files"; -import ConfirmationDialog from "~/components/ConfirmationDialog"; -import Input, { LabelText } from "~/components/Input"; -import Text from "~/components/Text"; -import useStores from "~/hooks/useStores"; -import { uploadFile } from "~/utils/files"; -import { compressImage } from "~/utils/compressImage"; -import { generateEmojiNameFromFilename } from "~/utils/emoji"; -import { AttachmentValidation, EmojiValidation } from "@shared/validations"; -import { bytesToHumanReadable } from "@shared/utils/files"; -import { VStack } from "./primitives/VStack"; - -type Props = { - onSubmit: () => void; -}; - -export function EmojiCreateDialog({ onSubmit }: Props) { - const { t } = useTranslation(); - const { emojis } = useStores(); - const [name, setName] = React.useState(""); - const [file, setFile] = React.useState(null); - const [isUploading, setIsUploading] = React.useState(false); - - const handleFileSelection = React.useCallback( - (file: File) => { - const isValidType = AttachmentValidation.emojiContentTypes.includes( - file.type - ); - - if (!isValidType) { - toast.error( - t("File type not supported. Please use PNG, JPG, GIF, or WebP.") - ); - return; - } - - // Validate file size - if (file.size > AttachmentValidation.emojiMaxFileSize) { - toast.error( - t("File size too large. Maximum size is {{ size }}.", { - size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize), - }) - ); - return; - } - - setFile(file); - - // Auto-populate name field if it's empty - setName((currentName) => { - if (!currentName.trim()) { - const generatedName = generateEmojiNameFromFilename(file.name); - return generatedName || currentName; - } - return currentName; - }); - }, - [t] - ); - - const onDrop = React.useCallback( - (acceptedFiles: File[]) => { - if (acceptedFiles.length > 0) { - handleFileSelection(acceptedFiles[0]); - } - }, - [handleFileSelection] - ); - - // Handle paste events - React.useEffect(() => { - const handlePaste = (event: ClipboardEvent) => { - const files = getDataTransferFiles(event); - if (files.length > 0) { - event.preventDefault(); - handleFileSelection(files[0]); - } - }; - - document.addEventListener("paste", handlePaste); - return () => document.removeEventListener("paste", handlePaste); - }, [handleFileSelection]); - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDropAccepted: onDrop, - accept: AttachmentValidation.emojiContentTypes, - maxSize: AttachmentValidation.emojiMaxFileSize, - maxFiles: 1, - }); - - const handleSubmit = async () => { - if (!name.trim()) { - toast.error(t("Please enter a name for the emoji")); - return; - } - - if (!file) { - toast.error(t("Please select an image file")); - return; - } - - setIsUploading(true); - try { - // Skip compression for GIFs to preserve animation - const fileToUpload = - file.type === "image/gif" - ? file - : await compressImage(file, { - maxHeight: 64, - maxWidth: 64, - }); - - const attachment = await uploadFile(fileToUpload, { - name: file.name, - preset: AttachmentPreset.Emoji, - }); - - await emojis.create({ - name: name.trim(), - attachmentId: attachment.id, - }); - - toast.success(t("Emoji created successfully")); - onSubmit(); - } finally { - setIsUploading(false); - } - }; - - const handleNameChange = (event: React.ChangeEvent) => { - const { value } = event.target; - setName(value); - }; - - const isValidName = EmojiValidation.allowedNameCharacters.test(name); - const isValid = name.trim().length > 0 && file && isValidName; - - return ( - - - {t( - "Square images with transparent backgrounds work best. If your image is too large, we’ll try to resize it for you." - )} - - - {t("Upload an image")} - - - - {file ? ( - <> - - {file.name} - - {t("Click or drag to replace")} - - - ) : ( - <> - - {isDragActive - ? t("Drop the image here") - : t("Click, drop, or paste an image here")} - - - {t("PNG, JPG, GIF, or WebP up to {{ size }}", { - size: bytesToHumanReadable( - AttachmentValidation.emojiMaxFileSize - ), - })} - - - )} - - - - - - {name.trim() && isValidName && ( - - {t("This emoji will be available as")} :{name}: - - )} - - ); -} - -const DropZone = styled.div` - border: 2px dashed ${s("inputBorder")}; - border-radius: 8px; - padding: 24px; - text-align: center; - cursor: var(--pointer); - transition: border-color 0.2s; - margin-bottom: 1em; - - &:hover { - border-color: ${s("inputBorderFocused")}; - } -`; - -const PreviewImage = styled.img` - width: 64px; - height: 64px; - object-fit: contain; - border-radius: 4px; -`; diff --git a/app/components/EmojiDialog/Components.tsx b/app/components/EmojiDialog/Components.tsx new file mode 100644 index 0000000000..d26c940048 --- /dev/null +++ b/app/components/EmojiDialog/Components.tsx @@ -0,0 +1,161 @@ +import * as React from "react"; +import { useDropzone } from "react-dropzone"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import styled from "styled-components"; +import { s } from "@shared/styles"; +import { getDataTransferFiles } from "@shared/utils/files"; +import { bytesToHumanReadable } from "@shared/utils/files"; +import { AttachmentValidation } from "@shared/validations"; +import Text from "~/components/Text"; +import { VStack } from "~/components/primitives/VStack"; + +interface UseEmojiFileUploadOptions { + /** Optional callback fired after a valid file is selected. */ + onFileSelected?: (file: File) => void; +} + +/** + * Hook that manages emoji image file selection with validation, drag-and-drop, + * and paste support. + */ +export function useEmojiFileUpload(options?: UseEmojiFileUploadOptions) { + const { t } = useTranslation(); + const [file, setFile] = React.useState(null); + + const handleFileSelection = React.useCallback( + (selected: File) => { + const isValidType = AttachmentValidation.emojiContentTypes.includes( + selected.type + ); + + if (!isValidType) { + toast.error( + t("File type not supported. Please use PNG, JPG, GIF, or WebP.") + ); + return; + } + + if (selected.size > AttachmentValidation.emojiMaxFileSize) { + toast.error( + t("File size too large. Maximum size is {{ size }}.", { + size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize), + }) + ); + return; + } + + setFile(selected); + options?.onFileSelected?.(selected); + }, + [t, options] + ); + + const handleDrop = React.useCallback( + (acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + handleFileSelection(acceptedFiles[0]); + } + }, + [handleFileSelection] + ); + + React.useEffect(() => { + const handlePaste = (event: ClipboardEvent) => { + const files = getDataTransferFiles(event); + if (files.length > 0) { + event.preventDefault(); + handleFileSelection(files[0]); + } + }; + + document.addEventListener("paste", handlePaste); + return () => document.removeEventListener("paste", handlePaste); + }, [handleFileSelection]); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDropAccepted: handleDrop, + accept: AttachmentValidation.emojiContentTypes, + maxSize: AttachmentValidation.emojiMaxFileSize, + maxFiles: 1, + }); + + return { file, getRootProps, getInputProps, isDragActive }; +} + +interface EmojiImageDropZoneProps { + /** The currently selected file, if any. */ + file: File | null; + /** Dropzone root props. */ + getRootProps: ReturnType["getRootProps"]; + /** Dropzone input props. */ + getInputProps: ReturnType["getInputProps"]; + /** Whether a drag is currently active. */ + isDragActive: boolean; +} + +/** + * Shared drop zone component for emoji image upload, showing either a file + * preview or placeholder text. + */ +export function EmojiImageDropZone({ + file, + getRootProps, + getInputProps, + isDragActive, +}: EmojiImageDropZoneProps) { + const { t } = useTranslation(); + + return ( + + + + {file ? ( + <> + + {file.name} + + {t("Click or drag to replace")} + + + ) : ( + <> + + {isDragActive + ? t("Drop the image here") + : t("Click, drop, or paste an image here")} + + + {t("PNG, JPG, GIF, or WebP up to {{ size }}", { + size: bytesToHumanReadable( + AttachmentValidation.emojiMaxFileSize + ), + })} + + + )} + + + ); +} + +const DropZone = styled.div` + border: 2px dashed ${s("inputBorder")}; + border-radius: 8px; + padding: 24px; + text-align: center; + cursor: var(--pointer); + transition: border-color 0.2s; + margin-bottom: 1em; + + &:hover { + border-color: ${s("inputBorderFocused")}; + } +`; + +const PreviewImage = styled.img` + width: 64px; + height: 64px; + object-fit: contain; + border-radius: 4px; +`; diff --git a/app/components/EmojiDialog/EmojiCreateDialog.tsx b/app/components/EmojiDialog/EmojiCreateDialog.tsx new file mode 100644 index 0000000000..ac23e76fb0 --- /dev/null +++ b/app/components/EmojiDialog/EmojiCreateDialog.tsx @@ -0,0 +1,132 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { AttachmentPreset } from "@shared/types"; +import { EmojiValidation } from "@shared/validations"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; +import Input, { LabelText } from "~/components/Input"; +import Text from "~/components/Text"; +import useStores from "~/hooks/useStores"; +import { uploadFile } from "~/utils/files"; +import { compressImage } from "~/utils/compressImage"; +import { generateEmojiNameFromFilename } from "~/utils/emoji"; +import { useEmojiFileUpload, EmojiImageDropZone } from "./Components"; + +interface Props { + /** Callback invoked after successful creation. */ + onSubmit: () => void; +} + +/** + * Dialog for creating a new custom emoji with image upload and name input. + */ +export function EmojiCreateDialog({ onSubmit }: Props) { + const { t } = useTranslation(); + const { emojis } = useStores(); + const [name, setName] = React.useState(""); + const [isUploading, setIsUploading] = React.useState(false); + + const handleFileSelected = React.useCallback((selected: File) => { + setName((currentName) => { + if (!currentName.trim()) { + const generatedName = generateEmojiNameFromFilename(selected.name); + return generatedName || currentName; + } + return currentName; + }); + }, []); + + const { file, getRootProps, getInputProps, isDragActive } = + useEmojiFileUpload({ onFileSelected: handleFileSelected }); + + const handleSubmit = async () => { + if (!name.trim()) { + toast.error(t("Please enter a name for the emoji")); + return; + } + + if (!file) { + toast.error(t("Please select an image file")); + return; + } + + setIsUploading(true); + try { + const fileToUpload = + file.type === "image/gif" + ? file + : await compressImage(file, { + maxHeight: 64, + maxWidth: 64, + }); + + const attachment = await uploadFile(fileToUpload, { + name: file.name, + preset: AttachmentPreset.Emoji, + }); + + await emojis.create({ + name: name.trim(), + attachmentId: attachment.id, + }); + + toast.success(t("Emoji created successfully")); + onSubmit(); + } finally { + setIsUploading(false); + } + }; + + const handleNameChange = (event: React.ChangeEvent) => { + const { value } = event.target; + setName(value); + }; + + const isValidName = EmojiValidation.allowedNameCharacters.test(name); + const isValid = name.trim().length > 0 && file && isValidName; + + return ( + + + {t( + "Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you." + )} + + + {t("Upload an image")} + + + + + {name.trim() && isValidName && ( + + {t("This emoji will be available as")} :{name}: + + )} + + ); +} diff --git a/app/components/EmojiDialog/EmojiReplaceDialog.tsx b/app/components/EmojiDialog/EmojiReplaceDialog.tsx new file mode 100644 index 0000000000..e2a891ffa1 --- /dev/null +++ b/app/components/EmojiDialog/EmojiReplaceDialog.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { AttachmentPreset } from "@shared/types"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; +import Text from "~/components/Text"; +import type Emoji from "~/models/Emoji"; +import useStores from "~/hooks/useStores"; +import { uploadFile } from "~/utils/files"; +import { compressImage } from "~/utils/compressImage"; +import { useEmojiFileUpload, EmojiImageDropZone } from "./Components"; + +interface Props { + /** The emoji whose image is being replaced. */ + emoji: Emoji; + /** Callback invoked after a successful replacement. */ + onSubmit: () => void; +} + +/** + * Dialog for replacing the image of an existing custom emoji. + */ +export function EmojiReplaceDialog({ emoji, onSubmit }: Props) { + const { t } = useTranslation(); + const { emojis } = useStores(); + const [isUploading, setIsUploading] = React.useState(false); + const { file, getRootProps, getInputProps, isDragActive } = + useEmojiFileUpload(); + + const handleSubmit = async () => { + if (!file) { + toast.error(t("Please select an image file")); + return; + } + + setIsUploading(true); + try { + const fileToUpload = + file.type === "image/gif" + ? file + : await compressImage(file, { + maxHeight: 64, + maxWidth: 64, + }); + + const attachment = await uploadFile(fileToUpload, { + name: file.name, + preset: AttachmentPreset.Emoji, + }); + + await emojis.update({ + id: emoji.id, + attachmentId: attachment.id, + }); + + toast.success(t("Emoji replaced")); + onSubmit(); + } finally { + setIsUploading(false); + } + }; + + return ( + + + }} + /> + + + + + ); +} diff --git a/app/components/IconPicker/components/EmojiPanel.tsx b/app/components/IconPicker/components/EmojiPanel.tsx index b47005cd6e..f771c7d4e3 100644 --- a/app/components/IconPicker/components/EmojiPanel.tsx +++ b/app/components/IconPicker/components/EmojiPanel.tsx @@ -6,7 +6,7 @@ import type { EmojiSkinTone } from "@shared/types"; import { EmojiCategory, IconType } from "@shared/types"; import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji"; import Flex from "~/components/Flex"; -import { EmojiCreateDialog } from "~/components/EmojiCreateDialog"; +import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog"; import { DisplayCategory } from "../utils"; import type { DataNode, EmojiNode } from "./GridTemplate"; import GridTemplate from "./GridTemplate"; diff --git a/app/hooks/useEmojiMenuActions.tsx b/app/hooks/useEmojiMenuActions.tsx index cf3932fec4..24ed17a5c5 100644 --- a/app/hooks/useEmojiMenuActions.tsx +++ b/app/hooks/useEmojiMenuActions.tsx @@ -1,9 +1,10 @@ import * as React from "react"; -import { TrashIcon } from "outline-icons"; +import { ReplaceIcon, TrashIcon } from "outline-icons"; import { Trans, useTranslation } from "react-i18next"; import { toast } from "sonner"; import type Emoji from "~/models/Emoji"; import ConfirmationDialog from "~/components/ConfirmationDialog"; +import { EmojiReplaceDialog } from "~/components/EmojiDialog/EmojiReplaceDialog"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { createAction } from "~/actions"; @@ -12,7 +13,7 @@ import { useMenuAction } from "~/hooks/useMenuAction"; /** * Hook that constructs the action menu for emoji management operations. - * + * * @param targetEmoji - the emoji to build actions for, or null to skip. * @returns action with children for use in menus, or undefined if emoji is null. */ @@ -21,6 +22,21 @@ export function useEmojiMenuActions(targetEmoji: Emoji | null) { const { dialogs } = useStores(); const can = usePolicy(targetEmoji ?? ({} as Emoji)); + const openReplaceDialog = React.useCallback(() => { + if (!targetEmoji) { + return; + } + dialogs.openModal({ + title: t("Replace image"), + content: ( + + ), + }); + }, [t, targetEmoji, dialogs]); + const openDeleteDialog = React.useCallback(() => { if (!targetEmoji) { return; @@ -28,27 +44,55 @@ export function useEmojiMenuActions(targetEmoji: Emoji | null) { dialogs.openModal({ title: t("Delete Emoji"), content: ( - + ), }); }, [t, targetEmoji, dialogs]); - const actionList = React.useMemo( - () => - !targetEmoji || !can.delete - ? [] - : [ - createAction({ - name: `${t("Delete")}…`, - icon: , - section: EmojiSecion, - visible: true, - dangerous: true, - perform: openDeleteDialog, - }), - ], - [t, targetEmoji, can.delete, openDeleteDialog] - ); + const actionList = React.useMemo(() => { + if (!targetEmoji) { + return []; + } + + const actions = []; + + if (can.update) { + actions.push( + createAction({ + name: `${t("Replace")}…`, + icon: , + section: EmojiSecion, + visible: true, + perform: openReplaceDialog, + }) + ); + } + + if (can.delete) { + actions.push( + createAction({ + name: `${t("Delete")}…`, + icon: , + section: EmojiSecion, + visible: true, + dangerous: true, + perform: openDeleteDialog, + }) + ); + } + + return actions; + }, [ + t, + targetEmoji, + can.update, + can.delete, + openReplaceDialog, + openDeleteDialog, + ]); return useMenuAction(actionList); } diff --git a/app/scenes/Settings/components/EmojisTable.tsx b/app/scenes/Settings/components/EmojisTable.tsx index 82bd8da2f7..a93cad326d 100644 --- a/app/scenes/Settings/components/EmojisTable.tsx +++ b/app/scenes/Settings/components/EmojisTable.tsx @@ -70,7 +70,12 @@ const EmojisTable = observer(function EmojisTable({ accessor: (emoji) => emoji.url, component: (emoji) => ( - + :{emoji.name}: ), diff --git a/app/stores/EmojiStore.ts b/app/stores/EmojiStore.ts index 007d91fa67..616eb632ab 100644 --- a/app/stores/EmojiStore.ts +++ b/app/stores/EmojiStore.ts @@ -9,6 +9,7 @@ export default class EmojisStore extends Store { RPCAction.Info, RPCAction.List, RPCAction.Create, + RPCAction.Update, RPCAction.Delete, ]; diff --git a/server/policies/emoji.ts b/server/policies/emoji.ts index 6380b67178..604615ad58 100644 --- a/server/policies/emoji.ts +++ b/server/policies/emoji.ts @@ -6,6 +6,6 @@ allow(User, "createEmoji", Team, isTeamModel); allow(User, "read", Emoji, isTeamModel); -allow(User, "delete", Emoji, (actor, emoji) => +allow(User, ["update", "delete"], Emoji, (actor, emoji) => or(isOwner(actor, emoji), isTeamAdmin(actor, emoji)) ); diff --git a/server/routes/api/emojis/emojis.test.ts b/server/routes/api/emojis/emojis.test.ts new file mode 100644 index 0000000000..e08a2dcab5 --- /dev/null +++ b/server/routes/api/emojis/emojis.test.ts @@ -0,0 +1,170 @@ +import { Emoji, Attachment } from "@server/models"; +import { + buildAdmin, + buildAttachment, + buildEmoji, + buildUser, +} from "@server/test/factories"; +import { getTestServer } from "@server/test/support"; + +const server = getTestServer(); + +describe("#emojis.update", () => { + it("should require authentication", async () => { + const res = await server.post("/api/emojis.update", { + body: { + id: "00000000-0000-0000-0000-000000000000", + attachmentId: "00000000-0000-0000-0000-000000000000", + }, + }); + expect(res.status).toEqual(401); + }); + + it("should replace the emoji attachment", async () => { + const user = await buildUser(); + const emoji = await buildEmoji({ + teamId: user.teamId, + createdById: user.id, + }); + const oldAttachmentId = emoji.attachmentId; + + const newAttachment = await buildAttachment({ + teamId: user.teamId, + userId: user.id, + }); + + const res = await server.post("/api/emojis.update", { + body: { + token: user.getJwtToken(), + id: emoji.id, + attachmentId: newAttachment.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(emoji.id); + expect(body.data.name).toEqual(emoji.name); + + // Verify the emoji now points to the new attachment and user + const updated = await Emoji.findByPk(emoji.id); + expect(updated!.attachmentId).toEqual(newAttachment.id); + expect(updated!.createdById).toEqual(user.id); + + // Verify old attachment was cleaned up + const oldAttachment = await Attachment.findByPk(oldAttachmentId); + expect(oldAttachment).toBeNull(); + }); + + it("should allow a team admin to replace another user's emoji", async () => { + const admin = await buildAdmin(); + const user = await buildUser({ teamId: admin.teamId }); + const emoji = await buildEmoji({ + teamId: admin.teamId, + createdById: user.id, + }); + + const newAttachment = await buildAttachment({ + teamId: admin.teamId, + userId: admin.id, + }); + + const res = await server.post("/api/emojis.update", { + body: { + token: admin.getJwtToken(), + id: emoji.id, + attachmentId: newAttachment.id, + }, + }); + + expect(res.status).toEqual(200); + + // Verify createdById is updated to the admin who replaced it + const updated = await Emoji.findByPk(emoji.id); + expect(updated!.createdById).toEqual(admin.id); + }); + + it("should not allow a non-owner to replace another user's emoji", async () => { + const user = await buildUser(); + const otherUser = await buildUser({ teamId: user.teamId }); + const emoji = await buildEmoji({ + teamId: user.teamId, + createdById: otherUser.id, + }); + + const newAttachment = await buildAttachment({ + teamId: user.teamId, + userId: user.id, + }); + + const res = await server.post("/api/emojis.update", { + body: { + token: user.getJwtToken(), + id: emoji.id, + attachmentId: newAttachment.id, + }, + }); + + expect(res.status).toEqual(403); + }); + + it("should not allow updating an emoji from another team", async () => { + const user = await buildUser(); + const otherUser = await buildUser(); + const emoji = await buildEmoji({ + teamId: otherUser.teamId, + createdById: otherUser.id, + }); + + const newAttachment = await buildAttachment({ + teamId: user.teamId, + userId: user.id, + }); + + const res = await server.post("/api/emojis.update", { + body: { + token: user.getJwtToken(), + id: emoji.id, + attachmentId: newAttachment.id, + }, + }); + + expect(res.status).toEqual(403); + }); + + it("should return 404 for non-existent emoji", async () => { + const user = await buildUser(); + const newAttachment = await buildAttachment({ + teamId: user.teamId, + userId: user.id, + }); + + const res = await server.post("/api/emojis.update", { + body: { + token: user.getJwtToken(), + id: "00000000-0000-0000-0000-000000000000", + attachmentId: newAttachment.id, + }, + }); + + expect(res.status).toEqual(404); + }); + + it("should return 404 for non-existent attachment", async () => { + const user = await buildUser(); + const emoji = await buildEmoji({ + teamId: user.teamId, + createdById: user.id, + }); + + const res = await server.post("/api/emojis.update", { + body: { + token: user.getJwtToken(), + id: emoji.id, + attachmentId: "00000000-0000-0000-0000-000000000000", + }, + }); + + expect(res.status).toEqual(404); + }); +}); diff --git a/server/routes/api/emojis/emojis.ts b/server/routes/api/emojis/emojis.ts index f301b06fd6..49fd382464 100644 --- a/server/routes/api/emojis/emojis.ts +++ b/server/routes/api/emojis/emojis.ts @@ -198,6 +198,56 @@ router.post( } ); +router.post( + "emojis.update", + auth(), + validate(T.EmojisUpdateSchema), + transaction(), + async (ctx: APIContext) => { + const { id, attachmentId } = ctx.input.body; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + const emoji = await Emoji.findByPk(id, { + transaction, + rejectOnEmpty: true, + lock: transaction.LOCK.UPDATE, + }); + authorize(user, "update", emoji); + + const attachment = await Attachment.findByPk(attachmentId, { + transaction, + rejectOnEmpty: true, + }); + authorize(user, "read", attachment); + + // Capture old attachment before reassigning so we can clean it up. + const oldAttachmentId = emoji.attachmentId; + + emoji.attachmentId = attachmentId; + emoji.createdById = user.id; + await emoji.save({ transaction }); + + if (oldAttachmentId !== attachmentId) { + const oldAttachment = await Attachment.findByPk(oldAttachmentId, { + transaction, + lock: transaction.LOCK.UPDATE, + }); + if (oldAttachment) { + await oldAttachment.destroy({ transaction }); + } + } + + emoji.attachment = attachment; + emoji.createdBy = user; + + ctx.body = { + data: presentEmoji(emoji), + policies: presentPolicies(user, [emoji]), + }; + } +); + router.post( "emojis.delete", auth(), diff --git a/server/routes/api/emojis/schema.ts b/server/routes/api/emojis/schema.ts index a2af24aa15..095f3cd7af 100644 --- a/server/routes/api/emojis/schema.ts +++ b/server/routes/api/emojis/schema.ts @@ -31,6 +31,15 @@ export const EmojisCreateSchema = BaseSchema.extend({ }), }); +export const EmojisUpdateSchema = BaseSchema.extend({ + body: z.object({ + /** ID of the emoji to update */ + id: z.uuid(), + /** ID of the new attachment to use as the emoji image */ + attachmentId: z.uuid(), + }), +}); + export const EmojisDeleteSchema = BaseSchema.extend({ body: z.object({ /** ID of the emoji to delete */ @@ -55,4 +64,6 @@ export type EmojisListReq = z.infer; export type EmojisCreateReq = z.infer; +export type EmojisUpdateReq = z.infer; + export type EmojisDeleteReq = z.infer; diff --git a/shared/components/CustomEmoji.tsx b/shared/components/CustomEmoji.tsx index 7b6fcc775b..fe11599875 100644 --- a/shared/components/CustomEmoji.tsx +++ b/shared/components/CustomEmoji.tsx @@ -3,14 +3,28 @@ import useShare from "@shared/hooks/useShare"; type Props = React.ImgHTMLAttributes & { value: string; size?: number | string; + /** Optional cache-busting key, e.g. updatedAt timestamp. */ + cacheKey?: string; }; -export const CustomEmoji = ({ value, size = 16, ...props }: Props) => { +export const CustomEmoji = ({ + value, + size = 16, + cacheKey, + ...props +}: Props) => { const { shareId } = useShare(); + let src = `/api/emojis.redirect?id=${value}`; + if (shareId) { + src += `&shareId=${shareId}`; + } + if (cacheKey) { + src += `&v=${encodeURIComponent(cacheKey)}`; + } return ( diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 3750c866ab..585a37016b 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -299,19 +299,21 @@ "Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}", "File type not supported. Please use PNG, JPG, GIF, or WebP.": "File type not supported. Please use PNG, JPG, GIF, or WebP.", "File size too large. Maximum size is {{ size }}.": "File size too large. Maximum size is {{ size }}.", - "Please enter a name for the emoji": "Please enter a name for the emoji", - "Please select an image file": "Please select an image file", - "Emoji created successfully": "Emoji created successfully", - "Add emoji": "Add emoji", - "Square images with transparent backgrounds work best. If your image is too large, we’ll try to resize it for you.": "Square images with transparent backgrounds work best. If your image is too large, we’ll try to resize it for you.", - "Upload an image": "Upload an image", "Click or drag to replace": "Click or drag to replace", "Drop the image here": "Drop the image here", "Click, drop, or paste an image here": "Click, drop, or paste an image here", "PNG, JPG, GIF, or WebP up to {{ size }}": "PNG, JPG, GIF, or WebP up to {{ size }}", + "Please enter a name for the emoji": "Please enter a name for the emoji", + "Please select an image file": "Please select an image file", + "Emoji created successfully": "Emoji created successfully", + "Add emoji": "Add emoji", + "Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you.": "Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you.", + "Upload an image": "Upload an image", "Choose a name": "Choose a name", "name can only contain lowercase letters, numbers, and underscores.": "name can only contain lowercase letters, numbers, and underscores.", "This emoji will be available as": "This emoji will be available as", + "Emoji replaced": "Emoji replaced", + "Upload a new image to replace the current one for {{emojiName}}. All existing uses of this emoji will be updated automatically.": "Upload a new image to replace the current one for {{emojiName}}. All existing uses of this emoji will be updated automatically.", "Module failed to load": "Module failed to load", "Loading Failed": "Loading Failed", "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",