From 430883f186a95a2f3c2aa82c01dda0de34d9cbc6 Mon Sep 17 00:00:00 2001
From: Salihu <91833785+salihudickson@users.noreply.github.com>
Date: Mon, 1 Dec 2025 02:31:50 +0100
Subject: [PATCH] feat: Custom emoji (#10513)
Towards #9278
---
app/actions/definitions/emojis.tsx | 21 ++
app/actions/sections.ts | 2 +
app/components/DocumentExplorer.tsx | 2 +-
app/components/Editor.tsx | 6 +-
app/components/EmojiCreateDialog.tsx | 217 +++++++++++++++++
.../IconPicker/components/Components.tsx | 12 +
.../components/CustomEmojiPanel.tsx | 170 +++++++++++++
.../IconPicker/components/EmojiPanel.tsx | 94 ++-----
.../IconPicker/components/GridTemplate.tsx | 15 +-
.../IconPicker/components/IconPanel.tsx | 60 +----
app/components/IconPicker/index.tsx | 27 ++-
app/components/IconPicker/useIconState.tsx | 89 +++++++
app/components/IconPicker/utils.ts | 9 +
.../Sidebar/components/SharedDocumentLink.tsx | 7 +-
app/hooks/useSettingsConfig.ts | 11 +
app/menus/EmojisMenu.tsx | 75 ++++++
app/models/Emoji.ts | 51 ++++
app/scenes/Collection/components/Overview.tsx | 1 -
app/scenes/Document/components/Document.tsx | 9 +-
app/scenes/Document/components/Editor.tsx | 4 +-
app/scenes/Document/components/Header.tsx | 8 +-
.../Document/components/PublicReferences.tsx | 8 +-
.../Document/components/ReferenceListItem.tsx | 7 +-
app/scenes/Settings/CustomEmojis.tsx | 171 +++++++++++++
.../Settings/components/EmojisTable.tsx | 105 ++++++++
app/scenes/Shared/Collection.tsx | 5 +-
app/scenes/Shared/Document.tsx | 9 +-
app/scenes/Shared/index.tsx | 18 +-
app/stores/EmojiStore.ts | 23 ++
app/stores/RootStore.ts | 3 +
app/utils/developer.ts | 2 +-
.../20251020204139-create-emojis.js | 73 ++++++
server/models/Collection.ts | 4 -
server/models/Document.ts | 4 -
server/models/Emoji.ts | 93 +++++++
server/models/Revision.ts | 4 -
server/models/helpers/AttachmentHelper.ts | 3 +
server/models/index.ts | 2 +
server/policies/emoji.ts | 11 +
server/policies/index.ts | 1 +
server/policies/utils.ts | 3 +
server/presenters/emoji.ts | 15 ++
server/presenters/index.ts | 2 +
server/routes/api/attachments/attachments.ts | 17 +-
server/routes/api/collections/schema.ts | 9 +-
server/routes/api/documents/documents.test.ts | 2 +-
server/routes/api/documents/schema.ts | 17 +-
server/routes/api/emojis/emojis.ts | 229 ++++++++++++++++++
server/routes/api/emojis/index.ts | 1 +
server/routes/api/emojis/schema.ts | 58 +++++
server/routes/api/index.ts | 2 +
server/utils/zod.ts | 7 +
shared/components/CustomEmoji.tsx | 7 +
shared/components/EmojiIcon.tsx | 6 +-
shared/components/Icon.tsx | 32 ++-
shared/hooks/useIsMounted.ts | 4 +-
shared/hooks/useShare.ts | 18 ++
shared/i18n/locales/en_US/translation.json | 29 ++-
shared/types.ts | 2 +
shared/utils/icon.ts | 7 +-
{app => shared}/utils/tree.ts | 2 +-
shared/validations.ts | 19 ++
62 files changed, 1686 insertions(+), 238 deletions(-)
create mode 100644 app/actions/definitions/emojis.tsx
create mode 100644 app/components/EmojiCreateDialog.tsx
create mode 100644 app/components/IconPicker/components/Components.tsx
create mode 100644 app/components/IconPicker/components/CustomEmojiPanel.tsx
create mode 100644 app/components/IconPicker/useIconState.tsx
create mode 100644 app/menus/EmojisMenu.tsx
create mode 100644 app/models/Emoji.ts
create mode 100644 app/scenes/Settings/CustomEmojis.tsx
create mode 100644 app/scenes/Settings/components/EmojisTable.tsx
create mode 100644 app/stores/EmojiStore.ts
create mode 100644 server/migrations/20251020204139-create-emojis.js
create mode 100644 server/models/Emoji.ts
create mode 100644 server/policies/emoji.ts
create mode 100644 server/presenters/emoji.ts
create mode 100644 server/routes/api/emojis/emojis.ts
create mode 100644 server/routes/api/emojis/index.ts
create mode 100644 server/routes/api/emojis/schema.ts
create mode 100644 shared/components/CustomEmoji.tsx
create mode 100644 shared/hooks/useShare.ts
rename {app => shared}/utils/tree.ts (94%)
diff --git a/app/actions/definitions/emojis.tsx b/app/actions/definitions/emojis.tsx
new file mode 100644
index 0000000000..0b18d6fa96
--- /dev/null
+++ b/app/actions/definitions/emojis.tsx
@@ -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: ,
+ 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: ,
+ });
+ },
+});
diff --git a/app/actions/sections.ts b/app/actions/sections.ts
index 60b54095ed..7c20e77565 100644
--- a/app/actions/sections.ts
+++ b/app/actions/sections.ts
@@ -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;
diff --git a/app/components/DocumentExplorer.tsx b/app/components/DocumentExplorer.tsx
index 58697d8f0f..44ec5b8501 100644
--- a/app/components/DocumentExplorer.tsx
+++ b/app/components/DocumentExplorer.tsx
@@ -18,6 +18,7 @@ import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
+import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
@@ -27,7 +28,6 @@ import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
-import { ancestors, descendants, flattenTree } from "~/utils/tree";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx
index d225915aa2..75d473f544 100644
--- a/app/components/Editor.tsx
+++ b/app/components/Editor.tsx
@@ -21,6 +21,7 @@ import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
+import useShare from "@shared/hooks/useShare";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -33,7 +34,6 @@ export type Props = Optional<
| "dictionary"
| "extensions"
> & {
- shareId?: string | undefined;
embedsDisabled?: boolean;
onSynced?: () => Promise;
onPublish?: (event: React.MouseEvent) => void;
@@ -41,9 +41,9 @@ export type Props = Optional<
};
function Editor(props: Props, ref: React.RefObject | null) {
- const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
- props;
+ const { id, onChange, onCreateCommentMark, onDeleteCommentMark } = props;
const { comments } = useStores();
+ const { shareId } = useShare();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef();
diff --git a/app/components/EmojiCreateDialog.tsx b/app/components/EmojiCreateDialog.tsx
new file mode 100644
index 0000000000..44b457ad76
--- /dev/null
+++ b/app/components/EmojiCreateDialog.tsx
@@ -0,0 +1,217 @@
+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 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 { AttachmentValidation, EmojiValidation } from "@shared/validations";
+import { bytesToHumanReadable } from "@shared/utils/files";
+
+type Props = {
+ onSubmit: () => void;
+};
+
+export function EmojiCreateDialog({ onSubmit }: Props) {
+ const { t } = useTranslation();
+ const { emojis } = useStores();
+ const [name, setName] = React.useState("");
+ const [file, setFile] = React.useState(null);
+ const [isUploading, setIsUploading] = React.useState(false);
+
+ const handleFileSelection = React.useCallback(
+ (file: File) => {
+ const isValidType = AttachmentValidation.emojiContentTypes.includes(
+ file.type
+ );
+
+ if (!isValidType) {
+ toast.error(
+ t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
+ );
+ return;
+ }
+
+ // Validate file size
+ if (file.size > AttachmentValidation.emojiMaxFileSize) {
+ toast.error(
+ t("File size too large. Maximum size is {{ size }}.", {
+ size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
+ })
+ );
+ return;
+ }
+
+ setFile(file);
+ },
+ [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 {
+ const compressed = await compressImage(file, {
+ maxHeight: 64,
+ maxWidth: 64,
+ });
+
+ const attachment = await uploadFile(compressed, {
+ name: file.name,
+ preset: AttachmentPreset.Emoji,
+ });
+
+ await emojis.create({
+ name: name.trim(),
+ attachmentId: attachment.id,
+ });
+
+ toast.success(t("Emoji created successfully"));
+ onSubmit();
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ const handleNameChange = (event: React.ChangeEvent) => {
+ const { value } = event.target;
+ setName(value);
+ };
+
+ const isValidName = EmojiValidation.allowedNameCharacters.test(name);
+ const isValid = name.trim().length > 0 && file && isValidName;
+
+ return (
+
+
+ {t(
+ "The emoji name should be unique and contain only lowercase letters, numbers, and underscores."
+ )}
+
+
+
+
+
+
+
+ {file ? (
+ <>
+
+ {file.name}
+
+ {t("Click or drag to replace")}
+
+ >
+ ) : (
+ <>
+
+ {isDragActive
+ ? t("Drop the image here")
+ : t("Click, drop, or paste an image here")}
+
+
+ {t("PNG, JPG, GIF, or WebP up to {{ size }}", {
+ size: bytesToHumanReadable(
+ AttachmentValidation.emojiMaxFileSize
+ ),
+ })}
+
+ >
+ )}
+
+
+
+ {name.trim() && isValidName && (
+
+ {t("This emoji will be available as")} :{name}:
+
+ )}
+
+ );
+}
+
+const DropZone = styled.div`
+ border: 2px dashed ${s("inputBorder")};
+ border-radius: 8px;
+ padding: 24px;
+ text-align: center;
+ cursor: var(--pointer);
+ transition: border-color 0.2s;
+
+ &:hover {
+ border-color: ${s("inputBorderFocused")};
+ }
+`;
+
+const PreviewImage = styled.img`
+ width: 64px;
+ height: 64px;
+ object-fit: contain;
+ border-radius: 4px;
+`;
diff --git a/app/components/IconPicker/components/Components.tsx b/app/components/IconPicker/components/Components.tsx
new file mode 100644
index 0000000000..ab7691d9da
--- /dev/null
+++ b/app/components/IconPicker/components/Components.tsx
@@ -0,0 +1,12 @@
+import Flex from "@shared/components/Flex";
+import styled from "styled-components";
+import InputSearch from "~/components/InputSearch";
+
+export const UserInputContainer = styled(Flex)`
+ height: 48px;
+ padding: 6px 12px 0px;
+`;
+
+export const StyledInputSearch = styled(InputSearch)`
+ flex-grow: 1;
+`;
diff --git a/app/components/IconPicker/components/CustomEmojiPanel.tsx b/app/components/IconPicker/components/CustomEmojiPanel.tsx
new file mode 100644
index 0000000000..01453052b2
--- /dev/null
+++ b/app/components/IconPicker/components/CustomEmojiPanel.tsx
@@ -0,0 +1,170 @@
+import React, { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import Flex from "~/components/Flex";
+import useStores from "~/hooks/useStores";
+import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
+import { IconType } from "@shared/types";
+import { DisplayCategory } from "../utils";
+import { StyledInputSearch, UserInputContainer } from "./Components";
+import { useIconState } from "../useIconState";
+import Emoji from "~/models/Emoji";
+
+const GRID_HEIGHT = 410;
+
+type Props = {
+ panelWidth: number;
+ height?: number;
+ query: string;
+ panelActive: boolean;
+ onEmojiChange: (emoji: string) => void;
+ onQueryChange: (query: string) => void;
+};
+
+const CustomEmojiPanel = ({
+ query,
+ panelActive,
+ panelWidth,
+ height = GRID_HEIGHT,
+ onEmojiChange,
+ onQueryChange,
+}: Props) => {
+ const { t } = useTranslation();
+ const searchRef = React.useRef(null);
+ const scrollableRef = React.useRef(null);
+ const [searchData, setSearchData] = useState([]);
+ const [freqEmojis, setFreqEmojis] = useState([]);
+ const { getFrequentIcons, incrementIconCount } = useIconState(
+ IconType.Custom
+ );
+
+ const { emojis } = useStores();
+
+ const handleFilter = React.useCallback(
+ (event: React.ChangeEvent) => {
+ onQueryChange(event.target.value);
+ },
+ [onQueryChange]
+ );
+
+ useEffect(() => {
+ if (query.trim()) {
+ const initialData = emojis.findByQuery(query);
+ if (initialData.length) {
+ setSearchData([
+ {
+ category: DisplayCategory.Search,
+ icons: initialData?.map(toIcon),
+ },
+ ]);
+ }
+
+ emojis
+ .fetchAll({
+ query,
+ })
+ .then((data) => {
+ if (data.length) {
+ const iconMap = new Map([
+ ...initialData.map((emoji): [string, EmojiNode] => [
+ emoji.name,
+ toIcon(emoji),
+ ]),
+ ...data.map((emoji): [string, EmojiNode] => [
+ emoji.name,
+ toIcon(emoji),
+ ]),
+ ]);
+
+ setSearchData([
+ {
+ category: DisplayCategory.Search,
+ icons: Array.from(iconMap.values()),
+ },
+ ]);
+ return;
+ }
+
+ setSearchData([]);
+ });
+ } else {
+ setSearchData([]);
+ }
+ }, [query, emojis]);
+
+ useEffect(() => {
+ getFrequentIcons().forEach((id) => {
+ emojis
+ .fetch(id)
+ .then((emoji) => {
+ setFreqEmojis((prev) => {
+ if (prev.some((item) => item.id === id)) {
+ return prev;
+ }
+ return [...prev, toIcon(emoji)];
+ });
+ })
+ .catch(() => {
+ // ignore
+ });
+ });
+ }, [getFrequentIcons, emojis]);
+
+ const handleEmojiSelection = React.useCallback(
+ ({ id }: { id: string }) => {
+ onEmojiChange(id);
+ incrementIconCount(id);
+ },
+ [onEmojiChange, incrementIconCount]
+ );
+
+ const templateData: DataNode[] = React.useMemo(
+ () => [
+ {
+ category: DisplayCategory.Frequent,
+ icons: freqEmojis,
+ },
+ {
+ category: DisplayCategory.All,
+ icons: emojis.orderedData.map(toIcon),
+ },
+ ],
+ [emojis.orderedData, freqEmojis]
+ );
+
+ React.useLayoutEffect(() => {
+ if (!panelActive) {
+ return;
+ }
+ scrollableRef.current?.scroll({ top: 0 });
+ requestAnimationFrame(() => searchRef.current?.focus());
+ }, [panelActive]);
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+const toIcon = (emoji: Emoji): EmojiNode => ({
+ type: IconType.Custom,
+ id: emoji.id,
+ value: emoji.id,
+ name: emoji.name,
+});
+
+export default CustomEmojiPanel;
diff --git a/app/components/IconPicker/components/EmojiPanel.tsx b/app/components/IconPicker/components/EmojiPanel.tsx
index 87486e5388..6cf3937ca8 100644
--- a/app/components/IconPicker/components/EmojiPanel.tsx
+++ b/app/components/IconPicker/components/EmojiPanel.tsx
@@ -1,77 +1,17 @@
import concat from "lodash/concat";
import * as React from "react";
import { useTranslation } from "react-i18next";
-import styled from "styled-components";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
-import InputSearch from "~/components/InputSearch";
-import usePersistedState from "~/hooks/usePersistedState";
-import {
- FREQUENTLY_USED_COUNT,
- DisplayCategory,
- emojiSkinToneKey,
- emojisFreqKey,
- lastEmojiKey,
- sortFrequencies,
-} from "../utils";
+import { DisplayCategory } from "../utils";
import GridTemplate, { DataNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
+import { StyledInputSearch, UserInputContainer } from "./Components";
+import { useIconState } from "../useIconState";
const GRID_HEIGHT = 410;
-const useEmojiState = () => {
- const [emojiSkinTone, setEmojiSkinTone] = usePersistedState(
- emojiSkinToneKey,
- EmojiSkinTone.Default
- );
- const [emojisFreq, setEmojisFreq] = usePersistedState>(
- emojisFreqKey,
- {}
- );
- const [lastEmoji, setLastEmoji] = usePersistedState(
- lastEmojiKey,
- undefined
- );
-
- const incrementEmojiCount = React.useCallback(
- (emoji: string) => {
- emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1;
- setEmojisFreq({ ...emojisFreq });
- setLastEmoji(emoji);
- },
- [emojisFreq, setEmojisFreq, setLastEmoji]
- );
-
- const getFreqEmojis = React.useCallback(() => {
- const freqs = Object.entries(emojisFreq);
-
- if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
- sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
- setEmojisFreq(Object.fromEntries(freqs));
- }
-
- const emojis = sortFrequencies(freqs)
- .slice(0, FREQUENTLY_USED_COUNT.Get)
- .map(([emoji, _]) => emoji);
-
- const isLastPresent = emojis.includes(lastEmoji ?? "");
- if (lastEmoji && !isLastPresent) {
- emojis.pop();
- emojis.push(lastEmoji);
- }
-
- return emojis;
- }, [emojisFreq, setEmojisFreq, lastEmoji]);
-
- return {
- emojiSkinTone,
- setEmojiSkinTone,
- incrementEmojiCount,
- getFreqEmojis,
- };
-};
-
type Props = {
panelWidth: number;
query: string;
@@ -97,11 +37,14 @@ const EmojiPanel = ({
const {
emojiSkinTone: skinTone,
setEmojiSkinTone,
- incrementEmojiCount,
- getFreqEmojis,
- } = useEmojiState();
+ incrementIconCount,
+ getFrequentIcons,
+ } = useIconState(IconType.Emoji);
- const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
+ const freqEmojis = React.useMemo(
+ () => getFrequentIcons(),
+ [getFrequentIcons]
+ );
const handleFilter = React.useCallback(
(event: React.ChangeEvent) => {
@@ -120,9 +63,9 @@ const EmojiPanel = ({
const handleEmojiSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onEmojiChange(value);
- incrementEmojiCount(id);
+ incrementIconCount(id);
},
- [onEmojiChange, incrementEmojiCount]
+ [onEmojiChange, incrementIconCount]
);
const isSearch = query !== "";
@@ -195,7 +138,7 @@ const getAllEmojis = ({
}): DataNode[] => {
const emojisWithCategory = getEmojisWithCategory({ skinTone });
- const getFrequentEmojis = (): DataNode => {
+ const getFrequentIcons = (): DataNode => {
const emojis = getEmojis({ ids: freqEmojis, skinTone });
return {
category: DisplayCategory.Frequent,
@@ -220,7 +163,7 @@ const getAllEmojis = ({
};
return concat(
- getFrequentEmojis(),
+ getFrequentIcons(),
getCategoryData(EmojiCategory.People),
getCategoryData(EmojiCategory.Nature),
getCategoryData(EmojiCategory.Foods),
@@ -232,13 +175,4 @@ const getAllEmojis = ({
);
};
-const UserInputContainer = styled(Flex)`
- height: 48px;
- padding: 6px 12px 0px;
-`;
-
-const StyledInputSearch = styled(InputSearch)`
- flex-grow: 1;
-`;
-
export default EmojiPanel;
diff --git a/app/components/IconPicker/components/GridTemplate.tsx b/app/components/IconPicker/components/GridTemplate.tsx
index fa093049b6..6f304622f7 100644
--- a/app/components/IconPicker/components/GridTemplate.tsx
+++ b/app/components/IconPicker/components/GridTemplate.tsx
@@ -9,6 +9,7 @@ import Text from "~/components/Text";
import { TRANSLATED_CATEGORIES } from "../utils";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
+import { CustomEmoji } from "@shared/components/CustomEmoji";
/**
* icon/emoji size is 24px; and we add 4px padding on all sides,
@@ -23,10 +24,11 @@ type OutlineNode = {
delay: number;
};
-type EmojiNode = {
- type: IconType.Emoji;
+export type EmojiNode = {
+ type: IconType.Emoji | IconType.Custom;
id: string;
value: string;
+ name?: string;
};
export type DataNode = {
@@ -86,7 +88,14 @@ const GridTemplate = (
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
- {item.value}
+ {item.type === IconType.Custom ? (
+
+ ) : (
+ item.value
+ )}
);
diff --git a/app/components/IconPicker/components/IconPanel.tsx b/app/components/IconPicker/components/IconPanel.tsx
index ddf9fa9506..382039ea81 100644
--- a/app/components/IconPicker/components/IconPanel.tsx
+++ b/app/components/IconPicker/components/IconPanel.tsx
@@ -5,16 +5,10 @@ import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
-import usePersistedState from "~/hooks/usePersistedState";
-import {
- FREQUENTLY_USED_COUNT,
- DisplayCategory,
- iconsFreqKey,
- lastIconKey,
- sortFrequencies,
-} from "../utils";
+import { DisplayCategory } from "../utils";
import ColorPicker from "./ColorPicker";
import GridTemplate, { DataNode } from "./GridTemplate";
+import { useIconState } from "../useIconState";
const IconNames = Object.keys(IconLibrary.mapping);
const TotalIcons = IconNames.length;
@@ -25,52 +19,6 @@ const TotalIcons = IconNames.length;
*/
const GRID_HEIGHT = 314;
-const useIconState = () => {
- const [iconsFreq, setIconsFreq] = usePersistedState>(
- iconsFreqKey,
- {}
- );
- const [lastIcon, setLastIcon] = usePersistedState(
- lastIconKey,
- undefined
- );
-
- const incrementIconCount = React.useCallback(
- (icon: string) => {
- iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1;
- setIconsFreq({ ...iconsFreq });
- setLastIcon(icon);
- },
- [iconsFreq, setIconsFreq, setLastIcon]
- );
-
- const getFreqIcons = React.useCallback(() => {
- const freqs = Object.entries(iconsFreq);
-
- if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
- sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
- setIconsFreq(Object.fromEntries(freqs));
- }
-
- const icons = sortFrequencies(freqs)
- .slice(0, FREQUENTLY_USED_COUNT.Get)
- .map(([icon, _]) => icon);
-
- const isLastPresent = icons.includes(lastIcon ?? "");
- if (lastIcon && !isLastPresent) {
- icons.pop();
- icons.push(lastIcon);
- }
-
- return icons;
- }, [iconsFreq, setIconsFreq, lastIcon]);
-
- return {
- incrementIconCount,
- getFreqIcons,
- };
-};
-
type Props = {
panelWidth: number;
initial: string;
@@ -97,9 +45,9 @@ const IconPanel = ({
const searchRef = React.useRef(null);
const scrollableRef = React.useRef(null);
- const { incrementIconCount, getFreqIcons } = useIconState();
+ const { incrementIconCount, getFrequentIcons } = useIconState(IconType.SVG);
- const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
+ const freqIcons = React.useMemo(() => getFrequentIcons(), [getFrequentIcons]);
const totalFreqIcons = freqIcons.length;
const filteredIcons = React.useMemo(
diff --git a/app/components/IconPicker/index.tsx b/app/components/IconPicker/index.tsx
index 301f773de7..5aea4effed 100644
--- a/app/components/IconPicker/index.tsx
+++ b/app/components/IconPicker/index.tsx
@@ -21,10 +21,13 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
+import CustomEmojiPanel from "./components/CustomEmojiPanel";
+import useStores from "~/hooks/useStores";
const TAB_NAMES = {
Icon: "icon",
Emoji: "emoji",
+ Custom: "custom",
} as const;
type TabName = (typeof TAB_NAMES)[keyof typeof TAB_NAMES];
@@ -61,7 +64,7 @@ const IconPicker = ({
children,
}: Props) => {
const { t } = useTranslation();
-
+ const { emojis } = useStores();
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
@@ -168,6 +171,12 @@ const IconPicker = ({
setActiveTab(defaultTab);
}, [defaultTab]);
+ React.useEffect(() => {
+ if (open) {
+ void emojis.fetchAll();
+ }
+ }, [open]);
+
if (isMobile) {
return (
@@ -245,6 +254,13 @@ const Content = ({
>
{t("Emojis")}
+
+ {t("Custom")}
+
{allowDelete && (
{t("Remove")}
@@ -271,6 +287,15 @@ const Content = ({
onQueryChange={onQueryChange}
/>
+
+
+
);
};
diff --git a/app/components/IconPicker/useIconState.tsx b/app/components/IconPicker/useIconState.tsx
new file mode 100644
index 0000000000..f59a9b62b9
--- /dev/null
+++ b/app/components/IconPicker/useIconState.tsx
@@ -0,0 +1,89 @@
+import {
+ customEmojisFreqKey,
+ emojisFreqKey,
+ emojiSkinToneKey,
+ FREQUENTLY_USED_COUNT,
+ iconsFreqKey,
+ lastCustomEmojiKey,
+ lastEmojiKey,
+ lastIconKey,
+ sortFrequencies,
+} from "./utils";
+import usePersistedState from "~/hooks/usePersistedState";
+import { EmojiSkinTone, IconType } from "@shared/types";
+import React from "react";
+
+const lastIconKeys = {
+ [IconType.Custom]: lastCustomEmojiKey,
+ [IconType.Emoji]: lastEmojiKey,
+ [IconType.SVG]: lastIconKey,
+};
+
+const freqIconKeys = {
+ [IconType.Custom]: customEmojisFreqKey,
+ [IconType.Emoji]: emojisFreqKey,
+ [IconType.SVG]: iconsFreqKey,
+};
+
+const skinToneKeys = {
+ [IconType.Custom]: "",
+ [IconType.Emoji]: emojiSkinToneKey,
+ [IconType.SVG]: "",
+};
+
+export const useIconState = (type: IconType) => {
+ const [emojiSkinTone, setEmojiSkinTone] = usePersistedState(
+ skinToneKeys[type],
+ EmojiSkinTone.Default
+ );
+
+ const [iconFreq, setIconFreq] = usePersistedState>(
+ freqIconKeys[type],
+ {}
+ );
+
+ const [lastIcon, setLastIcon] = usePersistedState(
+ lastIconKeys[type],
+ undefined
+ );
+
+ const incrementIconCount = React.useCallback(
+ (emoji: string) => {
+ iconFreq[emoji] = (iconFreq[emoji] ?? 0) + 1;
+ setIconFreq({ ...iconFreq });
+ setLastIcon(emoji);
+ },
+ [iconFreq, setIconFreq, setLastIcon]
+ );
+
+ const getFrequentIcons = React.useCallback((): string[] => {
+ const freqs = Object.entries(iconFreq);
+
+ if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
+ const trimmed = sortFrequencies(freqs).slice(
+ 0,
+ FREQUENTLY_USED_COUNT.Track
+ );
+ setIconFreq(Object.fromEntries(trimmed));
+ }
+
+ const emojis = sortFrequencies(freqs)
+ .slice(0, FREQUENTLY_USED_COUNT.Get)
+ .map(([emoji, _]) => emoji);
+
+ const isLastPresent = emojis.includes(lastIcon ?? "");
+ if (lastIcon && !isLastPresent) {
+ emojis.pop();
+ emojis.push(lastIcon);
+ }
+
+ return emojis;
+ }, [iconFreq, lastIcon, setIconFreq]);
+
+ return {
+ emojiSkinTone,
+ setEmojiSkinTone,
+ incrementIconCount,
+ getFrequentIcons,
+ };
+};
diff --git a/app/components/IconPicker/utils.ts b/app/components/IconPicker/utils.ts
index 6ef68a9690..e441bb8b27 100644
--- a/app/components/IconPicker/utils.ts
+++ b/app/components/IconPicker/utils.ts
@@ -18,6 +18,7 @@ export const TRANSLATED_CATEGORIES = {
Objects: i18next.t("Objects"),
Symbols: i18next.t("Symbols"),
Flags: i18next.t("Flags"),
+ Custom: i18next.t("Custom"),
};
export const FREQUENTLY_USED_COUNT = {
@@ -32,6 +33,8 @@ const STORAGE_KEYS = {
EmojisFrequency: "emojis-freq",
LastIcon: "last-icon",
LastEmoji: "last-emoji",
+ CustomEmojisFrequency: "custom-emojis-freq",
+ LastCustomEmoji: "last-custom-emoji",
};
const getStorageKey = (key: string) => `${STORAGE_KEYS.Base}.${key}`;
@@ -46,5 +49,11 @@ export const lastIconKey = getStorageKey(STORAGE_KEYS.LastIcon);
export const lastEmojiKey = getStorageKey(STORAGE_KEYS.LastEmoji);
+export const customEmojisFreqKey = getStorageKey(
+ STORAGE_KEYS.CustomEmojisFrequency
+);
+
+export const lastCustomEmojiKey = getStorageKey(STORAGE_KEYS.LastCustomEmoji);
+
export const sortFrequencies = (freqs: [string, number][]) =>
freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1));
diff --git a/app/components/Sidebar/components/SharedDocumentLink.tsx b/app/components/Sidebar/components/SharedDocumentLink.tsx
index 0a72512f60..b9cdbb2e56 100644
--- a/app/components/Sidebar/components/SharedDocumentLink.tsx
+++ b/app/components/Sidebar/components/SharedDocumentLink.tsx
@@ -8,7 +8,7 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import useStores from "~/hooks/useStores";
import { sharedModelPath } from "~/utils/routeHelpers";
-import { descendants } from "~/utils/tree";
+import { descendants } from "@shared/utils/tree";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -108,6 +108,7 @@ function DocumentLink(
t("Untitled");
const icon = node.icon ?? node.emoji;
+ const initial = title ? title.charAt(0).toUpperCase() : "?";
return (
<>
@@ -121,7 +122,9 @@ function DocumentLink(
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
- icon={icon && }
+ icon={
+ icon &&
+ }
label={title}
depth={depth}
exact={false}
diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts
index 522b3f18f1..e9815f6520 100644
--- a/app/hooks/useSettingsConfig.ts
+++ b/app/hooks/useSettingsConfig.ts
@@ -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"),
diff --git a/app/menus/EmojisMenu.tsx b/app/menus/EmojisMenu.tsx
new file mode 100644
index 0000000000..a5c47369ff
--- /dev/null
+++ b/app/menus/EmojisMenu.tsx
@@ -0,0 +1,75 @@
+import { TrashIcon } from "outline-icons";
+import { Trans, useTranslation } from "react-i18next";
+import { toast } from "sonner";
+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: (
+
+ ),
+ });
+ };
+
+ if (!can.delete) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ );
+};
+
+const DeleteEmojiDialog = ({
+ emoji,
+ onSubmit,
+}: {
+ emoji: Emoji;
+ onSubmit: () => void;
+}) => {
+ const { t } = useTranslation();
+
+ const handleSubmit = async () => {
+ if (emoji) {
+ await emoji.delete();
+ onSubmit();
+ toast.success(t("Emoji deleted"));
+ }
+ };
+
+ return (
+
+ ,
+ }}
+ />
+
+ );
+};
+
+export default EmojisMenu;
diff --git a/app/models/Emoji.ts b/app/models/Emoji.ts
new file mode 100644
index 0000000000..947c46153c
--- /dev/null
+++ b/app/models/Emoji.ts
@@ -0,0 +1,51 @@
+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 of the related attachment */
+ @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;
+
+ get searchContent(): string {
+ return this.name;
+ }
+
+ /**
+ * emoji name
+ */
+ get name() {
+ return this._name;
+ }
+
+ set name(value: string) {
+ this._name = value;
+ }
+}
+
+export default Emoji;
diff --git a/app/scenes/Collection/components/Overview.tsx b/app/scenes/Collection/components/Overview.tsx
index 9c3a69e18c..2067470ecb 100644
--- a/app/scenes/Collection/components/Overview.tsx
+++ b/app/scenes/Collection/components/Overview.tsx
@@ -94,7 +94,6 @@ function Overview({ collection, shareId }: Props) {
readOnly={!can.update || !!shareId}
userId={user?.id}
editorStyle={editorStyle}
- shareId={shareId}
/>
diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx
index 82c1ec993e..457e7ee754 100644
--- a/app/scenes/Document/components/Document.tsx
+++ b/app/scenes/Document/components/Document.tsx
@@ -535,7 +535,6 @@ class DocumentScene extends React.Component {
{
document.isSaving || this.isPublishing || this.isEmpty
}
savingIsDisabled={document.isSaving || this.isEmpty}
- sharedTree={this.props.sharedTree}
onSelectTemplate={this.handleSelectTemplate}
onSave={this.onSave}
/>
@@ -593,7 +591,6 @@ class DocumentScene extends React.Component {
key={embedsDisabled ? "disabled" : "enabled"}
ref={this.editor}
multiplayer={multiplayerEditor}
- shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
document={document}
@@ -616,11 +613,7 @@ class DocumentScene extends React.Component {
>
{shareId ? (
-
+
) : !revision ? (
diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx
index 31507c9f1f..ff2c7bac2a 100644
--- a/app/scenes/Document/components/Editor.tsx
+++ b/app/scenes/Document/components/Editor.tsx
@@ -34,6 +34,7 @@ import DocumentMeta from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle";
import first from "lodash/first";
import { getLangFor } from "~/utils/language";
+import useShare from "@shared/hooks/useShare";
const extensions = withUIExtensions(withComments(richExtensions));
@@ -67,12 +68,12 @@ function DocumentEditor(props: Props, ref: React.RefObject) {
const team = useCurrentTeam({ rejectOnEmpty: false });
const sidebarContext = useLocationSidebarContext();
const params = useQuery();
+ const { shareId } = useShare();
const {
document,
onChangeTitle,
onChangeIcon,
isDraft,
- shareId,
readOnly,
children,
multiplayer,
@@ -235,7 +236,6 @@ function DocumentEditor(props: Props, ref: React.RefObject) {
placeholder={t("Type '/' to insert, or start writing…")}
scrollTo={decodeURIComponentSafe(window.location.hash)}
readOnly={readOnly}
- shareId={shareId}
userId={user?.id}
focusedCommentId={focusedComment?.id}
onClickCommentMark={
diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx
index b8381f5ad2..b578f2c3c9 100644
--- a/app/scenes/Document/components/Header.tsx
+++ b/app/scenes/Document/components/Header.tsx
@@ -6,7 +6,6 @@ import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import useMeasure from "react-use-measure";
-import { NavigationNode } from "@shared/types";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
@@ -40,12 +39,11 @@ import ObservingBanner from "./ObservingBanner";
import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton";
import { AppearanceAction } from "~/components/Sharing/components/Actions";
+import useShare from "@shared/hooks/useShare";
type Props = {
document: Document;
revision: Revision | undefined;
- sharedTree: NavigationNode | undefined;
- shareId: string | null | undefined;
isDraft: boolean;
isEditing: boolean;
isSaving: boolean;
@@ -63,14 +61,12 @@ type Props = {
function DocumentHeader({
document,
revision,
- shareId,
isEditing,
isDraft,
isPublishing,
isSaving,
savingIsDisabled,
publishingIsDisabled,
- sharedTree,
onSelectTemplate,
onSave,
}: Props) {
@@ -85,8 +81,8 @@ function DocumentHeader({
const { hasHeadings, editor } = useDocumentContext();
const sidebarContext = useLocationSidebarContext();
const [measureRef, size] = useMeasure();
+ const { isShare, shareId, sharedTree } = useShare();
const isMobile = isMobileMedia || size.width < 700;
- const isShare = !!shareId;
// We cache this value for as long as the component is mounted so that if you
// apply a template there is still the option to replace it until the user
diff --git a/app/scenes/Document/components/PublicReferences.tsx b/app/scenes/Document/components/PublicReferences.tsx
index 3a9cd5fe11..6be93b816f 100644
--- a/app/scenes/Document/components/PublicReferences.tsx
+++ b/app/scenes/Document/components/PublicReferences.tsx
@@ -4,16 +4,16 @@ import { useTranslation } from "react-i18next";
import { NavigationNode } from "@shared/types";
import Subheading from "~/components/Subheading";
import ReferenceListItem from "./ReferenceListItem";
+import useShare from "@shared/hooks/useShare";
type Props = {
- shareId: string;
documentId: string;
- sharedTree?: NavigationNode;
};
function PublicReferences(props: Props) {
const { t } = useTranslation();
- const { shareId, documentId, sharedTree } = props;
+ const { sharedTree } = useShare();
+ const { documentId } = props;
// The sharedTree is the entire document tree starting at the shared document
// we must filter down the tree to only the part with the document we're
@@ -52,7 +52,7 @@ function PublicReferences(props: Props) {
<>
{t("Documents")}
{children.map((node) => (
-
+
))}
>
);
diff --git a/app/scenes/Document/components/ReferenceListItem.tsx b/app/scenes/Document/components/ReferenceListItem.tsx
index f397c32021..da5d77d311 100644
--- a/app/scenes/Document/components/ReferenceListItem.tsx
+++ b/app/scenes/Document/components/ReferenceListItem.tsx
@@ -6,6 +6,7 @@ import Icon from "@shared/components/Icon";
import { s, hover, ellipsis } from "@shared/styles";
import { IconType, NavigationNode } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
+import useShare from "@shared/hooks/useShare";
import Document from "~/models/Document";
import Flex from "~/components/Flex";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
@@ -15,7 +16,6 @@ import useStores from "~/hooks/useStores";
import { useCallback } from "react";
type Props = {
- shareId?: string;
document: Document | NavigationNode;
anchor?: string;
showCollection?: boolean;
@@ -59,11 +59,11 @@ function ReferenceListItem({
document,
showCollection,
anchor,
- shareId,
sidebarContext,
...rest
}: Props) {
const { documents } = useStores();
+ const { shareId } = useShare();
const prefetchDocument = useCallback(async () => {
await documents.prefetchDocument(document.id);
}, [documents, document.id]);
@@ -73,6 +73,7 @@ function ReferenceListItem({
const isEmoji = determineIconType(icon) === IconType.Emoji;
const title =
document instanceof Document ? document.titleWithDefault : document.title;
+ const initial = title.charAt(0).toUpperCase();
return (
{icon ? (
-
+
) : (
)}
diff --git a/app/scenes/Settings/CustomEmojis.tsx b/app/scenes/Settings/CustomEmojis.tsx
new file mode 100644
index 0000000000..431d2489da
--- /dev/null
+++ b/app/scenes/Settings/CustomEmojis.tsx
@@ -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") || "name",
+ 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 (
+ }
+ actions={
+ <>
+ {can.createEmoji && (
+
+ }
+ >
+ {t("New emoji")}…
+
+
+ )}
+ >
+ }
+ wide
+ >
+ {t("Emojis")}
+
+ {t(
+ "Custom emojis can be used throughout your workspace in documents, comments, and reactions."
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+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);
diff --git a/app/scenes/Settings/components/EmojisTable.tsx b/app/scenes/Settings/components/EmojisTable.tsx
new file mode 100644
index 0000000000..48b914cd26
--- /dev/null
+++ b/app/scenes/Settings/components/EmojisTable.tsx
@@ -0,0 +1,105 @@
+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 { CustomEmoji } from "@shared/components/CustomEmoji";
+import EmojisMenu from "~/menus/EmojisMenu";
+import { s } from "@shared/styles";
+import styled from "styled-components";
+
+const ROW_HEIGHT = 60;
+const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
+
+type Props = Omit, "columns" | "rowHeight"> & {
+ canManage: boolean;
+};
+
+const EmojisTable = observer(function EmojisTable({
+ canManage,
+ ...rest
+}: Props) {
+ const { t } = useTranslation();
+
+ const columns = React.useMemo(
+ (): TableColumn[] =>
+ compact([
+ {
+ type: "data",
+ id: "name",
+ header: t("Emoji"),
+ accessor: (emoji) => emoji.url,
+ component: (emoji) => (
+
+
+ :{emoji.name}:
+
+ ),
+ width: "1fr",
+ },
+ {
+ type: "data",
+ id: "createdBy",
+ header: t("Added by"),
+ accessor: (emoji) => emoji.createdBy,
+ sortable: false,
+ component: (emoji) => (
+
+ {emoji.createdBy && (
+ <>
+
+ {emoji.createdBy.name}
+ >
+ )}
+
+ ),
+ width: "2fr",
+ },
+ {
+ type: "data",
+ id: "createdAt",
+ header: t("Date added"),
+ accessor: (emoji) => emoji.createdAt,
+ component: (emoji) => ,
+ width: "1fr",
+ },
+ {
+ type: "action",
+ id: "action",
+ component: (emoji) => ,
+ width: "50px",
+ },
+ ]),
+ [t, canManage]
+ );
+
+ return (
+
+ );
+});
+
+export const EmojiPreview = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: monospace;
+ font-size: 14px;
+ color: ${s("textSecondary")};
+`;
+
+export default EmojisTable;
diff --git a/app/scenes/Shared/Collection.tsx b/app/scenes/Shared/Collection.tsx
index 47dbd0efa2..7cf56fa54c 100644
--- a/app/scenes/Shared/Collection.tsx
+++ b/app/scenes/Shared/Collection.tsx
@@ -5,6 +5,7 @@ import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { IconTitleWrapper } from "@shared/components/Icon";
+import useShare from "@shared/hooks/useShare";
import CollectionModel from "~/models/Collection";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
@@ -24,11 +25,11 @@ import { AppearanceAction } from "~/components/Sharing/components/Actions";
type Props = {
collection: CollectionModel;
- shareId: string;
};
-function SharedCollection({ collection, shareId }: Props) {
+function SharedCollection({ collection }: Props) {
const { t } = useTranslation();
+ const { shareId } = useShare();
const can = usePolicy(collection);
const isMobile = useMobile();
diff --git a/app/scenes/Shared/Document.tsx b/app/scenes/Shared/Document.tsx
index 0f3b22fb72..beb496df43 100644
--- a/app/scenes/Shared/Document.tsx
+++ b/app/scenes/Shared/Document.tsx
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
-import { NavigationNode, PublicTeam, TOCPosition } from "@shared/types";
+import { PublicTeam, TOCPosition } from "@shared/types";
import DocumentModel from "~/models/Document";
import DocumentComponent from "~/scenes/Document/components/Document";
import { useDocumentContext } from "~/components/DocumentContext";
@@ -8,14 +8,14 @@ import { useMemo } from "react";
import { parseDomain } from "@shared/utils/domains";
import useCurrentUser from "~/hooks/useCurrentUser";
import Branding from "~/components/Branding";
+import useShare from "@shared/hooks/useShare";
type Props = {
document: DocumentModel;
- shareId: string;
- sharedTree?: NavigationNode;
};
-function SharedDocument({ document, shareId, sharedTree }: Props) {
+function SharedDocument({ document }: Props) {
+ const { shareId } = useShare();
const team = useTeamContext() as PublicTeam | undefined;
const user = useCurrentUser({ rejectOnEmpty: false });
const { hasHeadings, setDocument } = useDocumentContext();
@@ -36,7 +36,6 @@ function SharedDocument({ document, shareId, sharedTree }: Props) {
import("../Login"));
@@ -229,7 +230,12 @@ function SharedScene() {
const hasSidebar = !!share.tree?.children.length;
return (
- <>
+
: null}
>
{model instanceof Document ? (
-
+
) : model instanceof Collection ? (
-
+
) : null}
- >
+
);
}
diff --git a/app/stores/EmojiStore.ts b/app/stores/EmojiStore.ts
new file mode 100644
index 0000000000..495f6ef8b4
--- /dev/null
+++ b/app/stores/EmojiStore.ts
@@ -0,0 +1,23 @@
+import { computed } from "mobx";
+import Emoji from "~/models/Emoji";
+import naturalSort from "@shared/utils/naturalSort";
+import RootStore from "./RootStore";
+import Store, { RPCAction } from "./base/Store";
+
+export default class EmojisStore extends Store {
+ actions = [
+ RPCAction.Info,
+ RPCAction.List,
+ RPCAction.Create,
+ RPCAction.Delete,
+ ];
+
+ constructor(rootStore: RootStore) {
+ super(rootStore, Emoji);
+ }
+
+ @computed
+ get orderedData(): Emoji[] {
+ return naturalSort(Array.from(this.data.values()), "name");
+ }
+}
diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts
index 8bb7c31f36..a04e6bb4cf 100644
--- a/app/stores/RootStore.ts
+++ b/app/stores/RootStore.ts
@@ -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);
diff --git a/app/utils/developer.ts b/app/utils/developer.ts
index 2097d94042..b9e1b938f4 100644
--- a/app/utils/developer.ts
+++ b/app/utils/developer.ts
@@ -1,6 +1,6 @@
import flatten from "lodash/flatten";
import stores from "~/stores";
-import { flattenTree } from "./tree";
+import { flattenTree } from "@shared/utils/tree";
/**
* Delete all databases in the browser.
diff --git a/server/migrations/20251020204139-create-emojis.js b/server/migrations/20251020204139-create-emojis.js
new file mode 100644
index 0000000000..05637afdfa
--- /dev/null
+++ b/server/migrations/20251020204139-create-emojis.js
@@ -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");
+ },
+};
diff --git a/server/models/Collection.ts b/server/models/Collection.ts
index c1f63386bc..48667f0f6c 100644
--- a/server/models/Collection.ts
+++ b/server/models/Collection.ts
@@ -238,10 +238,6 @@ class Collection extends ParanoidModel<
content: ProsemirrorData | null;
/** An icon (or) emoji to use as the collection icon. */
- @Length({
- max: 50,
- msg: `icon must be 50 characters or less`,
- })
@Column
icon: string | null;
diff --git a/server/models/Document.ts b/server/models/Document.ts
index 51df6d9a1f..b1d9c1b010 100644
--- a/server/models/Document.ts
+++ b/server/models/Document.ts
@@ -326,10 +326,6 @@ class Document extends ArchivableModel<
editorVersion: string | null;
/** An icon to use as the document icon. */
- @Length({
- max: 50,
- msg: `icon must be 50 characters or less`,
- })
@Column
icon: string | null;
diff --git a/server/models/Emoji.ts b/server/models/Emoji.ts
new file mode 100644
index 0000000000..075aa84d4b
--- /dev/null
+++ b/server/models/Emoji.ts
@@ -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,
+ Partial>
+> {
+ @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
+ ): Promise {
+ 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;
diff --git a/server/models/Revision.ts b/server/models/Revision.ts
index df4db33b55..97de054672 100644
--- a/server/models/Revision.ts
+++ b/server/models/Revision.ts
@@ -85,10 +85,6 @@ class Revision extends ParanoidModel<
content: ProsemirrorData | null;
/** The icon at the time of the revision. */
- @Length({
- max: 50,
- msg: `icon must be 50 characters or less`,
- })
@Column
@SkipChangeset
icon: string | null;
diff --git a/server/models/helpers/AttachmentHelper.ts b/server/models/helpers/AttachmentHelper.ts
index 7a34bdd76c..8979532b8a 100644
--- a/server/models/helpers/AttachmentHelper.ts
+++ b/server/models/helpers/AttachmentHelper.ts
@@ -2,6 +2,7 @@ import { addHours } from "date-fns";
import { AttachmentPreset } from "@shared/types";
import env from "@server/env";
import { ValidateKey } from "@server/validation";
+import { AttachmentValidation } from "@shared/validations";
export enum Buckets {
public = "public",
@@ -109,6 +110,8 @@ export default class AttachmentHelper {
return env.FILE_STORAGE_IMPORT_MAX_SIZE;
case AttachmentPreset.WorkspaceImport:
return env.FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE;
+ case AttachmentPreset.Emoji:
+ return AttachmentValidation.emojiMaxFileSize;
case AttachmentPreset.Avatar:
case AttachmentPreset.DocumentAttachment:
default:
diff --git a/server/models/index.ts b/server/models/index.ts
index 457adcc6eb..3436454d76 100644
--- a/server/models/index.ts
+++ b/server/models/index.ts
@@ -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";
diff --git a/server/policies/emoji.ts b/server/policies/emoji.ts
new file mode 100644
index 0000000000..6380b67178
--- /dev/null
+++ b/server/policies/emoji.ts
@@ -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))
+);
diff --git a/server/policies/index.ts b/server/policies/index.ts
index f4f0467c63..7b64b75a2c 100644
--- a/server/policies/index.ts
+++ b/server/policies/index.ts
@@ -26,3 +26,4 @@ import "./team";
import "./group";
import "./webhookSubscription";
import "./userMembership";
+import "./emoji";
diff --git a/server/policies/utils.ts b/server/policies/utils.ts
index a814f19163..9993bad3f5 100644
--- a/server/policies/utils.ts
+++ b/server/policies/utils.ts
@@ -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;
}
diff --git a/server/presenters/emoji.ts b/server/presenters/emoji.ts
new file mode 100644
index 0000000000..09a8c0a1d0
--- /dev/null
+++ b/server/presenters/emoji.ts
@@ -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,
+ };
+}
diff --git a/server/presenters/index.ts b/server/presenters/index.ts
index eae074b9cd..c7ae75767f 100644
--- a/server/presenters/index.ts
+++ b/server/presenters/index.ts
@@ -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,
};
diff --git a/server/routes/api/attachments/attachments.ts b/server/routes/api/attachments/attachments.ts
index 2175b2ecc8..79f9da55b7 100644
--- a/server/routes/api/attachments/attachments.ts
+++ b/server/routes/api/attachments/attachments.ts
@@ -93,13 +93,18 @@ router.post(
// All user types can upload an avatar so no additional authorization is needed.
if (preset === AttachmentPreset.Avatar) {
assertIn(contentType, AttachmentValidation.avatarContentTypes);
- } else if (preset === AttachmentPreset.DocumentAttachment && documentId) {
- const document = await Document.findByPk(documentId, {
- userId: user.id,
- transaction,
- });
- authorize(user, "update", document);
} else {
+ if (preset === AttachmentPreset.DocumentAttachment && documentId) {
+ const document = await Document.findByPk(documentId, {
+ userId: user.id,
+ transaction,
+ });
+ authorize(user, "update", document);
+ }
+ if (preset === AttachmentPreset.Emoji) {
+ assertIn(contentType, AttachmentValidation.emojiContentTypes);
+ }
+
authorize(user, "createAttachment", user.team);
}
diff --git a/server/routes/api/collections/schema.ts b/server/routes/api/collections/schema.ts
index 6913a3242b..e053cbaca0 100644
--- a/server/routes/api/collections/schema.ts
+++ b/server/routes/api/collections/schema.ts
@@ -1,14 +1,12 @@
import isUndefined from "lodash/isUndefined";
-import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import {
CollectionPermission,
CollectionStatusFilter,
FileOperationFormat,
} from "@shared/types";
-import { UrlHelper } from "@shared/utils/UrlHelper";
import { Collection } from "@server/models";
-import { zodIconType, zodIdType } from "@server/utils/zod";
+import { zodIconType, zodIdType, zodShareIdType } from "@server/utils/zod";
import { ValidateColor, ValidateIndex } from "@server/validation";
import { BaseSchema, ProsemirrorSchema } from "../schema";
@@ -54,10 +52,7 @@ export type CollectionsCreateReq = z.infer;
export const CollectionsInfoSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Share Id, if available */
- shareId: z
- .string()
- .refine((val) => isUUID(val) || UrlHelper.SHARE_URL_SLUG_REGEX.test(val))
- .optional(),
+ shareId: zodShareIdType().optional(),
}),
});
diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts
index 0c0f8406ff..bf62ab1d59 100644
--- a/server/routes/api/documents/documents.test.ts
+++ b/server/routes/api/documents/documents.test.ts
@@ -506,7 +506,7 @@ describe("#documents.info", () => {
});
const body = await res.json();
expect(res.status).toEqual(400);
- expect(body.message).toEqual("shareId: Invalid input");
+ expect(body.message).toEqual("shareId: Invalid uuid");
});
});
diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts
index 9b6f132d52..29290452e6 100644
--- a/server/routes/api/documents/schema.ts
+++ b/server/routes/api/documents/schema.ts
@@ -1,11 +1,9 @@
import formidable from "formidable";
import isEmpty from "lodash/isEmpty";
-import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import { DocumentPermission, StatusFilter } from "@shared/types";
-import { UrlHelper } from "@shared/utils/UrlHelper";
import { BaseSchema } from "@server/routes/api/schema";
-import { zodIconType, zodIdType } from "@server/utils/zod";
+import { zodIconType, zodIdType, zodShareIdType } from "@server/utils/zod";
import { ValidateColor } from "@server/validation";
const DocumentsSortParamsSchema = z.object({
@@ -57,10 +55,7 @@ const BaseSearchSchema = DateFilterSchema.extend({
statusFilter: z.nativeEnum(StatusFilter).array().optional(),
/** Filter results for the team derived from shareId */
- shareId: z
- .string()
- .refine((val) => isUUID(val) || UrlHelper.SHARE_URL_SLUG_REGEX.test(val))
- .optional(),
+ shareId: zodShareIdType().optional(),
/** Min words to be shown in the results snippets */
snippetMinWords: z.number().default(20),
@@ -146,11 +141,7 @@ export const DocumentsInfoSchema = BaseSchema.extend({
body: z.object({
id: zodIdType().optional(),
/** Share Id, if available */
- shareId: z
- .string()
- .refine((val) => isUUID(val) || UrlHelper.SHARE_URL_SLUG_REGEX.test(val))
- .optional(),
-
+ shareId: zodShareIdType().optional(),
/** @deprecated Version of the API to be used, remove in a few releases */
apiVersion: z.number().optional(),
}),
@@ -475,7 +466,7 @@ export type DocumentsMembershipsReq = z.infer<
export const DocumentsSitemapSchema = BaseSchema.extend({
query: z.object({
- shareId: z.string(),
+ shareId: zodShareIdType().optional(),
}),
});
diff --git a/server/routes/api/emojis/emojis.ts b/server/routes/api/emojis/emojis.ts
new file mode 100644
index 0000000000..1f6c6d79e2
--- /dev/null
+++ b/server/routes/api/emojis/emojis.ts
@@ -0,0 +1,229 @@
+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 BaseStorage from "@server/storage/files/BaseStorage";
+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";
+import { getTeamFromContext } from "@server/utils/passport";
+import { loadPublicShare } from "@server/commands/shareLoader";
+import { AuthorizationError } from "@server/errors";
+import { flattenTree } from "@shared/utils/tree";
+
+const router = new Router();
+
+router.post(
+ "emojis.info",
+ auth(),
+ validate(T.EmojisInfoSchema),
+ async (ctx: APIContext) => {
+ 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.get(
+ "emojis.redirect",
+ auth({ optional: true }),
+ validate(T.EmojisRedirectSchema),
+ async (ctx: APIContext) => {
+ const { id, shareId } = ctx.input.query;
+ const { user } = ctx.state.auth;
+
+ const emoji = await Emoji.unscoped().findByPk(id, {
+ rejectOnEmpty: true,
+ include: [
+ {
+ model: Attachment,
+ },
+ ],
+ });
+
+ if (shareId) {
+ const teamFromCtx = await getTeamFromContext(ctx, {
+ includeStateCookie: false,
+ });
+
+ const { sharedTree } = await loadPublicShare({
+ id: shareId,
+ teamId: teamFromCtx?.id,
+ });
+
+ // collect all icons from sharedTree
+ const isEmojiInSharedTree =
+ sharedTree &&
+ flattenTree(sharedTree).some((node) => node.icon === emoji.id);
+
+ if (!isEmojiInSharedTree) {
+ throw AuthorizationError();
+ }
+ } else {
+ authorize(user, "read", emoji);
+ }
+
+ ctx.set(
+ "Cache-Control",
+ `max-age=${BaseStorage.defaultSignedUrlExpires}, immutable`
+ );
+ ctx.redirect(await emoji.attachment.signedUrl);
+ }
+);
+
+router.post(
+ "emojis.list",
+ auth(),
+ pagination(),
+ validate(T.EmojisListSchema),
+ async (ctx: APIContext) => {
+ const { user } = ctx.state.auth;
+ const { query } = ctx.input.body;
+
+ let where: WhereOptions = {
+ 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) => {
+ 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) => {
+ 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;
diff --git a/server/routes/api/emojis/index.ts b/server/routes/api/emojis/index.ts
new file mode 100644
index 0000000000..a46fba707d
--- /dev/null
+++ b/server/routes/api/emojis/index.ts
@@ -0,0 +1 @@
+export { default } from "./emojis";
diff --git a/server/routes/api/emojis/schema.ts b/server/routes/api/emojis/schema.ts
new file mode 100644
index 0000000000..37da0ec61f
--- /dev/null
+++ b/server/routes/api/emojis/schema.ts
@@ -0,0 +1,58 @@
+import { z } from "zod";
+import { EmojiValidation } from "@shared/validations";
+import { BaseSchema } from "../schema";
+import { zodShareIdType } from "@server/utils/zod";
+
+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 const EmojisRedirectSchema = BaseSchema.extend({
+ query: z.object({
+ /** Id of the emoji */
+ id: z.string().uuid(),
+ /** Share Id, if available */
+ shareId: zodShareIdType().optional(),
+ }),
+});
+
+export type EmojisRedirectReq = z.infer;
+
+export type EmojisInfoReq = z.infer;
+
+export type EmojisListReq = z.infer;
+
+export type EmojisCreateReq = z.infer;
+
+export type EmojisDeleteReq = z.infer;
diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts
index 977d922255..dadd7b5224 100644
--- a/server/routes/api/index.ts
+++ b/server/routes/api/index.ts
@@ -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());
diff --git a/server/utils/zod.ts b/server/utils/zod.ts
index 0a10eeb193..1aff3a5541 100644
--- a/server/utils/zod.ts
+++ b/server/utils/zod.ts
@@ -20,6 +20,13 @@ export const zodIconType = () =>
z.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
+ z.string().uuid(),
+ ]);
+
+export const zodShareIdType = () =>
+ z.union([
+ z.string().uuid(),
+ z.string().regex(UrlHelper.SHARE_URL_SLUG_REGEX),
]);
export const zodTimezone = () =>
diff --git a/shared/components/CustomEmoji.tsx b/shared/components/CustomEmoji.tsx
new file mode 100644
index 0000000000..dfed5387ce
--- /dev/null
+++ b/shared/components/CustomEmoji.tsx
@@ -0,0 +1,7 @@
+import styled from "styled-components";
+
+export const CustomEmoji = styled.img<{ size?: number }>`
+ width: ${(props) => (props.size ? `${props.size}px` : "16px")};
+ height: ${(props) => (props.size ? `${props.size}px` : "16px")};
+ object-fit: contain;
+`;
diff --git a/shared/components/EmojiIcon.tsx b/shared/components/EmojiIcon.tsx
index 9376ef87e1..bd56454421 100644
--- a/shared/components/EmojiIcon.tsx
+++ b/shared/components/EmojiIcon.tsx
@@ -1,4 +1,4 @@
-import * as React from "react";
+import { isUUID } from "validator";
import styled from "styled-components";
import { s } from "../styles";
@@ -15,11 +15,9 @@ type Props = {
* in a way that can be used wherever an Icon would be.
*/
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
- const isUrl = emoji.includes("/");
-
return (
-
+
);
}
diff --git a/shared/components/Icon.tsx b/shared/components/Icon.tsx
index e155b4bc59..884219dc1a 100644
--- a/shared/components/Icon.tsx
+++ b/shared/components/Icon.tsx
@@ -1,15 +1,15 @@
import { observer } from "mobx-react";
import { getLuminance } from "polished";
-import * as React from "react";
import styled from "styled-components";
import useStores from "../hooks/useStores";
+import useShare from "../hooks/useShare";
import { IconType } from "../types";
import { IconLibrary } from "../utils/IconLibrary";
import { colorPalette } from "../utils/collections";
import { determineIconType } from "../utils/icon";
import EmojiIcon from "./EmojiIcon";
-// import Logger from "~/utils/Logger";
import Flex from "./Flex";
+import { CustomEmoji } from "./CustomEmoji";
export type Props = {
/** The icon to render */
@@ -37,6 +37,7 @@ const Icon = ({
forceColor,
className,
}: Props) => {
+ const { shareId } = useShare();
const iconType = determineIconType(icon);
if (!iconType) {
@@ -60,6 +61,16 @@ const Icon = ({
);
}
+ if (iconType === IconType.Custom) {
+ return (
+
+
+
+ );
+ }
+
return ;
} catch (_err) {
// Ignore
@@ -117,4 +128,21 @@ export const IconTitleWrapper = styled(Flex)<{ dir?: string }>`
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
`;
+const EmojiImageWrapper = styled(Flex)`
+ width: 24px;
+ height: 24px;
+ align-items: center;
+ justify-content: center;
+
+ ${IconTitleWrapper} & {
+ width: auto;
+ height: auto;
+
+ ${CustomEmoji} {
+ width: 26px;
+ height: 26px;
+ }
+ }
+`;
+
export default Icon;
diff --git a/shared/hooks/useIsMounted.ts b/shared/hooks/useIsMounted.ts
index c1b6f141b2..196b7aa97b 100644
--- a/shared/hooks/useIsMounted.ts
+++ b/shared/hooks/useIsMounted.ts
@@ -3,9 +3,9 @@ import * as React from "react";
/**
* Hook to check if component is still mounted
*
- * @returns {boolean} true if the component is mounted, false otherwise
+ * @returns true if the component is mounted, false otherwise
*/
-export default function useIsMounted() {
+export default function useIsMounted(): () => boolean {
const isMounted = React.useRef(false);
React.useEffect(() => {
diff --git a/shared/hooks/useShare.ts b/shared/hooks/useShare.ts
new file mode 100644
index 0000000000..920bb2953c
--- /dev/null
+++ b/shared/hooks/useShare.ts
@@ -0,0 +1,18 @@
+import { NavigationNode } from "../types";
+import * as React from "react";
+
+type ShareContextType = {
+ shareId?: string;
+ sharedTree?: NavigationNode;
+};
+
+export const ShareContext = React.createContext({});
+
+export default function useShare(): ShareContextType & { isShare: boolean } {
+ const value = React.useContext(ShareContext);
+
+ return {
+ ...value,
+ isShare: !!value.shareId,
+ };
+}
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 4f56fb3c06..84ece13ee0 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -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": "Disconnect",
"Disconnect analytics": "Disconnect analytics",
"Home": "Home",
@@ -172,6 +174,7 @@
"Navigation": "Navigation",
"Notification": "Notification",
"Groups": "Groups",
+ "Emoji": "Emoji",
"People": "People",
"Share": "Share",
"Workspace": "Workspace",
@@ -253,6 +256,20 @@
"Currently editing": "Currently editing",
"Currently viewing": "Currently viewing",
"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",
+ "Uploading": "Uploading",
+ "Add emoji": "Add emoji",
+ "The emoji name should be unique and contain only lowercase letters, numbers, and underscores.": "The emoji name should be unique and contain only lowercase letters, numbers, and underscores.",
+ "name can only contain lowercase letters, numbers, and underscores.": "name can only contain lowercase letters, numbers, and underscores.",
+ "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 }}",
+ "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.",
@@ -299,6 +316,8 @@
"Icon Picker": "Icon Picker",
"Icons": "Icons",
"Emojis": "Emojis",
+ "Custom Emojis": "Custom Emojis",
+ "Custom": "Custom",
"Remove": "Remove",
"All": "All",
"Frequently Used": "Frequently Used",
@@ -597,6 +616,9 @@
"Comment options": "Comment options",
"Enable viewer insights": "Enable viewer insights",
"Enable embeds": "Enable embeds",
+ "Delete Emoji": "Delete Emoji",
+ "Emoji deleted": "Emoji deleted",
+ "Are you sure you want to delete the {{emojiName}} emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the {{emojiName}} 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",
@@ -647,7 +669,6 @@
"30 days": "30 days",
"60 days": "60 days",
"90 days": "90 days",
- "Custom": "Custom",
"No expiration": "No expiration",
"The document archive is empty at the moment.": "The document archive is empty at the moment.",
"Drop documents to import": "Drop documents to import",
@@ -848,7 +869,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",
@@ -989,8 +1009,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",
@@ -1060,6 +1081,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",
diff --git a/shared/types.ts b/shared/types.ts
index c7360ad28e..931fa0367d 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -102,6 +102,7 @@ export enum AttachmentPreset {
WorkspaceImport = "workspaceImport",
Import = "import",
Avatar = "avatar",
+ Emoji = "emoji",
}
export enum IntegrationType {
@@ -564,6 +565,7 @@ export type ProsemirrorDoc = {
export enum IconType {
SVG = "svg",
Emoji = "emoji",
+ Custom = "custom",
}
export enum EmojiCategory {
diff --git a/shared/utils/icon.ts b/shared/utils/icon.ts
index 286cb28b30..d325e27ddf 100644
--- a/shared/utils/icon.ts
+++ b/shared/utils/icon.ts
@@ -1,3 +1,4 @@
+import { isUUID } from "validator";
import { IconType } from "../types";
import { IconLibrary } from "./IconLibrary";
@@ -9,5 +10,9 @@ export const determineIconType = (
if (!icon) {
return;
}
- return outlineIconNames.has(icon) ? IconType.SVG : IconType.Emoji;
+ return outlineIconNames.has(icon)
+ ? IconType.SVG
+ : isUUID(icon)
+ ? IconType.Custom
+ : IconType.Emoji;
};
diff --git a/app/utils/tree.ts b/shared/utils/tree.ts
similarity index 94%
rename from app/utils/tree.ts
rename to shared/utils/tree.ts
index b60b3f3fdd..0d3db071b4 100644
--- a/app/utils/tree.ts
+++ b/shared/utils/tree.ts
@@ -1,4 +1,4 @@
-import { NavigationNode } from "@shared/types";
+import { NavigationNode } from "../types";
export const flattenTree = (root: NavigationNode) => {
const flattened: NavigationNode[] = [];
diff --git a/shared/validations.ts b/shared/validations.ts
index f8ee43a3c8..1b2ba60fe0 100644
--- a/shared/validations.ts
+++ b/shared/validations.ts
@@ -2,6 +2,18 @@ export const AttachmentValidation = {
/** The limited allowable mime-types for user and team avatars */
avatarContentTypes: ["image/jpg", "image/jpeg", "image/png"],
+ /** The most widely supported mime-types across modern browsers */
+ emojiContentTypes: [
+ "image/png",
+ "image/webp",
+ "image/gif",
+ "image/jpeg",
+ "image/jpg",
+ ],
+
+ /** The maximum file size for emoji uploads */
+ emojiMaxFileSize: 1 * 1000 * 1000,
+
/** Image mime-types commonly supported by modern browsers */
imageContentTypes: [
"image/jpg",
@@ -129,3 +141,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 characters allowed in the name */
+ allowedNameCharacters: /^[a-z0-9_]*$/,
+};