Fix accept changes request for new review panel (#20956)

* Fix accept changes request for new review panel

* Implement pessimistic UI updates for new review panel (#20986)

* Implement pessimistic UI updates for new review panel

* deleteThread, reopenThread handlers

* use finally

* handleSubmit in useCallback

GitOrigin-RevId: 358181a6b5601ad1b3579e001564dfa4da67a81d
This commit is contained in:
Domagoj Kriskovic 2024-10-10 15:01:30 +02:00 committed by Copybot
parent 42a199043a
commit 4a3eed35d6
13 changed files with 313 additions and 137 deletions

View file

@ -29,6 +29,8 @@
"accept_all": "",
"accept_and_continue": "",
"accept_change": "",
"accept_change_error_description": "",
"accept_change_error_title": "",
"accept_invitation": "",
"accept_or_reject_each_changes_individually": "",
"accept_terms_and_conditions": "",
@ -53,6 +55,8 @@
"add_another_token": "",
"add_comma_separated_emails_help": "",
"add_comment": "",
"add_comment_error_message": "",
"add_comment_error_title": "",
"add_company_details": "",
"add_email_address": "",
"add_email_to_claim_features": "",
@ -303,6 +307,8 @@
"delete_authentication_token_info": "",
"delete_certificate": "",
"delete_comment": "",
"delete_comment_error_message": "",
"delete_comment_error_title": "",
"delete_comment_message": "",
"delete_comment_thread": "",
"delete_comment_thread_message": "",
@ -384,6 +390,8 @@
"easily_import_and_sync_your_references": "",
"easily_manage_your_project_files_everywhere": "",
"edit": "",
"edit_comment_error_message": "",
"edit_comment_error_title": "",
"edit_dictionary": "",
"edit_dictionary_empty": "",
"edit_dictionary_remove": "",
@ -1181,6 +1189,8 @@
"rename": "",
"rename_project": "",
"reopen": "",
"reopen_comment_error_message": "",
"reopen_comment_error_title": "",
"replace_figure": "",
"replace_from_another_project": "",
"replace_from_computer": "",
@ -1201,6 +1211,8 @@
"resize": "",
"resolve": "",
"resolve_comment": "",
"resolve_comment_error_message": "",
"resolve_comment_error_title": "",
"resolved_comments": "",
"restore": "",
"restore_file": "",

View file

@ -13,6 +13,8 @@ import { Button } from 'react-bootstrap'
import { ReviewPanelEntry } from './review-panel-entry'
import { ThreadId } from '../../../../../types/review-panel/review-panel'
import { Decoration } from '@codemirror/view'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { debugConsole } from '@/utils/debugging'
export const ReviewPanelAddComment: FC<{
docId: string
@ -25,8 +27,8 @@ export const ReviewPanelAddComment: FC<{
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
const { addComment } = useThreadsActionsContext()
const [error, setError] = useState<Error>()
const [submitting, setSubmitting] = useState(false)
const { showGenericMessageModal } = useModalsContext()
const handleClose = useCallback(() => {
view.dispatch({
@ -35,22 +37,27 @@ export const ReviewPanelAddComment: FC<{
}, [view, value])
const submitForm = useCallback(
message => {
async message => {
setSubmitting(true)
const content = view.state.sliceDoc(from, to)
addComment(from, content, message)
.catch(setError)
.finally(() => setSubmitting(false))
view.dispatch({
selection: EditorSelection.cursor(view.state.selection.main.anchor),
})
handleClose()
try {
await addComment(from, content, message)
handleClose()
view.dispatch({
selection: EditorSelection.cursor(view.state.selection.main.anchor),
})
} catch (err) {
debugConsole.error(err)
showGenericMessageModal(
t('add_comment_error_title'),
t('add_comment_error_message')
)
}
setSubmitting(false)
},
[addComment, view, handleClose, from, to]
[addComment, view, handleClose, from, to, showGenericMessageModal, t]
)
const { handleChange, handleKeyPress, content } =
@ -123,6 +130,7 @@ export const ReviewPanelAddComment: FC<{
t: value.spec.id as ThreadId,
}}
selectLineOnFocus={false}
disabled={submitting}
>
<form
className="review-panel-entry-content"
@ -158,7 +166,6 @@ export const ReviewPanelAddComment: FC<{
{t('comment')}
</Button>
</div>
{error && <div>{error.message}</div>}
</form>
</ReviewPanelEntry>
)

View file

@ -1,4 +1,4 @@
import { memo } from 'react'
import { memo, useCallback, useState } from 'react'
import { useRangesActionsContext } from '../context/ranges-context'
import {
Change,
@ -15,6 +15,7 @@ import { formatTimeBasedOnYear } from '@/features/utils/format-date'
import { useChangesUsersContext } from '../context/changes-users-context'
import { ReviewPanelChangeUser } from './review-panel-change-user'
import { ReviewPanelEntry } from './review-panel-entry'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
export const ReviewPanelChange = memo<{
change: Change<EditOperation>
@ -42,6 +43,27 @@ export const ReviewPanelChange = memo<{
const { acceptChanges, rejectChanges } = useRangesActionsContext()
const permissions = usePermissionsContext()
const changesUsers = useChangesUsersContext()
const { showGenericMessageModal } = useModalsContext()
const [accepting, setAccepting] = useState(false)
const acceptHandler = useCallback(async () => {
setAccepting(true)
try {
if (aggregate) {
await acceptChanges(change.id, aggregate.id)
} else {
await acceptChanges(change.id)
}
} catch (err) {
showGenericMessageModal(
t('accept_change_error_title'),
t('accept_change_error_description')
)
} finally {
setAccepting(false)
}
}, [acceptChanges, aggregate, change.id, showGenericMessageModal, t])
if (!changesUsers) {
// if users are not loaded yet, do not show "Unknown" user
@ -61,6 +83,7 @@ export const ReviewPanelChange = memo<{
position={change.op.p}
docId={docId}
hoverRanges={hoverRanges}
disabled={accepting}
>
<div
className="review-panel-entry-indicator"
@ -92,14 +115,7 @@ export const ReviewPanelChange = memo<{
description={t('accept_change')}
tooltipProps={{ className: 'review-panel-tooltip' }}
>
<Button
onClick={() =>
aggregate
? acceptChanges(change.id, aggregate.id)
: acceptChanges(change.id)
}
bsStyle={null}
>
<Button onClick={acceptHandler} bsStyle={null}>
<MaterialIcon
type="check"
className="review-panel-entry-actions-icon"

View file

@ -1,100 +1,98 @@
import { Dispatch, memo, SetStateAction, useCallback, useState } from 'react'
import { memo, useCallback } from 'react'
import { Change, CommentOperation } from '../../../../../types/change'
import { ReviewPanelMessage } from './review-panel-message'
import { useTranslation } from 'react-i18next'
import {
useThreadsActionsContext,
useThreadsContext,
} from '../context/threads-context'
import { useThreadsContext } from '../context/threads-context'
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
import ReviewPanelResolvedMessage from './review-panel-resolved-message'
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
import useSubmittableTextInput from '../hooks/use-submittable-text-input'
import { CommentId } from '../../../../../types/review-panel/review-panel'
export const ReviewPanelCommentContent = memo<{
comment: Change<CommentOperation>
isResolved: boolean
onEdit?: (commentId: CommentId, content: string) => Promise<void>
onReply?: (content: string) => Promise<void>
onDelete?: (commentId: CommentId) => Promise<void>
onResolve?: () => Promise<void>
onLeave?: () => void
onEnter?: () => void
}>(({ comment, isResolved, onLeave, onEnter }) => {
const { t } = useTranslation()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<Error>()
const threads = useThreadsContext()
const { resolveThread, addMessage } = useThreadsActionsContext()
}>(
({
comment,
isResolved,
onResolve,
onDelete,
onEdit,
onReply,
onLeave,
onEnter,
}) => {
const { t } = useTranslation()
const threads = useThreadsContext()
const handleSubmitReply = useCallback(
(content: string, setContent: Dispatch<SetStateAction<string>>) => {
setSubmitting(true)
addMessage(comment.op.t, content)
.then(() => {
setContent('')
})
.catch(error => {
setError(error)
})
.finally(() => {
setSubmitting(false)
})
},
[addMessage, comment.op.t]
)
const handleSubmit = useCallback(
(content, setContent) => onReply?.(content).then(() => setContent('')),
[onReply]
)
const { handleChange, handleKeyPress, content } =
useSubmittableTextInput(handleSubmitReply)
const { handleChange, handleKeyPress, content } =
useSubmittableTextInput(handleSubmit)
const thread = threads?.[comment.op.t]
if (!thread) {
return null
}
const thread = threads?.[comment.op.t]
if (!thread) {
return null
}
return (
<div
className="review-panel-entry-content"
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
{thread.messages.map((message, i) => {
const isReply = i !== 0
return (
<div
className="review-panel-entry-content"
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
{thread.messages.map((message, i) => {
const isReply = i !== 0
return (
<div key={message.id} className="review-panel-comment-wrapper">
{isReply && <div className="review-panel-comment-reply-divider" />}
<ReviewPanelMessage
message={message}
threadId={comment.op.t}
isReply={isReply}
hasReplies={!isReply && thread.messages.length > 1}
onResolve={() => resolveThread(comment.op.t)}
isThreadResolved={isResolved}
return (
<div key={message.id} className="review-panel-comment-wrapper">
{isReply && (
<div className="review-panel-comment-reply-divider" />
)}
<ReviewPanelMessage
message={message}
isReply={isReply}
hasReplies={!isReply && thread.messages.length > 1}
onResolve={onResolve}
onEdit={onEdit}
onDelete={onDelete}
isThreadResolved={isResolved}
/>
</div>
)
})}
{isResolved && (
<div className="review-panel-comment-wrapper">
<div className="review-panel-comment-reply-divider" />
<ReviewPanelResolvedMessage
thread={thread as ReviewPanelResolvedCommentThread}
/>
</div>
)
})}
)}
{isResolved && (
<div className="review-panel-comment-wrapper">
<div className="review-panel-comment-reply-divider" />
<ReviewPanelResolvedMessage
thread={thread as ReviewPanelResolvedCommentThread}
{!isResolved && (
<AutoExpandingTextArea
name="content"
className="review-panel-comment-input"
onChange={handleChange}
onKeyDown={handleKeyPress}
placeholder={t('reply')}
value={content}
/>
</div>
)}
{!isResolved && (
<AutoExpandingTextArea
name="content"
className="review-panel-comment-input"
onChange={handleChange}
onKeyDown={handleKeyPress}
placeholder={t('reply')}
value={content}
disabled={submitting}
/>
)}
{error && <div>{error.message}</div>}
</div>
)
})
)}
</div>
)
}
)
ReviewPanelCommentContent.displayName = 'ReviewPanelCommentContent'

View file

@ -1,10 +1,17 @@
import { memo } from 'react'
import { memo, useCallback, useState } from 'react'
import { Change, CommentOperation } from '../../../../../types/change'
import { useThreadsContext } from '../context/threads-context'
import {
useThreadsActionsContext,
useThreadsContext,
} from '../context/threads-context'
import classnames from 'classnames'
import { ReviewPanelEntry } from './review-panel-entry'
import MaterialIcon from '@/shared/components/material-icon'
import { ReviewPanelCommentContent } from './review-panel-comment-content'
import { CommentId } from '../../../../../types/review-panel/review-panel'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { useTranslation } from 'react-i18next'
import { debugConsole } from '@/utils/debugging'
export const ReviewPanelComment = memo<{
comment: Change<CommentOperation>
@ -16,6 +23,82 @@ export const ReviewPanelComment = memo<{
hovered?: boolean
}>(({ comment, top, hovered, onEnter, onLeave, docId, hoverRanges }) => {
const threads = useThreadsContext()
const { resolveThread, editMessage, deleteMessage, addMessage } =
useThreadsActionsContext()
const { showGenericMessageModal } = useModalsContext()
const { t } = useTranslation()
const [processing, setProcessing] = useState(false)
const handleResolveComment = useCallback(async () => {
setProcessing(true)
try {
await resolveThread(comment.op.t)
} catch (err) {
debugConsole.error(err)
showGenericMessageModal(
t('resolve_comment_error_title'),
t('resolve_comment_error_message')
)
} finally {
setProcessing(false)
}
}, [comment.op.t, resolveThread, showGenericMessageModal, t])
const handleEditMessage = useCallback(
async (commentId: CommentId, content: string) => {
setProcessing(true)
try {
await editMessage(comment.op.t, commentId, content)
} catch (err) {
debugConsole.error(err)
showGenericMessageModal(
t('edit_comment_error_title'),
t('edit_comment_error_message')
)
} finally {
setProcessing(false)
}
},
[comment.op.t, editMessage, showGenericMessageModal, t]
)
const handleDeleteMessage = useCallback(
async (commentId: CommentId) => {
setProcessing(true)
try {
await deleteMessage(comment.op.t, commentId)
} catch (err) {
debugConsole.error(err)
showGenericMessageModal(
t('delete_comment_error_title'),
t('delete_comment_error_message')
)
} finally {
setProcessing(false)
}
},
[comment.op.t, deleteMessage, showGenericMessageModal, t]
)
const handleSubmitReply = useCallback(
async (content: string) => {
setProcessing(true)
try {
await addMessage(comment.op.t, content)
} catch (err) {
debugConsole.error(err)
showGenericMessageModal(
t('add_comment_error_title'),
t('add_comment_error_message')
)
throw err
} finally {
setProcessing(false)
}
},
[addMessage, comment.op.t, showGenericMessageModal, t]
)
const thread = threads?.[comment.op.t]
if (!thread || thread.resolved || thread.messages.length === 0) {
@ -33,6 +116,7 @@ export const ReviewPanelComment = memo<{
op={comment.op}
position={comment.op.p}
hoverRanges={hoverRanges}
disabled={processing}
>
<div
className="review-panel-entry-indicator"
@ -46,6 +130,10 @@ export const ReviewPanelComment = memo<{
isResolved={false}
onLeave={onLeave}
onEnter={onEnter}
onResolve={handleResolveComment}
onEdit={handleEditMessage}
onDelete={handleDeleteMessage}
onReply={handleSubmitReply}
/>
</ReviewPanelEntry>
)

View file

@ -21,6 +21,7 @@ export const ReviewPanelEntry: FC<{
className?: string
selectLineOnFocus?: boolean
hoverRanges?: boolean
disabled?: boolean
}> = ({
children,
position,
@ -30,6 +31,7 @@ export const ReviewPanelEntry: FC<{
selectLineOnFocus = true,
docId,
hoverRanges = true,
disabled,
}) => {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
@ -95,6 +97,7 @@ export const ReviewPanelEntry: FC<{
{
'review-panel-entry-focused': focused,
'review-panel-entry-highlighted': highlighted,
'review-panel-entry-disabled': disabled,
},
className
)}

View file

@ -1,10 +1,9 @@
import { FC, useCallback, useState } from 'react'
import {
CommentId,
ReviewPanelCommentThreadMessage,
ThreadId,
} from '../../../../../types/review-panel/review-panel'
import { useTranslation } from 'react-i18next'
import { useThreadsActionsContext } from '../context/threads-context'
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
import Tooltip from '@/shared/components/tooltip'
import { Button } from 'react-bootstrap'
@ -17,49 +16,39 @@ import ReviewPanelDeleteCommentModal from './review-panel-delete-comment-modal'
export const ReviewPanelMessage: FC<{
message: ReviewPanelCommentThreadMessage
threadId: ThreadId
hasReplies: boolean
isReply: boolean
onResolve: () => void
onResolve?: () => Promise<void>
onEdit?: (commentId: CommentId, content: string) => Promise<void>
onDelete?: (CommentId: CommentId) => Promise<void>
isThreadResolved: boolean
}> = ({
message,
threadId,
isReply,
hasReplies,
onResolve,
onEdit,
onDelete,
isThreadResolved,
}) => {
const { t } = useTranslation()
const [editing, setEditing] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<Error>()
const [content, setContent] = useState(message.content)
const { editMessage, deleteMessage } = useThreadsActionsContext()
const handleEditOption = useCallback(() => setEditing(true), [])
const showDeleteModal = useCallback(() => setDeleting(true), [])
const hideDeleteModal = useCallback(() => setDeleting(false), [])
const handleSubmit = useCallback(async () => {
await editMessage(threadId, message.id, content)
.catch(error => {
setError(error)
})
.finally(() => {
setEditing(false)
})
}, [content, editMessage, message.id, threadId])
const handleSubmit = useCallback(() => {
onEdit?.(message.id, content)
setEditing(false)
}, [content, message.id, onEdit])
const handleDelete = useCallback(async () => {
await deleteMessage(threadId, message.id)
.catch(error => {
setError(error)
})
.finally(() => {
setDeleting(false)
})
}, [deleteMessage, message.id, threadId])
const handleDelete = useCallback(() => {
onDelete?.(message.id)
setDeleting(false)
}, [message.id, onDelete])
if (editing) {
return (
@ -83,7 +72,6 @@ export const ReviewPanelMessage: FC<{
value={content}
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
/>
{error && <div>{error.message}</div>}
</div>
)
}

View file

@ -1,4 +1,4 @@
import React, { FC } from 'react'
import React, { FC, useCallback, useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { ThreadId } from '../../../../../types/review-panel/review-panel'
import { useThreadsActionsContext } from '../context/threads-context'
@ -8,17 +8,57 @@ import { ExpandableContent } from './review-panel-expandable-content'
import { ReviewPanelCommentContent } from './review-panel-comment-content'
import { Change, CommentOperation } from '../../../../../types/change'
import Tooltip from '@/shared/components/tooltip'
import classNames from 'classnames'
import { debugConsole } from '@/utils/debugging'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
export const ReviewPanelResolvedThread: FC<{
id: string
id: ThreadId
comment: Change<CommentOperation>
docName: string
}> = ({ id, comment, docName }) => {
const { t } = useTranslation()
const { reopenThread, deleteThread } = useThreadsActionsContext()
const [processing, setProcessing] = useState(false)
const { showGenericMessageModal } = useModalsContext()
const handleReopenThread = useCallback(async () => {
setProcessing(true)
try {
await reopenThread(id)
} catch (err) {
debugConsole.error(err)
showGenericMessageModal(
t('reopen_comment_error_title'),
t('reopen_comment_error_message')
)
} finally {
setProcessing(false)
}
}, [id, reopenThread, showGenericMessageModal, t])
const handleDeleteThread = useCallback(async () => {
setProcessing(true)
try {
await deleteThread(id)
} catch (err) {
debugConsole.error(err)
showGenericMessageModal(
t('delete_comment_error_title'),
t('delete_comment_error_message')
)
} finally {
setProcessing(false)
}
}, [id, deleteThread, showGenericMessageModal, t])
return (
<div className="review-panel-resolved-comment" key={id}>
<div
className={classNames('review-panel-resolved-comment', {
'review-panel-resolved-disabled': processing,
})}
key={id}
>
<div className="review-panel-resolved-comment-header">
<div>
<Trans
@ -38,7 +78,7 @@ export const ReviewPanelResolvedThread: FC<{
overlayProps={{ placement: 'bottom' }}
description={t('reopen')}
>
<Button onClick={() => reopenThread(id as ThreadId)}>
<Button onClick={handleReopenThread}>
<MaterialIcon type="refresh" accessibilityLabel={t('reopen')} />
</Button>
</Tooltip>
@ -48,7 +88,7 @@ export const ReviewPanelResolvedThread: FC<{
overlayProps={{ placement: 'bottom' }}
description={t('delete')}
>
<Button onClick={() => deleteThread(id as ThreadId)}>
<Button onClick={handleDeleteThread}>
<MaterialIcon type="delete" accessibilityLabel={t('delete')} />
</Button>
</Tooltip>

View file

@ -6,6 +6,7 @@ import useProjectRanges from '../hooks/use-project-ranges'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import Icon from '@/shared/components/icon'
import { Change, CommentOperation } from '../../../../../types/change'
import { ThreadId } from '../../../../../types/review-panel/review-panel'
export const ReviewPanelResolvedThreadsMenu: FC = () => {
const { t } = useTranslation()
@ -92,7 +93,7 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => {
return (
<ReviewPanelResolvedThread
key={thread.id}
id={thread.id}
id={thread.id as ThreadId}
comment={comment}
docName={docNameForThread.get(thread.id) ?? t('unknown')}
/>

View file

@ -16,6 +16,8 @@ import {
import RangesTracker from '@overleaf/ranges-tracker'
import { rejectChanges } from '@/features/source-editor/extensions/changes/reject-changes'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
import { postJSON } from '@/infrastructure/fetch-json'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
export type Ranges = {
docId: string
@ -57,7 +59,7 @@ const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
export const RangesProvider: FC = ({ children }) => {
const view = useCodeMirrorViewContext()
const { projectId } = useIdeReactContext()
const [currentDoc] = useScopeValue<DocumentContainer | null>(
'editor.sharejs_doc'
)
@ -115,8 +117,10 @@ export const RangesProvider: FC = ({ children }) => {
const actions = useMemo(
() => ({
acceptChanges(...ids: string[]) {
async acceptChanges(...ids: string[]) {
if (currentDoc?.ranges) {
const url = `/project/${projectId}/doc/${currentDoc.doc_id}/changes/accept`
await postJSON(url, { body: { change_ids: ids } })
currentDoc.ranges.removeChangeIds(ids)
setRanges(buildRanges(currentDoc))
}
@ -127,7 +131,7 @@ export const RangesProvider: FC = ({ children }) => {
}
},
}),
[currentDoc, view]
[currentDoc, projectId, view]
)
return (

View file

@ -248,7 +248,6 @@ export const ThreadsProvider: FC = ({ children }) => {
await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
body: { content },
})
// TODO: error_submitting_comment
},
async editMessage(
threadId: ThreadId,

View file

@ -31,6 +31,11 @@
z-index: 1;
}
.review-panel-entry.review-panel-entry-disabled {
opacity: 0.5;
pointer-events: none;
}
.review-panel-entry-indicator {
display: none;
}
@ -235,6 +240,11 @@
}
}
.review-panel-resolved-disabled {
opacity: 0.5;
pointer-events: none;
}
.review-panel-resolved-comments-loading {
text-align: center;
}

View file

@ -33,6 +33,8 @@
"accept_all": "Accept all",
"accept_and_continue": "Accept and continue",
"accept_change": "Accept change",
"accept_change_error_description": "There was an error accepting a track change. Please try again in a few moments.",
"accept_change_error_title": "Accept Change Error",
"accept_invitation": "Accept invitation",
"accept_or_reject_each_changes_individually": "Accept or reject each change individually",
"accept_terms_and_conditions": "Accept terms and conditions",
@ -64,6 +66,8 @@
"add_another_token": "Add another token",
"add_comma_separated_emails_help": "Separate multiple email addresses using the comma (,) character.",
"add_comment": "Add comment",
"add_comment_error_message": "There was an error adding your comment. Please try again in a few moments.",
"add_comment_error_title": "Add Comment Error",
"add_company_details": "Add Company Details",
"add_email": "Add Email",
"add_email_address": "Add email address",
@ -427,6 +431,8 @@
"delete_authentication_token_info": "Youre about to delete a Git authentication token. If you do, it can no longer be used to authenticate your identity when performing Git operations.",
"delete_certificate": "Delete certificate",
"delete_comment": "Delete comment",
"delete_comment_error_message": "There was an error deleting your comment. Please try again in a few moments.",
"delete_comment_error_title": "Delete Comment Error",
"delete_comment_message": "You cannot undo this action.",
"delete_comment_thread": "Delete comment thread",
"delete_comment_thread_message": "This will delete the whole comment thread. You cannot undo this action.",
@ -526,6 +532,8 @@
"easily_manage_your_project_files_everywhere": "Easily manage your project files, everywhere",
"easy_collaboration_for_students": "Easy collaboration for students. Supports longer or more complex projects.",
"edit": "Edit",
"edit_comment_error_message": "There was an error editing your comment. Please try again in a few moments.",
"edit_comment_error_title": "Edit Comment Error",
"edit_dictionary": "Edit Dictionary",
"edit_dictionary_empty": "Your custom dictionary is empty.",
"edit_dictionary_remove": "Remove from dictionary",
@ -1680,6 +1688,8 @@
"rename_project": "Rename Project",
"renaming": "Renaming",
"reopen": "Re-open",
"reopen_comment_error_message": "There was an error reopening your comment. Please try again in a few moments.",
"reopen_comment_error_title": "Reopen Comment Error",
"replace_figure": "Replace figure",
"replace_from_another_project": "Replace from another project",
"replace_from_computer": "Replace from computer",