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 && ( + + + + )} + + } + 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) =>