From fed7d78495006daadc454bb8324f75d1afb39429 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:25:33 +0100 Subject: [PATCH] wip --- .env.example | 11 + .../public/locales/en-US/translation.json | 9 + .../common/editor-paste-handler.tsx | 38 ++- .../integration-link/badge-color.ts | 21 ++ .../integration-link-view.module.css | 39 ++++ .../integration-link-view.tsx | 221 ++++++++++++++---- .../integration-mention-view.tsx | 138 +++++++++++ .../integration-paste-menu.tsx | 168 +++++++++++++ .../components/integration-link/use-unfurl.ts | 44 ++++ .../features/editor/extensions/extensions.ts | 7 + .../extensions/integration-paste-menu.ts | 44 ++++ .../src/features/editor/page-editor.tsx | 2 + .../components/integration-list-skeleton.tsx | 66 ++++++ .../integration/pages/connections.tsx | 7 +- .../integration/pages/integrations.tsx | 7 +- .../services/integration-service.ts | 11 +- .../integration/types/integration.types.ts | 11 + .../src/collaboration/collaboration.util.ts | 6 +- .../core/integration/dto/integration.dto.ts | 18 +- .../integration/oauth/oauth.controller.ts | 7 +- .../core/integration/oauth/oauth.service.ts | 15 ++ .../integration-provider.interface.ts | 25 ++ .../repos/integration-connection.repo.ts | 29 ++- .../core/integration/unfurl/unfurl.service.ts | 66 +++++- ...ns.ts => 20260807T1264122-integrations.ts} | 0 .../src/lib/integration-link/index.ts | 3 +- .../integration-link-patterns.ts | 25 ++ .../lib/integration-link/integration-link.ts | 93 ++++---- .../integration-link/integration-mention.ts | 82 +++++++ 29 files changed, 1091 insertions(+), 122 deletions(-) create mode 100644 apps/client/src/features/editor/components/integration-link/badge-color.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/use-unfurl.ts create mode 100644 apps/client/src/features/editor/extensions/integration-paste-menu.ts create mode 100644 apps/client/src/features/integration/components/integration-list-skeleton.tsx rename apps/server/src/database/migrations/{20260522T1264122-integrations.ts => 20260807T1264122-integrations.ts} (100%) create mode 100644 packages/editor-ext/src/lib/integration-link/integration-mention.ts diff --git a/.env.example b/.env.example index 6c5756fad..77ccb39ec 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,17 @@ DRAWIO_URL= # Gotenberg URL for server-side PDF export GOTENBERG_URL= +# Integration OAuth apps (create one per provider you enable) +INTEGRATION_GITHUB_CLIENT_ID= +INTEGRATION_GITHUB_CLIENT_SECRET= +# Set only for GitHub Enterprise Server, e.g https://github.example.com +INTEGRATION_GITHUB_BASE_URL= + +INTEGRATION_GITLAB_CLIENT_ID= +INTEGRATION_GITLAB_CLIENT_SECRET= +# Set only for self-hosted GitLab, e.g https://gitlab.example.com +INTEGRATION_GITLAB_BASE_URL= + DISABLE_TELEMETRY=false # Allow other sites to embed Docmost in an iframe. diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 18c981434..489f1a27d 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -30,6 +30,7 @@ "Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred page width.": "Choose your preferred page width.", "Confirm": "Confirm", + "Connect to {{name}} to update": "Connect to {{name}} to update", "Copy as Markdown": "Copy as Markdown", "Copy link": "Copy link", "Create": "Create", @@ -41,6 +42,10 @@ "Dark": "Dark", "Date": "Date", "Delete": "Delete", + "Initiative": "Initiative", + "Open in Slack": "Open in Slack", + "Paste as": "Paste as", + "Project": "Project", "Remove from page": "Remove from page", "Base options": "Base options", "Delete group": "Delete group", @@ -150,6 +155,8 @@ "page": "page", "Page deleted successfully": "Page deleted successfully", "Page history": "Page history", + "replies": "replies", + "reply": "reply", "Select version": "Select version", "Highlight changes": "Highlight changes", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", @@ -184,6 +191,8 @@ "Invitation sent": "Invitation sent", "Settings": "Settings", "Setup workspace": "Setup workspace", + "show less": "show less", + "show more": "show more", "Sign In": "Sign In", "Sign Up": "Sign Up", "Slug": "Slug", diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index bc7a19b9d..4df5365ce 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -6,6 +6,7 @@ import { createMentionAction } from "@/features/editor/components/link/internal- import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { Editor } from "@tiptap/core"; import { matchIntegrationLink } from "@docmost/editor-ext"; +import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu"; import { getAttachmentInfo, uploadFile, @@ -34,14 +35,49 @@ export const handlePaste = ( const integrationMatch = matchIntegrationLink(clipboardData.trim()); if (integrationMatch && editor.state.selection.empty) { event.preventDefault(); + const pastedUrl = clipboardData.trim(); editor .chain() .focus() .setIntegrationLink({ - url: clipboardData.trim(), + url: pastedUrl, provider: integrationMatch.provider, status: "pending", }) + // Anchor the "Paste as" menu to the inserted node, in the SAME + // transaction: BubbleMenu ignores meta-only transactions (it only + // re-evaluates when the doc or selection changed). Locate the node via + // the range this transaction's own steps touched, never by url, so a + // duplicate of the same link elsewhere in the doc can't steal the menu. + .command(({ tr }) => { + let start: number | null = null; + let end: number | null = null; + tr.mapping.maps.forEach((map, index) => { + const rest = tr.mapping.slice(index + 1); + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + const mappedStart = rest.map(newStart, -1); + const mappedEnd = rest.map(newEnd, 1); + start = start === null ? mappedStart : Math.min(start, mappedStart); + end = end === null ? mappedEnd : Math.max(end, mappedEnd); + }); + }); + if (start === null || end === null) return true; + + let pastedPos: number | null = null; + tr.doc.nodesBetween( + start, + Math.min(end, tr.doc.content.size), + (node, pos) => { + if (node.type.name === "integrationLink") { + pastedPos = pos; + } + }, + ); + if (pastedPos !== null) { + tr.setMeta(integrationPasteMenuKey, { pos: pastedPos }); + } + return true; + }) .run(); return true; } diff --git a/apps/client/src/features/editor/components/integration-link/badge-color.ts b/apps/client/src/features/editor/components/integration-link/badge-color.ts new file mode 100644 index 000000000..cc5ab1795 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/badge-color.ts @@ -0,0 +1,21 @@ +export function toBadgeColor(raw?: string): string { + if (!raw) return "gray"; + const hex = raw.toLowerCase().replace("#", ""); + if (/^[0-9a-f]{6}$/.test(hex)) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2 / 255; + if (max - min < 30) return l > 0.6 ? "gray" : "dark"; + if (r > g && r > b) return g > 160 ? "orange" : "red"; + if (g > r && g > b) return r > 160 ? "lime" : "green"; + if (b > r && b > g) return r > 100 ? "violet" : "blue"; + if (r > 200 && g > 200) return "yellow"; + if (r > 200 && b > 200) return "pink"; + if (g > 200 && b > 200) return "cyan"; + return "gray"; + } + return raw; +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css b/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css index f841d4dff..ef269f139 100644 --- a/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css +++ b/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css @@ -12,3 +12,42 @@ :global([data-mantine-color-scheme="dark"]) .card:hover { background-color: var(--mantine-color-dark-5); } + +.mention { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + vertical-align: text-bottom; + text-decoration: none; + color: inherit; + border-radius: var(--mantine-radius-sm); +} + +:global(.node-integrationMention) .mention { + border-bottom: none !important; + font-weight: 400; +} + +.mention:hover { + background-color: var(--mantine-color-gray-0); +} + +:global([data-mantine-color-scheme="dark"]) .mention:hover { + background-color: var(--mantine-color-dark-5); +} + +.mentionText { + text-decoration: underline; + text-decoration-color: var(--mantine-color-gray-4); + text-underline-offset: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 340px; + font-weight: 500; +} + +.mentionIcon { + flex-shrink: 0; +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx index 30ad5082d..940e19f70 100644 --- a/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx +++ b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx @@ -8,61 +8,194 @@ import { Skeleton, Anchor, Stack, + Button, } from "@mantine/core"; -import { useEffect, useCallback, memo } from "react"; +import { useCallback, useState, memo } from "react"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; import { getIntegrationIcon } from "@/features/integration/components/integration-icons"; -import { unfurlUrl } from "@/features/integration/services/integration-service"; +import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service"; +import { timeAgo } from "@/lib/time"; +import { useUnfurl } from "./use-unfurl"; +import { toBadgeColor } from "./badge-color"; import classes from "./integration-link-view.module.css"; -function toBadgeColor(raw?: string): string { - if (!raw) return "gray"; - const hex = raw.toLowerCase().replace("#", ""); - if (/^[0-9a-f]{6}$/.test(hex)) { - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const l = (max + min) / 2 / 255; - if (max - min < 30) return l > 0.6 ? "gray" : "dark"; - if (r > g && r > b) return g > 160 ? "orange" : "red"; - if (g > r && g > b) return r > 160 ? "lime" : "green"; - if (b > r && b > g) return r > 100 ? "violet" : "blue"; - if (r > 200 && g > 200) return "yellow"; - if (r > 200 && b > 200) return "pink"; - if (g > 200 && b > 200) return "cyan"; - return "gray"; - } - return raw; +const SLACK_TEXT_CLAMP_LINES = 4; + +function SlackMessageCard({ + url, + unfurlData, +}: { + url: string; + unfurlData: Record; +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + const meta = unfurlData.metadata ?? {}; + const postedAt = meta.ts ? new Date(parseFloat(meta.ts) * 1000) : null; + const text: string = unfurlData.description ?? ""; + const isLong = + text.length > 280 || text.split("\n").length > SLACK_TEXT_CLAMP_LINES; + + const footer = [ + meta.replyCount + ? `${meta.replyCount} ${meta.replyCount === 1 ? t("reply") : t("replies")}` + : null, + unfurlData.status, + meta.teamName, + ] + .filter(Boolean) + .join(" • "); + + return ( + + + + + {(unfurlData.author ?? "?").charAt(0)} + + + + + + {unfurlData.author} + + {postedAt && ( + + {timeAgo(postedAt)} + + )} + + + {text && ( + + {text} + + )} + + {isLong && ( + setExpanded((v) => !v)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setExpanded((v) => !v); + } + }} + > + {expanded ? t("show less") : t("show more")} + + )} + + {footer && ( + + {footer} + + )} + + + + {getIntegrationIcon("slack", 18)} + + + + + ); } function IntegrationLinkView(props: any) { const { node, updateAttributes, editor } = props; const { url, provider, unfurlData, status } = node.attrs; + const { t } = useTranslation(); - const doUnfurl = useCallback(async () => { - if (status !== "pending" || !url) return; + const { needsConnection } = useUnfurl(url, status, updateAttributes); + const [connecting, setConnecting] = useState(false); - try { - const result = await unfurlUrl({ url }); - if (result) { - updateAttributes({ - unfurlData: result, - status: "loaded", + const handleConnect = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!needsConnection) return; + + setConnecting(true); + try { + const result = await getOAuthAuthorizeUrl({ + integrationId: needsConnection.integrationId, + returnPath: window.location.pathname, + }); + window.location.href = result.authorizationUrl; + } catch (error) { + setConnecting(false); + notifications.show({ + message: + error?.["response"]?.data?.message || + t("Failed to start OAuth connection"), + color: "red", }); - } else { - updateAttributes({ status: "error" }); } - } catch { - updateAttributes({ status: "error" }); - } - }, [url, status, updateAttributes]); + }, + [needsConnection, t], + ); - useEffect(() => { - if (status === "pending") { - doUnfurl(); - } - }, [status, doUnfurl]); + if (needsConnection) { + return ( + + + +
+ {getIntegrationIcon(provider, 28)} +
+ + + + {needsConnection.title} + + {needsConnection.description && ( + + {needsConnection.description} + + )} + + + +
+
+
+ ); + } if (status === "pending") { return ( @@ -92,6 +225,12 @@ function IntegrationLinkView(props: any) { ); } + // metadata.ts marks legacy message unfurls stored before metadata.type existed. + const slackMeta = provider === "slack" ? unfurlData.metadata : null; + if (slackMeta?.type === "message" || (slackMeta && !slackMeta.type && slackMeta.ts)) { + return ; + } + return ( + {data.status} + + ) : null; + + let content; + if (!data) { + // pending / error / needs-connection: a compact link chip + content = ( + <> + {getIntegrationIcon(provider, 14)} + {shortUrl(url)} + + ); + } else if (isSlackMessage) { + content = ( + <> + + {(data.author ?? "?").charAt(0)} + + {data.author && ( + + {data.author} + + )} + + {(data.description ?? "").split("\n")[0] || shortUrl(url)} + + {getIntegrationIcon("slack", 14)} + {data.status && ( + + {data.status} + + )} + + ); + } else if (issueNumber) { + content = ( + <> + {getIntegrationIcon(provider, 14)} + + #{issueNumber} + + {data.title} + {statusBadge} + + ); + } else if (typeLabel) { + content = ( + <> + {getIntegrationIcon(provider, 14)} + + {typeLabel} + + {data.title} + {statusBadge} + + ); + } else { + content = ( + <> + {getIntegrationIcon(provider, 14)} + {data.title || shortUrl(url)} + {statusBadge} + + ); + } + + return ( + + + {content} + + + ); +} + +export default memo(IntegrationMentionView); diff --git a/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx new file mode 100644 index 000000000..47a2a8b5f --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx @@ -0,0 +1,168 @@ +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { posToDOMRect, useEditorState } from "@tiptap/react"; +import { useCallback, useEffect } from "react"; +import { Button, Paper, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; +import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu"; + +const INTEGRATION_NODE_TYPES = ["integrationLink", "integrationMention"]; + +export function IntegrationPasteMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + + const menuState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) return null; + return integrationPasteMenuKey.getState(ctx.editor.state) ?? null; + }, + }); + + const findTarget = useCallback(() => { + const state = integrationPasteMenuKey.getState(editor.state); + if (!state) return null; + const node = editor.state.doc.nodeAt(state.pos); + if (!node || !INTEGRATION_NODE_TYPES.includes(node.type.name)) return null; + return { node, pos: state.pos }; + }, [editor]); + + const shouldShow = useCallback(() => Boolean(findTarget()), [findTarget]); + + const getReferencedVirtualElement = useCallback(() => { + const target = findTarget(); + if (!target) return undefined; + const dom = editor.view.nodeDOM(target.pos) as HTMLElement | null; + const domRect = + dom?.getBoundingClientRect?.() ?? + posToDOMRect( + editor.view, + target.pos, + target.pos + target.node.nodeSize, + ); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor, findTarget]); + + const dismiss = useCallback(() => { + editor.view.dispatch( + editor.state.tr.setMeta(integrationPasteMenuKey, null), + ); + }, [editor]); + + useEffect(() => { + if (!menuState) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") dismiss(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [menuState, dismiss]); + + const convert = useCallback( + (target: "preview" | "mention" | "url") => { + const found = findTarget(); + if (!found) { + dismiss(); + return; + } + const { node, pos } = found; + const attrs = { ...node.attrs }; + const from = pos; + const to = pos + node.nodeSize; + const isBlock = node.type.name === "integrationLink"; + + if (target === "preview" && !isBlock) { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .deleteRange({ from, to }) + .insertContentAt(from, { type: "integrationLink", attrs }) + .run(); + } else if (target === "mention" && isBlock) { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .deleteRange({ from, to }) + .insertContentAt(from, { + type: "paragraph", + content: [ + { type: "integrationMention", attrs }, + { type: "text", text: " " }, + ], + }) + .run(); + } else if (target === "url") { + const linkText = { + type: "text", + text: attrs.url, + marks: [{ type: "link", attrs: { href: attrs.url } }], + }; + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .deleteRange({ from, to }) + .insertContentAt( + from, + isBlock ? { type: "paragraph", content: [linkText] } : linkText, + ) + .run(); + } else { + // already in the requested form + dismiss(); + } + }, + [editor, findTarget, dismiss], + ); + + return ( + + + + {t("Paste as")} + + + + + + + + + ); +} diff --git a/apps/client/src/features/editor/components/integration-link/use-unfurl.ts b/apps/client/src/features/editor/components/integration-link/use-unfurl.ts new file mode 100644 index 000000000..4db0bfc8f --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/use-unfurl.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useState } from "react"; +import { unfurlUrl } from "@/features/integration/services/integration-service"; +import { UnfurlNeedsConnection } from "@/features/integration/types/integration.types"; + +// Fetches the unfurl for a node still in "pending" and writes the result into +// its attrs. A needs-connection response stays local, never in the attrs: the +// doc keeps status "pending" so a viewer who IS connected still unfurls and +// materializes the card for everyone. +export function useUnfurl( + url: string, + status: string, + updateAttributes: (attrs: Record) => void, +) { + const [needsConnection, setNeedsConnection] = + useState(null); + + const doUnfurl = useCallback(async () => { + if (status !== "pending" || !url) return; + + try { + const result = await unfurlUrl({ url }); + if (result && "needsConnection" in result) { + setNeedsConnection(result); + } else if (result) { + updateAttributes({ + unfurlData: result, + status: "loaded", + }); + } else { + updateAttributes({ status: "error" }); + } + } catch { + updateAttributes({ status: "error" }); + } + }, [url, status, updateAttributes]); + + useEffect(() => { + if (status === "pending") { + doUnfurl(); + } + }, [status, doUnfurl]); + + return { needsConnection }; +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 5c10b607c..9909e0e9d 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -57,6 +57,7 @@ import { UniqueID, SharedStorage, IntegrationLink, + IntegrationMention, Columns, Column, Status, @@ -93,6 +94,8 @@ import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx"; +import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx"; +import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx"; @@ -383,6 +386,10 @@ export const mainExtensions = [ IntegrationLink.configure({ view: IntegrationLinkView, }), + IntegrationMention.configure({ + view: IntegrationMentionView, + }), + IntegrationPasteMenuExtension, Status.configure({ view: StatusView, }), diff --git a/apps/client/src/features/editor/extensions/integration-paste-menu.ts b/apps/client/src/features/editor/extensions/integration-paste-menu.ts new file mode 100644 index 000000000..66936c2e9 --- /dev/null +++ b/apps/client/src/features/editor/extensions/integration-paste-menu.ts @@ -0,0 +1,44 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +export type IntegrationPasteMenuState = { pos: number } | null; + +export const integrationPasteMenuKey = new PluginKey( + "integrationPasteMenu", +); + +// Holds the position of a just-pasted integration node so the "Paste as" +// menu can anchor to it. Any other edit or selection change dismisses it. +export const IntegrationPasteMenuExtension = Extension.create({ + name: "integrationPasteMenu", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: integrationPasteMenuKey, + state: { + init: (): IntegrationPasteMenuState => null, + apply(tr, prev): IntegrationPasteMenuState { + const meta = tr.getMeta(integrationPasteMenuKey); + if (meta !== undefined) return meta; + if (!prev) return null; + // Clicking or typing elsewhere dismisses. + if (tr.selectionSet) return null; + // Structural follow-ups (unique-id assignment, trailing node) + // keep the menu anchored: remap and re-validate the position. + if (tr.docChanged) { + const pos = tr.mapping.map(prev.pos); + const node = tr.doc.nodeAt(pos); + const isIntegrationNode = + node && + (node.type.name === "integrationLink" || + node.type.name === "integrationMention"); + return isIntegrationNode ? { pos } : null; + } + return prev; + }, + }, + }), + ]; + }, +}); diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 7149e7938..09e97ff4f 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -75,6 +75,7 @@ import { useEditorScroll } from "./hooks/use-editor-scroll"; import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; +import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { useTranslation } from "react-i18next"; import { @@ -453,6 +454,7 @@ function CollabPageEditor({ + )} {editor && !editorIsEditable && (editable || canComment) && ( diff --git a/apps/client/src/features/integration/components/integration-list-skeleton.tsx b/apps/client/src/features/integration/components/integration-list-skeleton.tsx new file mode 100644 index 000000000..798def1a3 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-list-skeleton.tsx @@ -0,0 +1,66 @@ +import { Box, Group, Skeleton, Stack } from "@mantine/core"; + +const TITLE_WIDTHS = [64, 52, 60, 44, 58, 96, 56]; +const DESCRIPTION_WIDTHS = [300, 250, 320, 180, 290, 270, 260]; + +type IntegrationListSkeletonProps = { + rows?: number; + withBadges?: boolean; +}; + +export default function IntegrationListSkeleton({ + rows = 7, + withBadges = true, +}: IntegrationListSkeletonProps) { + return ( + + ); +} diff --git a/apps/client/src/features/integration/pages/connections.tsx b/apps/client/src/features/integration/pages/connections.tsx index d395f70fd..cd3e686fa 100644 --- a/apps/client/src/features/integration/pages/connections.tsx +++ b/apps/client/src/features/integration/pages/connections.tsx @@ -1,10 +1,11 @@ -import { Text, Loader, Center, Alert, Stack } from "@mantine/core"; +import { Text, Alert, Stack } from "@mantine/core"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; import { notifications } from "@mantine/notifications"; import { getAppName } from "@/lib/config"; import SettingsTitle from "@/components/settings/settings-title"; import ConnectionRow from "../components/connection-row"; +import IntegrationListSkeleton from "../components/integration-list-skeleton"; import { useAvailableIntegrations, useInstalledIntegrations, @@ -70,9 +71,7 @@ export default function Connections() { )} {isLoading ? ( -
- -
+ ) : !available?.length ? ( {t("No integrations available.")} diff --git a/apps/client/src/features/integration/pages/integrations.tsx b/apps/client/src/features/integration/pages/integrations.tsx index 508ed6d6f..524964a5d 100644 --- a/apps/client/src/features/integration/pages/integrations.tsx +++ b/apps/client/src/features/integration/pages/integrations.tsx @@ -1,10 +1,11 @@ -import { Text, Loader, Center, Alert, Stack } from "@mantine/core"; +import { Text, Alert, Stack } from "@mantine/core"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; import { useState, useCallback } from "react"; import { getAppName } from "@/lib/config"; import SettingsTitle from "@/components/settings/settings-title"; import IntegrationRow from "../components/integration-row"; +import IntegrationListSkeleton from "../components/integration-list-skeleton"; import IntegrationSettingsModal from "../components/integration-settings-modal"; import { useAvailableIntegrations, @@ -105,9 +106,7 @@ export default function Integrations() { )} {isLoading ? ( -
- -
+ ) : !available?.length ? ( {t("No integrations available.")} diff --git a/apps/client/src/features/integration/services/integration-service.ts b/apps/client/src/features/integration/services/integration-service.ts index 44e501fe1..541ed9c26 100644 --- a/apps/client/src/features/integration/services/integration-service.ts +++ b/apps/client/src/features/integration/services/integration-service.ts @@ -5,6 +5,7 @@ import { ConnectionStatus, UserConnection, UnfurlResult, + UnfurlNeedsConnection, } from "../types/integration.types"; export async function getAvailableIntegrations(): Promise< @@ -60,6 +61,7 @@ export async function getConnectionStatus(data: { export async function getOAuthAuthorizeUrl(data: { integrationId: string; + returnPath?: string; }): Promise<{ authorizationUrl: string }> { const req = await api.post<{ authorizationUrl: string }>( "/integrations/oauth/authorize", @@ -91,10 +93,9 @@ export async function disconnectIntegration(data: { export async function unfurlUrl(data: { url: string; -}): Promise { - const req = await api.post<{ data: UnfurlResult | null }>( - "/integrations/unfurl", - data, - ); +}): Promise { + const req = await api.post<{ + data: UnfurlResult | UnfurlNeedsConnection | null; + }>("/integrations/unfurl", data); return req.data.data; } diff --git a/apps/client/src/features/integration/types/integration.types.ts b/apps/client/src/features/integration/types/integration.types.ts index 3092b9f01..1efb36f96 100644 --- a/apps/client/src/features/integration/types/integration.types.ts +++ b/apps/client/src/features/integration/types/integration.types.ts @@ -52,3 +52,14 @@ export type UnfurlResult = { authorAvatarUrl?: string; metadata?: Record; }; + +// Returned when the link's provider needs a per-user connection the +// requesting user has not authorized yet. +export type UnfurlNeedsConnection = { + needsConnection: true; + integrationId: string; + integrationType: string; + integrationName: string; + title: string; + description?: string; +}; diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index e8e8d4273..5df118643 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -45,6 +45,8 @@ import { TransclusionSource, TransclusionReference, BaseEmbed, + IntegrationLink, + IntegrationMention, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; @@ -110,7 +112,9 @@ export const tiptapExtensions = [ Status, TransclusionSource, TransclusionReference, - BaseEmbed + BaseEmbed, + IntegrationLink, + IntegrationMention ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/core/integration/dto/integration.dto.ts b/apps/server/src/core/integration/dto/integration.dto.ts index f7edef072..726a5fbd9 100644 --- a/apps/server/src/core/integration/dto/integration.dto.ts +++ b/apps/server/src/core/integration/dto/integration.dto.ts @@ -1,4 +1,12 @@ -import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; +import { + IsBoolean, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Matches, + MaxLength, +} from 'class-validator'; export class InstallIntegrationDto { @IsNotEmpty() @@ -42,6 +50,14 @@ export class OAuthAuthorizeDto { @IsNotEmpty() @IsString() integrationId: string; + + // In-app path to land on after OAuth; single leading slash keeps the + // redirect on the workspace origin. + @IsOptional() + @IsString() + @MaxLength(512) + @Matches(/^\/(?!\/)[^\s\\]*$/) + returnPath?: string; } export class OAuthDisconnectDto { diff --git a/apps/server/src/core/integration/oauth/oauth.controller.ts b/apps/server/src/core/integration/oauth/oauth.controller.ts index 598a147c4..f40d8f9dc 100644 --- a/apps/server/src/core/integration/oauth/oauth.controller.ts +++ b/apps/server/src/core/integration/oauth/oauth.controller.ts @@ -46,6 +46,7 @@ export class OAuthController { dto.integrationId, workspace.id, user.id, + dto.returnPath, ); return { authorizationUrl }; @@ -94,6 +95,8 @@ export class OAuthController { // own hostname/customDomain (canonical DB truth, not user input), then // signed into the state JWT. Tampering would invalidate the signature. const returnUrl = statePayload.returnUrl; + // States signed before returnPath existed fall back to the admin page. + const returnPath = statePayload.returnPath ?? '/settings/integrations'; try { await this.oauthService.exchangeCodeForTokens( @@ -104,11 +107,11 @@ export class OAuthController { statePayload.workspaceId, ); - return res.redirect(`${returnUrl}/settings/integrations`, 302).send(); + return res.redirect(`${returnUrl}${returnPath}`, 302).send(); } catch (err) { this.logger.error(`OAuth callback error for ${type}: ${(err as Error).message}`); return res - .redirect(`${returnUrl}/settings/integrations?error=oauth_failed`, 302) + .redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302) .send(); } } diff --git a/apps/server/src/core/integration/oauth/oauth.service.ts b/apps/server/src/core/integration/oauth/oauth.service.ts index 9892e0339..0e0c01413 100644 --- a/apps/server/src/core/integration/oauth/oauth.service.ts +++ b/apps/server/src/core/integration/oauth/oauth.service.ts @@ -37,6 +37,9 @@ export type OAuthStatePayload = { // accept), and this lets the callback redirect the user back to their own // workspace host (subdomain or custom domain) after token exchange. returnUrl: string; + // Settings page (relative to returnUrl) to land on after the callback. + // Derived server-side from the flow that started it, never from user input. + returnPath?: string; exp: number; }; @@ -57,6 +60,7 @@ export class OAuthService { integrationId: string, workspaceId: string, userId: string, + returnPathOverride?: string, ): Promise<{ authorizationUrl: string }> { const integration = await this.integrationRepo.findById(integrationId); if (!integration || integration.workspaceId !== workspaceId) { @@ -79,12 +83,22 @@ export class OAuthService { workspace ?? { hostname: null, customDomain: null }, ); + // Per-user connects are initiated from the account connections page; + // workspace-scoped authorizes from the admin integrations page. A connect + // started elsewhere (e.g. an editor connect card) passes its own path. + const returnPath = + returnPathOverride ?? + ((provider.definition.oauth.connectionScope ?? 'user') === 'workspace' + ? '/settings/integrations' + : '/settings/account/connections'); + const state = this.createSignedState({ integrationId, type: integration.type, userId, workspaceId, returnUrl, + returnPath, exp: Date.now() + 10 * 60 * 1000, }); @@ -154,6 +168,7 @@ export class OAuthService { userId, workspaceId, returnUrl, + returnPath: '/settings/integrations', exp: Date.now() + 10 * 60 * 1000, }); diff --git a/apps/server/src/core/integration/registry/integration-provider.interface.ts b/apps/server/src/core/integration/registry/integration-provider.interface.ts index 19ee25076..3c70db367 100644 --- a/apps/server/src/core/integration/registry/integration-provider.interface.ts +++ b/apps/server/src/core/integration/registry/integration-provider.interface.ts @@ -65,6 +65,23 @@ export type UnfurlOpts = { accessToken: string; match: RegExpMatchArray; patternType: string; + settings?: Record; +}; + +export type LinkDescription = { + title: string; + description?: string; +}; + +// Returned instead of an UnfurlResult when the link needs a per-user +// connection the requesting user does not have yet. +export type UnfurlNeedsConnection = { + needsConnection: true; + integrationId: string; + integrationType: string; + integrationName: string; + title: string; + description?: string; }; export abstract class IntegrationProvider { @@ -82,5 +99,13 @@ export abstract class IntegrationProvider { unfurl?(opts: UnfurlOpts): Promise; + // Tokenless summary of a matched link (e.g. "Pull Request #13337"), + // shown on the connect prompt before the user has authorized. + describeLink?( + patternType: string, + match: RegExpMatchArray, + url: string, + ): LinkDescription | null; + handleEvent?(opts: HandleEventOpts): Promise; } diff --git a/apps/server/src/core/integration/repos/integration-connection.repo.ts b/apps/server/src/core/integration/repos/integration-connection.repo.ts index 838b11f22..a3a47de59 100644 --- a/apps/server/src/core/integration/repos/integration-connection.repo.ts +++ b/apps/server/src/core/integration/repos/integration-connection.repo.ts @@ -78,19 +78,24 @@ export class IntegrationConnectionRepo { trx?: KyselyTransaction, ): Promise { const db = dbOrTx(this.db, trx); + // The (integration_id, user_id) unique index is partial on kind='user'; + // ON CONFLICT must repeat that predicate or Postgres cannot infer it. return db .insertInto('integrationConnections') .values(connection) .onConflict((oc) => - oc.columns(['integrationId', 'userId']).doUpdateSet({ - accessToken: connection.accessToken, - refreshToken: connection.refreshToken, - tokenExpiresAt: connection.tokenExpiresAt, - scopes: connection.scopes, - providerUserId: connection.providerUserId, - metadata: connection.metadata, - updatedAt: new Date(), - }), + oc + .columns(['integrationId', 'userId']) + .where(sql.ref('kind'), '=', 'user') + .doUpdateSet({ + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + tokenExpiresAt: connection.tokenExpiresAt, + scopes: connection.scopes, + providerUserId: connection.providerUserId, + metadata: connection.metadata, + updatedAt: new Date(), + }), ) .returningAll() .executeTakeFirstOrThrow(); @@ -125,9 +130,9 @@ export class IntegrationConnectionRepo { ); } - // No need to clear other rows: the migration 20260524T020000 made the - // (integration_id, user_id) constraint partial-on-kind='user', so a - // workspace insert never conflicts with the installer's user-link row. + // No need to clear other rows: the (integration_id, user_id) unique index + // is partial on kind='user', so a workspace insert never conflicts with + // the installer's user-link row. return db .insertInto('integrationConnections') diff --git a/apps/server/src/core/integration/unfurl/unfurl.service.ts b/apps/server/src/core/integration/unfurl/unfurl.service.ts index 54bad96de..63a675cb9 100644 --- a/apps/server/src/core/integration/unfurl/unfurl.service.ts +++ b/apps/server/src/core/integration/unfurl/unfurl.service.ts @@ -5,6 +5,7 @@ import { IntegrationRepo } from '../repos/integration.repo'; import { OAuthService } from '../oauth/oauth.service'; import { UnfurlResult, + UnfurlNeedsConnection, IntegrationProvider, } from '../registry/integration-provider.interface'; import { RedisService } from '@nestjs-labs/nestjs-ioredis'; @@ -33,7 +34,7 @@ export class UnfurlService { url: string, userId: string, workspaceId: string, - ): Promise { + ): Promise { const cacheKey = this.buildCacheKey(workspaceId, userId, url); const cached = await this.redis.get(cacheKey); if (cached) { @@ -52,13 +53,30 @@ export class UnfurlService { return null; } - const connection = await this.connectionRepo.findByIntegrationAndUser( - integration.id, - userId, - ); + // Workspace-scoped providers (Slack) share one bot connection that serves + // every member; user-scoped providers need the requester's own token. + const connectionScope = + provider.definition.oauth?.connectionScope ?? 'user'; + const connection = + connectionScope === 'workspace' + ? await this.connectionRepo.findWorkspaceConnection(integration.id) + : await this.connectionRepo.findByIntegrationAndUser( + integration.id, + userId, + ); if (!connection) { - return null; + if (connectionScope === 'workspace') { + return null; + } + // Not cached: the card should load as soon as the user connects. + return this.buildNeedsConnection( + provider, + integration.id, + patternType, + match, + url, + ); } try { @@ -70,6 +88,7 @@ export class UnfurlService { accessToken, match, patternType, + settings: (integration.settings as Record) ?? {}, }); await this.redis.set( @@ -86,6 +105,34 @@ export class UnfurlService { } } + private buildNeedsConnection( + provider: IntegrationProvider, + integrationId: string, + patternType: string, + match: RegExpMatchArray, + url: string, + ): UnfurlNeedsConnection { + const described = + provider.describeLink?.(patternType, match, url) ?? null; + + let fallbackDescription: string | undefined; + try { + const parsed = new URL(url); + fallbackDescription = `${parsed.host}${parsed.pathname}`; + } catch { + fallbackDescription = undefined; + } + + return { + needsConnection: true, + integrationId, + integrationType: provider.definition.type, + integrationName: provider.definition.name, + title: described?.title ?? `${provider.definition.name} link`, + description: described?.description ?? fallbackDescription, + }; + } + private async resolveProvider( url: string, workspaceId: string, @@ -93,7 +140,12 @@ export class UnfurlService { provider: IntegrationProvider; match: RegExpMatchArray; patternType: string; - integration: { id: string; isEnabled: boolean; type: string }; + integration: { + id: string; + isEnabled: boolean; + type: string; + settings: unknown; + }; } | null> { const staticResult = this.registry.findUnfurlProvider(url); if (staticResult) { diff --git a/apps/server/src/database/migrations/20260522T1264122-integrations.ts b/apps/server/src/database/migrations/20260807T1264122-integrations.ts similarity index 100% rename from apps/server/src/database/migrations/20260522T1264122-integrations.ts rename to apps/server/src/database/migrations/20260807T1264122-integrations.ts diff --git a/packages/editor-ext/src/lib/integration-link/index.ts b/packages/editor-ext/src/lib/integration-link/index.ts index d8aeafb73..398d11777 100644 --- a/packages/editor-ext/src/lib/integration-link/index.ts +++ b/packages/editor-ext/src/lib/integration-link/index.ts @@ -1,8 +1,9 @@ -export { IntegrationLink } from "./integration-link"; +export { IntegrationLink, createIntegrationAttributes } from "./integration-link"; export type { IntegrationLinkOptions, IntegrationLinkAttributes, } from "./integration-link"; +export { IntegrationMention } from "./integration-mention"; export { integrationLinkPatterns, matchIntegrationLink, diff --git a/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts b/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts index ed9b40f35..ed6f230d5 100644 --- a/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts +++ b/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts @@ -4,6 +4,19 @@ export type IntegrationLinkPattern = { }; export const integrationLinkPatterns: IntegrationLinkPattern[] = [ + // Slack message permalink (host-specific; must precede the host-agnostic + // GitHub patterns, whose repo form would swallow /archives/) + { + provider: "slack", + regex: + /^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/p(\d+)(?:\?thread_ts=[\d.]+&cid=[A-Za-z\d]+)?$/, + }, + // Slack channel + { + provider: "slack", + regex: + /^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/?$/, + }, // GitHub PR commit (must be before generic PR pattern) { provider: "github", @@ -76,6 +89,18 @@ export const integrationLinkPatterns: IntegrationLinkPattern[] = [ regex: /^https?:\/\/[^\/]+\/(.+)\/-\/issues\/(\d+)/, }, + // GitLab work item (new URL format for issues) + { + provider: "gitlab", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/work_items\/(\d+)/, + }, + // GitLab work item opened as a drawer over the list (?show=base64 payload) + { + provider: "gitlab", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/work_items\/?\?(?:.*&)?show=/, + }, // GitLab commit { provider: "gitlab", diff --git a/packages/editor-ext/src/lib/integration-link/integration-link.ts b/packages/editor-ext/src/lib/integration-link/integration-link.ts index 9a1a6c006..6a1d15286 100644 --- a/packages/editor-ext/src/lib/integration-link/integration-link.ts +++ b/packages/editor-ext/src/lib/integration-link/integration-link.ts @@ -14,6 +14,55 @@ export interface IntegrationLinkAttributes { status: "pending" | "loaded" | "error"; } +// Shared by IntegrationLink (block) and IntegrationMention (inline) so both +// serialize the same attrs and stay convertible into each other. +export function createIntegrationAttributes() { + return { + url: { + default: "", + parseHTML: (element: HTMLElement) => { + const url = element.getAttribute("data-url"); + return sanitizeUrl(url); + }, + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-url": sanitizeUrl(attributes.url), + }), + }, + provider: { + default: "", + parseHTML: (element: HTMLElement) => element.getAttribute("data-provider"), + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-provider": attributes.provider, + }), + }, + unfurlData: { + default: null, + parseHTML: (element: HTMLElement) => { + const data = element.getAttribute("data-unfurl"); + if (!data) return null; + try { + return JSON.parse(data); + } catch { + return null; + } + }, + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-unfurl": attributes.unfurlData + ? JSON.stringify(attributes.unfurlData) + : null, + }), + }, + status: { + default: "pending", + parseHTML: (element: HTMLElement) => + element.getAttribute("data-status") ?? "pending", + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-status": attributes.status, + }), + }, + }; +} + declare module "@tiptap/core" { interface Commands { integrationLink: { @@ -41,49 +90,7 @@ export const IntegrationLink = Node.create({ }, addAttributes() { - return { - url: { - default: "", - parseHTML: (element) => { - const url = element.getAttribute("data-url"); - return sanitizeUrl(url); - }, - renderHTML: (attributes: IntegrationLinkAttributes) => ({ - "data-url": sanitizeUrl(attributes.url), - }), - }, - provider: { - default: "", - parseHTML: (element) => element.getAttribute("data-provider"), - renderHTML: (attributes: IntegrationLinkAttributes) => ({ - "data-provider": attributes.provider, - }), - }, - unfurlData: { - default: null, - parseHTML: (element) => { - const data = element.getAttribute("data-unfurl"); - if (!data) return null; - try { - return JSON.parse(data); - } catch { - return null; - } - }, - renderHTML: (attributes: IntegrationLinkAttributes) => ({ - "data-unfurl": attributes.unfurlData - ? JSON.stringify(attributes.unfurlData) - : null, - }), - }, - status: { - default: "pending", - parseHTML: (element) => element.getAttribute("data-status") ?? "pending", - renderHTML: (attributes: IntegrationLinkAttributes) => ({ - "data-status": attributes.status, - }), - }, - }; + return createIntegrationAttributes(); }, parseHTML() { diff --git a/packages/editor-ext/src/lib/integration-link/integration-mention.ts b/packages/editor-ext/src/lib/integration-link/integration-mention.ts new file mode 100644 index 000000000..c91ef6f85 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-mention.ts @@ -0,0 +1,82 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { sanitizeUrl } from "../utils"; +import { + createIntegrationAttributes, + IntegrationLinkAttributes, + IntegrationLinkOptions, +} from "./integration-link"; + +declare module "@tiptap/core" { + interface Commands { + integrationMention: { + setIntegrationMention: ( + attributes: Partial, + ) => ReturnType; + }; + } +} + +// Inline counterpart of IntegrationLink: same attrs, flows with text. +export const IntegrationMention = Node.create({ + name: "integrationMention", + inline: true, + group: "inline", + atom: true, + selectable: true, + draggable: false, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + addAttributes() { + return createIntegrationAttributes(); + }, + + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const url = HTMLAttributes["data-url"]; + const safeUrl = sanitizeUrl(url); + + return [ + "span", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl], + ]; + }, + + addCommands() { + return { + setIntegrationMention: + (attrs) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + ...attrs, + url: sanitizeUrl(attrs.url), + }, + }); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(this.options.view); + }, +});