'use client'; import React, { FC, useCallback, useEffect, useMemo, useRef, useState, ClipboardEvent, forwardRef, useImperativeHandle, } from 'react'; import clsx from 'clsx'; 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, } 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 { Placeholder } from '@tiptap/extensions'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { InformationComponent } from '@gitroom/frontend/components/launches/information.component'; import { LockIcon, ConnectionLineIcon, ResetIcon, TrashIcon, EmojiIcon, DelayIcon, } from '@gitroom/frontend/components/ui/icons'; import { DelayComponent } from '@gitroom/frontend/components/new-launch/delay.component'; const MAX_UPLOAD_SIZE = 1024 * 1024 * 1024; // 1 GB 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; }> = () => { const t = useT(); const { setGlobalValueText, setInternalValueText, addRemoveInternal, internal, global, current, addInternalValue, addGlobalValue, setInternalValueMedia, appendInternalValueMedia, appendGlobalValueMedia, setGlobalValueMedia, changeOrderGlobal, changeOrderInternal, isCreateSet, deleteGlobalValue, deleteInternalValue, setGlobalValue, setInternalValue, setInternalDelay, setGlobalDelay, internalFromAll, totalChars, postComment, dummy, editor, loadedState, setLoadedState, selectedIntegration, chars, comments, } = 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, comments: state.comments, 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, setGlobalDelay: state.setGlobalDelay, setInternalDelay: state.setInternalDelay, 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), delay: 0, ...(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( t( 'are_you_sure_go_back_to_global_mode', 'This action is irreversible. Are you sure you want to go back to global mode?' ), t('yes_go_back_to_global_mode', 'Yes, go back to global mode') ) ) { setLoaded(false); addRemoveInternal(current); } }, [addRemoveInternal, current, t]); const addValue = useCallback( (index: number) => () => { setTimeout(() => { // scroll the the bottom document.querySelector('#social-content').scrollTo({ top: document.querySelector('#social-content').scrollHeight, }); }, 20); if (internal) { return addInternalValue(index, current, [ { delay: 0, content: '', id: makeId(10), media: [], }, ]); } return addGlobalValue(index, [ { delay: 0, content: '', id: makeId(10), media: [], }, ]); }, [current, global, internal] ); const deletePost = useCallback( (index: number) => async () => { if ( !(await deleteDialog( t( 'are_you_sure_delete_this_post', 'Are you sure you want to delete this post?' ), t('yes_delete_it', 'Yes, delete it!') )) ) { return; } if (internal) { deleteInternalValue(current, index); return setLoaded(false); } deleteGlobalValue(index); setLoaded(false); }, [current, global, internal, t] ); if (!loaded || !loadedState) { return null; } return (
{isCreateSet && current !== 'global' && ( <>
{t( 'cant_edit_networks_when_creating_set', "You can't edit networks when creating a set" )}
)} {!canEdit && !isCreateSet && ( <>
{ setLoaded(false); addRemoveInternal(current); }} className="text-center absolute w-full h-full p-[20px] left-0 top-0 items-center justify-center flex z-[101] flex-col gap-[16px]" >
{t( 'click_to_exit_global_editing', 'Click this button to exit global editing and customize the post for this channel' )}
{t('edit_content', 'Edit content')}
)} {items.map((g, index) => (
0) || (!comments && index > 0)) && 'hidden' )} >
{index > 0 && (
)} {(canEdit && items.length - 1 === index) || !comments ? (
{comments && ( )}
{!!internal && !existingData?.integration && (
{t( 'editing_a_specific_network', 'Editing a Specific Network' )}
{t('back_to_global', 'Back to global')}
)}
) : null} } />
{comments && (
{items.length > 1 && ( )} {index > 0 && ( )}
)}
))}
); }; export const Editor: FC<{ editorType?: 'none' | '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; comments: boolean | 'no-media'; identifier?: string; totalChars?: number; selectedIntegration: SelectedIntegrations[]; dummy: boolean; chars: Record; childButton?: React.ReactNode; }> = (props) => { const { editorType = 'normal', allValues, pictures, setImages, num, identifier, appendImages, dummy, chars, childButton, comments, } = props; const [id] = useState(makeId(10)); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const t = useT(); const toaster = useToaster(); const editorRef = useRef(undefined); const [loading, setLoading] = useState(false); const uppy = useUppyUploader({ onUploadSuccess: (result: any) => { appendImages(result); uppy.clear(); }, allowedFileTypes: 'image/*,video/mp4', onStart: () => {}, onEnd: () => setLoading(false), }); const onDrop = useCallback( (acceptedFiles: File[]) => { const totalSize = acceptedFiles.reduce((acc, file) => acc + file.size, 0); if (totalSize > MAX_UPLOAD_SIZE) { toaster.show( t( 'upload_size_limit_exceeded', 'Upload size limit exceeded. Maximum 1 GB per upload session.' ), 'warning' ); return; } setLoading(true); for (const file of acceptedFiles) { uppy.addFile(file); } }, [uppy, toaster, t] ); const paste = useCallback( async (event: ClipboardEvent | File[]) => { if (num > 0 && comments === 'no-media') { return; } // @ts-ignore const clipboardItems = event.clipboardData?.items; if (!clipboardItems) { return; } const files: File[] = []; // @ts-ignore for (const item of clipboardItems) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) { files.push(file); } } } const totalSize = files.reduce((acc, file) => acc + file.size, 0); if (totalSize > MAX_UPLOAD_SIZE) { toaster.show( t( 'upload_size_limit_exceeded', 'Upload size limit exceeded. Maximum 1 GB per upload session.' ), 'warning' ); return; } if (files.length > 0) { setLoading(true); } for (const file of files) { uppy.addFile(file); } }, [uppy, num, comments, toaster, t] ); const { getRootProps, isDragActive } = useDropzone({ onDrop: (files) => { if (loading) { toaster.show( 'Upload current in progress, please wait and then try again.', 'warning' ); return; } onDrop(files); }, noDrag: num > 0 && comments === 'no-media', }); 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] ); const [loadedEditor, setLoadedEditor] = useState(editorType); const [showEditor, setShowEditor] = useState(true); useEffect(() => { if (editorType === loadedEditor) { return; } setLoadedEditor(editorType); setShowEditor(false); }, [editorType]); useEffect(() => { if (showEditor) { return; } setTimeout(() => { setShowEditor(true); }, 20); }, [showEditor]); if (!showEditor) { return null; } return (
0 && '!rounded-bs-[0]' )} id={id} >
{t('drop_files_here_to_upload', '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 && ( 0 && comments === 'no-media'} allData={allValues} text={valueWithoutHtml} label={t('attachments', 'Attachments')} description="" value={props.pictures} dummy={dummy} name="image" information={ 0} chars={chars} totalChars={valueWithoutHtml.length} totalAllowedChars={props.totalChars} text={valueWithoutHtml} /> } toolBar={
{editorType !== 'none' && ( <> )} {(editorType === 'markdown' || editorType === 'html') && identifier !== 'telegram' && ( <> )}
setEmojiPickerOpen(!emojiPickerOpen)} >
1 ? 'top-[35px]' : 'bottom-[35px]' )} > { addText(e.emoji); setEmojiPickerOpen(false); }} open={emojiPickerOpen} />
} onChange={(value) => { setImages(value.target.value); }} onOpen={() => {}} onClose={() => {}} /> )}
{childButton}
); }; export const OnlyEditor = forwardRef< any, { editorType: 'none' | 'normal' | 'markdown' | 'html'; value: string; onChange: (value: string) => void; paste?: (event: ClipboardEvent | File[]) => void; } >(({ editorType, value, onChange, paste }, ref) => { const t = useT(); 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, Placeholder.configure({ placeholder: t('write_something', 'Write something …'), emptyEditorClass: 'is-editor-empty', }), ...(editorType === 'html' || editorType === 'markdown' ? [ Link.configure({ openOnClick: false, autolink: true, defaultProtocol: 'https', protocols: ['http', 'https'], isAllowedUri: (url, ctx) => { try { // prevent transforming plain emails like foo@bar.com into links const trimmed = String(url).trim(); const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (emailPattern.test(trimmed)) { return false; } // 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 { // prevent auto-linking of plain emails like foo@bar.com const trimmed = String(url).trim(); const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (emailPattern.test(trimmed)) { return false; } // 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), }), ] : []), ...(editorType === 'html' || editorType === 'markdown' ? [ 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 ; });