'use client'; import React, { FC, useCallback, useEffect, useMemo, useRef, useState, ClipboardEvent, forwardRef, useImperativeHandle, Fragment, } from 'react'; import clsx from 'clsx'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import EmojiPicker from 'emoji-picker-react'; import { Theme } from 'emoji-picker-react'; import { BoldText } from '@gitroom/frontend/components/new-launch/bold.text'; import { UText } from '@gitroom/frontend/components/new-launch/u.text'; import { SignatureBox } from '@gitroom/frontend/components/signature'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { SelectedIntegrations, useLaunchStore, } from '@gitroom/frontend/components/new-launch/store'; import { useShallow } from 'zustand/react/shallow'; import { AddPostButton } from '@gitroom/frontend/components/new-launch/add.post.button'; import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.component'; import { UpDownArrow } from '@gitroom/frontend/components/launches/up.down.arrow'; import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data'; import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core'; import { useDropzone } from 'react-dropzone'; import { useUppyUploader } from '@gitroom/frontend/components/media/new.uploader'; import { Dashboard } from '@uppy/react'; import Link from '@tiptap/extension-link'; import { useEditor, EditorContent, Extension, mergeAttributes, Node, } from '@tiptap/react'; import Document from '@tiptap/extension-document'; import Bold from '@tiptap/extension-bold'; import Text from '@tiptap/extension-text'; import Paragraph from '@tiptap/extension-paragraph'; import Underline from '@tiptap/extension-underline'; import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation'; import { History } from '@tiptap/extension-history'; import { BulletList, ListItem } from '@tiptap/extension-list'; import { Bullets } from '@gitroom/frontend/components/new-launch/bullets.component'; import Heading from '@tiptap/extension-heading'; import { HeadingComponent } from '@gitroom/frontend/components/new-launch/heading.component'; import Mention from '@tiptap/extension-mention'; import { suggestion } from '@gitroom/frontend/components/new-launch/mention.component'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { AComponent } from '@gitroom/frontend/components/new-launch/a.component'; import { capitalize } from 'lodash'; const InterceptBoldShortcut = Extension.create({ name: 'preventBoldWithUnderline', addKeyboardShortcuts() { return { 'Mod-b': () => { // For example, toggle bold while removing underline this?.editor?.commands?.unsetUnderline(); return this?.editor?.commands?.toggleBold(); }, }; }, }); const InterceptUnderlineShortcut = Extension.create({ name: 'preventUnderlineWithUnderline', addKeyboardShortcuts() { return { 'Mod-u': () => { // For example, toggle bold while removing underline this?.editor?.commands?.unsetBold(); return this?.editor?.commands?.toggleUnderline(); }, }; }, }); export const EditorWrapper: FC<{ totalPosts: number; value: string; }> = (props) => { const { setGlobalValueText, setInternalValueText, addRemoveInternal, internal, global, current, addInternalValue, addGlobalValue, setInternalValueMedia, appendInternalValueMedia, appendGlobalValueMedia, setGlobalValueMedia, changeOrderGlobal, changeOrderInternal, isCreateSet, deleteGlobalValue, deleteInternalValue, setGlobalValue, setInternalValue, internalFromAll, totalChars, postComment, dummy, editor, loadedState, setLoadedState, selectedIntegration, chars, } = useLaunchStore( useShallow((state) => ({ internal: state.internal.find((p) => p.integration.id === state.current), internalFromAll: state.integrations.find((p) => p.id === state.current), global: state.global, current: state.current, addRemoveInternal: state.addRemoveInternal, dummy: state.dummy, setInternalValueText: state.setInternalValueText, setGlobalValueText: state.setGlobalValueText, addInternalValue: state.addInternalValue, addGlobalValue: state.addGlobalValue, setGlobalValueMedia: state.setGlobalValueMedia, setInternalValueMedia: state.setInternalValueMedia, changeOrderGlobal: state.changeOrderGlobal, changeOrderInternal: state.changeOrderInternal, isCreateSet: state.isCreateSet, deleteGlobalValue: state.deleteGlobalValue, deleteInternalValue: state.deleteInternalValue, setGlobalValue: state.setGlobalValue, setInternalValue: state.setInternalValue, totalChars: state.totalChars, appendInternalValueMedia: state.appendInternalValueMedia, appendGlobalValueMedia: state.appendGlobalValueMedia, postComment: state.postComment, editor: state.editor, loadedState: state.loaded, setLoadedState: state.setLoaded, selectedIntegration: state.selectedIntegrations, chars: state.chars, })) ); const existingData = useExistingData(); const [loaded, setLoaded] = useState(true); useEffect(() => { if (loaded && loadedState) { return; } setLoadedState(true); setLoaded(true); }, [loaded, loadedState]); const canEdit = useMemo(() => { return current === 'global' || !!internal; }, [current, internal]); const items = useMemo(() => { if (internal) { return internal.integrationValue; } return global; }, [internal, global]); const setValue = useCallback( (value: string[]) => { const newValue = value.map((p, index) => { return { id: makeId(10), ...(items?.[index]?.media ? { media: items[index].media } : { media: [] }), content: p, }; }); if (internal) { return setInternalValue(current, newValue); } return setGlobalValue(newValue); }, [internal, items] ); useCopilotReadable({ description: 'Current content of posts', value: items.map((p) => p.content), }); useCopilotAction({ name: 'setPosts', description: 'a thread of posts', parameters: [ { name: 'content', type: 'string[]', description: 'a thread of posts', }, ], handler: async ({ content }) => { setValue(content); }, }); const changeValue = useCallback( (index: number) => (value: string) => { if (internal) { return setInternalValueText(current, index, value); } return setGlobalValueText(index, value); }, [current, global, internal] ); const changeImages = useCallback( (index: number) => (value: any[]) => { if (internal) { return setInternalValueMedia(current, index, value); } return setGlobalValueMedia(index, value); }, [current, global, internal] ); const appendImages = useCallback( (index: number) => (value: any[]) => { if (internal) { return appendInternalValueMedia(current, index, value); } return appendGlobalValueMedia(index, value); }, [current, global, internal] ); const changeOrder = useCallback( (index: number) => (direction: 'up' | 'down') => { if (internal) { changeOrderInternal(current, index, direction); return setLoaded(false); } changeOrderGlobal(index, direction); setLoaded(false); }, [changeOrderInternal, changeOrderGlobal, current, global, internal] ); const goBackToGlobal = useCallback(async () => { if ( await deleteDialog( 'This action is irreversible. Are you sure you want to go back to global mode?', 'Yes, go back to global mode' ) ) { setLoaded(false); addRemoveInternal(current); } }, [addRemoveInternal, current]); const addValue = useCallback( (index: number) => () => { if (internal) { return addInternalValue(index, current, [ { content: '', id: makeId(10), media: [], }, ]); } return addGlobalValue(index, [ { content: '', id: makeId(10), media: [], }, ]); }, [current, global, internal] ); const deletePost = useCallback( (index: number) => async () => { if ( !(await deleteDialog( 'Are you sure you want to delete this post?', 'Yes, delete it!' )) ) { return; } if (internal) { deleteInternalValue(current, index); return setLoaded(false); } deleteGlobalValue(index); setLoaded(false); }, [current, global, internal] ); if (!loaded || !loadedState) { return null; } return (
{items.map((g, index) => (
{!canEdit && !isCreateSet && (
{ if (index !== 0) { return; } setLoaded(false); addRemoveInternal(current); }} className="select-none cursor-pointer absolute w-full h-full left-0 top-0 bg-red-600/10 z-[100]" > {index === 0 && (
Edit
)}
)}
{canEdit ? ( ) : (
)} } />
{index === 0 && current !== 'global' && canEdit && !existingData.integration && ( )} {items.length > 1 && ( )}
))}
); }; export const Editor: FC<{ editorType?: 'normal' | 'markdown' | 'html'; totalPosts: number; value: string; num?: number; pictures?: any[]; allValues?: any[]; onChange: (value: string) => void; setImages?: (value: any[]) => void; appendImages?: (value: any[]) => void; autoComplete?: boolean; validateChars?: boolean; identifier?: string; totalChars?: number; selectedIntegration: SelectedIntegrations[]; dummy: boolean; chars: Record; childButton?: React.ReactNode; }> = (props) => { const { editorType = 'normal', allValues, pictures, setImages, num, validateChars, identifier, appendImages, selectedIntegration, dummy, chars, childButton, } = props; const user = useUser(); const [id] = useState(makeId(10)); const newRef = useRef(null); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const t = useT(); const editorRef = useRef(); const uppy = useUppyUploader({ onUploadSuccess: (result: any) => { appendImages(result); uppy.clear(); }, allowedFileTypes: 'image/*,video/mp4', }); const onDrop = useCallback( (acceptedFiles: File[]) => { for (const file of acceptedFiles) { uppy.addFile(file); } }, [uppy] ); const paste = useCallback( async (event: ClipboardEvent | File[]) => { // @ts-ignore const clipboardItems = event.clipboardData?.items; if (!clipboardItems) { return; } // @ts-ignore for (const item of clipboardItems) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) { uppy.addFile(file); } } } }, [uppy] ); const { getRootProps, isDragActive } = useDropzone({ onDrop }); const valueWithoutHtml = useMemo(() => { return stripHtmlValidation('normal', props.value || '', true); }, [props.value]); const addText = useCallback( (emoji: string) => { editorRef?.current?.editor?.commands?.insertContent(emoji); editorRef?.current?.editor?.commands?.focus(); }, [props.value, id] ); return (
{(editorType === 'markdown' || editorType === 'html') && identifier !== 'telegram' && ( <> )}
setEmojiPickerOpen(!emojiPickerOpen)} > {'\uD83D\uDE00'}
{ addText(e.emoji); setEmojiPickerOpen(false); }} open={emojiPickerOpen} />
{validateChars && props.value.length === 0 && pictures?.length === 0 && (
Your post should have at least one character or one image.
)}
Drop your files here to upload
{ if (editorRef?.current?.editor?.isFocused) { return; } editorRef?.current?.editor?.commands?.focus('end'); }} >
{ if (editorRef?.current?.editor?.isFocused) { return; } editorRef?.current?.editor?.commands?.focus('end'); }} > {setImages && ( { setImages(value.target.value); }} onOpen={() => {}} onClose={() => {}} /> )}
{childButton}
{(props?.totalChars || 0) > 0 ? (
props.totalChars && '!text-red-500' )} > {valueWithoutHtml.length}/{props.totalChars}
) : (
{selectedIntegration?.map((p) => (
chars?.[p.integration.id] && '!text-red-500' } > {p.integration.name} ({capitalize(p.integration.identifier)} ):
chars?.[p.integration.id] && '!text-red-500' } > {valueWithoutHtml.length}/{chars?.[p.integration.id]}
))}
)}
); }; export const OnlyEditor = forwardRef< any, { editorType: 'normal' | 'markdown' | 'html'; value: string; onChange: (value: string) => void; paste?: (event: ClipboardEvent | File[]) => void; } >(({ editorType, value, onChange, paste }, ref) => { const fetch = useFetch(); const { internal } = useLaunchStore( useShallow((state) => ({ internal: state.internal.find((p) => p.integration.id === state.current), })) ); const loadList = useCallback( async (query: string) => { if (query.length < 2) { return []; } if (!internal?.integration.id) { return []; } try { const load = await fetch('/integrations/mentions', { method: 'POST', body: JSON.stringify({ name: 'mention', id: internal.integration.id, data: { query }, }), }); const result = await load.json(); return result; } catch (error) { console.error('Error loading mentions:', error); return []; } }, [internal, fetch] ); const editor = useEditor({ extensions: [ Document, Paragraph, Text, Underline, Bold, InterceptBoldShortcut, InterceptUnderlineShortcut, BulletList, ListItem, Link.configure({ openOnClick: false, autolink: true, defaultProtocol: 'https', protocols: ['http', 'https'], isAllowedUri: (url, ctx) => { try { // construct URL const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`); // use default validation if (!ctx.defaultValidate(parsedUrl.href)) { return false; } // disallowed protocols const disallowedProtocols = ['ftp', 'file', 'mailto']; const protocol = parsedUrl.protocol.replace(':', ''); if (disallowedProtocols.includes(protocol)) { return false; } // only allow protocols specified in ctx.protocols const allowedProtocols = ctx.protocols.map((p) => typeof p === 'string' ? p : p.scheme ); if (!allowedProtocols.includes(protocol)) { return false; } // all checks have passed return true; } catch { return false; } }, shouldAutoLink: (url) => { try { // construct URL const parsedUrl = url.includes(':') ? new URL(url) : new URL(`https://${url}`); // only auto-link if the domain is not in the disallowed list const disallowedDomains = [ 'example-no-autolink.com', 'another-no-autolink.com', ]; const domain = parsedUrl.hostname; return !disallowedDomains.includes(domain); } catch { return false; } }, }), ...(internal?.integration?.id ? [ Mention.configure({ HTMLAttributes: { class: 'mention', }, renderHTML({ options, node }) { return [ 'span', mergeAttributes(options.HTMLAttributes, { 'data-mention-id': node.attrs.id || '', 'data-mention-label': node.attrs.label || '', }), `@${node.attrs.label}`, ]; }, suggestion: suggestion(loadList), }), ] : []), Heading.configure({ levels: [1, 2, 3], }), History.configure({ depth: 100, // default is 100 newGroupDelay: 100, // default is 500ms }), ], content: value || '', shouldRerenderOnTransaction: true, immediatelyRender: false, // @ts-ignore onPaste: paste, onUpdate: (innerProps) => { onChange?.(innerProps.editor.getHTML()); }, }); useImperativeHandle(ref, () => ({ editor, })); return ; });