diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 282692528d..e2431ad7fc 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -58,6 +58,7 @@ "are_you_affiliated_with_an_institution": "", "are_you_getting_an_undefined_control_sequence_error": "", "are_you_still_at": "", + "are_you_sure": "", "ascending": "", "ask_proj_owner_to_upgrade_for_full_history": "", "ask_proj_owner_to_upgrade_for_longer_compiles": "", @@ -425,6 +426,7 @@ "history_view_a11y_description": "", "history_view_all": "", "history_view_labels": "", + "hit_enter_to_reply": "", "hotkey_add_a_comment": "", "hotkey_autocomplete_menu": "", "hotkey_beginning_of_document": "", @@ -621,6 +623,7 @@ "new_to_latex_look_at": "", "newsletter": "", "next_payment_of_x_collectected_on_y": "", + "no_comments": "", "no_existing_password": "", "no_folder": "", "no_image_files_found": "", @@ -797,11 +800,13 @@ "replace_from_computer": "", "replace_from_project_files": "", "replace_from_url": "", + "reply": "", "repository_name": "", "republish": "", "resend": "", "resend_confirmation_email": "", "resending_confirmation_email": "", + "resolve": "", "resolved_comments": "", "restore_file": "", "restoring": "", diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/current-file-container.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/current-file-container.tsx index 8a9afb1107..6ecb25e170 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/current-file-container.tsx +++ b/services/web/frontend/js/features/source-editor/components/review-panel/current-file-container.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import ChangeEntry from './entries/change-entry' import AggregateChangeEntry from './entries/aggregate-change-entry' import CommentEntry from './entries/comment-entry' @@ -5,9 +6,12 @@ import AddCommentEntry from './entries/add-comment-entry' import BulkActionsEntry from './entries/bulk-actions-entry' import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context' import useCodeMirrorContentHeight from '../../hooks/use-codemirror-content-height' +import { ReviewPanelEntry } from '../../../../../../types/review-panel/entry' +import { ThreadId } from '../../../../../../types/review-panel/review-panel' function CurrentFileContainer() { - const { entries, openDocId, permissions } = useReviewPanelValueContext() + const { commentThreads, entries, openDocId, permissions, loadingThreads } = + useReviewPanelValueContext() const contentHeight = useCodeMirrorContentHeight() console.log('Review panel got content height', contentHeight) @@ -15,6 +19,12 @@ function CurrentFileContainer() { const currentDocEntries = openDocId && openDocId in entries ? entries[openDocId] : undefined + const objectEntries = useMemo(() => { + return Object.entries(currentDocEntries || {}) as Array< + [ThreadId, ReviewPanelEntry] + > + }, [currentDocEntries]) + return (
- {currentDocEntries && - Object.entries(currentDocEntries).map(([id, entry]) => { + {openDocId && + objectEntries.map(([id, entry]) => { if (!entry.visible) { return null } @@ -40,8 +50,16 @@ function CurrentFileContainer() { return } - if (entry.type === 'comment') { - return + if (entry.type === 'comment' && !loadingThreads) { + return ( + + ) } if (entry.type === 'add-comment' && permissions.comment) { diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx index dad228e191..4aed2535ef 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx +++ b/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx @@ -1,7 +1,195 @@ +import { useState, useRef } from 'react' +import { useTranslation } from 'react-i18next' import EntryContainer from './entry-container' +import Comment from './comment' +import EntryActions from './entry-actions' +import AutoExpandingTextArea, { + resetHeight, +} from '../../../../../shared/components/auto-expanding-text-area' +import Icon from '../../../../../shared/components/icon' +import { + useReviewPanelUpdaterFnsContext, + useReviewPanelValueContext, +} from '../../../context/review-panel/review-panel-context' +import classnames from 'classnames' +import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry' +import { + DocId, + ReviewPanelCommentThreads, + ThreadId, +} from '../../../../../../../types/review-panel/review-panel' -function CommentEntry() { - return Comment entry +type CommentEntryProps = { + docId: DocId + entry: ReviewPanelCommentEntry + entryId: ThreadId + threads: ReviewPanelCommentThreads +} + +function CommentEntry({ docId, entry, entryId, threads }: CommentEntryProps) { + const { t } = useTranslation() + const { + permissions, + gotoEntry, + toggleReviewPanel, + resolveComment, + submitReply, + handleLayoutChange, + } = useReviewPanelValueContext() + const { setEntryHover } = useReviewPanelUpdaterFnsContext() + + const [replyContent, setReplyContent] = useState('') + const [animating, setAnimating] = useState(false) + const entryDivRef = useRef(null) + + const thread = + entry.thread_id in threads ? threads[entry.thread_id] : undefined + + const handleEntryClick = (e: React.MouseEvent) => { + const target = e.target as Element + if ( + [ + 'rp-entry', + 'rp-comment-loaded', + 'rp-comment-content', + 'rp-comment-reply', + 'rp-entry-metadata', + ].some(className => [...target.classList].includes(className)) + ) { + gotoEntry(docId, entry.offset) + } + } + + const handleAnimateAndCallOnResolve = () => { + setAnimating(true) + + if (entryDivRef.current) { + entryDivRef.current.style.top = '0' + } + + setTimeout(() => { + resolveComment(docId, entryId) + }, 350) + } + + const handleCommentReplyKeyPress = ( + e: React.KeyboardEvent + ) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault() + + if (replyContent.length) { + ;(e.target as HTMLTextAreaElement).blur() + submitReply(entry, replyContent) + setReplyContent('') + resetHeight(e) + } + } + } + + const handleOnReply = () => { + if (replyContent.length) { + submitReply(entry, replyContent) + setReplyContent('') + } + } + + if (!thread) { + return null + } + + return ( + + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
setEntryHover(true)} + onMouseLeave={() => setEntryHover(false)} + onClick={handleEntryClick} + > +
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+ +
+
+ {!thread.submitting && (!thread || thread.messages.length === 0) && ( +
{t('no_comments')}
+ )} +
+ {thread.messages.map(comment => ( + + ))} +
+ {thread.submitting && ( +
+ +
+ )} + {permissions.comment && ( +
+ setReplyContent(e.target.value)} + onKeyPress={handleCommentReplyKeyPress} + onClick={e => e.stopPropagation()} + onResize={handleLayoutChange} + placeholder={t('hit_enter_to_reply')} + value={replyContent} + /> +
+ )} + + {permissions.comment && permissions.write && ( + + {t('resolve')} + + )} + {permissions.comment && ( + + {t('reply')} + + )} + +
+
+ + ) } export default CommentEntry diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx new file mode 100644 index 0000000000..5553f8672a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx @@ -0,0 +1,119 @@ +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area' +import { formatTime } from '../../../../utils/format-date' +import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context' +import { + ReviewPanelCommentThread, + ReviewPanelCommentThreadMessage, + ThreadId, +} from '../../../../../../../types/review-panel/review-panel' + +type CommentProps = { + thread: ReviewPanelCommentThread + threadId: ThreadId + comment: ReviewPanelCommentThreadMessage +} + +function Comment({ thread, threadId, comment }: CommentProps) { + const { t } = useTranslation() + const { deleteComment, handleLayoutChange, saveEdit } = + useReviewPanelValueContext() + const [deleting, setDeleting] = useState(false) + const [editing, setEditing] = useState(false) + + const handleConfirmDelete = () => { + setDeleting(true) + handleLayoutChange() + } + + const handleDoDelete = () => { + setDeleting(false) + deleteComment(threadId, comment.id) + handleLayoutChange() + } + + const handleCancelDelete = () => { + setDeleting(false) + handleLayoutChange() + } + + const handleStartEditing = () => { + setEditing(true) + handleLayoutChange() + } + + const handleSaveEditOnEnter = ( + e: React.KeyboardEvent + ) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault() + handleSaveEdit(e) + } + } + + const handleSaveEdit = ( + e: + | React.FocusEvent + | React.KeyboardEvent + ) => { + setEditing(false) + saveEdit(threadId, comment.id, (e.target as HTMLTextAreaElement).value) + } + + return ( +
+

+ {editing ? ( + e.stopPropagation()} + onResize={handleLayoutChange} + /> + ) : ( + <> + + {comment.user.name}: + +   + {comment.content} + + )} +

+ {!editing && ( +
+ {!deleting && formatTime(comment.timestamp, 'MMM d, y h:mm a')} + {comment.user.isSelf && !deleting && ( + +  •  + + {thread.messages.length > 1 && ( + <> +  •  + + + )} + + )} + {comment.user.isSelf && deleting && ( + + {t('are_you_sure')} •  + +  •  + + + )} +
+ )} +
+ ) +} + +export default Comment diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/entry-actions.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/entry-actions.tsx new file mode 100644 index 0000000000..270214d417 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/review-panel/entries/entry-actions.tsx @@ -0,0 +1,19 @@ +import classnames from 'classnames' + +function EntryActions({ + className, + ...rest +}: React.ComponentPropsWithoutRef<'div'>) { + return
+} + +EntryActions.Button = function EntryActionsButton({ + className, + ...rest +}: React.ComponentPropsWithoutRef<'button'>) { + return ( +