diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 745fc3c5..95f18b3f 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -21,6 +21,8 @@ import { FileUploadButton } from './file-upload-button' import { linkClass } from './site-link' import { mentionSuggestion } from './editor/mention-suggestion' import { DisplayMention } from './editor/mention' +import { contractMentionSuggestion } from './editor/contract-mention-suggestion' +import { DisplayContractMention } from './editor/contract-mention' import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' @@ -97,7 +99,12 @@ export function useTextEditor(props: { CharacterCount.configure({ limit: max }), simple ? DisplayImage : Image, DisplayLink, - DisplayMention.configure({ suggestion: mentionSuggestion }), + DisplayMention.configure({ + suggestion: mentionSuggestion, + }), + DisplayContractMention.configure({ + suggestion: contractMentionSuggestion, + }), Iframe, TiptapTweet, ], @@ -316,13 +323,21 @@ export function RichContent(props: { smallImage ? DisplayImage : Image, DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, + DisplayContractMention.configure({ + // Needed to set a different PluginKey for Prosemirror + suggestion: contractMentionSuggestion, + }), Iframe, TiptapTweet, ], content, editable: false, }) - useEffect(() => void editor?.commands?.setContent(content), [editor, content]) + useEffect( + // Check isDestroyed here so hot reload works, see https://github.com/ueberdosis/tiptap/issues/1451#issuecomment-941988769 + () => void !editor?.isDestroyed && editor?.commands?.setContent(content), + [editor, content] + ) return } diff --git a/web/components/editor/contract-mention-list.tsx b/web/components/editor/contract-mention-list.tsx new file mode 100644 index 00000000..bda9d2fc --- /dev/null +++ b/web/components/editor/contract-mention-list.tsx @@ -0,0 +1,68 @@ +import { SuggestionProps } from '@tiptap/suggestion' +import clsx from 'clsx' +import { Contract } from 'common/contract' +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' +import { contractPath } from 'web/lib/firebase/contracts' +import { Avatar } from '../avatar' + +// copied from https://tiptap.dev/api/nodes/mention#usage +const M = forwardRef((props: SuggestionProps, ref) => { + const { items: contracts, command } = props + + const [selectedIndex, setSelectedIndex] = useState(0) + useEffect(() => setSelectedIndex(0), [contracts]) + + const submitUser = (index: number) => { + const contract = contracts[index] + if (contract) + command({ id: contract.id, label: contractPath(contract) } as any) + } + + const onUp = () => + setSelectedIndex((i) => (i + contracts.length - 1) % contracts.length) + const onDown = () => setSelectedIndex((i) => (i + 1) % contracts.length) + const onEnter = () => submitUser(selectedIndex) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: any) => { + if (event.key === 'ArrowUp') { + onUp() + return true + } + if (event.key === 'ArrowDown') { + onDown() + return true + } + if (event.key === 'Enter') { + onEnter() + return true + } + return false + }, + })) + + return ( +
+ {!contracts.length ? ( + No results... + ) : ( + contracts.map((contract, i) => ( + + )) + )} +
+ ) +}) + +// Just to keep the formatting pretty +export { M as MentionList } diff --git a/web/components/editor/contract-mention-suggestion.ts b/web/components/editor/contract-mention-suggestion.ts new file mode 100644 index 00000000..39d024e7 --- /dev/null +++ b/web/components/editor/contract-mention-suggestion.ts @@ -0,0 +1,27 @@ +import type { MentionOptions } from '@tiptap/extension-mention' +import { searchInAny } from 'common/util/parse' +import { orderBy } from 'lodash' +import { getCachedContracts } from 'web/hooks/use-contracts' +import { MentionList } from './contract-mention-list' +import { PluginKey } from 'prosemirror-state' +import { makeMentionRender } from './mention-suggestion' + +type Suggestion = MentionOptions['suggestion'] + +const beginsWith = (text: string, query: string) => + text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) + +export const contractMentionSuggestion: Suggestion = { + char: '%', + allowSpaces: true, + pluginKey: new PluginKey('contract-mention'), + items: async ({ query }) => + orderBy( + (await getCachedContracts()).filter((c) => + searchInAny(query, c.question) + ), + [(c) => [c.question].some((s) => beginsWith(s, query))], + ['desc', 'desc'] + ).slice(0, 5), + render: makeMentionRender(MentionList), +} diff --git a/web/components/editor/contract-mention.tsx b/web/components/editor/contract-mention.tsx new file mode 100644 index 00000000..ddc81bc0 --- /dev/null +++ b/web/components/editor/contract-mention.tsx @@ -0,0 +1,42 @@ +import Mention from '@tiptap/extension-mention' +import { + mergeAttributes, + NodeViewWrapper, + ReactNodeViewRenderer, +} from '@tiptap/react' +import clsx from 'clsx' +import { useContract } from 'web/hooks/use-contract' +import { ContractCard } from '../contract/contract-card' + +const name = 'contract-mention-component' + +const ContractMentionComponent = (props: any) => { + const contract = useContract(props.node.attrs.id) + + return ( + + {contract && ( + + )} + + ) +} + +/** + * Mention extension that renders React. See: + * https://tiptap.dev/guide/custom-extensions#extend-existing-extensions + * https://tiptap.dev/guide/node-views/react#render-a-react-component + */ +export const DisplayContractMention = Mention.extend({ + name: 'contract-mention', + parseHTML: () => [{ tag: name }], + renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], + addNodeView: () => + ReactNodeViewRenderer(ContractMentionComponent, { + // On desktop, render cards below half-width so you can stack two + className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1', + }), +}) diff --git a/web/components/editor/mention-suggestion.ts b/web/components/editor/mention-suggestion.ts index 9f016d47..b4eeeebe 100644 --- a/web/components/editor/mention-suggestion.ts +++ b/web/components/editor/mention-suggestion.ts @@ -5,6 +5,7 @@ import { orderBy } from 'lodash' import tippy from 'tippy.js' import { getCachedUsers } from 'web/hooks/use-users' import { MentionList } from './mention-list' +type Render = Suggestion['render'] type Suggestion = MentionOptions['suggestion'] @@ -24,12 +25,16 @@ export const mentionSuggestion: Suggestion = { ], ['desc', 'desc'] ).slice(0, 5), - render: () => { + render: makeMentionRender(MentionList), +} + +export function makeMentionRender(mentionList: any): Render { + return () => { let component: ReactRenderer let popup: ReturnType return { onStart: (props) => { - component = new ReactRenderer(MentionList, { + component = new ReactRenderer(mentionList, { props, editor: props.editor, }) @@ -59,10 +64,16 @@ export const mentionSuggestion: Suggestion = { }) }, onKeyDown(props) { - if (props.event.key === 'Escape') { - popup?.[0].hide() - return true - } + if (props.event.key) + if ( + props.event.key === 'Escape' || + // Also break out of the mention if the tooltip isn't visible + (props.event.key === 'Enter' && !popup?.[0].state.isShown) + ) { + popup?.[0].destroy() + component?.destroy() + return false + } return (component?.ref as any)?.onKeyDown(props) }, onExit() { @@ -70,5 +81,5 @@ export const mentionSuggestion: Suggestion = { component?.destroy() }, } - }, + } } diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 1ea2f232..87eefa38 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -9,8 +9,9 @@ import { listenForNewContracts, getUserBetContracts, getUserBetContractsQuery, + listAllContracts, } from 'web/lib/firebase/contracts' -import { useQueryClient } from 'react-query' +import { QueryClient, useQueryClient } from 'react-query' import { MINUTE_MS } from 'common/util/time' export const useContracts = () => { @@ -23,6 +24,12 @@ export const useContracts = () => { return contracts } +const q = new QueryClient() +export const getCachedContracts = async () => + q.fetchQuery(['contracts'], () => listAllContracts(1000), { + staleTime: Infinity, + }) + export const useActiveContracts = () => { const [activeContracts, setActiveContracts] = useState< Contract[] | undefined diff --git a/web/package.json b/web/package.json index 114ded1e..ba25a6e1 100644 --- a/web/package.json +++ b/web/package.json @@ -48,6 +48,7 @@ "nanoid": "^3.3.4", "next": "12.2.5", "node-fetch": "3.2.4", + "prosemirror-state": "1.4.1", "react": "17.0.2", "react-beautiful-dnd": "13.1.1", "react-confetti": "6.0.1",