Compare commits

...

16 Commits

Author SHA1 Message Date
Salihu 3aad383a12 use store methods for custom emoji search 2025-11-20 22:00:17 +01:00
Salihu ef2ea41d63 serialize custom emojis like regular images 2025-11-20 22:00:17 +01:00
Salihu 44f84ff4a5 validate emoji url 2025-11-20 22:00:17 +01:00
Salihu 7b143029a3 minor fix 2025-11-20 22:00:17 +01:00
Salihu e0e6547313 custom emoji rules 2025-11-20 22:00:17 +01:00
Salihu dfdfbbe2f9 use data-url for custom emojis 2025-11-20 22:00:17 +01:00
Salihu 1697c94b1a to dos 2025-11-20 22:00:17 +01:00
Salihu 5d8756d57f custom emoji in document and comments 2025-11-20 22:00:17 +01:00
Salihu b565328e02 revert document icon 2025-11-20 22:00:17 +01:00
Salihu af7e932c56 remove url from custom emoji record 2025-11-20 21:59:08 +01:00
Salihu cf918e45a5 custom emoji menu (#10631) 2025-11-14 16:09:24 +01:00
Salihu 20f75f741d minor fix 2025-11-12 17:27:04 +01:00
Salihu 5423753139 change emoji image size 2025-11-11 21:10:14 +01:00
Salihu a08e89ca4d minor fixes 2025-11-11 20:55:08 +01:00
Salihu af8174eec8 revert document icon 2025-11-04 20:52:32 +01:00
Salihu d196afa30a custom emoji 2025-11-04 20:51:29 +01:00
31 changed files with 1219 additions and 50 deletions
+21
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} />,
});
},
});
+2
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;
+173
View File
@@ -0,0 +1,173 @@
import * as React from "react";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { compressImage } from "~/utils/compressImage";
import Logger from "~/utils/Logger";
import { EmojiValidation } from "@shared/validations";
type Props = {
onSubmit: () => void;
};
export function EmojiCreateDialog({ onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [name, setName] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const onDrop = React.useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setFile(acceptedFiles[0]);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDropAccepted: onDrop,
accept: [".png", ".jpg", ".jpeg", ".gif", ".webp"],
maxFiles: 1,
maxSize: 1024 * 1024, // 1MB
});
const handleSubmit = async () => {
if (!name.trim()) {
toast.error(t("Please enter a name for the emoji"));
return;
}
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
const compressed = await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(compressed, {
name: file.name,
preset: AttachmentPreset.DocumentAttachment,
});
const emoji = await emojis.create({
name: name.trim(),
attachmentId: attachment.id,
});
emojis.add(emoji);
toast.success(t("Emoji created successfully"));
onSubmit();
} catch (error) {
toast.error(t("Failed to create emoji"));
Logger.error("Failed to create emoji", error);
} finally {
setIsUploading(false);
}
};
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setName(value);
};
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
const isValid = name.trim().length > 0 && file && isValidName;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores."
)}
</Text>
<Input
label={t("Emoji name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only should lowercase letters, numbers, and underscores."
)
: undefined
}
/>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<Flex column align="center" gap={8}>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text size="medium">{file.name}</Text>
<Text size="medium" type="secondary">
{t("Click or drag to replace")}
</Text>
</>
) : (
<>
<Text size="medium">
{isDragActive
? t("Drop the image here")
: t("Click or drag an image here")}
</Text>
<Text size="medium" type="secondary">
{t("PNG, JPG, GIF, or WebP up to 1MB")}
</Text>
</>
)}
</Flex>
</DropZone>
{name.trim() && isValidName && (
<div style={{ marginTop: "8px" }}>
<Text type="secondary">
{t("This emoji will be available as")} <code>:{name}:</code>
</Text>
</div>
)}
</ConfirmationDialog>
);
}
const DropZone = styled.div`
border: 2px dashed ${s("divider")};
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s;
&:hover {
border-color: ${s("accent")};
}
`;
const PreviewImage = styled.img`
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 4px;
`;
+2 -1
View File
@@ -33,7 +33,7 @@ import TrashLink from "./components/TrashLink";
function AppSidebar() {
const { t } = useTranslation();
const { documents, ui, collections } = useStores();
const { documents, ui, collections, emojis } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team);
@@ -43,6 +43,7 @@ function AppSidebar() {
if (!user.isViewer) {
void documents.fetchDrafts();
void emojis.fetchAll();
}
}, [documents, collections, user.isViewer]);
+55 -22
View File
@@ -1,18 +1,21 @@
import capitalize from "lodash/capitalize";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useState } from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji";
import type { Emoji as ShortEmojiType } from "@shared/types";
import EmojiMenuItem from "./EmojiMenuItem";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
import { isInternalUrl } from "@shared/utils/urls";
import useStores from "~/hooks/useStores";
type Emoji = {
name: string;
title: string;
emoji: string;
description: string;
attrs: { markup: string; "data-name": string };
attrs: { markup: string; "data-name": string; "data-url"?: string };
};
type Props = Omit<
@@ -22,28 +25,37 @@ type Props = Omit<
const EmojiMenu = (props: Props) => {
const { search = "" } = props;
const [items, setItems] = useState<Emoji[]>([]);
const { emojis } = useStores();
const items = useMemo(
() =>
emojiSearch({ query: search })
.map((item) => {
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
// @ts-expect-error emojiMartToGemoji key
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.value;
useEffect(() => {
const updateItems = (results: ShortEmojiType[]) => {
setItems(results.map(toMenuItem).slice(0, 15));
};
return {
name: "emoji",
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
})
.slice(0, 15),
[search]
);
// search through regular emojis
const localResults = emojiSearch({ query: search });
updateItems(localResults);
// Fetch and merge custom emojis
emojis.fetchPage({ query: search }).then((serverData) => {
if (!serverData.length) {return;}
const customEmojis = serverData.map((e) => ({
id: e.id,
name: e.name,
search: e.name,
value: e.url,
}));
const mergedResults = emojiSearch({
query: search,
emojis: customEmojis,
});
updateItems(mergedResults);
});
}, [search, emojis]);
const renderMenuItem = useCallback(
(item, _index, options) => (
@@ -67,4 +79,25 @@ const EmojiMenu = (props: Props) => {
);
};
const toMenuItem = (item: ShortEmojiType): Emoji => {
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
// @ts-expect-error emojiMartToGemoji key
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.value;
const isCustom = isInternalUrl(emoji);
return {
name: "emoji",
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: {
markup: shortcode,
"data-name": isCustom ? item.name : shortcode,
"data-url": isCustom ? emoji : undefined,
},
};
};
export default EmojiMenu;
+7 -1
View File
@@ -2,6 +2,8 @@ import styled from "styled-components";
import SuggestionsMenuItem, {
Props as SuggestionsMenuItemProps,
} from "./SuggestionsMenuItem";
import { isInternalUrl } from "@shared/utils/urls";
import { EmojiImage } from "@shared/components/customEmojis";
const Emoji = styled.span`
font-size: 16px;
@@ -19,7 +21,11 @@ export default function EmojiMenuItem({ emoji, ...rest }: EmojiMenuItemProps) {
return (
<SuggestionsMenuItem
{...rest}
icon={<Emoji className="emoji">{emoji}</Emoji>}
icon={
<Emoji className="emoji">
{isInternalUrl(emoji) ? <EmojiImage src={emoji} /> : emoji}
</Emoji>
}
/>
);
}
+11
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"),
+73
View File
@@ -0,0 +1,73 @@
import { TrashIcon } from "outline-icons";
import { Trans, useTranslation } from "react-i18next";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { IconButton } from "~/components/IconPicker/components/IconButton";
import Tooltip from "~/components/Tooltip";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Emoji from "~/models/Emoji";
const EmojisMenu = ({ emoji }: { emoji: Emoji }) => {
const { t } = useTranslation();
const { dialogs } = useStores();
const can = usePolicy(emoji);
const handleDelete = () => {
dialogs.openModal({
title: t("Delete Emoji"),
content: (
<DeleteEmojiDialog emoji={emoji} onSubmit={dialogs.closeAllModals} />
),
});
};
if (!can.delete) {
return null;
}
return (
<Tooltip content={`${t("Delete Emoji")}`}>
<IconButton onClick={handleDelete}>
<TrashIcon />
</IconButton>
</Tooltip>
);
};
const DeleteEmojiDialog = ({
emoji,
onSubmit,
}: {
emoji: Emoji;
onSubmit: () => void;
}) => {
const { t } = useTranslation();
const handleSubmit = async () => {
if (emoji) {
await emoji.delete();
onSubmit();
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("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;
+47
View File
@@ -0,0 +1,47 @@
import { observable } from "mobx";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class Emoji extends Model {
static modelName = "Emoji";
/** The name of the emoji */
@Field
@observable
private _name: string;
/** The URL of the emoji image */
@Field
@observable
url: string;
/** The ID */
@Field
@observable
attachmentId: string;
/** The user who created this emoji */
@Relation(() => User)
@observable
createdBy?: User;
/** The ID of the user who created this emoji */
@Field
@observable
createdById: string;
/**
* emoji name
*/
get name() {
return this._name;
}
set name(value: string) {
this._name = value;
}
}
export default Emoji;
+171
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") || "createdAt",
direction: (params.get("direction") || "asc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
const sort: ColumnSort = useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const { data, error, loading, next } = useTableRequest({
data: getFilteredEmojis({
emojis,
query: reqParams.query,
}),
sort,
reqFn: emojis.fetchPage,
reqParams,
});
const updateParams = useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
useEffect(() => {
if (error) {
toast.error(t("Could not load emojis"));
}
}, [t, error]);
useEffect(() => {
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateParams]);
return (
<Scene
title={t("Emojis")}
icon={<SmileyIcon />}
actions={
<>
{can.createEmoji && (
<Action>
<Button
type="button"
data-on="click"
data-event-category="emoji"
data-event-action="create"
action={createEmoji}
context={context}
icon={<PlusIcon />}
>
{t("New emoji")}
</Button>
</Action>
)}
</>
}
wide
>
<Heading>{t("Emojis")}</Heading>
<Text as="p" type="secondary">
{t(
"Custom emojis can be used throughout your workspace in documents, comments, and reactions."
)}
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<EmojisTable
data={data ?? []}
sort={sort}
canManage={can.update}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
</Scene>
);
}
function getFilteredEmojis({
emojis,
query,
}: {
emojis: EmojisStore;
query?: string;
}) {
let filteredEmojis = emojis.orderedData;
if (query) {
filteredEmojis = filteredEmojis.filter((emoji) =>
emoji.name.toLowerCase().includes(query.toLowerCase())
);
}
return filteredEmojis;
}
export default observer(Emojis);
@@ -0,0 +1,94 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Flex from "@shared/components/Flex";
import Emoji from "~/models/Emoji";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { HEADER_HEIGHT } from "~/components/Header";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import { FILTER_HEIGHT } from "./StickyFilters";
import { EmojiImage, EmojiPreview } from "@shared/components/customEmojis";
import EmojisMenu from "~/menus/EmojisMenu";
const ROW_HEIGHT = 60;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<Emoji>, "columns" | "rowHeight"> & {
canManage: boolean;
};
const EmojisTable = observer(function EmojisTable({
canManage,
...rest
}: Props) {
const { t } = useTranslation();
const columns = React.useMemo(
(): TableColumn<Emoji>[] =>
compact([
{
type: "data",
id: "name",
header: t("Emoji"),
accessor: (emoji) => emoji.url,
component: (emoji) => (
<EmojiPreview>
<EmojiImage src={emoji.url} alt={emoji.name} />
<span>:{emoji.name}:</span>
</EmojiPreview>
),
width: "1fr",
},
{
type: "data",
id: "createdBy",
header: t("Added by"),
accessor: (emoji) => emoji.createdBy,
sortable: false,
component: (emoji) => (
<Flex align="center" gap={8}>
{emoji.createdBy && (
<>
<Avatar model={emoji.createdBy} size={AvatarSize.Small} />
{emoji.createdBy.name}
</>
)}
</Flex>
),
width: "2fr",
},
{
type: "data",
id: "createdAt",
header: t("Date added"),
accessor: (emoji) => emoji.createdAt,
component: (emoji) => <Time dateTime={emoji.createdAt} addSuffix />,
width: "1fr",
},
{
type: "action",
id: "action",
component: (emoji) => <EmojisMenu emoji={emoji} />,
width: "50px",
},
]),
[t, canManage]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
{...rest}
/>
);
});
export default EmojisTable;
+16
View File
@@ -0,0 +1,16 @@
import Emoji from "~/models/Emoji";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
export default class EmojisStore extends Store<Emoji> {
actions = [
RPCAction.List,
RPCAction.Create,
RPCAction.Update,
RPCAction.Delete,
];
constructor(rootStore: RootStore) {
super(rootStore, Emoji);
}
}
+3
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);
@@ -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");
},
};
+93
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;
+2
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
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))
);
+1
View File
@@ -26,3 +26,4 @@ import "./team";
import "./group";
import "./webhookSubscription";
import "./userMembership";
import "./emoji";
+3
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;
}
+15
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,
};
}
+2
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,
};
+177
View File
@@ -0,0 +1,177 @@
import Router from "koa-router";
import { WhereOptions, Op } from "sequelize";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Emoji, User, Attachment } from "@server/models";
import { authorize } from "@server/policies";
import { presentEmoji, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"emojis.info",
auth(),
validate(T.EmojisInfoSchema),
async (ctx: APIContext<T.EmojisInfoReq>) => {
const { id, name } = ctx.input.body;
const { user } = ctx.state.auth;
const include = [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: Attachment,
as: "attachment",
paranoid: false,
},
];
let emoji;
if (id) {
emoji = await Emoji.findByPk(id, {
rejectOnEmpty: true,
include,
});
} else if (name) {
emoji = await Emoji.findOne({
where: {
name,
teamId: user.teamId,
},
include,
rejectOnEmpty: true,
});
}
authorize(user, "read", emoji);
ctx.body = {
data: presentEmoji(emoji),
policies: presentPolicies(user, [emoji]),
};
}
);
router.post(
"emojis.list",
auth(),
pagination(),
validate(T.EmojisListSchema),
async (ctx: APIContext<T.EmojisListReq>) => {
const { user } = ctx.state.auth;
const { query } = ctx.input.body;
let where: WhereOptions<Emoji> = {
teamId: user.teamId,
};
if (query) {
where = {
...where,
name: {
[Op.iLike]: `%${query}%`,
},
};
}
const [emojis, total] = await Promise.all([
Emoji.findAll({
where,
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: Attachment,
as: "attachment",
paranoid: false,
},
],
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Emoji.count({
where,
}),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: emojis.map(presentEmoji),
policies: presentPolicies(user, emojis),
};
}
);
router.post(
"emojis.create",
rateLimiter(RateLimiterStrategy.TenPerMinute),
auth(),
validate(T.EmojisCreateSchema),
transaction(),
async (ctx: APIContext<T.EmojisCreateReq>) => {
const { name, attachmentId } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const attachment = await Attachment.findByPk(attachmentId, {
transaction,
rejectOnEmpty: true,
});
authorize(user, "read", attachment);
const emoji = await Emoji.createWithCtx(ctx, {
name,
attachmentId,
teamId: user.teamId,
createdById: user.id,
createdBy: user,
});
emoji.createdBy = user;
emoji.attachment = attachment;
ctx.body = {
data: presentEmoji(emoji),
policies: presentPolicies(user, [emoji]),
};
}
);
router.post(
"emojis.delete",
auth(),
validate(T.EmojisDeleteSchema),
transaction(),
async (ctx: APIContext<T.EmojisDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const emoji = await Emoji.findByPk(id, {
transaction: ctx.state.transaction,
rejectOnEmpty: true,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "delete", emoji);
await emoji.destroyWithCtx(ctx);
ctx.body = {
success: true,
};
}
);
export default router;
+1
View File
@@ -0,0 +1 @@
export { default } from "./emojis";
+45
View File
@@ -0,0 +1,45 @@
export type EmojisInfoReq = z.infer<typeof EmojisInfoSchema>;
import { z } from "zod";
import { EmojiValidation } from "@shared/validations";
import { BaseSchema } from "../schema";
export const EmojisInfoSchema = BaseSchema.extend({
body: z
.object({
/** ID of the emoji to fetch */
id: z.string().uuid().optional(),
/** Name of the emoji to fetch */
name: z.string().min(1).max(EmojiValidation.maxNameLength).optional(),
})
.refine((data) => data.id || data.name, {
message: "Either id or name is required",
}),
});
export const EmojisListSchema = BaseSchema.extend({
body: z.object({
query: z.string().optional(),
}),
});
export const EmojisCreateSchema = BaseSchema.extend({
body: z.object({
/** Name/shortcode for the emoji (e.g., "awesome") */
name: z.string().min(1).max(EmojiValidation.maxNameLength),
/** URL to the emoji image */
attachmentId: z.string().uuid(),
}),
});
export const EmojisDeleteSchema = BaseSchema.extend({
body: z.object({
/** ID of the emoji to delete */
id: z.string().uuid(),
}),
});
export type EmojisListReq = z.infer<typeof EmojisListSchema>;
export type EmojisCreateReq = z.infer<typeof EmojisCreateSchema>;
export type EmojisDeleteReq = z.infer<typeof EmojisDeleteSchema>;
+2
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());
+17
View File
@@ -0,0 +1,17 @@
import { s } from "../styles";
import styled from "styled-components";
export const EmojiImage = styled.img`
width: 16px;
height: 16px;
object-fit: contain;
`;
export const EmojiPreview = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-family: monospace;
font-size: 14px;
color: ${s("textSecondary")};
`;
+7
View File
@@ -828,6 +828,13 @@ h6:not(.placeholder)::before {
margin-${props.rtl ? "right" : "left"}: -1em;
}
.emoji.custom-emoji {
width: 1.2em;
height: 1.2em;
vertical-align: text-bottom;
display: inline-block;
}
.heading-anchor,
.heading-fold {
display: inline-block;
+37 -9
View File
@@ -11,6 +11,7 @@ import Extension from "../lib/Extension";
import { getEmojiFromName } from "../lib/emoji";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import emojiRule from "../rules/emoji";
import { isInternalUrl } from "@shared/utils/urls";
export default class Emoji extends Extension {
get type() {
@@ -28,6 +29,10 @@ export default class Emoji extends Extension {
default: "grey_question",
validate: "string",
},
"data-url": {
default: null,
validate: (url: string) => !url || isInternalUrl(url),
},
},
inline: true,
content: "text*",
@@ -48,20 +53,33 @@ export default class Emoji extends Extension {
],
toDOM: (node) => {
const name = node.attrs["data-name"];
const url = node.attrs["data-url"];
return [
"strong",
{
class: `emoji ${name}`,
"data-name": name,
},
getEmojiFromName(name),
];
if (url) {
return [
"img",
{
class: `emoji custom-emoji ${name}`,
"data-name": name,
src: url,
},
];
} else {
return [
"strong",
{
class: `emoji ${name}`,
"data-name": name,
},
getEmojiFromName(name),
];
}
},
leafText: (node) => getEmojiFromName(node.attrs["data-name"]),
};
}
// to do: custom emoji rules
get rulePlugins() {
return [emojiRule];
}
@@ -87,7 +105,17 @@ export default class Emoji extends Extension {
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
const name = node.attrs["data-name"];
if (name) {
const url = node.attrs["data-url"];
if (url) {
const alt = node.attrs["data-name"] || "";
const prefix = state.inList ? "" : " ";
const escapedAlt = state.esc(alt, false);
const escapedUrl = state.esc(url, false);
const markdown = `${prefix}![${escapedAlt}](${escapedUrl})`;
state.write(markdown);
} else if (name) {
state.write(`:${name}:`);
}
}
+23 -2
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 analytics": "Disconnect analytics",
"Home": "Home",
"Drafts": "Drafts",
@@ -171,6 +173,7 @@
"Navigation": "Navigation",
"Notification": "Notification",
"Groups": "Groups",
"Emoji": "Emoji",
"People": "People",
"Share": "Share",
"Workspace": "Workspace",
@@ -256,6 +259,20 @@
"Currently editing": "Currently editing",
"Currently viewing": "Currently viewing",
"Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}",
"Please enter a name for the emoji": "Please enter a name for the emoji",
"Please select an image file": "Please select an image file",
"Emoji created successfully": "Emoji created successfully",
"Failed to create emoji": "Failed to create emoji",
"Uploading": "Uploading",
"Add emoji": "Add emoji",
"Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores.": "Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores.",
"Emoji name": "Emoji name",
"name can only should lowercase letters, numbers, and underscores.": "name can only should lowercase letters, numbers, and underscores.",
"Click or drag to replace": "Click or drag to replace",
"Drop the image here": "Drop the image here",
"Click or drag an image here": "Click or drag an image here",
"PNG, JPG, GIF, or WebP up to 1MB": "PNG, JPG, GIF, or WebP up to 1MB",
"This emoji will be available as": "This emoji will be available as",
"Module failed to load": "Module failed to load",
"Loading Failed": "Loading Failed",
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
@@ -597,6 +614,8 @@
"Comment options": "Comment options",
"Enable viewer insights": "Enable viewer insights",
"Enable embeds": "Enable embeds",
"Delete Emoji": "Delete Emoji",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
"File": "File",
"Group members": "Group members",
"Edit group": "Edit group",
@@ -846,7 +865,6 @@
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Sign In",
"Continue with Email": "Continue with Email",
@@ -984,8 +1002,9 @@
"Your import is being processed, you can safely leave this page": "Your import is being processed, you can safely leave this page",
"File not supported please upload a valid ZIP file": "File not supported please upload a valid ZIP file",
"Set the default permission level for collections created from the import": "Set the default permission level for collections created from the import",
"Uploading": "Uploading",
"Start import": "Start import",
"Added by": "Added by",
"Date added": "Date added",
"Processing": "Processing",
"Expired": "Expired",
"Completed": "Completed",
@@ -1055,6 +1074,8 @@
"Editors": "Editors",
"All status": "All status",
"Active": "Active",
"Could not load emojis": "Could not load emojis",
"Custom emojis can be used throughout your workspace in documents, comments, and reactions.": "Custom emojis can be used throughout your workspace in documents, comments, and reactions.",
"Left": "Left",
"Right": "Right",
"Settings saved": "Settings saved",
+28 -15
View File
@@ -99,10 +99,11 @@ const Emojis = allowFlagEmoji
)
);
const searcher = new FuzzySearch(Object.values(Emojis), ["search"], {
caseSensitive: false,
sort: true,
});
const searcher = (emojis: searchEmojis[]) =>
new FuzzySearch(emojis, ["search"], {
caseSensitive: false,
sort: true,
});
// Codes defined by unicode.org
const SKINTONE_CODE_TO_ENUM = {
@@ -191,27 +192,39 @@ export const getEmojisWithCategory = ({
export const getEmojiVariants = ({ id }: { id: string }) =>
EMOJI_ID_TO_VARIANTS[id];
type searchEmojis = Emoji & {
search?: string;
skins?: Skin[];
};
export const search = ({
emojis = [],
query,
skinTone,
}: {
emojis?: searchEmojis[];
query: string;
skinTone?: EmojiSkinTone;
}) => {
const queryLowercase = query.toLowerCase();
const emojiSkinTone = skinTone ?? EmojiSkinTone.Default;
}): Emoji[] => {
const matchedEmojis = searcher([...Object.values(Emojis), ...emojis])
.search(query.toLowerCase())
.map((emoji) => {
if (!emoji.skins) {
return emoji;
}
const matchedEmojis = searcher
.search(queryLowercase)
.map(
(emoji) =>
const emojiSkinTone = skinTone ?? EmojiSkinTone.Default;
return (
EMOJI_ID_TO_VARIANTS[emoji.id][emojiSkinTone] ??
EMOJI_ID_TO_VARIANTS[emoji.id][EmojiSkinTone.Default]
);
);
});
return sortBy(matchedEmojis, (emoji) => {
const nlc = emoji.name.toLowerCase();
return query === nlc ? -1 : nlc.startsWith(queryLowercase) ? 0 : 1;
});
return query === nlc ? -1 : nlc.startsWith(query) ? 0 : 1;
}) as Emoji[];
};
/**
@@ -221,4 +234,4 @@ export const search = ({
* @returns The emoji id, if found.
*/
export const getEmojiId = (emoji: string): string | undefined =>
searcher.search(emoji)[0]?.id;
searcher(Object.values(Emojis)).search(emoji)[0]?.id;
+7
View File
@@ -129,3 +129,10 @@ export const WebhookSubscriptionValidation = {
/** The maximum number of webhooks per team */
maxSubscriptions: 10,
};
export const EmojiValidation = {
/** The maximum length of the emoji name */
maxNameLength: 25,
/* the allow characters in the name */
allowedNameCharacters: /^[a-z0-9_]*$/,
};