Capability to mention users in a document (#4838)

* feat: mention user

* fix: trigger api call on every letter typed

* fix: this allows command menu to re-render upon props change, shouldComponentUpdate prevented re-rendering when necessary

* fix: add node

* fix: mention node styling

* fix: Caret not visible after inserting mention

* fix: apply mentionRule

* fix: label is to be obtained from content, not attrs

* feat: add mentions table and model

* fix: typo

* fix: make all mention nodes visible in shared doc

* feat: parse mention ids from doc text

* feat: MentionsProcessor

* feat: documents.publish tests

* feat: tests for MentionsProcessor

* feat: schedule notifs for mentions

* fix: get rid of Mention model

* fix: put actor id and mention id in raw md

* Revert "fix: put actor id and mention id in raw md"

This reverts commit 3bb8a22e3c560971dccad6d2f82266256bcb2d96.

* Revert "Revert "fix: put actor id and mention id in raw md""

This reverts commit 3c5b36c40cebf147663908cf27d0dce6488adfad.

* fix: review

* fix: no need of set

* fix: show avatar

* fix: get rid of eventName

* fix: font-weight

* fix: prioritize mention notifs

* fix: store id in md

* fix: no need of prepending m

* fix: fetchPage

* fix: Avatars incorrect color

* fix: remove scanRE

* fix: test

* fix: include alphabet other than latin

* lockfile

* fix: regex should test for letters, marks and digits

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2023-03-07 04:24:57 +05:30
committed by GitHub
parent 09435ed798
commit de031b365c
18 changed files with 704 additions and 20 deletions

View File

@@ -62,7 +62,10 @@ type State = {
selectedIndex: number;
};
class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
class CommandMenu<T extends MenuItem> extends React.PureComponent<
Props<T>,
State
> {
menuRef = React.createRef<HTMLDivElement>();
inputRef = React.createRef<HTMLInputElement>();
@@ -80,14 +83,6 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
window.addEventListener("keydown", this.handleKeyDown);
}
shouldComponentUpdate(nextProps: Props<T>, nextState: State) {
return (
nextProps.search !== this.props.search ||
nextProps.isActive !== this.props.isActive ||
nextState !== this.state
);
}
componentDidUpdate(prevProps: Props<T>) {
if (!prevProps.isActive && this.props.isActive) {
// reset scroll position to top when opening menu as the contents are
@@ -210,7 +205,7 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
return;
}
default:
this.insertBlock(item);
this.insertNode(item);
}
};
@@ -239,7 +234,7 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
return;
}
this.insertBlock({
this.insertNode({
name: "embed",
attrs: {
href,
@@ -268,7 +263,7 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
event.preventDefault();
event.stopPropagation();
this.insertBlock({
this.insertNode({
name: "embed",
attrs: {
href,
@@ -331,7 +326,7 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
this.props.onClearSearch();
};
insertBlock(item: MenuItem) {
insertNode(item: MenuItem) {
this.clearSearch();
const command = item.name ? this.props.commands[item.name] : undefined;
@@ -341,6 +336,11 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
} else {
this.props.commands[`create${capitalize(item.name)}`](item.attrs);
}
if (item.appendSpace) {
const { view } = this.props;
const { dispatch } = view;
dispatch(view.state.tr.insertText(" "));
}
this.props.onClose();
}

View File

@@ -0,0 +1,113 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { v4 } from "uuid";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Flex from "~/components/Flex";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import CommandMenu, { Props } from "./CommandMenu";
import MentionMenuItem from "./MentionMenuItem";
interface MentionItem extends MenuItem {
name: string;
user: User;
appendSpace: boolean;
attrs: {
id: string;
type: MentionType;
modelId: string;
label: string;
actorId?: string;
};
}
type MentionMenuProps = Omit<
Props<MentionItem>,
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch"
>;
function MentionMenu({ search, ...rest }: MentionMenuProps) {
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { users, auth } = useStores();
const { data, request } = useRequest(
React.useCallback(() => users.fetchPage({ query: search }), [users, search])
);
React.useEffect(() => {
request();
}, [request]);
React.useEffect(() => {
if (data) {
setItems(
data.map((user) => ({
name: "mention",
user,
title: user.name,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.User,
modelId: user.id,
actorId: auth.user?.id,
label: user.name,
},
}))
);
}
}, [auth.user?.id, data]);
const clearSearch = () => {
const { state, dispatch } = rest.view;
// clear search input
dispatch(
state.tr.insertText(
"",
state.selection.$from.pos - (search ?? "").length - 1,
state.selection.to
)
);
};
const containerId = "mention-menu-container";
return (
<CommandMenu
{...rest}
id={containerId}
filterable={false}
onClearSearch={clearSearch}
search={search}
renderMenuItem={(item, _index, options) => (
<MentionMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.title}
label={item.attrs.label}
containerId={containerId}
icon={
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={item.user}
showBorder={false}
alt={t("Profile picture")}
size={16}
/>
</Flex>
}
/>
)}
items={items}
/>
);
}
export default MentionMenu;

View File

@@ -0,0 +1,15 @@
import * as React from "react";
import CommandMenuItem, {
Props as CommandMenuItemProps,
} from "./CommandMenuItem";
type MentionMenuItemProps = Omit<CommandMenuItemProps, "shortcut" | "theme"> & {
label: string;
};
export default function MentionMenuItem({
label,
...rest
}: MentionMenuItemProps) {
return <CommandMenuItem {...rest} title={label} />;
}

View File

@@ -41,6 +41,7 @@ import EditorContext from "./components/EditorContext";
import EmojiMenu from "./components/EmojiMenu";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import MentionMenu from "./components/MentionMenu";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
@@ -142,6 +143,8 @@ type State = {
blockMenuSearch: string;
/** If the emoji insert menu is visible */
emojiMenuOpen: boolean;
/** If the mention user menu is visible */
mentionMenuOpen: boolean;
};
/**
@@ -175,6 +178,7 @@ export class Editor extends React.PureComponent<
linkMenuOpen: false,
blockMenuSearch: "",
emojiMenuOpen: false,
mentionMenuOpen: false,
};
isBlurred: boolean;
@@ -214,6 +218,8 @@ export class Editor extends React.PureComponent<
this.events.on(EventType.blockMenuClose, this.handleCloseBlockMenu);
this.events.on(EventType.emojiMenuOpen, this.handleOpenEmojiMenu);
this.events.on(EventType.emojiMenuClose, this.handleCloseEmojiMenu);
this.events.on(EventType.mentionMenuOpen, this.handleOpenMentionMenu);
this.events.on(EventType.mentionMenuClose, this.handleCloseMentionMenu);
}
/**
@@ -673,6 +679,10 @@ export class Editor extends React.PureComponent<
this.setState({ emojiMenuOpen: true, blockMenuSearch: search });
};
private handleOpenMentionMenu = (search: string) => {
this.setState({ mentionMenuOpen: true, blockMenuSearch: search });
};
private handleCloseEmojiMenu = () => {
if (!this.state.emojiMenuOpen) {
return;
@@ -680,6 +690,13 @@ export class Editor extends React.PureComponent<
this.setState({ emojiMenuOpen: false });
};
private handleCloseMentionMenu = () => {
if (!this.state.mentionMenuOpen) {
return;
}
this.setState({ mentionMenuOpen: false });
};
private handleOpenLinkMenu = () => {
this.setState({ blockMenuOpen: false, linkMenuOpen: true });
};
@@ -772,6 +789,16 @@ export class Editor extends React.PureComponent<
search={this.state.blockMenuSearch}
onClose={this.handleCloseEmojiMenu}
/>
<MentionMenu
view={this.view}
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
onShowToast={this.props.onShowToast}
isActive={this.state.mentionMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseMentionMenu}
/>
<BlockMenu
view={this.view}
commands={this.commands}

View File

@@ -231,13 +231,14 @@ export default abstract class BaseStore<T extends BaseModel> {
const res = await client.post(`/${this.apiEndpoint}.list`, params);
invariant(res?.data, "Data not available");
let response: T[] = [];
runInAction(`list#${this.modelName}`, () => {
this.addPolicies(res.policies);
res.data.forEach(this.add);
response = res.data.map(this.add);
this.isLoaded = true;
});
const response = res.data;
response[PAGINATION_SYMBOL] = res.pagination;
return response;
} finally {

View File

@@ -158,6 +158,7 @@ declare module "styled-components" {
inputBorder: string;
inputBorderFocused: string;
listItemHoverBackground: string;
mentionBackground: string;
buttonNeutralBackground: string;
buttonNeutralText: string;
buttonNeutralBorder: string;

View File

@@ -0,0 +1,83 @@
import * as React from "react";
import { Document } from "@server/models";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = {
to: string;
documentId: string;
actorName: string;
teamUrl: string;
mentionId: string;
};
type BeforeSend = {
document: Document;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when someone mentions them in a doucment
*/
export default class MentionNotificationEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ documentId }: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
return { document };
}
protected subject({ actorName, document }: Props) {
return `${actorName} mentioned you in "${document.title}"`;
}
protected preview({ actorName }: Props): string {
return `${actorName} mentioned you`;
}
protected renderAsText({
actorName,
teamUrl,
document,
mentionId,
}: Props): string {
return `
You were mentioned
${actorName} mentioned you in the document "${document.title}".
Open Document: ${teamUrl}${document.url}?mentionId=${mentionId}
`;
}
protected render({ document, actorName, teamUrl, mentionId }: Props) {
const link = `${teamUrl}${document.url}?ref=notification-email&mentionId=${mentionId}`;
return (
<EmailTemplate>
<Header />
<Body>
<Heading>You were mentioned</Heading>
<p>
{actorName} mentioned you in the document{" "}
<a href={link}>{document.title}</a>.
</p>
<p>
<Button href={link}>Open Document</Button>
</p>
</Body>
</EmailTemplate>
);
}
}

View File

@@ -1,10 +1,12 @@
import { subHours } from "date-fns";
import { differenceBy } from "lodash";
import { Op } from "sequelize";
import { Minute } from "@shared/utils/time";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { sequelize } from "@server/database/sequelize";
import CollectionNotificationEmail from "@server/emails/templates/CollectionNotificationEmail";
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
import MentionNotificationEmail from "@server/emails/templates/MentionNotificationEmail";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import {
@@ -25,6 +27,7 @@ import {
DocumentEvent,
CommentEvent,
} from "@server/types";
import parseMentions from "@server/utils/parseMentions";
import CommentCreatedNotificationTask from "../tasks/CommentCreatedNotificationTask";
import BaseProcessor from "./BaseProcessor";
@@ -68,7 +71,7 @@ export default class NotificationsProcessor extends BaseProcessor {
const [collection, document, team] = await Promise.all([
Collection.findByPk(event.collectionId),
Document.findByPk(event.documentId),
Document.findByPk(event.documentId, { includeState: true }),
Team.findByPk(event.teamId),
]);
@@ -85,6 +88,34 @@ export default class NotificationsProcessor extends BaseProcessor {
false
);
// send notifs to mentioned users
const mentions = parseMentions(document);
for (const mention of mentions) {
const [recipient, actor] = await Promise.all([
User.findByPk(mention.modelId),
User.findByPk(mention.actorId),
]);
if (recipient && actor && recipient.id !== actor.id) {
const notification = await Notification.create({
event: event.name,
userId: recipient.id,
actorId: document.updatedBy.id,
teamId: team.id,
documentId: document.id,
});
await MentionNotificationEmail.schedule(
{
to: recipient.email,
documentId: event.documentId,
actorName: actor.name,
teamUrl: team.url,
mentionId: mention.id,
},
{ notificationId: notification.id }
);
}
}
for (const recipient of recipients) {
const notify = await this.shouldNotify(document, recipient.user);
@@ -115,7 +146,7 @@ export default class NotificationsProcessor extends BaseProcessor {
async revisionCreated(event: RevisionEvent) {
const [collection, document, revision, team] = await Promise.all([
Collection.findByPk(event.collectionId),
Document.findByPk(event.documentId),
Document.findByPk(event.documentId, { includeState: true }),
Revision.findByPk(event.modelId),
Team.findByPk(event.teamId),
]);
@@ -147,6 +178,37 @@ export default class NotificationsProcessor extends BaseProcessor {
return;
}
// send notifs to newly mentioned users
const prev = await revision.previous();
const oldMentions = prev ? parseMentions(prev) : [];
const newMentions = parseMentions(document);
const mentions = differenceBy(newMentions, oldMentions, "id");
for (const mention of mentions) {
const [recipient, actor] = await Promise.all([
User.findByPk(mention.modelId),
User.findByPk(mention.actorId),
]);
if (recipient && actor && recipient.id !== actor.id) {
const notification = await Notification.create({
event: event.name,
userId: recipient.id,
actorId: document.updatedBy.id,
teamId: team.id,
documentId: document.id,
});
await MentionNotificationEmail.schedule(
{
to: recipient.email,
documentId: event.documentId,
actorName: actor.name,
teamUrl: team.url,
mentionId: mention.id,
},
{ notificationId: notification.id }
);
}
}
for (const recipient of recipients) {
const notify = await this.shouldNotify(document, recipient.user);

View File

@@ -0,0 +1,30 @@
import { buildDocument } from "@server/test/factories";
import parseMentions from "./parseMentions";
it("should not parse normal links as mentions", async () => {
const document = await buildDocument({
text: `# Header
[link not mention](http://google.com)`,
});
const result = parseMentions(document);
expect(result.length).toBe(0);
});
it("should return an array of mentions", async () => {
const document = await buildDocument({
text: `# Header
@[Alan Kay](mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64) :wink:
More text
@[Bret Victor](mention://34095ac1-c808-45c0-8c6e-6c554497de64/user/2767ba0e-ac5c-4533-b9cf-4f5fc456600e) :fire:`,
});
const result = parseMentions(document);
expect(result.length).toBe(2);
expect(result[0].id).toBe("2767ba0e-ac5c-4533-b9cf-4f5fc456600e");
expect(result[1].id).toBe("34095ac1-c808-45c0-8c6e-6c554497de64");
expect(result[0].modelId).toBe("34095ac1-c808-45c0-8c6e-6c554497de64");
expect(result[1].modelId).toBe("2767ba0e-ac5c-4533-b9cf-4f5fc456600e");
});

View File

@@ -0,0 +1,35 @@
import { Node } from "prosemirror-model";
import { Document, Revision } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
/**
* Parse a list of mentions contained in a document or revision
*
* @param document Document or Revision
* @returns An array of mentions in passed document or revision
*/
export default function parseMentions(
document: Document | Revision
): Record<string, string>[] {
const node = DocumentHelper.toProsemirror(document);
const mentions: Record<string, string>[] = [];
function findMentions(node: Node) {
if (
node.type.name === "mention" &&
!mentions.some((m) => m.id === node.attrs.id)
) {
mentions.push(node.attrs);
}
if (!node.content.size) {
return;
}
node.content.descendants(findMentions);
}
findMentions(node);
return mentions;
}

View File

@@ -56,7 +56,7 @@ math-inline {
}
math-inline .math-render {
math-inline .math-render {
display: inline-block;
font-size: 0.85em;
}
@@ -120,6 +120,17 @@ font-size: 1em;
line-height: 1.6em;
width: 100%;
.mention {
background: ${props.theme.mentionBackground};
border-radius: 12px;
padding-bottom: 2px;
padding-top: 1px;
padding-left: 4px;
padding-right: 4px;
font-weight: 500;
font-size: 14px;
}
> div {
background: transparent;
}
@@ -978,7 +989,7 @@ mark {
display: inline;
}
&.code-hidden {
&.code-hidden {
button,
select,
button.show-diagram-button {

View File

@@ -0,0 +1,193 @@
import Token from "markdown-it/lib/token";
import { InputRule } from "prosemirror-inputrules";
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
import { EditorState, TextSelection, Plugin } from "prosemirror-state";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { run } from "../plugins/BlockMenuTrigger";
import isInCode from "../queries/isInCode";
import mentionRule from "../rules/mention";
import { Dispatch, EventType } from "../types";
import Node from "./Node";
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
const OPEN_REGEX = /(?:^|\s)@([\p{L}\p{M}\d]+)?$/u;
const CLOSE_REGEX = /(?:^|\s)@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u;
export default class Mention extends Node {
get name() {
return "mention";
}
get schema(): NodeSpec {
return {
attrs: {
type: {},
label: {},
modelId: {},
actorId: {
default: undefined,
},
id: {},
},
inline: true,
content: "text*",
marks: "",
group: "inline",
atom: true,
parseDOM: [
{
tag: `span.${this.name}`,
preserveWhitespace: "full",
getAttrs: (dom: HTMLElement) => ({
type: dom.dataset.type,
modelId: dom.dataset.id,
actorId: dom.dataset.actorId,
label: dom.innerText,
id: dom.id,
}),
},
],
toDOM: (node) => {
return [
"span",
{
class: `${node.type.name}`,
id: node.attrs.id,
"data-type": node.attrs.type,
"data-id": node.attrs.modelId,
"data-actorId": node.attrs.actorId,
},
node.attrs.label,
];
},
toPlainText: (node) => `@${node.attrs.label}`,
};
}
get rulePlugins() {
return [mentionRule];
}
get plugins() {
return [
new Plugin({
props: {
handleClick: () => {
this.editor.events.emit(EventType.mentionMenuClose);
return false;
},
handleKeyDown: (view, event) => {
// Prosemirror input rules are not triggered on backspace, however
// we need them to be evaluted for the filter trigger to work
// correctly. This additional handler adds inputrules-like handling.
if (event.key === "Backspace") {
// timeout ensures that the delete has been handled by prosemirror
// and any characters removed, before we evaluate the rule.
setTimeout(() => {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
if (match) {
this.editor.events.emit(
EventType.mentionMenuOpen,
match[1]
);
} else {
this.editor.events.emit(EventType.mentionMenuClose);
}
return null;
});
});
}
// If the query is active and we're navigating the block menu then
// just ignore the key events in the editor itself until we're done
if (
event.key === "Enter" ||
event.key === "ArrowUp" ||
event.key === "ArrowDown" ||
event.key === "Tab"
) {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
// just tell Prosemirror we handled it and not to do anything
return match ? true : null;
});
}
return false;
},
},
}),
];
}
commands({ type }: { type: NodeType }) {
return (attrs: Record<string, string>) => (
state: EditorState,
dispatch: Dispatch
) => {
const { selection } = state;
const position =
selection instanceof TextSelection
? selection.$cursor?.pos
: selection.$to.pos;
if (position === undefined) {
return false;
}
const node = type.create(attrs);
const transaction = state.tr.insert(position, node);
dispatch(transaction);
return true;
};
}
inputRules(): InputRule[] {
return [
// main regex should match only:
// @word
new InputRule(OPEN_REGEX, (state, match) => {
if (
match &&
state.selection.$from.parent.type.name === "paragraph" &&
!isInCode(state)
) {
this.editor.events.emit(EventType.mentionMenuOpen, match[1]);
}
return null;
}),
// invert regex should match some of these scenarios:
// @<space>word
// @<space>
// @word<space>
new InputRule(CLOSE_REGEX, (state, match) => {
if (match) {
this.editor.events.emit(EventType.mentionMenuClose);
}
return null;
}),
];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
const mType = node.attrs.type;
const mId = node.attrs.modelId;
const label = node.attrs.label;
const id = node.attrs.id;
state.write(`@[${label}](mention://${id}/${mType}/${mId})`);
}
parseMarkdown() {
return {
node: "mention",
getAttrs: (tok: Token) => ({
id: tok.attrGet("id"),
type: tok.attrGet("type"),
modelId: tok.attrGet("modelId"),
label: tok.content,
}),
};
}
}

View File

@@ -9,6 +9,7 @@ import Underline from "../marks/Underline";
import Doc from "../nodes/Doc";
import Emoji from "../nodes/Emoji";
import Image from "../nodes/Image";
import Mention from "../nodes/Mention";
import Node from "../nodes/Node";
import Paragraph from "../nodes/Paragraph";
import Text from "../nodes/Text";
@@ -43,6 +44,7 @@ const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
DateTime,
Keys,
ClipboardTextSerializer,
Mention,
];
export default basicPackage;

View File

@@ -0,0 +1,102 @@
import MarkdownIt from "markdown-it";
import StateCore from "markdown-it/lib/rules_core/state_core";
import Token from "markdown-it/lib/token";
function renderMention(tokens: Token[], idx: number) {
const id = tokens[idx].attrGet("id");
const mType = tokens[idx].attrGet("type");
const mId = tokens[idx].attrGet("modelId");
const label = tokens[idx].content;
return `<span id="${id}" class="mention" data-type="${mType}" data-id="${mId}">${label}</span>`;
}
function parseMentions(state: StateCore) {
const hrefRE = /^mention:\/\/([a-z0-9-]+)\/([a-z]+)\/([a-z0-9-]+)$/;
for (let i = 0; i < state.tokens.length; i++) {
const tok = state.tokens[i];
if (!(tok.type === "inline" && tok.children)) {
continue;
}
const canChunkComposeMentionToken = (chunk: Token[]) => {
// no group of tokens of size less than 4 can compose a mention token
if (chunk.length < 4) {
return false;
}
const [precToken, openToken, textToken, closeToken] = chunk;
// check for the valid order of tokens required to compose a mention token
if (
!(
precToken.type === "text" &&
precToken.content &&
precToken.content.endsWith("@") &&
openToken.type === "link_open" &&
textToken.content &&
closeToken.type === "link_close"
)
) {
return false;
}
// "link_open" token should have valid href
const attr = openToken.attrs?.[0];
if (!(attr && attr[0] === "href" && hrefRE.test(attr[1]))) {
return false;
}
// can probably compose a mention token if arrived here
return true;
};
const chunkWithMentionToken = (chunk: Token[]) => {
const [precToken, openToken, textToken] = chunk;
// remove "@" from preceding token
precToken.content = precToken.content.slice(0, -1);
// href must be present, otherwise the hrefRE test in canChunkComposeMentionToken would've failed
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const href = openToken.attrs![0][1];
const matches = href.match(hrefRE);
const [id, mType, mId] = matches!.slice(1);
const mentionToken = new Token("mention", "", 0);
mentionToken.attrSet("id", id);
mentionToken.attrSet("type", mType);
mentionToken.attrSet("modelId", mId);
mentionToken.content = textToken.content;
// "link_open", followed by "text" and "link_close" tokens are coalesced
// into "mention" token, hence removed
return [precToken, mentionToken];
};
let newChildren: Token[] = [];
let j = 0;
while (j < tok.children.length) {
// attempt to grab next four tokens that could potentially construct a mention token
const chunk = tok.children.slice(j, j + 4);
if (canChunkComposeMentionToken(chunk)) {
newChildren = newChildren.concat(chunkWithMentionToken(chunk));
// skip by 4 since mention token for this group of tokens has been composed
// and the group cannot compose mention tokens any further
j += 4;
} else {
// push the tokens which do not participate in composing a mention token as it is
newChildren.push(tok.children[j]);
j++;
}
}
state.tokens[i].children = newChildren;
}
}
export default function mention(md: MarkdownIt) {
md.renderer.rules.mention = renderMention;
md.core.ruler.after("inline", "mention", parseMentions);
}

View File

@@ -13,6 +13,8 @@ export enum EventType {
emojiMenuClose = "emojiMenuClose",
linkMenuOpen = "linkMenuOpen",
linkMenuClose = "linkMenuClose",
mentionMenuOpen = "mentionMenuOpen",
mentionMenuClose = "mentionMenuClose",
}
export type MenuItem = {
@@ -26,6 +28,7 @@ export type MenuItem = {
attrs?: Record<string, any>;
visible?: boolean;
active?: (state: EditorState) => boolean;
appendSpace?: boolean;
};
export type ComponentProps = {

View File

@@ -232,6 +232,7 @@
"Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?",
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.": "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
"Profile picture": "Profile picture",
"Insert column after": "Insert column after",
"Insert column before": "Insert column before",
"Insert row after": "Insert row after",
@@ -808,7 +809,6 @@
"<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.": "<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.",
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.",
"Delete My Account": "Delete My Account",
"Profile picture": "Profile picture",
"You joined": "You joined",
"Joined": "Joined",
"{{ time }} ago.": "{{ time }} ago.",

View File

@@ -142,6 +142,7 @@ export const buildLightTheme = (input: Partial<Colors>): DefaultTheme => {
inputBorder: colors.slateLight,
inputBorderFocused: colors.slate,
listItemHoverBackground: colors.warmGrey,
mentionBackground: colors.warmGrey,
toolbarHoverBackground: colors.black,
toolbarBackground: colors.almostBlack,
toolbarInput: colors.white10,
@@ -210,6 +211,7 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
inputBorder: colors.slateDark,
inputBorderFocused: colors.slate,
listItemHoverBackground: colors.white10,
mentionBackground: colors.white10,
toolbarHoverBackground: colors.slate,
toolbarBackground: colors.white,
toolbarInput: colors.black10,

View File

@@ -34,6 +34,10 @@ export enum FileOperationState {
Expired = "expired",
}
export enum MentionType {
User = "user",
}
export type PublicEnv = {
URL: string;
CDN_URL: string;