diff --git a/client/components/Editor/Editor.tsx b/client/components/Editor/Editor.tsx index 5601f30d96..f4a1e50100 100644 --- a/client/components/Editor/Editor.tsx +++ b/client/components/Editor/Editor.tsx @@ -32,9 +32,10 @@ type Props = { collaborativeOptions?: Maybe; discussionsOptions?: Maybe; debounceEditsMs?: number; - customMarks?: Record; + customMarks?: Record; customNodes?: Record; customPlugins?: Record; + disableLinkCreation?: boolean; enableSuggestions?: boolean; initialContent?: DocJson | null; isReadOnly?: boolean; @@ -58,6 +59,7 @@ const Editor = (props: Props) => { customNodes = {}, customPlugins = {}, debounceEditsMs = 0, + disableLinkCreation = false, discussionsOptions, enableSuggestions = false, initialContent: providedInitialContent, @@ -91,6 +93,7 @@ const Editor = (props: Props) => { noteManager, discussionsOptions: discussionsOptions || null, collaborativeOptions: collaborativeOptions || null, + disableLinkCreation, initialDoc: initialDocNode, isReadOnly, nodeLabels, diff --git a/client/components/Editor/hooks/useInitialValues.ts b/client/components/Editor/hooks/useInitialValues.ts index b0eebdfac3..af6011c90a 100644 --- a/client/components/Editor/hooks/useInitialValues.ts +++ b/client/components/Editor/hooks/useInitialValues.ts @@ -20,7 +20,7 @@ type InitialValues = { type InitialValuesOptions = { noteManager?: NoteManager; customNodes: Record; - customMarks: Record; + customMarks: Record; nodeLabels: NodeLabelMap; initialContent: DocJson; isReadOnly: boolean; diff --git a/client/components/Editor/plugins/inputRules.ts b/client/components/Editor/plugins/inputRules.ts index 59dd1f125b..20f98520b6 100644 --- a/client/components/Editor/plugins/inputRules.ts +++ b/client/components/Editor/plugins/inputRules.ts @@ -153,9 +153,10 @@ function linkRule(markType: MarkType) { // : (Schema) → Plugin // A set of input rules for creating the basic block quotes, lists, // code blocks, and heading. -export default (schema) => { +export default (schema, options?) => { const rules = smartQuotes.concat(ellipsis, emDash); - if (schema.marks.link) rules.unshift(linkRule(schema.marks.link)); + if (schema.marks.link && !options?.disableLinkCreation) + rules.unshift(linkRule(schema.marks.link)); if (schema.nodes.blockquote) rules.push(blockQuoteRule(schema.nodes.blockquote)); if (schema.nodes.ordered_list) rules.push(orderedListRule(schema.nodes.ordered_list)); if (schema.nodes.bullet_list) rules.push(bulletListRule(schema.nodes.bullet_list)); diff --git a/client/components/Editor/plugins/keymap.ts b/client/components/Editor/plugins/keymap.ts index a86addadaa..4de9a99240 100644 --- a/client/components/Editor/plugins/keymap.ts +++ b/client/components/Editor/plugins/keymap.ts @@ -54,7 +54,7 @@ const mac = typeof navigator !== 'undefined' ? /Mac/.test(navigator.platform) : // * **Mod-BracketLeft** to `lift` // * **Escape** to `selectParentNode` -export default (schema) => { +export default (schema, options?) => { const keys = {}; const bind = (key, cmd) => { keys[key] = cmd; @@ -88,7 +88,7 @@ export default (schema) => { if (schema.marks.code) { bind('Mod-<', toggleMark(schema.marks.code)); } - if (schema.marks.link) { + if (schema.marks.link && !options?.disableLinkCreation) { bind('Mod-k', toggleMark(schema.marks.link)); } @@ -195,13 +195,10 @@ export default (schema) => { // All but the custom block splitting command and the add link command in this chain are taken // from the default chain in baseKeymap. We provide our own block splitter that preserves text // align attributes between paragraphs. - const customEnterCommand = chainCommands( - addLinkCommand, - newlineInCode, - createParagraphNear, - liftEmptyBlock, - customBlockSplitter, - ); + const enterCommands = options?.disableLinkCreation + ? [newlineInCode, createParagraphNear, liftEmptyBlock, customBlockSplitter] + : [addLinkCommand, newlineInCode, createParagraphNear, liftEmptyBlock, customBlockSplitter]; + const customEnterCommand = chainCommands(...enterCommands); return [keymap(keys), keymap({ ...baseKeymap, Enter: customEnterCommand })]; }; diff --git a/client/components/Editor/types.ts b/client/components/Editor/types.ts index 35622891b0..872bdcbe90 100644 --- a/client/components/Editor/types.ts +++ b/client/components/Editor/types.ts @@ -68,6 +68,7 @@ export type PluginsOptions = { noteManager?: NoteManager; collaborativeOptions?: null | CollaborativeOptions; discussionsOptions?: null | DiscussionsOptions; + disableLinkCreation?: boolean; initialDoc: Node; isReadOnly?: boolean; nodeLabels: NodeLabelMap; diff --git a/client/components/FormattingBar/buttons.ts b/client/components/FormattingBar/buttons.ts index a6531e7e1f..fca444f5a9 100644 --- a/client/components/FormattingBar/buttons.ts +++ b/client/components/FormattingBar/buttons.ts @@ -362,7 +362,7 @@ export const reviewButtonSet = [ ], ]; -export const discussionButtonSet = [[strong, em, link], [rightToLeft], [simpleMedia]]; +export const discussionButtonSet = [[strong, em], [rightToLeft], [simpleMedia]]; export const inlineMenuButtonSet = [[heading1, heading2, strong, em, link]]; diff --git a/client/containers/Pub/PubDocument/PubDiscussions/Discussion/DefangedLinkPopover.tsx b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/DefangedLinkPopover.tsx new file mode 100644 index 0000000000..d8853e364b --- /dev/null +++ b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/DefangedLinkPopover.tsx @@ -0,0 +1,67 @@ +import React, { useCallback, useState } from 'react'; + +import './defangedLink.scss'; + +import { Popover, Position } from '@blueprintjs/core'; + +type ClickedLink = { + href: string; + rect: DOMRect; +}; + +type Props = { + children: React.ReactNode; +}; + +const DefangedLinkPopover = ({ children }: Props) => { + const [clicked, setClicked] = useState(null); + + const handleClick = useCallback((evt: React.MouseEvent) => { + const target = (evt.target as HTMLElement).closest('.defanged-link'); + if (!target) return; + evt.preventDefault(); + evt.stopPropagation(); + const href = target.getAttribute('data-href') ?? ''; + setClicked({ href, rect: target.getBoundingClientRect() }); + }, []); + + const handleClose = useCallback(() => setClicked(null), []); + + return ( +
+ {children} + {clicked && ( + +

+ We no longer allow links in comments. This comment was made before + the policy went into effect. +

+

It pointed to:

+ {clicked.href} +
+ } + > + + + )} + + ); +}; + +export default DefangedLinkPopover; diff --git a/client/containers/Pub/PubDocument/PubDiscussions/Discussion/DiscussionInput.tsx b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/DiscussionInput.tsx index 990cbc4f47..7cec155f41 100644 --- a/client/containers/Pub/PubDocument/PubDiscussions/Discussion/DiscussionInput.tsx +++ b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/DiscussionInput.tsx @@ -18,6 +18,8 @@ import { buttons, FormattingBar } from 'components/FormattingBar'; import { usePubContext } from 'containers/Pub/pubHooks'; import { usePageContext } from 'utils/hooks'; +import { noLinksMarks } from './noLinks'; + type OwnProps = { discussionData: any; isPubBottomInput?: boolean; @@ -236,6 +238,8 @@ const DiscussionInput = (props: Props) => { /> { setChangeObject(editorChangeObject); diff --git a/client/containers/Pub/PubDocument/PubDiscussions/Discussion/ThreadComment.tsx b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/ThreadComment.tsx index cd21b75076..ca7bc12ac7 100644 --- a/client/containers/Pub/PubDocument/PubDiscussions/Discussion/ThreadComment.tsx +++ b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/ThreadComment.tsx @@ -15,6 +15,9 @@ import { getPartsOfFullName } from 'utils/names'; import './threadComment.scss'; +import DefangedLinkPopover from './DefangedLinkPopover'; +import { noLinksMarks } from './noLinks'; + type Props = { discussionData: any; threadCommentData: any; @@ -103,12 +106,16 @@ const ThreadComment = (props: Props) => { onChange?: Callback, ) => { return ( - + + + ); }; const commenterName = discussionData.commenter?.name ?? threadCommentData.commenter?.name; diff --git a/client/containers/Pub/PubDocument/PubDiscussions/Discussion/defangedLink.scss b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/defangedLink.scss new file mode 100644 index 0000000000..2022d053f2 --- /dev/null +++ b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/defangedLink.scss @@ -0,0 +1,19 @@ +.defanged-link-popover-content { + padding: 10px 14px; + max-width: 360px; + font-size: 13px; + line-height: 1.4; +} +.defanged-link-popover-notice { + margin: 0 0 6px; + color: #5c7080; +} +.defanged-link-popover-url { + display: block; + padding: 4px 8px; + background: #f5f5f5; + border-radius: 3px; + font-size: 12px; + word-break: break-all; + color: #394b59; +} diff --git a/client/containers/Pub/PubDocument/PubDiscussions/Discussion/noLinks.ts b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/noLinks.ts new file mode 100644 index 0000000000..37ae9cfc41 --- /dev/null +++ b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/noLinks.ts @@ -0,0 +1,40 @@ +import type { DOMOutputSpec, MarkSpec } from 'prosemirror-model'; + +// strip protocol, bracket dots so crawlers won't follow or index the url +const defangUrl = (href: string): string => { + if (!href) return ''; + return href.replace(/^https?:\/\//, '').replace(/\./g, '[.]'); +}; + +export const noLinksMarks = { + link: { + inclusive: false, + attrs: { + href: { default: '' }, + }, + parseDOM: [ + { + tag: 'span.defanged-link', + getAttrs: (dom) => ({ + href: (dom.getAttribute('data-href') ?? '').replace(/\[.\]/g, '.'), + }), + }, + { + tag: 'a[href]', + getAttrs: (dom) => ({ + href: dom.getAttribute('href'), + }), + }, + ], + toDOM: (node) => { + return [ + 'span', + { + class: 'defanged-link', + 'data-href': defangUrl(node.attrs.href), + }, + 0, + ] as DOMOutputSpec; + }, + }, +} satisfies Record; diff --git a/client/containers/Pub/PubDocument/PubDiscussions/Discussion/threadComment.scss b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/threadComment.scss index f25ff63fb1..1a299b2e8c 100644 --- a/client/containers/Pub/PubDocument/PubDiscussions/Discussion/threadComment.scss +++ b/client/containers/Pub/PubDocument/PubDiscussions/Discussion/threadComment.scss @@ -1,127 +1,131 @@ @import 'styles/variables.scss'; .thread-comment-component { - display: flex; - direction: ltr; + display: flex; + direction: ltr; - &:not(:last-child):not(.input) { - margin-bottom: 1em; - border-bottom: 1px solid #f0f0f7; - } - &.is-spam { - .discussion-body-wrapper { - opacity: 0.85; - } - border-left: 3px solid #d9534f; - padding-left: 0.5em; - background: rgba(217, 83, 79, 0.03); - } - .spam-badge { - display: inline-block; - font-size: 10px; - font-weight: 600; - line-height: 1; - padding: 2px 5px; - margin-left: 4px; - background: #d9534f; - color: white; - border-radius: 2px; - vertical-align: middle; - text-transform: uppercase; - letter-spacing: 0.5px; - } - .thread-comment-spam-banner { - width: 100%; - margin-bottom: 0.5em; - padding: 0.4em 0.6em; - font-size: 12px; - background: #fcf3cd; - color: #856404; - border-radius: 2px; - border-left: 3px solid #ffc107; - } - &.is-preview { - .content-wrapper { - margin-bottom: 0; - } - } - .avatar-wrapper { - margin-right: 0.5em; - } - .content-wrapper { - flex: 1 1 auto; - min-width: 0; - margin-bottom: 1em; - } - .preview-text { - white-space: nowrap; - overflow: hidden; - max-height: 19px; - font-style: 14px; - > .editor.ProseMirror { - min-height: 0px; - p { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-bottom: 0px; - } - } - } - .discussion-body-wrapper { - > .editor.ProseMirror { - min-height: 0px; - h1, - h2, - h3, - h4, - h5, - h6, - p, - li { - font-family: $base-font; - line-height: 1.3; - } - } - &.editable { - > .editor { - min-height: 75px; - border: 1px solid #ddd; - background: white; - border-radius: 2px; - padding: 0.5em; - margin-bottom: 0.5em; - .prosemirror-placeholder { - white-space: unset; - width: unset; - } - } - } - } - .simple-input { - background: #f4f4f7; - border-radius: 2px; - border: 0px solid white; - padding: 0.5em; - width: 100%; - font-style: italic; - } + &:not(:last-child):not(.input) { + margin-bottom: 1em; + border-bottom: 1px solid #f0f0f7; + } + &.is-spam { + .discussion-body-wrapper { + opacity: 0.85; + } + border-left: 3px solid #d9534f; + padding-left: 0.5em; + background: rgba(217, 83, 79, 0.03); + } + .spam-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + line-height: 1; + padding: 2px 5px; + margin-left: 4px; + background: #d9534f; + color: white; + border-radius: 2px; + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.5px; + } + .thread-comment-spam-banner { + width: 100%; + margin-bottom: 0.5em; + padding: 0.4em 0.6em; + font-size: 12px; + background: #fcf3cd; + color: #856404; + border-radius: 2px; + border-left: 3px solid #ffc107; + } + &.is-preview { + .content-wrapper { + margin-bottom: 0; + } + } + .avatar-wrapper { + margin-right: 0.5em; + } + .content-wrapper { + flex: 1 1 auto; + min-width: 0; + margin-bottom: 1em; + } + .preview-text { + white-space: nowrap; + overflow: hidden; + max-height: 19px; + font-style: 14px; + > .editor.ProseMirror { + min-height: 0px; + p { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 0px; + } + } + } + .defanged-link { + text-decoration: underline; + cursor: pointer; + } + .discussion-body-wrapper { + > .editor.ProseMirror { + min-height: 0px; + h1, + h2, + h3, + h4, + h5, + h6, + p, + li { + font-family: $base-font; + line-height: 1.3; + } + } + &.editable { + > .editor { + min-height: 75px; + border: 1px solid #ddd; + background: white; + border-radius: 2px; + padding: 0.5em; + margin-bottom: 0.5em; + .prosemirror-placeholder { + white-space: unset; + width: unset; + } + } + } + } + .simple-input { + background: #f4f4f7; + border-radius: 2px; + border: 0px solid white; + padding: 0.5em; + width: 100%; + font-style: italic; + } - .guest-name-input { - display: none; - } - &:focus-within { - .guest-name-input { - display: block; - } - } + .guest-name-input { + display: none; + } + &:focus-within { + .guest-name-input { + display: block; + } + } - .discussion-primary-button, - .discussion-cancel-button { - margin-bottom: 1em; - white-space: nowrap; - &:not(:last-child) { - margin-right: 1em; - } - } + .discussion-primary-button, + .discussion-cancel-button { + margin-bottom: 1em; + white-space: nowrap; + &:not(:last-child) { + margin-right: 1em; + } + } }