From de031b365c4d8ac38bdc9080d8a9fead8a31c4d0 Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Tue, 7 Mar 2023 04:24:57 +0530 Subject: [PATCH] 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 --- app/editor/components/CommandMenu.tsx | 26 +-- app/editor/components/MentionMenu.tsx | 113 ++++++++++ app/editor/components/MentionMenuItem.tsx | 15 ++ app/editor/index.tsx | 27 +++ app/stores/BaseStore.ts | 5 +- app/typings/styled-components.d.ts | 1 + .../templates/MentionNotificationEmail.tsx | 83 ++++++++ .../processors/NotificationsProcessor.ts | 66 +++++- server/utils/parseMentions.test.ts | 30 +++ server/utils/parseMentions.ts | 35 ++++ shared/editor/components/Styles.ts | 15 +- shared/editor/nodes/Mention.ts | 193 ++++++++++++++++++ shared/editor/packages/basic.ts | 2 + shared/editor/rules/mention.ts | 102 +++++++++ shared/editor/types/index.ts | 3 + shared/i18n/locales/en_US/translation.json | 2 +- shared/styles/theme.ts | 2 + shared/types.ts | 4 + 18 files changed, 704 insertions(+), 20 deletions(-) create mode 100644 app/editor/components/MentionMenu.tsx create mode 100644 app/editor/components/MentionMenuItem.tsx create mode 100644 server/emails/templates/MentionNotificationEmail.tsx create mode 100644 server/utils/parseMentions.test.ts create mode 100644 server/utils/parseMentions.ts create mode 100644 shared/editor/nodes/Mention.ts create mode 100644 shared/editor/rules/mention.ts diff --git a/app/editor/components/CommandMenu.tsx b/app/editor/components/CommandMenu.tsx index 922858554..947706ce8 100644 --- a/app/editor/components/CommandMenu.tsx +++ b/app/editor/components/CommandMenu.tsx @@ -62,7 +62,10 @@ type State = { selectedIndex: number; }; -class CommandMenu extends React.Component, State> { +class CommandMenu extends React.PureComponent< + Props, + State +> { menuRef = React.createRef(); inputRef = React.createRef(); @@ -80,14 +83,6 @@ class CommandMenu extends React.Component, State> { window.addEventListener("keydown", this.handleKeyDown); } - shouldComponentUpdate(nextProps: Props, nextState: State) { - return ( - nextProps.search !== this.props.search || - nextProps.isActive !== this.props.isActive || - nextState !== this.state - ); - } - componentDidUpdate(prevProps: Props) { if (!prevProps.isActive && this.props.isActive) { // reset scroll position to top when opening menu as the contents are @@ -210,7 +205,7 @@ class CommandMenu extends React.Component, State> { return; } default: - this.insertBlock(item); + this.insertNode(item); } }; @@ -239,7 +234,7 @@ class CommandMenu extends React.Component, State> { return; } - this.insertBlock({ + this.insertNode({ name: "embed", attrs: { href, @@ -268,7 +263,7 @@ class CommandMenu extends React.Component, State> { event.preventDefault(); event.stopPropagation(); - this.insertBlock({ + this.insertNode({ name: "embed", attrs: { href, @@ -331,7 +326,7 @@ class CommandMenu extends React.Component, 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 extends React.Component, 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(); } diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx new file mode 100644 index 000000000..9ae3b5f7a --- /dev/null +++ b/app/editor/components/MentionMenu.tsx @@ -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, + "renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch" +>; + +function MentionMenu({ search, ...rest }: MentionMenuProps) { + const [items, setItems] = React.useState([]); + 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 ( + ( + + + + } + /> + )} + items={items} + /> + ); +} + +export default MentionMenu; diff --git a/app/editor/components/MentionMenuItem.tsx b/app/editor/components/MentionMenuItem.tsx new file mode 100644 index 000000000..6c81db187 --- /dev/null +++ b/app/editor/components/MentionMenuItem.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import CommandMenuItem, { + Props as CommandMenuItemProps, +} from "./CommandMenuItem"; + +type MentionMenuItemProps = Omit & { + label: string; +}; + +export default function MentionMenuItem({ + label, + ...rest +}: MentionMenuItemProps) { + return ; +} diff --git a/app/editor/index.tsx b/app/editor/index.tsx index e17e34ee6..6573c6e7d 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -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} /> + { 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 { diff --git a/app/typings/styled-components.d.ts b/app/typings/styled-components.d.ts index e10b47007..5981671d8 100644 --- a/app/typings/styled-components.d.ts +++ b/app/typings/styled-components.d.ts @@ -158,6 +158,7 @@ declare module "styled-components" { inputBorder: string; inputBorderFocused: string; listItemHoverBackground: string; + mentionBackground: string; buttonNeutralBackground: string; buttonNeutralText: string; buttonNeutralBorder: string; diff --git a/server/emails/templates/MentionNotificationEmail.tsx b/server/emails/templates/MentionNotificationEmail.tsx new file mode 100644 index 000000000..39418b0c2 --- /dev/null +++ b/server/emails/templates/MentionNotificationEmail.tsx @@ -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 ( + +
+ + + You were mentioned +

+ {actorName} mentioned you in the document{" "} + {document.title}. +

+

+ +

+ + + ); + } +} diff --git a/server/queues/processors/NotificationsProcessor.ts b/server/queues/processors/NotificationsProcessor.ts index 10a569e22..b98651fbc 100644 --- a/server/queues/processors/NotificationsProcessor.ts +++ b/server/queues/processors/NotificationsProcessor.ts @@ -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); diff --git a/server/utils/parseMentions.test.ts b/server/utils/parseMentions.test.ts new file mode 100644 index 000000000..bd8fa67cb --- /dev/null +++ b/server/utils/parseMentions.test.ts @@ -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"); +}); diff --git a/server/utils/parseMentions.ts b/server/utils/parseMentions.ts new file mode 100644 index 000000000..cd78459fe --- /dev/null +++ b/server/utils/parseMentions.ts @@ -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[] { + const node = DocumentHelper.toProsemirror(document); + const mentions: Record[] = []; + + 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; +} diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 3d29b152a..03c10edc8 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -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 { diff --git a/shared/editor/nodes/Mention.ts b/shared/editor/nodes/Mention.ts new file mode 100644 index 000000000..0fdf9eaa2 --- /dev/null +++ b/shared/editor/nodes/Mention.ts @@ -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) => ( + 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: + // @word + // @ + // @word + 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, + }), + }; + } +} diff --git a/shared/editor/packages/basic.ts b/shared/editor/packages/basic.ts index 10ed5c687..ab7d50a8d 100644 --- a/shared/editor/packages/basic.ts +++ b/shared/editor/packages/basic.ts @@ -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; diff --git a/shared/editor/rules/mention.ts b/shared/editor/rules/mention.ts new file mode 100644 index 000000000..5423e7c68 --- /dev/null +++ b/shared/editor/rules/mention.ts @@ -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 `${label}`; +} + +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); +} diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts index 274c95f28..57b8424c3 100644 --- a/shared/editor/types/index.ts +++ b/shared/editor/types/index.ts @@ -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; visible?: boolean; active?: (state: EditorState) => boolean; + appendSpace?: boolean; }; export type ComponentProps = { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 4fe45eb60..3a58a61e3 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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 @@ "Note: Signing back in will cause a new account to be automatically reprovisioned.": "Note: 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.", diff --git a/shared/styles/theme.ts b/shared/styles/theme.ts index 712937c4e..76f94cfc3 100644 --- a/shared/styles/theme.ts +++ b/shared/styles/theme.ts @@ -142,6 +142,7 @@ export const buildLightTheme = (input: Partial): 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): DefaultTheme => { inputBorder: colors.slateDark, inputBorderFocused: colors.slate, listItemHoverBackground: colors.white10, + mentionBackground: colors.white10, toolbarHoverBackground: colors.slate, toolbarBackground: colors.white, toolbarInput: colors.black10, diff --git a/shared/types.ts b/shared/types.ts index f672d4d82..a646f54a1 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -34,6 +34,10 @@ export enum FileOperationState { Expired = "expired", } +export enum MentionType { + User = "user", +} + export type PublicEnv = { URL: string; CDN_URL: string;