mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aad383a12 | |||
| ef2ea41d63 | |||
| 44f84ff4a5 | |||
| 7b143029a3 | |||
| e0e6547313 | |||
| dfdfbbe2f9 | |||
| 1697c94b1a | |||
| 5d8756d57f | |||
| b565328e02 | |||
| af7e932c56 | |||
| cf918e45a5 | |||
| 20f75f741d | |||
| 5423753139 | |||
| a08e89ca4d | |||
| af8174eec8 | |||
| d196afa30a |
@@ -0,0 +1,21 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import { createAction } from "~/actions";
|
||||
import { TeamSection } from "../sections";
|
||||
import stores from "~/stores";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
|
||||
|
||||
export const createEmoji = createAction({
|
||||
name: ({ t }) => `${t("New emoji")}…`,
|
||||
analyticsName: "Create emoji",
|
||||
icon: <PlusIcon />,
|
||||
keywords: "emoji custom upload image",
|
||||
section: TeamSection,
|
||||
visible: () =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Upload emoji"),
|
||||
content: <EmojiCreateDialog onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -38,6 +38,8 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const GroupSection = ({ t }: ActionContext) => t("Groups");
|
||||
|
||||
export const EmojiSecion = ({ t }: ActionContext) => t("Emoji");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
UserSection.priority = 0.5;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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 ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { EmojiValidation } from "@shared/validations";
|
||||
|
||||
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 onDrop = React.useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
setFile(acceptedFiles[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDropAccepted: onDrop,
|
||||
accept: [".png", ".jpg", ".jpeg", ".gif", ".webp"],
|
||||
maxFiles: 1,
|
||||
maxSize: 1024 * 1024, // 1MB
|
||||
});
|
||||
|
||||
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 compressed = await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
});
|
||||
|
||||
const emoji = await emojis.create({
|
||||
name: name.trim(),
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
emojis.add(emoji);
|
||||
toast.success(t("Emoji created successfully"));
|
||||
onSubmit();
|
||||
} catch (error) {
|
||||
toast.error(t("Failed to create emoji"));
|
||||
Logger.error("Failed to create emoji", error);
|
||||
} 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(
|
||||
"Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores."
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
label={t("Emoji name")}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my_custom_emoji"
|
||||
autoFocus
|
||||
required
|
||||
error={
|
||||
!isValidName
|
||||
? t(
|
||||
"name can only should lowercase letters, numbers, and underscores."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DropZone {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<Flex column align="center" gap={8}>
|
||||
{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 or drag an image here")}
|
||||
</Text>
|
||||
<Text size="medium" type="secondary">
|
||||
{t("PNG, JPG, GIF, or WebP up to 1MB")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</DropZone>
|
||||
|
||||
{name.trim() && isValidName && (
|
||||
<div style={{ marginTop: "8px" }}>
|
||||
<Text type="secondary">
|
||||
{t("This emoji will be available as")} <code>:{name}:</code>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const DropZone = styled.div`
|
||||
border: 2px dashed ${s("divider")};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: ${s("accent")};
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewImage = styled.img`
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
@@ -33,7 +33,7 @@ import TrashLink from "./components/TrashLink";
|
||||
|
||||
function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { documents, ui, collections } = useStores();
|
||||
const { documents, ui, collections, emojis } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team);
|
||||
@@ -43,6 +43,7 @@ function AppSidebar() {
|
||||
|
||||
if (!user.isViewer) {
|
||||
void documents.fetchDrafts();
|
||||
void emojis.fetchAll();
|
||||
}
|
||||
}, [documents, collections, user.isViewer]);
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
|
||||
import { search as emojiSearch } from "@shared/utils/emoji";
|
||||
import type { Emoji as ShortEmojiType } from "@shared/types";
|
||||
import EmojiMenuItem from "./EmojiMenuItem";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Emoji = {
|
||||
name: string;
|
||||
title: string;
|
||||
emoji: string;
|
||||
description: string;
|
||||
attrs: { markup: string; "data-name": string };
|
||||
attrs: { markup: string; "data-name": string; "data-url"?: string };
|
||||
};
|
||||
|
||||
type Props = Omit<
|
||||
@@ -22,28 +25,37 @@ type Props = Omit<
|
||||
|
||||
const EmojiMenu = (props: Props) => {
|
||||
const { search = "" } = props;
|
||||
const [items, setItems] = useState<Emoji[]>([]);
|
||||
const { emojis } = useStores();
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
emojiSearch({ query: search })
|
||||
.map((item) => {
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
// @ts-expect-error emojiMartToGemoji key
|
||||
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
|
||||
const emoji = item.value;
|
||||
useEffect(() => {
|
||||
const updateItems = (results: ShortEmojiType[]) => {
|
||||
setItems(results.map(toMenuItem).slice(0, 15));
|
||||
};
|
||||
|
||||
return {
|
||||
name: "emoji",
|
||||
title: emoji,
|
||||
description: capitalize(item.name.toLowerCase()),
|
||||
emoji,
|
||||
attrs: { markup: shortcode, "data-name": shortcode },
|
||||
};
|
||||
})
|
||||
.slice(0, 15),
|
||||
[search]
|
||||
);
|
||||
// search through regular emojis
|
||||
const localResults = emojiSearch({ query: search });
|
||||
updateItems(localResults);
|
||||
|
||||
// Fetch and merge custom emojis
|
||||
emojis.fetchPage({ query: search }).then((serverData) => {
|
||||
if (!serverData.length) {return;}
|
||||
|
||||
const customEmojis = serverData.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
search: e.name,
|
||||
value: e.url,
|
||||
}));
|
||||
|
||||
const mergedResults = emojiSearch({
|
||||
query: search,
|
||||
emojis: customEmojis,
|
||||
});
|
||||
|
||||
updateItems(mergedResults);
|
||||
});
|
||||
}, [search, emojis]);
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
@@ -67,4 +79,25 @@ const EmojiMenu = (props: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const toMenuItem = (item: ShortEmojiType): Emoji => {
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
// @ts-expect-error emojiMartToGemoji key
|
||||
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
|
||||
const emoji = item.value;
|
||||
const isCustom = isInternalUrl(emoji);
|
||||
|
||||
return {
|
||||
name: "emoji",
|
||||
title: emoji,
|
||||
description: capitalize(item.name.toLowerCase()),
|
||||
emoji,
|
||||
attrs: {
|
||||
markup: shortcode,
|
||||
"data-name": isCustom ? item.name : shortcode,
|
||||
"data-url": isCustom ? emoji : undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default EmojiMenu;
|
||||
|
||||
@@ -2,6 +2,8 @@ import styled from "styled-components";
|
||||
import SuggestionsMenuItem, {
|
||||
Props as SuggestionsMenuItemProps,
|
||||
} from "./SuggestionsMenuItem";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { EmojiImage } from "@shared/components/customEmojis";
|
||||
|
||||
const Emoji = styled.span`
|
||||
font-size: 16px;
|
||||
@@ -19,7 +21,11 @@ export default function EmojiMenuItem({ emoji, ...rest }: EmojiMenuItemProps) {
|
||||
return (
|
||||
<SuggestionsMenuItem
|
||||
{...rest}
|
||||
icon={<Emoji className="emoji">{emoji}</Emoji>}
|
||||
icon={
|
||||
<Emoji className="emoji">
|
||||
{isInternalUrl(emoji) ? <EmojiImage src={emoji} /> : emoji}
|
||||
</Emoji>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Icon,
|
||||
PlusIcon,
|
||||
InternetIcon,
|
||||
SmileyIcon,
|
||||
} from "outline-icons";
|
||||
import { ComponentProps, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -44,6 +45,7 @@ const Profile = lazy(() => import("~/scenes/Settings/Profile"));
|
||||
const Security = lazy(() => import("~/scenes/Settings/Security"));
|
||||
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
|
||||
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
|
||||
const CustomEmojis = lazy(() => import("~/scenes/Settings/CustomEmojis"));
|
||||
|
||||
export type ConfigItem = {
|
||||
name: string;
|
||||
@@ -162,6 +164,15 @@ const useSettingsConfig = () => {
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
{
|
||||
name: t("Emojis"),
|
||||
path: settingsPath("emojis"),
|
||||
component: CustomEmojis.Component,
|
||||
preload: CustomEmojis.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: SmileyIcon,
|
||||
},
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("api-keys"),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import { IconButton } from "~/components/IconPicker/components/IconButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Emoji from "~/models/Emoji";
|
||||
|
||||
const EmojisMenu = ({ emoji }: { emoji: Emoji }) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(emoji);
|
||||
|
||||
const handleDelete = () => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete Emoji"),
|
||||
content: (
|
||||
<DeleteEmojiDialog emoji={emoji} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (!can.delete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={`${t("Delete Emoji")}…`}>
|
||||
<IconButton onClick={handleDelete}>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteEmojiDialog = ({
|
||||
emoji,
|
||||
onSubmit,
|
||||
}: {
|
||||
emoji: Emoji;
|
||||
onSubmit: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (emoji) {
|
||||
await emoji.delete();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
|
||||
values={{
|
||||
emojiName: emoji.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojisMenu;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { observable } from "mobx";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Emoji extends Model {
|
||||
static modelName = "Emoji";
|
||||
|
||||
/** The name of the emoji */
|
||||
@Field
|
||||
@observable
|
||||
private _name: string;
|
||||
|
||||
/** The URL of the emoji image */
|
||||
@Field
|
||||
@observable
|
||||
url: string;
|
||||
|
||||
/** The ID */
|
||||
@Field
|
||||
@observable
|
||||
attachmentId: string;
|
||||
|
||||
/** The user who created this emoji */
|
||||
@Relation(() => User)
|
||||
@observable
|
||||
createdBy?: User;
|
||||
|
||||
/** The ID of the user who created this emoji */
|
||||
@Field
|
||||
@observable
|
||||
createdById: string;
|
||||
|
||||
/**
|
||||
* emoji name
|
||||
*/
|
||||
get name() {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
set name(value: string) {
|
||||
this._name = value;
|
||||
}
|
||||
}
|
||||
|
||||
export default Emoji;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon, SmileyIcon } from "outline-icons";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createEmoji } from "~/actions/definitions/emojis";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import EmojisTable from "./components/EmojisTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import EmojisStore from "~/stores/EmojiStore";
|
||||
|
||||
function Emojis() {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const team = useCurrentTeam();
|
||||
const context = useActionContext();
|
||||
const { emojis } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const params = useQuery();
|
||||
const can = usePolicy(team);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const reqParams = useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
sort: params.get("sort") || "createdAt",
|
||||
direction: (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC",
|
||||
}),
|
||||
[params]
|
||||
);
|
||||
|
||||
const sort: ColumnSort = useMemo(
|
||||
() => ({
|
||||
id: reqParams.sort,
|
||||
desc: reqParams.direction === "DESC",
|
||||
}),
|
||||
[reqParams.sort, reqParams.direction]
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: getFilteredEmojis({
|
||||
emojis,
|
||||
query: reqParams.query,
|
||||
}),
|
||||
sort,
|
||||
reqFn: emojis.fetchPage,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
const updateParams = useCallback(
|
||||
(name: string, value: string) => {
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete(name);
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load emojis"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => updateParams("query", query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateParams]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("Emojis")}
|
||||
icon={<SmileyIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createEmoji && (
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
data-on="click"
|
||||
data-event-category="emoji"
|
||||
data-event-action="create"
|
||||
action={createEmoji}
|
||||
context={context}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("New emoji")}…
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
wide
|
||||
>
|
||||
<Heading>{t("Emojis")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Custom emojis can be used throughout your workspace in documents, comments, and reactions."
|
||||
)}
|
||||
</Text>
|
||||
<StickyFilters gap={8}>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<EmojisTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
canManage={can.update}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</ConditionalFade>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
function getFilteredEmojis({
|
||||
emojis,
|
||||
query,
|
||||
}: {
|
||||
emojis: EmojisStore;
|
||||
query?: string;
|
||||
}) {
|
||||
let filteredEmojis = emojis.orderedData;
|
||||
|
||||
if (query) {
|
||||
filteredEmojis = filteredEmojis.filter((emoji) =>
|
||||
emoji.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filteredEmojis;
|
||||
}
|
||||
|
||||
export default observer(Emojis);
|
||||
@@ -0,0 +1,94 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Emoji from "~/models/Emoji";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import {
|
||||
type Props as TableProps,
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Time from "~/components/Time";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
import { EmojiImage, EmojiPreview } from "@shared/components/customEmojis";
|
||||
import EmojisMenu from "~/menus/EmojisMenu";
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<Emoji>, "columns" | "rowHeight"> & {
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
const EmojisTable = observer(function EmojisTable({
|
||||
canManage,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = React.useMemo(
|
||||
(): TableColumn<Emoji>[] =>
|
||||
compact([
|
||||
{
|
||||
type: "data",
|
||||
id: "name",
|
||||
header: t("Emoji"),
|
||||
accessor: (emoji) => emoji.url,
|
||||
component: (emoji) => (
|
||||
<EmojiPreview>
|
||||
<EmojiImage src={emoji.url} alt={emoji.name} />
|
||||
<span>:{emoji.name}:</span>
|
||||
</EmojiPreview>
|
||||
),
|
||||
width: "1fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdBy",
|
||||
header: t("Added by"),
|
||||
accessor: (emoji) => emoji.createdBy,
|
||||
sortable: false,
|
||||
component: (emoji) => (
|
||||
<Flex align="center" gap={8}>
|
||||
{emoji.createdBy && (
|
||||
<>
|
||||
<Avatar model={emoji.createdBy} size={AvatarSize.Small} />
|
||||
{emoji.createdBy.name}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
header: t("Date added"),
|
||||
accessor: (emoji) => emoji.createdAt,
|
||||
component: (emoji) => <Time dateTime={emoji.createdAt} addSuffix />,
|
||||
width: "1fr",
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (emoji) => <EmojisMenu emoji={emoji} />,
|
||||
width: "50px",
|
||||
},
|
||||
]),
|
||||
[t, canManage]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default EmojisTable;
|
||||
@@ -0,0 +1,16 @@
|
||||
import Emoji from "~/models/Emoji";
|
||||
import RootStore from "./RootStore";
|
||||
import Store, { RPCAction } from "./base/Store";
|
||||
|
||||
export default class EmojisStore extends Store<Emoji> {
|
||||
actions = [
|
||||
RPCAction.List,
|
||||
RPCAction.Create,
|
||||
RPCAction.Update,
|
||||
RPCAction.Delete,
|
||||
];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Emoji);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import DialogsStore from "./DialogsStore";
|
||||
import DocumentPresenceStore from "./DocumentPresenceStore";
|
||||
import DocumentsStore from "./DocumentsStore";
|
||||
import EventsStore from "./EventsStore";
|
||||
import EmojisStore from "./EmojiStore";
|
||||
import FileOperationsStore from "./FileOperationsStore";
|
||||
import GroupMembershipsStore from "./GroupMembershipsStore";
|
||||
import GroupUsersStore from "./GroupUsersStore";
|
||||
@@ -44,6 +45,7 @@ export default class RootStore {
|
||||
comments: CommentsStore;
|
||||
dialogs: DialogsStore;
|
||||
documents: DocumentsStore;
|
||||
emojis: EmojisStore;
|
||||
events: EventsStore;
|
||||
groups: GroupsStore;
|
||||
groupUsers: GroupUsersStore;
|
||||
@@ -77,6 +79,7 @@ export default class RootStore {
|
||||
this.registerStore(GroupMembershipsStore);
|
||||
this.registerStore(CommentsStore);
|
||||
this.registerStore(DocumentsStore);
|
||||
this.registerStore(EmojisStore);
|
||||
this.registerStore(EventsStore);
|
||||
this.registerStore(GroupsStore);
|
||||
this.registerStore(GroupUsersStore);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.createTable(
|
||||
"emojis",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
attachmentId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "attachments",
|
||||
key: "id",
|
||||
},
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
teamId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "teams",
|
||||
key: "id",
|
||||
},
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
createdById: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "users",
|
||||
key: "id",
|
||||
},
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await queryInterface.addIndex("emojis", ["teamId"], { transaction });
|
||||
await queryInterface.addIndex("emojis", ["createdById"], { transaction });
|
||||
await queryInterface.addIndex("emojis", ["attachmentId"], {
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex("emojis", ["teamId", "name"], {
|
||||
unique: true,
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.dropTable("emojis");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
type SaveOptions,
|
||||
} from "sequelize";
|
||||
import {
|
||||
BeforeCreate,
|
||||
BeforeDestroy,
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import { EmojiValidation } from "@shared/validations";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
import Length from "./validators/Length";
|
||||
import { Matches } from "class-validator";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import Attachment from "./Attachment";
|
||||
|
||||
@Table({ tableName: "emojis", modelName: "emoji" })
|
||||
@Fix
|
||||
class Emoji extends IdModel<
|
||||
InferAttributes<Emoji>,
|
||||
Partial<InferCreationAttributes<Emoji>>
|
||||
> {
|
||||
@Length({
|
||||
max: EmojiValidation.maxNameLength,
|
||||
msg: `emoji name must be less than ${EmojiValidation.maxNameLength} characters`,
|
||||
})
|
||||
@Matches(EmojiValidation.allowedNameCharacters, {
|
||||
message:
|
||||
"emoji name can only contain lowercase letters, numbers, and underscores",
|
||||
})
|
||||
@Column(DataType.STRING)
|
||||
name: string;
|
||||
|
||||
// associations
|
||||
@BelongsTo(() => Attachment, "attachmentId")
|
||||
attachment: Attachment;
|
||||
|
||||
@ForeignKey(() => Attachment)
|
||||
@Column(DataType.UUID)
|
||||
attachmentId: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
// hooks
|
||||
@BeforeCreate
|
||||
static async checkUniqueName(
|
||||
model: Emoji,
|
||||
options: SaveOptions<Emoji>
|
||||
): Promise<void> {
|
||||
const existingEmoji = await this.findOne({
|
||||
where: {
|
||||
name: model.name,
|
||||
teamId: model.teamId,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
|
||||
if (existingEmoji) {
|
||||
throw ValidationError(`Emoji with name "${model.name}" already exists.`);
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeDestroy
|
||||
static async deleteAttachmentFromS3(model: Emoji) {
|
||||
const attachment = await Attachment.findByPk(model.attachmentId);
|
||||
if (attachment) {
|
||||
await FileStorage.deleteFile(attachment.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Emoji;
|
||||
@@ -67,3 +67,5 @@ export { default as WebhookSubscription } from "./WebhookSubscription";
|
||||
export { default as WebhookDelivery } from "./WebhookDelivery";
|
||||
|
||||
export { default as Subscription } from "./Subscription";
|
||||
|
||||
export { default as Emoji } from "./Emoji";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { User, Emoji, Team } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { isOwner, isTeamAdmin, isTeamModel, or } from "./utils";
|
||||
|
||||
allow(User, "createEmoji", Team, isTeamModel);
|
||||
|
||||
allow(User, "read", Emoji, isTeamModel);
|
||||
|
||||
allow(User, "delete", Emoji, (actor, emoji) =>
|
||||
or(isOwner(actor, emoji), isTeamAdmin(actor, emoji))
|
||||
);
|
||||
@@ -26,3 +26,4 @@ import "./team";
|
||||
import "./group";
|
||||
import "./webhookSubscription";
|
||||
import "./userMembership";
|
||||
import "./emoji";
|
||||
|
||||
@@ -55,6 +55,9 @@ export function isOwner(
|
||||
if ("userId" in model) {
|
||||
return actor.id === model.userId;
|
||||
}
|
||||
if ("createdById" in model) {
|
||||
return actor.id === model.createdById;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Emoji } from "@server/models";
|
||||
import presentUser from "./user";
|
||||
|
||||
export default function present(emoji: Emoji) {
|
||||
return {
|
||||
id: emoji.id,
|
||||
name: emoji.name,
|
||||
teamId: emoji.teamId,
|
||||
url: emoji.attachment?.url,
|
||||
createdBy: emoji.createdBy ? presentUser(emoji.createdBy) : undefined,
|
||||
createdById: emoji.createdById,
|
||||
createdAt: emoji.createdAt,
|
||||
updatedAt: emoji.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import presentSubscription from "./subscription";
|
||||
import presentTeam from "./team";
|
||||
import presentUser from "./user";
|
||||
import presentView from "./view";
|
||||
import presentEmoji from "./emoji";
|
||||
|
||||
export {
|
||||
presentApiKey,
|
||||
@@ -61,4 +62,5 @@ export {
|
||||
presentTeam,
|
||||
presentUser,
|
||||
presentView,
|
||||
presentEmoji,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Router from "koa-router";
|
||||
import { WhereOptions, Op } from "sequelize";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Emoji, User, Attachment } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentEmoji, presentPolicies } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
"emojis.info",
|
||||
auth(),
|
||||
validate(T.EmojisInfoSchema),
|
||||
async (ctx: APIContext<T.EmojisInfoReq>) => {
|
||||
const { id, name } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const include = [
|
||||
{
|
||||
model: User,
|
||||
as: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: Attachment,
|
||||
as: "attachment",
|
||||
paranoid: false,
|
||||
},
|
||||
];
|
||||
|
||||
let emoji;
|
||||
if (id) {
|
||||
emoji = await Emoji.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
include,
|
||||
});
|
||||
} else if (name) {
|
||||
emoji = await Emoji.findOne({
|
||||
where: {
|
||||
name,
|
||||
teamId: user.teamId,
|
||||
},
|
||||
include,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
}
|
||||
|
||||
authorize(user, "read", emoji);
|
||||
|
||||
ctx.body = {
|
||||
data: presentEmoji(emoji),
|
||||
policies: presentPolicies(user, [emoji]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"emojis.list",
|
||||
auth(),
|
||||
pagination(),
|
||||
validate(T.EmojisListSchema),
|
||||
async (ctx: APIContext<T.EmojisListReq>) => {
|
||||
const { user } = ctx.state.auth;
|
||||
const { query } = ctx.input.body;
|
||||
|
||||
let where: WhereOptions<Emoji> = {
|
||||
teamId: user.teamId,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
where = {
|
||||
...where,
|
||||
name: {
|
||||
[Op.iLike]: `%${query}%`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [emojis, total] = await Promise.all([
|
||||
Emoji.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: Attachment,
|
||||
as: "attachment",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
}),
|
||||
Emoji.count({
|
||||
where,
|
||||
}),
|
||||
]);
|
||||
|
||||
ctx.body = {
|
||||
pagination: { ...ctx.state.pagination, total },
|
||||
data: emojis.map(presentEmoji),
|
||||
policies: presentPolicies(user, emojis),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"emojis.create",
|
||||
rateLimiter(RateLimiterStrategy.TenPerMinute),
|
||||
auth(),
|
||||
validate(T.EmojisCreateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.EmojisCreateReq>) => {
|
||||
const { name, attachmentId } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const attachment = await Attachment.findByPk(attachmentId, {
|
||||
transaction,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
authorize(user, "read", attachment);
|
||||
|
||||
const emoji = await Emoji.createWithCtx(ctx, {
|
||||
name,
|
||||
attachmentId,
|
||||
teamId: user.teamId,
|
||||
createdById: user.id,
|
||||
createdBy: user,
|
||||
});
|
||||
emoji.createdBy = user;
|
||||
emoji.attachment = attachment;
|
||||
|
||||
ctx.body = {
|
||||
data: presentEmoji(emoji),
|
||||
policies: presentPolicies(user, [emoji]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"emojis.delete",
|
||||
auth(),
|
||||
validate(T.EmojisDeleteSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.EmojisDeleteReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const emoji = await Emoji.findByPk(id, {
|
||||
transaction: ctx.state.transaction,
|
||||
rejectOnEmpty: true,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "delete", emoji);
|
||||
|
||||
await emoji.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./emojis";
|
||||
@@ -0,0 +1,45 @@
|
||||
export type EmojisInfoReq = z.infer<typeof EmojisInfoSchema>;
|
||||
import { z } from "zod";
|
||||
import { EmojiValidation } from "@shared/validations";
|
||||
import { BaseSchema } from "../schema";
|
||||
|
||||
export const EmojisInfoSchema = BaseSchema.extend({
|
||||
body: z
|
||||
.object({
|
||||
/** ID of the emoji to fetch */
|
||||
id: z.string().uuid().optional(),
|
||||
/** Name of the emoji to fetch */
|
||||
name: z.string().min(1).max(EmojiValidation.maxNameLength).optional(),
|
||||
})
|
||||
.refine((data) => data.id || data.name, {
|
||||
message: "Either id or name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export const EmojisListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
query: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const EmojisCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Name/shortcode for the emoji (e.g., "awesome") */
|
||||
name: z.string().min(1).max(EmojiValidation.maxNameLength),
|
||||
/** URL to the emoji image */
|
||||
attachmentId: z.string().uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const EmojisDeleteSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** ID of the emoji to delete */
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type EmojisListReq = z.infer<typeof EmojisListSchema>;
|
||||
|
||||
export type EmojisCreateReq = z.infer<typeof EmojisCreateSchema>;
|
||||
|
||||
export type EmojisDeleteReq = z.infer<typeof EmojisDeleteSchema>;
|
||||
@@ -18,6 +18,7 @@ import comments from "./comments/comments";
|
||||
import cron from "./cron";
|
||||
import developer from "./developer";
|
||||
import documents from "./documents";
|
||||
import emojis from "./emojis";
|
||||
import events from "./events";
|
||||
import fileOperationsRoute from "./fileOperations";
|
||||
import groupMemberships from "./groupMemberships";
|
||||
@@ -84,6 +85,7 @@ router.use("/", users.routes());
|
||||
router.use("/", collections.routes());
|
||||
router.use("/", comments.routes());
|
||||
router.use("/", documents.routes());
|
||||
router.use("/", emojis.routes());
|
||||
router.use("/", pins.routes());
|
||||
router.use("/", revisions.routes());
|
||||
router.use("/", views.routes());
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { s } from "../styles";
|
||||
import styled from "styled-components";
|
||||
|
||||
export const EmojiImage = styled.img`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
`;
|
||||
|
||||
export const EmojiPreview = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
@@ -828,6 +828,13 @@ h6:not(.placeholder)::before {
|
||||
margin-${props.rtl ? "right" : "left"}: -1em;
|
||||
}
|
||||
|
||||
.emoji.custom-emoji {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
vertical-align: text-bottom;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.heading-anchor,
|
||||
.heading-fold {
|
||||
display: inline-block;
|
||||
|
||||
@@ -11,6 +11,7 @@ import Extension from "../lib/Extension";
|
||||
import { getEmojiFromName } from "../lib/emoji";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import emojiRule from "../rules/emoji";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
|
||||
export default class Emoji extends Extension {
|
||||
get type() {
|
||||
@@ -28,6 +29,10 @@ export default class Emoji extends Extension {
|
||||
default: "grey_question",
|
||||
validate: "string",
|
||||
},
|
||||
"data-url": {
|
||||
default: null,
|
||||
validate: (url: string) => !url || isInternalUrl(url),
|
||||
},
|
||||
},
|
||||
inline: true,
|
||||
content: "text*",
|
||||
@@ -48,20 +53,33 @@ export default class Emoji extends Extension {
|
||||
],
|
||||
toDOM: (node) => {
|
||||
const name = node.attrs["data-name"];
|
||||
const url = node.attrs["data-url"];
|
||||
|
||||
return [
|
||||
"strong",
|
||||
{
|
||||
class: `emoji ${name}`,
|
||||
"data-name": name,
|
||||
},
|
||||
getEmojiFromName(name),
|
||||
];
|
||||
if (url) {
|
||||
return [
|
||||
"img",
|
||||
{
|
||||
class: `emoji custom-emoji ${name}`,
|
||||
"data-name": name,
|
||||
src: url,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
"strong",
|
||||
{
|
||||
class: `emoji ${name}`,
|
||||
"data-name": name,
|
||||
},
|
||||
getEmojiFromName(name),
|
||||
];
|
||||
}
|
||||
},
|
||||
leafText: (node) => getEmojiFromName(node.attrs["data-name"]),
|
||||
};
|
||||
}
|
||||
|
||||
// to do: custom emoji rules
|
||||
get rulePlugins() {
|
||||
return [emojiRule];
|
||||
}
|
||||
@@ -87,7 +105,17 @@ export default class Emoji extends Extension {
|
||||
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
const name = node.attrs["data-name"];
|
||||
if (name) {
|
||||
const url = node.attrs["data-url"];
|
||||
|
||||
if (url) {
|
||||
const alt = node.attrs["data-name"] || "";
|
||||
const prefix = state.inList ? "" : " ";
|
||||
const escapedAlt = state.esc(alt, false);
|
||||
const escapedUrl = state.esc(url, false);
|
||||
|
||||
const markdown = `${prefix}`;
|
||||
state.write(markdown);
|
||||
} else if (name) {
|
||||
state.write(`:${name}:`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,8 @@
|
||||
"You have left the shared document": "You have left the shared document",
|
||||
"Could not leave document": "Could not leave document",
|
||||
"Apply template": "Apply template",
|
||||
"New emoji": "New emoji",
|
||||
"Upload emoji": "Upload emoji",
|
||||
"Disconnect analytics": "Disconnect analytics",
|
||||
"Home": "Home",
|
||||
"Drafts": "Drafts",
|
||||
@@ -171,6 +173,7 @@
|
||||
"Navigation": "Navigation",
|
||||
"Notification": "Notification",
|
||||
"Groups": "Groups",
|
||||
"Emoji": "Emoji",
|
||||
"People": "People",
|
||||
"Share": "Share",
|
||||
"Workspace": "Workspace",
|
||||
@@ -256,6 +259,20 @@
|
||||
"Currently editing": "Currently editing",
|
||||
"Currently viewing": "Currently viewing",
|
||||
"Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}",
|
||||
"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",
|
||||
"Failed to create emoji": "Failed to create emoji",
|
||||
"Uploading": "Uploading",
|
||||
"Add emoji": "Add emoji",
|
||||
"Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores.": "Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores.",
|
||||
"Emoji name": "Emoji name",
|
||||
"name can only should lowercase letters, numbers, and underscores.": "name can only should lowercase letters, numbers, and underscores.",
|
||||
"Click or drag to replace": "Click or drag to replace",
|
||||
"Drop the image here": "Drop the image here",
|
||||
"Click or drag an image here": "Click or drag an image here",
|
||||
"PNG, JPG, GIF, or WebP up to 1MB": "PNG, JPG, GIF, or WebP up to 1MB",
|
||||
"This emoji will be available as": "This emoji will be available as",
|
||||
"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.",
|
||||
@@ -597,6 +614,8 @@
|
||||
"Comment options": "Comment options",
|
||||
"Enable viewer insights": "Enable viewer insights",
|
||||
"Enable embeds": "Enable embeds",
|
||||
"Delete Emoji": "Delete Emoji",
|
||||
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
|
||||
"File": "File",
|
||||
"Group members": "Group members",
|
||||
"Edit group": "Edit group",
|
||||
@@ -846,7 +865,6 @@
|
||||
"Inline LaTeX": "Inline LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Sign In",
|
||||
"Continue with Email": "Continue with Email",
|
||||
@@ -984,8 +1002,9 @@
|
||||
"Your import is being processed, you can safely leave this page": "Your import is being processed, you can safely leave this page",
|
||||
"File not supported – please upload a valid ZIP file": "File not supported – please upload a valid ZIP file",
|
||||
"Set the default permission level for collections created from the import": "Set the default permission level for collections created from the import",
|
||||
"Uploading": "Uploading",
|
||||
"Start import": "Start import",
|
||||
"Added by": "Added by",
|
||||
"Date added": "Date added",
|
||||
"Processing": "Processing",
|
||||
"Expired": "Expired",
|
||||
"Completed": "Completed",
|
||||
@@ -1055,6 +1074,8 @@
|
||||
"Editors": "Editors",
|
||||
"All status": "All status",
|
||||
"Active": "Active",
|
||||
"Could not load emojis": "Could not load emojis",
|
||||
"Custom emojis can be used throughout your workspace in documents, comments, and reactions.": "Custom emojis can be used throughout your workspace in documents, comments, and reactions.",
|
||||
"Left": "Left",
|
||||
"Right": "Right",
|
||||
"Settings saved": "Settings saved",
|
||||
|
||||
+28
-15
@@ -99,10 +99,11 @@ const Emojis = allowFlagEmoji
|
||||
)
|
||||
);
|
||||
|
||||
const searcher = new FuzzySearch(Object.values(Emojis), ["search"], {
|
||||
caseSensitive: false,
|
||||
sort: true,
|
||||
});
|
||||
const searcher = (emojis: searchEmojis[]) =>
|
||||
new FuzzySearch(emojis, ["search"], {
|
||||
caseSensitive: false,
|
||||
sort: true,
|
||||
});
|
||||
|
||||
// Codes defined by unicode.org
|
||||
const SKINTONE_CODE_TO_ENUM = {
|
||||
@@ -191,27 +192,39 @@ export const getEmojisWithCategory = ({
|
||||
export const getEmojiVariants = ({ id }: { id: string }) =>
|
||||
EMOJI_ID_TO_VARIANTS[id];
|
||||
|
||||
type searchEmojis = Emoji & {
|
||||
search?: string;
|
||||
skins?: Skin[];
|
||||
};
|
||||
|
||||
export const search = ({
|
||||
emojis = [],
|
||||
query,
|
||||
skinTone,
|
||||
}: {
|
||||
emojis?: searchEmojis[];
|
||||
query: string;
|
||||
skinTone?: EmojiSkinTone;
|
||||
}) => {
|
||||
const queryLowercase = query.toLowerCase();
|
||||
const emojiSkinTone = skinTone ?? EmojiSkinTone.Default;
|
||||
}): Emoji[] => {
|
||||
const matchedEmojis = searcher([...Object.values(Emojis), ...emojis])
|
||||
.search(query.toLowerCase())
|
||||
.map((emoji) => {
|
||||
if (!emoji.skins) {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
const matchedEmojis = searcher
|
||||
.search(queryLowercase)
|
||||
.map(
|
||||
(emoji) =>
|
||||
const emojiSkinTone = skinTone ?? EmojiSkinTone.Default;
|
||||
|
||||
return (
|
||||
EMOJI_ID_TO_VARIANTS[emoji.id][emojiSkinTone] ??
|
||||
EMOJI_ID_TO_VARIANTS[emoji.id][EmojiSkinTone.Default]
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
return sortBy(matchedEmojis, (emoji) => {
|
||||
const nlc = emoji.name.toLowerCase();
|
||||
return query === nlc ? -1 : nlc.startsWith(queryLowercase) ? 0 : 1;
|
||||
});
|
||||
return query === nlc ? -1 : nlc.startsWith(query) ? 0 : 1;
|
||||
}) as Emoji[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -221,4 +234,4 @@ export const search = ({
|
||||
* @returns The emoji id, if found.
|
||||
*/
|
||||
export const getEmojiId = (emoji: string): string | undefined =>
|
||||
searcher.search(emoji)[0]?.id;
|
||||
searcher(Object.values(Emojis)).search(emoji)[0]?.id;
|
||||
|
||||
@@ -129,3 +129,10 @@ export const WebhookSubscriptionValidation = {
|
||||
/** The maximum number of webhooks per team */
|
||||
maxSubscriptions: 10,
|
||||
};
|
||||
|
||||
export const EmojiValidation = {
|
||||
/** The maximum length of the emoji name */
|
||||
maxNameLength: 25,
|
||||
/* the allow characters in the name */
|
||||
allowedNameCharacters: /^[a-z0-9_]*$/,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user