import type { ReactNode, JSX } from 'react';
import React, {
useCallback, useState, useEffect, useLayoutEffect,
useMemo,
} from 'react';
import { GlobalCodeMirrorEditorKey } from '@growi/editor';
import { CodeMirrorEditorComment } from '@growi/editor/dist/client/components/CodeMirrorEditorComment';
import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
import { UserPicture } from '@growi/ui/dist/components';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
import {
TabContent, TabPane,
} from 'reactstrap';
import { uploadAttachments } from '~/client/services/upload-attachments';
import { toastError } from '~/client/util/toastr';
import { useCurrentUser } from '~/states/global';
import { useCurrentPagePath } from '~/states/page';
import { isSlackConfiguredAtom } from '~/states/server-configurations';
import { useAcceptedUploadFileType } from '~/stores-universal/context';
import { useNextThemes } from '~/stores-universal/use-next-themes';
import { useSWRxPageComment } from '~/stores/comment';
import {
useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning, useEditorSettings,
} from '~/stores/editor';
import { useCommentEditorDirtyMap } from '~/stores/ui';
import loggerFactory from '~/utils/logger';
import { NotAvailableForGuest } from '../NotAvailableForGuest';
import { NotAvailableIfReadOnlyUserNotAllowedToComment } from '../NotAvailableForReadOnlyUser';
import { CommentPreview } from './CommentPreview';
import { SwitchingButtonGroup } from './SwitchingButtonGroup';
import '@growi/editor/dist/style.css';
import styles from './CommentEditor.module.scss';
const logger = loggerFactory('growi:components:CommentEditor');
const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
const CommentEditorLayout = ({ children }: { children: ReactNode }): JSX.Element => {
return (
);
};
type CommentEditorProps = {
pageId: string,
replyTo?: string,
revisionId: string,
currentCommentId?: string,
commentBody?: string,
onCanceled?: () => void,
onCommented?: () => void,
}
export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
const {
pageId, replyTo, revisionId,
currentCommentId, commentBody, onCanceled, onCommented,
} = props;
const currentUser = useCurrentUser();
const currentPagePath = useCurrentPagePath();
const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
const isSlackConfigured = useAtomValue(isSlackConfiguredAtom);
const { data: editorSettings } = useEditorSettings();
const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
const {
evaluate: evaluateEditorDirtyMap,
clean: cleanEditorDirtyMap,
} = useCommentEditorDirtyMap();
const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
const { resolvedTheme } = useNextThemes();
mutateResolvedTheme({ themeData: resolvedTheme });
const editorKey = useMemo(() => {
if (replyTo != null) {
return `comment_replyTo_${replyTo}`;
}
if (currentCommentId != null) {
return `comment_edit_${currentCommentId}`;
}
return GlobalCodeMirrorEditorKey.COMMENT_NEW;
}, [currentCommentId, replyTo]);
const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
const [showPreview, setShowPreview] = useState(false);
const [error, setError] = useState();
const [slackChannels, setSlackChannels] = useState('');
const { t } = useTranslation('');
const handleSelect = useCallback((showPreview: boolean) => {
setShowPreview(showPreview);
}, []);
// DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
const slackChannelsDataString = slackChannelsData?.toString();
const initializeSlackEnabled = useCallback(() => {
setSlackChannels(slackChannelsDataString ?? '');
mutateIsSlackEnabled(false);
}, [mutateIsSlackEnabled, slackChannelsDataString]);
useEffect(() => {
initializeSlackEnabled();
}, [initializeSlackEnabled]);
const isSlackEnabledToggleHandler = (isSlackEnabled: boolean) => {
mutateIsSlackEnabled(isSlackEnabled, false);
};
const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
setSlackChannels(slackChannels);
}, []);
const initializeEditor = useCallback(async() => {
const dirtyNum = await cleanEditorDirtyMap(editorKey);
mutateIsEnabledUnsavedWarning(dirtyNum > 0);
setShowPreview(false);
setError(undefined);
initializeSlackEnabled();
}, [editorKey, cleanEditorDirtyMap, mutateIsEnabledUnsavedWarning, initializeSlackEnabled]);
const cancelButtonClickedHandler = useCallback(() => {
initializeEditor();
onCanceled?.();
}, [onCanceled, initializeEditor]);
const postCommentHandler = useCallback(async() => {
const commentBodyToPost = codeMirrorEditor?.getDocString() ?? '';
try {
if (currentCommentId != null) {
// update current comment
await updateComment(commentBodyToPost, revisionId, currentCommentId);
}
else {
// post new comment
const postCommentArgs = {
commentForm: {
comment: commentBodyToPost,
revisionId,
replyTo,
},
slackNotificationForm: {
isSlackEnabled,
slackChannels,
},
};
await postComment(postCommentArgs);
}
initializeEditor();
onCommented?.();
// Insert empty string as new comment editor is opened after comment
codeMirrorEditor?.initDoc('');
}
catch (err) {
const errorMessage = err.message || 'An unknown error occured when posting comment';
setError(errorMessage);
}
// eslint-disable-next-line max-len
}, [currentCommentId, initializeEditor, onCommented, codeMirrorEditor, updateComment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment]);
// the upload event handler
const uploadHandler = useCallback((files: File[]) => {
uploadAttachments(pageId, files, {
onUploaded: (attachment) => {
const fileName = attachment.originalName;
const prefix = attachment.fileFormat.startsWith('image/')
? '!' // use "" syntax when image
: '';
const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
codeMirrorEditor?.insertText(insertText);
},
onError: (error) => {
toastError(error);
},
});
}, [codeMirrorEditor, pageId]);
const cmProps = useMemo(() => ({
onChange: async(value: string) => {
const dirtyNum = await evaluateEditorDirtyMap(editorKey, value);
mutateIsEnabledUnsavedWarning(dirtyNum > 0);
},
}), [editorKey, evaluateEditorDirtyMap, mutateIsEnabledUnsavedWarning]);
// initialize CodeMirrorEditor
useEffect(() => {
if (commentBody == null) {
return;
}
codeMirrorEditor?.initDoc(commentBody);
}, [codeMirrorEditor, commentBody]);
// set handler to focus
useLayoutEffect(() => {
if (showPreview) return;
codeMirrorEditor?.focus();
}, [codeMirrorEditor, showPreview]);
const errorMessage = useMemo(() => {error}, [error]);
const cancelButton = useMemo(() => (
), [cancelButtonClickedHandler, t]);
const submitButton = useMemo(() => {
return (
);
}, [postCommentHandler, t]);
return (
{t('page_comment.add_a_comment')}
{errorMessage && errorMessage}
{isSlackConfigured && isSlackEnabled != null
&& (
)
}
{cancelButton}{submitButton}
{error && errorMessage}
{cancelButton}{submitButton}
);
};
export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
const { onCommented, onCanceled, ...rest } = props;
const currentUser = useCurrentUser();
const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
const { resolvedTheme } = useNextThemes();
mutateResolvedTheme({ themeData: resolvedTheme });
const [isReadyToUse, setIsReadyToUse] = useState(false);
const { t } = useTranslation('');
const render = useCallback((): JSX.Element => {
return (
);
}, [currentUser, t]);
return isReadyToUse
? (
{
onCommented?.();
setIsReadyToUse(false);
}}
onCanceled={() => {
onCanceled?.();
setIsReadyToUse(false);
}}
{...rest}
/>
)
: render();
};