feat: Custom emoji (#10513)

Towards #9278
This commit is contained in:
Salihu
2025-12-01 02:31:50 +01:00
committed by GitHub
parent 25a1bf6889
commit 430883f186
62 changed files with 1686 additions and 238 deletions

View File

@@ -0,0 +1,21 @@
import { PlusIcon } from "outline-icons";
import { createAction } from "~/actions";
import { TeamSection } from "../sections";
import stores from "~/stores";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
export const createEmoji = createAction({
name: ({ t }) => `${t("New emoji")}`,
analyticsName: "Create emoji",
icon: <PlusIcon />,
keywords: "emoji custom upload image",
section: TeamSection,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Upload emoji"),
content: <EmojiCreateDialog onSubmit={stores.dialogs.closeAllModals} />,
});
},
});

View File

@@ -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;

View File

@@ -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. */

View File

@@ -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<void>;
onPublish?: (event: React.MouseEvent) => void;
@@ -41,9 +41,9 @@ export type Props = Optional<
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | 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<SharedEditor>();

View File

@@ -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<File | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const handleFileSelection = React.useCallback(
(file: File) => {
const isValidType = AttachmentValidation.emojiContentTypes.includes(
file.type
);
if (!isValidType) {
toast.error(
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
);
return;
}
// Validate file size
if (file.size > AttachmentValidation.emojiMaxFileSize) {
toast.error(
t("File size too large. Maximum size is {{ size }}.", {
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
})
);
return;
}
setFile(file);
},
[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<HTMLInputElement>) => {
const { value } = event.target;
setName(value);
};
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
const isValid = name.trim().length > 0 && file && isValidName;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"The emoji name should be unique and contain only lowercase letters, numbers, and underscores."
)}
</Text>
<Input
label={t("Name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<Flex column align="center" gap={8}>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text size="medium">{file.name}</Text>
<Text size="medium" type="secondary">
{t("Click or drag to replace")}
</Text>
</>
) : (
<>
<Text size="medium">
{isDragActive
? t("Drop the image here")
: t("Click, drop, or paste an image here")}
</Text>
<Text size="medium" type="secondary">
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
size: bytesToHumanReadable(
AttachmentValidation.emojiMaxFileSize
),
})}
</Text>
</>
)}
</Flex>
</DropZone>
{name.trim() && isValidName && (
<Text type="secondary" style={{ marginTop: "8px" }}>
{t("This emoji will be available as")} <code>:{name}:</code>
</Text>
)}
</ConfirmationDialog>
);
}
const DropZone = styled.div`
border: 2px dashed ${s("inputBorder")};
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: var(--pointer);
transition: border-color 0.2s;
&:hover {
border-color: ${s("inputBorderFocused")};
}
`;
const PreviewImage = styled.img`
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 4px;
`;

View File

@@ -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;
`;

View File

@@ -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<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const [searchData, setSearchData] = useState<DataNode[]>([]);
const [freqEmojis, setFreqEmojis] = useState<EmojiNode[]>([]);
const { getFrequentIcons, incrementIconCount } = useIconState(
IconType.Custom
);
const { emojis } = useStores();
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Flex column>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search")}`}
onChange={handleFilter}
/>
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={height - 48}
data={searchData.length ? searchData : templateData}
onIconSelect={handleEmojiSelection}
/>
</Flex>
);
};
const toIcon = (emoji: Emoji): EmojiNode => ({
type: IconType.Custom,
id: emoji.id,
value: emoji.id,
name: emoji.name,
});
export default CustomEmojiPanel;

View File

@@ -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<EmojiSkinTone>(
emojiSkinToneKey,
EmojiSkinTone.Default
);
const [emojisFreq, setEmojisFreq] = usePersistedState<Record<string, number>>(
emojisFreqKey,
{}
);
const [lastEmoji, setLastEmoji] = usePersistedState<string | undefined>(
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<HTMLInputElement>) => {
@@ -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;

View File

@@ -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 })}
>
<Emoji width={24} height={24}>
{item.value}
{item.type === IconType.Custom ? (
<CustomEmoji
src={`/api/emojis.redirect?id=${item.value}`}
title={item.name}
/>
) : (
item.value
)}
</Emoji>
</IconButton>
);

View File

@@ -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<Record<string, number>>(
iconsFreqKey,
{}
);
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
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<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(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(

View File

@@ -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 (
<Drawer open={open} onOpenChange={setOpen}>
@@ -245,6 +254,13 @@ const Content = ({
>
{t("Emojis")}
</StyledTab>
<StyledTab
value={TAB_NAMES["Custom"]}
aria-label={t("Custom Emojis")}
$active={activeTab === TAB_NAMES["Custom"]}
>
{t("Custom")}
</StyledTab>
</Tabs.List>
{allowDelete && (
<RemoveButton onClick={onIconRemove}>{t("Remove")}</RemoveButton>
@@ -271,6 +287,15 @@ const Content = ({
onQueryChange={onQueryChange}
/>
</StyledTabContent>
<StyledTabContent value={TAB_NAMES["Custom"]}>
<CustomEmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={open && activeTab === TAB_NAMES["Custom"]}
onEmojiChange={onIconChange}
onQueryChange={onQueryChange}
/>
</StyledTabContent>
</Tabs.Root>
);
};

View File

@@ -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<EmojiSkinTone>(
skinToneKeys[type],
EmojiSkinTone.Default
);
const [iconFreq, setIconFreq] = usePersistedState<Record<string, number>>(
freqIconKeys[type],
{}
);
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
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,
};
};

View File

@@ -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));

View File

@@ -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 value={icon} color={node.color} />}
icon={
icon && <Icon value={icon} color={node.color} initial={initial} />
}
label={title}
depth={depth}
exact={false}

View File

@@ -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"),

75
app/menus/EmojisMenu.tsx Normal file
View File

@@ -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: (
<DeleteEmojiDialog emoji={emoji} onSubmit={dialogs.closeAllModals} />
),
});
};
if (!can.delete) {
return null;
}
return (
<Tooltip content={t("Delete Emoji")}>
<IconButton onClick={handleDelete}>
<TrashIcon />
</IconButton>
</Tooltip>
);
};
const DeleteEmojiDialog = ({
emoji,
onSubmit,
}: {
emoji: Emoji;
onSubmit: () => void;
}) => {
const { t } = useTranslation();
const handleSubmit = async () => {
if (emoji) {
await emoji.delete();
onSubmit();
toast.success(t("Emoji deleted"));
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
values={{
emojiName: emoji.name,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
};
export default EmojisMenu;

51
app/models/Emoji.ts Normal file
View File

@@ -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;

View File

@@ -94,7 +94,6 @@ function Overview({ collection, shareId }: Props) {
readOnly={!can.update || !!shareId}
userId={user?.id}
editorStyle={editorStyle}
shareId={shareId}
/>
<div ref={childRef} />
</MeasuredContainer>

View File

@@ -535,7 +535,6 @@ class DocumentScene extends React.Component<Props> {
<Header
document={document}
revision={revision}
shareId={shareId}
isDraft={document.isDraft}
isEditing={!readOnly && !!user?.separateEditMode}
isSaving={this.isSaving}
@@ -544,7 +543,6 @@ class DocumentScene extends React.Component<Props> {
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<Props> {
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<Props> {
>
{shareId ? (
<ReferencesWrapper>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
<PublicReferences documentId={document.id} />
</ReferencesWrapper>
) : !revision ? (
<ReferencesWrapper>

View File

@@ -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<any>) {
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<any>) {
placeholder={t("Type '/' to insert, or start writing…")}
scrollTo={decodeURIComponentSafe(window.location.hash)}
readOnly={readOnly}
shareId={shareId}
userId={user?.id}
focusedCommentId={focusedComment?.id}
onClickCommentMark={

View File

@@ -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

View File

@@ -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) {
<>
<Subheading>{t("Documents")}</Subheading>
{children.map((node) => (
<ReferenceListItem key={node.id} document={node} shareId={shareId} />
<ReferenceListItem key={node.id} document={node} />
))}
</>
);

View File

@@ -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 (
<DocumentLink
@@ -92,7 +93,7 @@ function ReferenceListItem({
>
<Content gap={4} dir="auto">
{icon ? (
<Icon value={icon} color={color ?? undefined} />
<Icon value={icon} color={color ?? undefined} initial={initial} />
) : (
<DocumentIcon />
)}

View File

@@ -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 (
<Scene
title={t("Emojis")}
icon={<SmileyIcon />}
actions={
<>
{can.createEmoji && (
<Action>
<Button
type="button"
data-on="click"
data-event-category="emoji"
data-event-action="create"
action={createEmoji}
context={context}
icon={<PlusIcon />}
>
{t("New emoji")}
</Button>
</Action>
)}
</>
}
wide
>
<Heading>{t("Emojis")}</Heading>
<Text as="p" type="secondary">
{t(
"Custom emojis can be used throughout your workspace in documents, comments, and reactions."
)}
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<EmojisTable
data={data ?? []}
sort={sort}
canManage={can.update}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
</Scene>
);
}
function getFilteredEmojis({
emojis,
query,
}: {
emojis: EmojisStore;
query?: string;
}) {
let filteredEmojis = emojis.orderedData;
if (query) {
filteredEmojis = filteredEmojis.filter((emoji) =>
emoji.name.toLowerCase().includes(query.toLowerCase())
);
}
return filteredEmojis;
}
export default observer(Emojis);

View File

@@ -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<TableProps<Emoji>, "columns" | "rowHeight"> & {
canManage: boolean;
};
const EmojisTable = observer(function EmojisTable({
canManage,
...rest
}: Props) {
const { t } = useTranslation();
const columns = React.useMemo(
(): TableColumn<Emoji>[] =>
compact([
{
type: "data",
id: "name",
header: t("Emoji"),
accessor: (emoji) => emoji.url,
component: (emoji) => (
<EmojiPreview>
<CustomEmoji src={emoji.url} alt={emoji.name} size={28} />
<span>:{emoji.name}:</span>
</EmojiPreview>
),
width: "1fr",
},
{
type: "data",
id: "createdBy",
header: t("Added by"),
accessor: (emoji) => emoji.createdBy,
sortable: false,
component: (emoji) => (
<Flex align="center" gap={8}>
{emoji.createdBy && (
<>
<Avatar model={emoji.createdBy} size={AvatarSize.Small} />
{emoji.createdBy.name}
</>
)}
</Flex>
),
width: "2fr",
},
{
type: "data",
id: "createdAt",
header: t("Date added"),
accessor: (emoji) => emoji.createdAt,
component: (emoji) => <Time dateTime={emoji.createdAt} addSuffix />,
width: "1fr",
},
{
type: "action",
id: "action",
component: (emoji) => <EmojisMenu emoji={emoji} />,
width: "50px",
},
]),
[t, canManage]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
{...rest}
/>
);
});
export const EmojiPreview = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-family: monospace;
font-size: 14px;
color: ${s("textSecondary")};
`;
export default EmojisTable;

View File

@@ -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();

View File

@@ -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) {
<DocumentComponent
abilities={abilities}
document={document}
sharedTree={sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly

View File

@@ -32,6 +32,7 @@ import { Collection as CollectionScene } from "./Collection";
import { Document as DocumentScene } from "./Document";
import DelayedMount from "~/components/DelayedMount";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { ShareContext } from "@shared/hooks/useShare";
const Login = lazyWithRetry(() => import("../Login"));
@@ -229,7 +230,12 @@ function SharedScene() {
const hasSidebar = !!share.tree?.children.length;
return (
<>
<ShareContext.Provider
value={{
shareId,
sharedTree: share.tree,
}}
>
<Helmet>
<link
rel="canonical"
@@ -244,20 +250,16 @@ function SharedScene() {
sidebar={hasSidebar ? <Sidebar share={share} /> : null}
>
{model instanceof Document ? (
<DocumentScene
document={model}
shareId={shareId}
sharedTree={share.tree}
/>
<DocumentScene document={model} />
) : model instanceof Collection ? (
<CollectionScene collection={model} shareId={shareId} />
<CollectionScene collection={model} />
) : null}
</Layout>
<ClickablePadding minHeight="20vh" />
</DocumentContextProvider>
</ThemeProvider>
</TeamContext.Provider>
</>
</ShareContext.Provider>
);
}

23
app/stores/EmojiStore.ts Normal file
View File

@@ -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<Emoji> {
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");
}
}

View File

@@ -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);

View File

@@ -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.

View File

@@ -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");
},
};

View File

@@ -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;

View File

@@ -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;

93
server/models/Emoji.ts Normal file
View File

@@ -0,0 +1,93 @@
import {
InferAttributes,
InferCreationAttributes,
type SaveOptions,
} from "sequelize";
import {
BeforeCreate,
BeforeDestroy,
BelongsTo,
Column,
DataType,
ForeignKey,
Table,
} from "sequelize-typescript";
import { EmojiValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
import { Matches } from "class-validator";
import FileStorage from "@server/storage/files";
import Attachment from "./Attachment";
@Table({ tableName: "emojis", modelName: "emoji" })
@Fix
class Emoji extends IdModel<
InferAttributes<Emoji>,
Partial<InferCreationAttributes<Emoji>>
> {
@Length({
max: EmojiValidation.maxNameLength,
msg: `emoji name must be less than ${EmojiValidation.maxNameLength} characters`,
})
@Matches(EmojiValidation.allowedNameCharacters, {
message:
"emoji name can only contain lowercase letters, numbers, and underscores",
})
@Column(DataType.STRING)
name: string;
// associations
@BelongsTo(() => Attachment, "attachmentId")
attachment: Attachment;
@ForeignKey(() => Attachment)
@Column(DataType.UUID)
attachmentId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
// hooks
@BeforeCreate
static async checkUniqueName(
model: Emoji,
options: SaveOptions<Emoji>
): Promise<void> {
const existingEmoji = await this.findOne({
where: {
name: model.name,
teamId: model.teamId,
},
transaction: options.transaction,
});
if (existingEmoji) {
throw ValidationError(`Emoji with name "${model.name}" already exists.`);
}
}
@BeforeDestroy
static async deleteAttachmentFromS3(model: Emoji) {
const attachment = await Attachment.findByPk(model.attachmentId);
if (attachment) {
await FileStorage.deleteFile(attachment.key);
}
}
}
export default Emoji;

View File

@@ -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;

View File

@@ -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:

View File

@@ -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";

11
server/policies/emoji.ts Normal file
View File

@@ -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))
);

View File

@@ -26,3 +26,4 @@ import "./team";
import "./group";
import "./webhookSubscription";
import "./userMembership";
import "./emoji";

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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,
};

View File

@@ -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);
}

View File

@@ -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<typeof CollectionsCreateSchema>;
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(),
}),
});

View File

@@ -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");
});
});

View File

@@ -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(),
}),
});

View File

@@ -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<T.EmojisInfoReq>) => {
const { id, name } = ctx.input.body;
const { user } = ctx.state.auth;
const include = [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: Attachment,
as: "attachment",
paranoid: false,
},
];
let emoji;
if (id) {
emoji = await Emoji.findByPk(id, {
rejectOnEmpty: true,
include,
});
} else if (name) {
emoji = await Emoji.findOne({
where: {
name,
teamId: user.teamId,
},
include,
rejectOnEmpty: true,
});
}
authorize(user, "read", emoji);
ctx.body = {
data: presentEmoji(emoji),
policies: presentPolicies(user, [emoji]),
};
}
);
router.get(
"emojis.redirect",
auth({ optional: true }),
validate(T.EmojisRedirectSchema),
async (ctx: APIContext<T.EmojisRedirectReq>) => {
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<T.EmojisListReq>) => {
const { user } = ctx.state.auth;
const { query } = ctx.input.body;
let where: WhereOptions<Emoji> = {
teamId: user.teamId,
};
if (query) {
where = {
...where,
name: {
[Op.iLike]: `%${query}%`,
},
};
}
const [emojis, total] = await Promise.all([
Emoji.findAll({
where,
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: Attachment,
as: "attachment",
paranoid: false,
},
],
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Emoji.count({
where,
}),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: emojis.map(presentEmoji),
policies: presentPolicies(user, emojis),
};
}
);
router.post(
"emojis.create",
rateLimiter(RateLimiterStrategy.TenPerMinute),
auth(),
validate(T.EmojisCreateSchema),
transaction(),
async (ctx: APIContext<T.EmojisCreateReq>) => {
const { name, attachmentId } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const attachment = await Attachment.findByPk(attachmentId, {
transaction,
rejectOnEmpty: true,
});
authorize(user, "read", attachment);
const emoji = await Emoji.createWithCtx(ctx, {
name,
attachmentId,
teamId: user.teamId,
createdById: user.id,
createdBy: user,
});
emoji.createdBy = user;
emoji.attachment = attachment;
ctx.body = {
data: presentEmoji(emoji),
policies: presentPolicies(user, [emoji]),
};
}
);
router.post(
"emojis.delete",
auth(),
validate(T.EmojisDeleteSchema),
transaction(),
async (ctx: APIContext<T.EmojisDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const emoji = await Emoji.findByPk(id, {
transaction: ctx.state.transaction,
rejectOnEmpty: true,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "delete", emoji);
await emoji.destroyWithCtx(ctx);
ctx.body = {
success: true,
};
}
);
export default router;

View File

@@ -0,0 +1 @@
export { default } from "./emojis";

View File

@@ -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<typeof EmojisRedirectSchema>;
export type EmojisInfoReq = z.infer<typeof EmojisInfoSchema>;
export type EmojisListReq = z.infer<typeof EmojisListSchema>;
export type EmojisCreateReq = z.infer<typeof EmojisCreateSchema>;
export type EmojisDeleteReq = z.infer<typeof EmojisDeleteSchema>;

View File

@@ -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());

View File

@@ -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 = () =>

View File

@@ -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;
`;

View File

@@ -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 (
<Span $size={size} {...rest}>
<SVG size={size} emoji={isUrl ? "<22>" : emoji} />
<SVG size={size} emoji={isUUID(emoji) ? "<22>" : emoji} />
</Span>
);
}

View File

@@ -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 (
<EmojiImageWrapper>
<CustomEmoji
src={`/api/emojis.redirect?id=${icon}${shareId ? `&shareId=${shareId}` : ""}`}
/>
</EmojiImageWrapper>
);
}
return <EmojiIcon emoji={icon} size={size} className={className} />;
} 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;

View File

@@ -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(() => {

18
shared/hooks/useShare.ts Normal file
View File

@@ -0,0 +1,18 @@
import { NavigationNode } from "../types";
import * as React from "react";
type ShareContextType = {
shareId?: string;
sharedTree?: NavigationNode;
};
export const ShareContext = React.createContext<ShareContextType>({});
export default function useShare(): ShareContextType & { isShare: boolean } {
const value = React.useContext(ShareContext);
return {
...value,
isShare: !!value.shareId,
};
}

View File

@@ -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 <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
"File": "File",
"Group members": "Group members",
"Edit group": "Edit group",
@@ -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",

View File

@@ -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 {

View File

@@ -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;
};

View File

@@ -1,4 +1,4 @@
import { NavigationNode } from "@shared/types";
import { NavigationNode } from "../types";
export const flattenTree = (root: NavigationNode) => {
const flattened: NavigationNode[] = [];

View File

@@ -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_]*$/,
};