feat: Allow replacing custom emoji image (#11998)

* feat: Allow replacing custom emoji image
This commit is contained in:
Tom Moor
2026-04-10 18:51:59 -04:00
committed by GitHub
parent c6a1db6bd1
commit fb9f4bb991
15 changed files with 707 additions and 264 deletions
+1 -1
View File
@@ -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")}`,
-233
View File
@@ -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<File | null>(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<HTMLInputElement>) => {
const { value } = event.target;
setName(value);
};
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
const isValid = name.trim().length > 0 && file && isValidName;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"Square images with transparent backgrounds work best. If your image is too large, well try to resize it for you."
)}
</Text>
<LabelText as="label">{t("Upload an image")}</LabelText>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<VStack>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text size="medium">{file.name}</Text>
<Text size="medium" type="secondary">
{t("Click or drag to replace")}
</Text>
</>
) : (
<>
<Text size="medium">
{isDragActive
? t("Drop the image here")
: t("Click, drop, or paste an image here")}
</Text>
<Text size="medium" type="secondary">
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
size: bytesToHumanReadable(
AttachmentValidation.emojiMaxFileSize
),
})}
</Text>
</>
)}
</VStack>
</DropZone>
<Input
label={t("Choose a name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
{name.trim() && isValidName && (
<Text type="secondary" style={{ marginTop: "8px" }}>
{t("This emoji will be available as")} <code>:{name}:</code>
</Text>
)}
</ConfirmationDialog>
);
}
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;
`;
+161
View File
@@ -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<File | null>(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<typeof useDropzone>["getRootProps"];
/** Dropzone input props. */
getInputProps: ReturnType<typeof useDropzone>["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 (
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<VStack>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text size="medium">{file.name}</Text>
<Text size="medium" type="secondary">
{t("Click or drag to replace")}
</Text>
</>
) : (
<>
<Text size="small">
{isDragActive
? t("Drop the image here")
: t("Click, drop, or paste an image here")}
</Text>
<Text size="small" type="secondary">
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
size: bytesToHumanReadable(
AttachmentValidation.emojiMaxFileSize
),
})}
</Text>
</>
)}
</VStack>
</DropZone>
);
}
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;
`;
@@ -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<HTMLInputElement>) => {
const { value } = event.target;
setName(value);
};
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
const isValid = name.trim().length > 0 && file && isValidName;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you."
)}
</Text>
<LabelText as="label">{t("Upload an image")}</LabelText>
<EmojiImageDropZone
file={file}
getRootProps={getRootProps}
getInputProps={getInputProps}
isDragActive={isDragActive}
/>
<Input
label={t("Choose a name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
{name.trim() && isValidName && (
<Text type="secondary" style={{ marginTop: "8px" }}>
{t("This emoji will be available as")} <code>:{name}:</code>
</Text>
)}
</ConfirmationDialog>
);
}
@@ -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 (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!file || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Save")}
>
<Text as="p" type="secondary">
<Trans
defaults="Upload a new image to replace the current one for <em>{{emojiName}}</em>. All existing uses of this emoji will be updated automatically."
values={{ emojiName: `:${emoji.name}:` }}
components={{ em: <code /> }}
/>
</Text>
<EmojiImageDropZone
file={file}
getRootProps={getRootProps}
getInputProps={getInputProps}
isDragActive={isDragActive}
/>
</ConfirmationDialog>
);
}
@@ -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";
+63 -19
View File
@@ -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: (
<EmojiReplaceDialog
emoji={targetEmoji}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [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: (
<DeleteEmojiDialog emoji={targetEmoji} onSubmit={dialogs.closeAllModals} />
<DeleteEmojiDialog
emoji={targetEmoji}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, targetEmoji, dialogs]);
const actionList = React.useMemo(
() =>
!targetEmoji || !can.delete
? []
: [
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
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: <ReplaceIcon />,
section: EmojiSecion,
visible: true,
perform: openReplaceDialog,
})
);
}
if (can.delete) {
actions.push(
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: EmojiSecion,
visible: true,
dangerous: true,
perform: openDeleteDialog,
})
);
}
return actions;
}, [
t,
targetEmoji,
can.update,
can.delete,
openReplaceDialog,
openDeleteDialog,
]);
return useMenuAction(actionList);
}
@@ -70,7 +70,12 @@ const EmojisTable = observer(function EmojisTable({
accessor: (emoji) => emoji.url,
component: (emoji) => (
<EmojiPreview>
<CustomEmoji value={emoji.id} alt={emoji.name} size={28} />
<CustomEmoji
value={emoji.id}
alt={emoji.name}
size={28}
cacheKey={emoji.updatedAt}
/>
<span>:{emoji.name}:</span>
</EmojiPreview>
),
+1
View File
@@ -9,6 +9,7 @@ export default class EmojisStore extends Store<Emoji> {
RPCAction.Info,
RPCAction.List,
RPCAction.Create,
RPCAction.Update,
RPCAction.Delete,
];
+1 -1
View File
@@ -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))
);
+170
View File
@@ -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);
});
});
+50
View File
@@ -198,6 +198,56 @@ router.post(
}
);
router.post(
"emojis.update",
auth(),
validate(T.EmojisUpdateSchema),
transaction(),
async (ctx: APIContext<T.EmojisUpdateReq>) => {
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(),
+11
View File
@@ -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<typeof EmojisListSchema>;
export type EmojisCreateReq = z.infer<typeof EmojisCreateSchema>;
export type EmojisUpdateReq = z.infer<typeof EmojisUpdateSchema>;
export type EmojisDeleteReq = z.infer<typeof EmojisDeleteSchema>;
+16 -2
View File
@@ -3,14 +3,28 @@ import useShare from "@shared/hooks/useShare";
type Props = React.ImgHTMLAttributes<HTMLImageElement> & {
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 (
<img
alt=""
src={`/api/emojis.redirect?id=${value}${shareId ? `&shareId=${shareId}` : ""}`}
src={src}
style={{ width: size, height: size, objectFit: "contain" }}
{...props}
/>
+8 -6
View File
@@ -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, well try to resize it for you.": "Square images with transparent backgrounds work best. If your image is too large, well 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 <em>{{emojiName}}</em>. All existing uses of this emoji will be updated automatically.": "Upload a new image to replace the current one for <em>{{emojiName}}</em>. 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.",