mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
feat: Allow replacing custom emoji image (#11998)
* feat: Allow replacing custom emoji image
This commit is contained in:
@@ -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")}…`,
|
||||
|
||||
@@ -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, we’ll 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;
|
||||
`;
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ export default class EmojisStore extends Store<Emoji> {
|
||||
RPCAction.Info,
|
||||
RPCAction.List,
|
||||
RPCAction.Create,
|
||||
RPCAction.Update,
|
||||
RPCAction.Delete,
|
||||
];
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 <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.",
|
||||
|
||||
Reference in New Issue
Block a user