first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,210 @@
import { forwardRef, memo, MouseEventHandler, useState } from 'react'
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import MaterialIcon from '@/shared/components/material-icon'
import classNames from 'classnames'
import {
useTrackChangesStateActionsContext,
useTrackChangesStateContext,
} from '../context/track-changes-state-context'
import { useUserContext } from '@/shared/context/user-context'
import { useTranslation } from 'react-i18next'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import usePersistedState from '@/shared/hooks/use-persisted-state'
import { sendMB } from '@/infrastructure/event-tracking'
import { useEditorContext } from '@/shared/context/editor-context'
import { useProjectContext } from '@/shared/context/project-context'
import UpgradeTrackChangesModal from './upgrade-track-changes-modal'
type Mode = 'view' | 'review' | 'edit'
const useCurrentMode = (): Mode => {
const trackChanges = useTrackChangesStateContext()
const user = useUserContext()
const trackChangesForCurrentUser =
trackChanges?.onForEveryone ||
(user && user.id && trackChanges?.onForMembers[user.id])
const { permissionsLevel } = useEditorContext()
if (permissionsLevel === 'readOnly') {
return 'view'
} else if (permissionsLevel === 'review') {
return 'review'
} else if (trackChangesForCurrentUser) {
return 'review'
} else {
return 'edit'
}
}
function ReviewModeSwitcher() {
const { t } = useTranslation()
const { saveTrackChangesForCurrentUser } =
useTrackChangesStateActionsContext()
const mode = useCurrentMode()
const { permissionsLevel } = useEditorContext()
const { write, trackedWrite } = usePermissionsContext()
const project = useProjectContext()
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
const showViewOption = permissionsLevel === 'readOnly'
return (
<div className="review-mode-switcher-container">
<Dropdown className="review-mode-switcher" align="end">
<DropdownToggle
as={ModeSwitcherToggleButton}
id="review-mode-switcher"
/>
<DropdownMenu flip={false}>
<OLDropdownMenuItem
disabled={!write}
onClick={() => {
if (mode === 'edit') {
return
}
sendMB('editing-mode-change', {
role: permissionsLevel,
previousMode: mode,
newMode: 'edit',
})
saveTrackChangesForCurrentUser(false)
}}
description={t('can_edit_content')}
leadingIcon="edit"
active={write && mode === 'edit'}
>
{t('editing')}
</OLDropdownMenuItem>
<OLDropdownMenuItem
disabled={permissionsLevel === 'readOnly'}
onClick={() => {
if (mode === 'review') {
return
}
if (!project.features.trackChanges) {
setShowUpgradeModal(true)
} else {
sendMB('editing-mode-change', {
role: permissionsLevel,
previousMode: mode,
newMode: 'review',
})
saveTrackChangesForCurrentUser(true)
}
}}
description={
permissionsLevel === 'review' && !trackedWrite
? t('comment_only')
: t('edits_become_suggestions')
}
leadingIcon="rate_review"
active={trackedWrite && mode === 'review'}
>
{t('reviewing')}
</OLDropdownMenuItem>
{showViewOption && (
<OLDropdownMenuItem
description={t('can_view_content')}
leadingIcon="visibility"
active={mode === 'view'}
>
{t('viewing')}
</OLDropdownMenuItem>
)}
</DropdownMenu>
</Dropdown>
<UpgradeTrackChangesModal
show={showUpgradeModal}
setShow={setShowUpgradeModal}
/>
</div>
)
}
const ModeSwitcherToggleButton = forwardRef<
HTMLButtonElement,
{ onClick: MouseEventHandler<HTMLButtonElement>; 'aria-expanded': boolean }
>(({ onClick, 'aria-expanded': ariaExpanded }, ref) => {
const { t } = useTranslation()
const mode = useCurrentMode()
if (mode === 'edit') {
return (
<ModeSwitcherToggleButtonContent
ref={ref}
onClick={onClick}
className="editing"
iconType="edit"
label={t('editing')}
ariaExpanded={ariaExpanded}
/>
)
} else if (mode === 'review') {
return (
<ModeSwitcherToggleButtonContent
ref={ref}
onClick={onClick}
className="reviewing"
iconType="rate_review"
label={t('reviewing')}
ariaExpanded={ariaExpanded}
/>
)
}
return (
<ModeSwitcherToggleButtonContent
ref={ref}
onClick={onClick}
className="viewing"
iconType="visibility"
label={t('viewing')}
ariaExpanded={ariaExpanded}
/>
)
})
const ModeSwitcherToggleButtonContent = forwardRef<
HTMLButtonElement,
{
onClick: MouseEventHandler<HTMLButtonElement>
className: string
iconType: string
label: string
ariaExpanded: boolean
}
>(({ onClick, className, iconType, label, ariaExpanded }, ref) => {
const [isFirstTimeUsed, setIsFirstTimeUsed] = usePersistedState(
`modeSwitcherFirstTimeUsed`,
true
)
return (
<button
className={classNames('review-mode-switcher-toggle-button', className, {
'review-mode-switcher-toggle-button-expanded': isFirstTimeUsed,
})}
ref={ref}
onClick={event => {
setIsFirstTimeUsed(false)
onClick(event)
}}
aria-expanded={ariaExpanded}
>
<MaterialIcon className="material-symbols-outlined" type={iconType} />
<div className="review-mode-switcher-toggle-label" aria-label={label}>
{label}
</div>
<MaterialIcon type="keyboard_arrow_down" />
</button>
)
})
ModeSwitcherToggleButton.displayName = 'ModeSwitcherToggleButton'
ModeSwitcherToggleButtonContent.displayName = 'ModeSwitcherToggleButtonContent'
export default memo(ReviewModeSwitcher)

View File

@@ -0,0 +1,173 @@
import { FormEventHandler, useCallback, useState, useRef, memo } from 'react'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-context'
import { EditorSelection } from '@codemirror/state'
import { useTranslation } from 'react-i18next'
import { useThreadsActionsContext } from '../context/threads-context'
import { removeNewCommentRangeEffect } from '@/features/source-editor/extensions/review-tooltip'
import useSubmittableTextInput from '../hooks/use-submittable-text-input'
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
import { ReviewPanelEntry } from './review-panel-entry'
import { ThreadId } from '../../../../../types/review-panel/review-panel'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/features/ui/components/ol/ol-button'
export const ReviewPanelAddComment = memo<{
docId: string
from: number
to: number
threadId: string
top: number | undefined
}>(function ReviewPanelAddComment({ from, to, threadId, top, docId }) {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
const { addComment } = useThreadsActionsContext()
const [submitting, setSubmitting] = useState(false)
const { showGenericMessageModal } = useModalsContext()
const handleClose = useCallback(() => {
view.dispatch({
effects: removeNewCommentRangeEffect.of(threadId),
})
}, [view, threadId])
const submitForm = useCallback(
async message => {
setSubmitting(true)
const content = view.state.sliceDoc(from, to)
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, showGenericMessageModal, t]
)
const { handleChange, handleKeyPress, content } =
useSubmittableTextInput(submitForm)
const handleBlur = useCallback(() => {
if (content === '') {
window.setTimeout(() => {
handleClose()
})
}
}, [content, handleClose])
const handleSubmit = useCallback<FormEventHandler>(
event => {
event.preventDefault()
submitForm(content)
},
[submitForm, content]
)
// We only ever want to focus the element once
const hasBeenFocused = useRef(false)
// Auto-focus the textarea once the element has been correctly positioned.
// We cannot use the autofocus attribute as we need to wait until the parent element
// has been positioned (with the "top" attribute) to avoid scrolling to the initial
// position of the element
const observerCallback = useCallback(mutationList => {
if (hasBeenFocused.current) {
return
}
for (const mutation of mutationList) {
if (mutation.target.style.top) {
const textArea = mutation.target.getElementsByTagName('textarea')[0]
if (textArea) {
textArea.focus()
hasBeenFocused.current = true
}
}
}
}, [])
const handleElement = useCallback(
(element: HTMLElement | null) => {
if (element) {
element.dispatchEvent(new Event('review-panel:position'))
const observer = new MutationObserver(observerCallback)
const entryWrapper = element.closest('.review-panel-entry')
if (entryWrapper) {
observer.observe(entryWrapper, {
attributes: true,
attributeFilter: ['style'],
})
return () => observer.disconnect()
}
}
},
[observerCallback]
)
return (
<ReviewPanelEntry
docId={docId}
top={top}
position={from}
op={{
p: from,
c: state.sliceDoc(from, to),
t: threadId as ThreadId,
}}
selectLineOnFocus={false}
disabled={submitting}
>
<form
className="review-panel-entry-content"
onBlur={handleBlur}
onSubmit={handleSubmit}
ref={handleElement}
>
<AutoExpandingTextArea
name="message"
className="review-panel-add-comment-textarea"
onChange={handleChange}
onKeyPress={handleKeyPress}
placeholder={t('add_your_comment_here')}
value={content}
disabled={submitting}
/>
<div className="review-panel-add-comment-buttons">
<OLButton
variant="ghost"
size="sm"
className="review-panel-add-comment-cancel-button"
disabled={submitting}
onClick={handleClose}
>
{t('cancel')}
</OLButton>
<OLButton
type="submit"
variant="primary"
size="sm"
disabled={content === '' || submitting}
>
{t('comment')}
</OLButton>
</div>
</form>
</ReviewPanelEntry>
)
})

View File

@@ -0,0 +1,16 @@
import { memo, useMemo } from 'react'
import { useChangesUsersContext } from '../context/changes-users-context'
import { Change } from '../../../../../types/change'
import ReviewPanelEntryUser from './review-panel-entry-user'
export const ReviewPanelChangeUser = memo<{ change: Change }>(({ change }) => {
const changesUsers = useChangesUsersContext()
const userId = change.metadata?.user_id
const user = useMemo(
() => (userId ? changesUsers?.get(userId) : undefined),
[changesUsers, userId]
)
return <ReviewPanelEntryUser user={user} />
})
ReviewPanelChangeUser.displayName = 'ReviewPanelChangeUser'

View File

@@ -0,0 +1,239 @@
import { memo, useCallback, useState } from 'react'
import { useRangesActionsContext } from '../context/ranges-context'
import {
Change,
DeleteOperation,
EditOperation,
} from '../../../../../types/change'
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
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'
import { ExpandableContent } from './review-panel-expandable-content'
import { useUserContext } from '@/shared/context/user-context'
import { PreventSelectingEntry } from './review-panel-prevent-selecting'
export const ReviewPanelChange = memo<{
change: Change<EditOperation>
aggregate?: Change<DeleteOperation>
top?: number
editable?: boolean
docId: string
hoverRanges?: boolean
hovered?: boolean
onEnter?: (changeId: string) => void
onLeave?: (changeId: string) => void
}>(
({
change,
aggregate,
top,
docId,
hoverRanges,
editable = true,
hovered,
onEnter,
onLeave,
}) => {
const { t } = useTranslation()
const { acceptChanges, rejectChanges } = useRangesActionsContext()
const permissions = usePermissionsContext()
const changesUsers = useChangesUsersContext()
const { showGenericMessageModal } = useModalsContext()
const user = useUserContext()
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
return null
}
const isChangeAuthor = change.metadata?.user_id === user.id
const aggregateChange = aggregate && /\S/.test(aggregate.op.d)
return (
<ReviewPanelEntry
className={classnames('review-panel-entry-change', {
'review-panel-entry-insert': 'i' in change.op,
'review-panel-entry-delete': 'd' in change.op,
'review-panel-entry-hover': hovered,
// TODO: aggregate
})}
top={top}
op={change.op}
position={change.op.p}
docId={docId}
hoverRanges={hoverRanges}
disabled={accepting}
onEnterEntryIndicator={onEnter && (() => onEnter(change.id))}
onLeaveEntryIndicator={onLeave && (() => onLeave(change.id))}
entryIndicator="edit"
>
<div
className="review-panel-entry-content"
onMouseEnter={onEnter && (() => onEnter(change.id))}
onMouseLeave={onLeave && (() => onLeave(change.id))}
>
<div className="review-panel-entry-header">
<div>
<ReviewPanelChangeUser change={change} />
{change.metadata?.ts && (
<div className="review-panel-entry-time">
<FormatTimeBasedOnYear date={change.metadata.ts} />
</div>
)}
</div>
{editable && (
<div className="review-panel-entry-actions">
{permissions.write && (
<PreventSelectingEntry>
<OLTooltip
id="accept-change"
overlayProps={{ placement: 'bottom' }}
description={t('accept_change')}
tooltipProps={{ className: 'review-panel-tooltip' }}
>
<button
type="button"
className="btn"
onClick={acceptHandler}
tabIndex={0}
>
<MaterialIcon
type="check"
className="review-panel-entry-actions-icon"
accessibilityLabel={t('accept_change')}
/>
</button>
</OLTooltip>
</PreventSelectingEntry>
)}
{(permissions.write ||
(permissions.trackedWrite && isChangeAuthor)) && (
<PreventSelectingEntry>
<OLTooltip
id="reject-change"
description={t('reject_change')}
overlayProps={{ placement: 'bottom' }}
tooltipProps={{ className: 'review-panel-tooltip' }}
>
<button
tabIndex={0}
type="button"
className="btn"
onClick={() =>
aggregate
? rejectChanges(change.id, aggregate.id)
: rejectChanges(change.id)
}
>
<MaterialIcon
className="review-panel-entry-actions-icon"
accessibilityLabel={t('reject_change')}
type="close"
/>
</button>
</OLTooltip>
</PreventSelectingEntry>
)}
</div>
)}
</div>
<div className="review-panel-change-body">
{'i' in change.op && (
<>
{aggregateChange ? (
<MaterialIcon
className="review-panel-entry-icon review-panel-entry-change-icon review-panel-entry-icon-changed"
type="edit"
/>
) : (
<MaterialIcon
className="review-panel-entry-icon review-panel-entry-change-icon review-panel-entry-icon-accept"
type="add_circle"
/>
)}
{aggregateChange ? (
<span>
{t('aggregate_changed')}:{' '}
<del className="review-panel-content-highlight">
<ExpandableContent
inline
content={aggregate.op.d}
checkNewLines={false}
/>
</del>{' '}
{t('aggregate_to')}{' '}
<ExpandableContent
inline
content={change.op.i}
checkNewLines={false}
/>
</span>
) : (
<span>
{t('tracked_change_added')}:&nbsp;
<ins className="review-panel-content-highlight">
<ExpandableContent
content={change.op.i}
checkNewLines={false}
/>
</ins>
</span>
)}
</>
)}
{'d' in change.op && (
<>
<MaterialIcon
className="review-panel-entry-icon review-panel-entry-change-icon review-panel-entry-icon-reject"
type="delete"
/>
<span>
{t('tracked_change_deleted')}:&nbsp;
<del className="review-panel-content-highlight">
<ExpandableContent
content={change.op.d}
checkNewLines={false}
/>
</del>
</span>
</>
)}
</div>
</div>
</ReviewPanelEntry>
)
}
)
ReviewPanelChange.displayName = 'ReviewPanelChange'

View File

@@ -0,0 +1,123 @@
import { memo, useCallback, useState } from 'react'
import { Change, CommentOperation } from '../../../../../types/change'
import { ReviewPanelMessage } from './review-panel-message'
import { useTranslation } from 'react-i18next'
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,
ThreadId,
} from '../../../../../types/review-panel/review-panel'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
export const ReviewPanelCommentContent = memo<{
comment: Change<CommentOperation>
isResolved: boolean
onEdit?: (commentId: CommentId, content: string) => Promise<void>
onReply?: (content: string) => Promise<void>
onDeleteMessage?: (commentId: CommentId) => Promise<void>
onDeleteThread?: (threadId: ThreadId) => Promise<void>
onResolve?: () => Promise<void>
onLeave?: (changeId: string) => void
onEnter?: (changeId: string) => void
}>(
({
comment,
isResolved,
onResolve,
onDeleteMessage,
onDeleteThread,
onEdit,
onReply,
onLeave,
onEnter,
}) => {
const { t } = useTranslation()
const threads = useThreadsContext()
const permissions = usePermissionsContext()
const [submitting, setSubmitting] = useState(false)
const handleSubmit = useCallback(
(content, setContent) => {
if (!onReply || submitting) {
return
}
setSubmitting(true)
onReply(content)
.then(() => {
setContent('')
})
.finally(() => {
setSubmitting(false)
})
},
[onReply, submitting]
)
const { handleChange, handleKeyPress, content } =
useSubmittableTextInput(handleSubmit)
const thread = threads?.[comment.op.t]
if (!thread) {
return null
}
return (
<div
className="review-panel-entry-content"
onMouseEnter={onEnter && (() => onEnter(comment.id))}
onMouseLeave={onLeave && (() => onLeave(comment.id))}
>
{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}
isReply={isReply}
hasReplies={!isReply && thread.messages.length > 1}
onResolve={onResolve}
onEdit={onEdit}
onDelete={() =>
isReply
? onDeleteMessage?.(message.id)
: onDeleteThread?.(comment.op.t)
}
isThreadResolved={isResolved}
/>
</div>
)
})}
{isResolved && (
<div className="review-panel-comment-wrapper">
<div className="review-panel-comment-reply-divider" />
<ReviewPanelResolvedMessage
thread={thread as ReviewPanelResolvedCommentThread}
/>
</div>
)}
{permissions.comment && !isResolved && (
<AutoExpandingTextArea
name="content"
className="review-panel-comment-input"
onChange={handleChange}
onKeyPress={handleKeyPress}
placeholder={t('reply')}
value={content}
/>
)}
</div>
)
}
)
ReviewPanelCommentContent.displayName = 'ReviewPanelCommentContent'

View File

@@ -0,0 +1,67 @@
import MaterialIcon from '@/shared/components/material-icon'
import { FC, memo, forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import classnames from 'classnames'
const ReviewPanelCommentDropdownToggleButton = forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>((props, ref) => (
<button {...props} ref={ref} className={classnames(props.className, 'btn')} />
))
ReviewPanelCommentDropdownToggleButton.displayName =
'ReviewPanelCommentDropdownToggleButton'
const ReviewPanelCommentOptions: FC<{
onEdit: () => void
onDelete: () => void
id: string
canEdit: boolean
canDelete: boolean
}> = ({ onEdit, onDelete, id, canEdit, canDelete }) => {
const { t } = useTranslation()
if (!canEdit && !canDelete) {
return null
}
return (
<Dropdown align="end">
<DropdownToggle
tabIndex={0}
as={ReviewPanelCommentDropdownToggleButton}
id={`review-panel-comment-options-btn-${id}`}
>
<MaterialIcon
type="more_vert"
className="review-panel-entry-actions-icon"
accessibilityLabel={t('more_options')}
/>
</DropdownToggle>
<DropdownMenu flip={false}>
{canEdit && (
<li role="none">
<DropdownItem as="button" onClick={onEdit}>
{t('edit')}
</DropdownItem>
</li>
)}
{canDelete && (
<li role="none">
<DropdownItem as="button" onClick={onDelete}>
{t('delete')}
</DropdownItem>
</li>
)}
</DropdownMenu>
</Dropdown>
)
}
export default memo(ReviewPanelCommentOptions)

View File

@@ -0,0 +1,180 @@
import { memo, useCallback, useState } from 'react'
import { Change, CommentOperation } from '../../../../../types/change'
import {
useThreadsActionsContext,
useThreadsContext,
} from '../context/threads-context'
import classnames from 'classnames'
import { ReviewPanelEntry } from './review-panel-entry'
import { ReviewPanelCommentContent } from './review-panel-comment-content'
import {
CommentId,
ThreadId,
} from '../../../../../types/review-panel/review-panel'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useTranslation } from 'react-i18next'
import { debugConsole } from '@/utils/debugging'
export const ReviewPanelComment = memo<{
comment: Change<CommentOperation>
docId: string
top?: number
hoverRanges?: boolean
onEnter?: (changeId: string) => void
onLeave?: (changeId: string) => void
hovered?: boolean
}>(({ comment, top, hovered, onEnter, onLeave, docId, hoverRanges }) => {
const threads = useThreadsContext()
const {
resolveThread,
editMessage,
deleteMessage,
deleteOwnMessage,
deleteThread,
addMessage,
} = useThreadsActionsContext()
const { showGenericMessageModal } = useModalsContext()
const { t } = useTranslation()
const permissions = usePermissionsContext()
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 {
if (permissions.resolveAllComments) {
// Owners and editors can delete any message
await deleteMessage(comment.op.t, commentId)
} else if (permissions.resolveOwnComments) {
// Reviewers can only delete their own messages
await deleteOwnMessage(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,
deleteOwnMessage,
showGenericMessageModal,
t,
permissions.resolveOwnComments,
permissions.resolveAllComments,
]
)
const handleDeleteThread = useCallback(
async (commentId: ThreadId) => {
setProcessing(true)
try {
await deleteThread(commentId)
} catch (err) {
debugConsole.error(err)
showGenericMessageModal(
t('delete_comment_error_title'),
t('delete_comment_error_message')
)
} finally {
setProcessing(false)
}
},
[deleteThread, 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) {
return null
}
return (
<ReviewPanelEntry
className={classnames('review-panel-entry-comment', {
'review-panel-entry-loaded': !!threads?.[comment.op.t],
'review-panel-entry-hover': hovered,
})}
docId={docId}
top={top}
op={comment.op}
position={comment.op.p}
hoverRanges={hoverRanges}
disabled={processing}
onEnterEntryIndicator={onEnter && (() => onEnter(comment.id))}
onLeaveEntryIndicator={onLeave && (() => onLeave(comment.id))}
entryIndicator="comment"
>
<ReviewPanelCommentContent
comment={comment}
isResolved={false}
onLeave={onLeave}
onEnter={onEnter}
onResolve={handleResolveComment}
onEdit={handleEditMessage}
onDeleteMessage={handleDeleteMessage}
onDeleteThread={handleDeleteThread}
onReply={handleSubmitReply}
/>
</ReviewPanelEntry>
)
})
ReviewPanelComment.displayName = 'ReviewPanelComment'

View File

@@ -0,0 +1,33 @@
import ReactDOM from 'react-dom'
import { useCodeMirrorViewContext } from '../../source-editor/components/codemirror-context'
import { memo } from 'react'
import ReviewPanel from './review-panel'
import TrackChangesOnWidget from './track-changes-on-widget'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import ReviewModeSwitcher from './review-mode-switcher'
import getMeta from '@/utils/meta'
import useReviewPanelLayout from '../hooks/use-review-panel-layout'
function ReviewPanelContainer() {
const view = useCodeMirrorViewContext()
const { showPanel, mini } = useReviewPanelLayout()
const { wantTrackChanges } = useEditorManagerContext()
const enableReviewerRole = getMeta('ol-isReviewerRoleEnabled')
if (!view) {
return null
}
const showTrackChangesWidget = !enableReviewerRole && wantTrackChanges && mini
return ReactDOM.createPortal(
<>
{showTrackChangesWidget && <TrackChangesOnWidget />}
{enableReviewerRole && <ReviewModeSwitcher />}
{showPanel && <ReviewPanel mini={mini} />}
</>,
view.scrollDOM
)
}
export default memo(ReviewPanelContainer)

View File

@@ -0,0 +1,363 @@
import {
FC,
memo,
useCallback,
useEffect,
useRef,
useState,
useMemo,
} from 'react'
import { ReviewPanelAddComment } from './review-panel-add-comment'
import { ReviewPanelChange } from './review-panel-change'
import { ReviewPanelComment } from './review-panel-comment'
import {
Change,
CommentOperation,
DeleteOperation,
EditOperation,
} from '../../../../../types/change'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-context'
import { useRangesContext } from '../context/ranges-context'
import { useThreadsContext } from '../context/threads-context'
import { isDeleteChange, isInsertChange } from '@/utils/operations'
import { positionItems } from '../utils/position-items'
import { canAggregate } from '../utils/can-aggregate'
import ReviewPanelEmptyState from './review-panel-empty-state'
import useEventListener from '@/shared/hooks/use-event-listener'
import { hasActiveRange } from '@/features/review-panel-new/utils/has-active-range'
import { reviewTooltipStateField } from '@/features/source-editor/extensions/review-tooltip'
import ReviewPanelMoreCommentsButton from './review-panel-more-comments-button'
import useMoreCommments from '../hooks/use-more-comments'
import { Decoration } from '@codemirror/view'
import { debounce } from 'lodash'
type AggregatedRanges = {
changes: Change<EditOperation>[]
comments: Change<CommentOperation>[]
aggregates: Map<string, Change<DeleteOperation>>
}
const ReviewPanelCurrentFile: FC = () => {
const view = useCodeMirrorViewContext()
const ranges = useRangesContext()
const threads = useThreadsContext()
const state = useCodeMirrorStateContext()
const [hoveredEntry, setHoveredEntry] = useState<string | null>(null)
const hoverTimeout = useRef<number>(0)
const handleEntryEnter = useCallback((id: string) => {
clearTimeout(hoverTimeout.current)
setHoveredEntry(id)
}, [])
const handleEntryLeave = useCallback((id: string) => {
clearTimeout(hoverTimeout.current)
hoverTimeout.current = window.setTimeout(() => {
setHoveredEntry(null)
}, 100)
}, [])
const [aggregatedRanges, setAggregatedRanges] = useState<AggregatedRanges>()
const containerRef = useRef<HTMLDivElement | null>(null)
const previousFocusedItem = useRef(new Map<string, number>())
const buildAggregatedRanges = useCallback(() => {
if (ranges) {
const output: AggregatedRanges = {
aggregates: new Map(),
changes: [],
comments: [],
}
let precedingChange: Change<EditOperation> | null = null
for (const change of ranges.changes) {
if (
precedingChange &&
isInsertChange(precedingChange) &&
isDeleteChange(change) &&
canAggregate(change, precedingChange)
) {
output.aggregates.set(precedingChange.id, change)
} else {
output.changes.push(change)
}
precedingChange = change
}
if (threads) {
for (const comment of ranges.comments) {
if (threads[comment.op.t] && !threads[comment.op.t]?.resolved) {
output.comments.push(comment)
}
}
}
setAggregatedRanges(output)
}
}, [threads, ranges])
useEffect(() => {
buildAggregatedRanges()
}, [buildAggregatedRanges])
useEventListener('editor:viewport-changed', buildAggregatedRanges)
const [positions, setPositions] = useState<Map<string, number>>(new Map())
const positionsRef = useRef<Map<string, number>>(new Map())
const addCommentRanges = state.field(
reviewTooltipStateField,
false
)?.addCommentRanges
const setUpdatedPositions = useMemo(
() =>
debounce(() => {
setPositions(new Map(positionsRef.current))
window.setTimeout(() => {
containerRef.current?.dispatchEvent(
new Event('review-panel:position')
)
})
}, 50),
[]
)
const positionsMeasureRequest = useCallback(() => {
if (aggregatedRanges) {
view.requestMeasure({
key: 'review-panel-position',
read(view) {
const contentRect = view.contentDOM.getBoundingClientRect()
const docLength = view.state.doc.length
const screenPosition = (position: number): number | undefined => {
const pos = Math.min(position, docLength) // TODO: needed?
const coords = view.coordsAtPos(pos)
return coords ? Math.round(coords.top - contentRect.top) : undefined
}
for (const change of aggregatedRanges.changes) {
const position = screenPosition(change.op.p)
if (position) {
positionsRef.current.set(change.id, position)
}
}
for (const comment of aggregatedRanges.comments) {
const position = screenPosition(comment.op.p)
if (position) {
positionsRef.current.set(comment.id, position)
}
}
if (!addCommentRanges) {
return
}
const cursor = addCommentRanges.iter()
while (cursor.value) {
const { from } = cursor
const position = screenPosition(from)
if (position) {
positionsRef.current.set(
`new-comment-${cursor.value.spec.id}`,
position
)
}
cursor.next()
}
},
write() {
setUpdatedPositions()
},
})
}
}, [view, aggregatedRanges, addCommentRanges, setUpdatedPositions])
useEffect(positionsMeasureRequest, [positionsMeasureRequest])
useEventListener('editor:geometry-change', positionsMeasureRequest)
const showEmptyState = useMemo(
() => hasActiveRange(ranges, threads) === false,
[ranges, threads]
)
const addCommentEntries = useMemo(() => {
if (!addCommentRanges) {
return []
}
const cursor = addCommentRanges.iter()
const entries = []
while (cursor.value) {
const id = `new-comment-${cursor.value.spec.id}`
if (!positions.has(id)) {
cursor.next()
continue
}
const { from, to, value } = cursor
entries.push({
id,
from,
to,
threadId: value.spec.id,
top: positions.get(id),
})
cursor.next()
}
return entries
}, [addCommentRanges, positions])
const {
onEntriesPositioned,
onMoreCommentsAboveClick,
onMoreCommentsBelowClick,
} = useMoreCommments(
aggregatedRanges?.changes ?? [],
aggregatedRanges?.comments ?? [],
addCommentRanges ?? Decoration.none
)
const updatePositions = useCallback(() => {
const docId = ranges?.docId
if (containerRef.current && docId) {
const positioningRes = positionItems(
containerRef.current,
previousFocusedItem.current.get(docId),
docId
)
onEntriesPositioned()
if (positioningRes) {
previousFocusedItem.current.set(
positioningRes.docId,
positioningRes.activeItemIndex
)
}
}
}, [ranges?.docId, onEntriesPositioned])
useEffect(() => {
const timer = window.setTimeout(() => {
updatePositions()
}, 50)
return () => {
window.clearTimeout(timer)
}
}, [state, updatePositions])
const handleContainer = useCallback(
(element: HTMLDivElement | null) => {
containerRef.current = element
if (containerRef.current) {
containerRef.current.addEventListener(
'review-panel:position',
updatePositions
)
}
},
[updatePositions]
)
useEffect(() => {
return () => {
if (containerRef.current) {
containerRef.current.removeEventListener(
'review-panel:position',
updatePositions
)
}
}
}, [updatePositions])
if (!aggregatedRanges) {
return null
}
return (
<>
{showEmptyState && <ReviewPanelEmptyState />}
{onMoreCommentsAboveClick && (
<ReviewPanelMoreCommentsButton
onClick={onMoreCommentsAboveClick}
direction="upward"
/>
)}
<div ref={handleContainer}>
{addCommentEntries.map(entry => {
const { id, from, to, threadId, top } = entry
return (
<ReviewPanelAddComment
docId={ranges!.docId}
key={id}
from={from}
to={to}
threadId={threadId}
top={top}
/>
)
})}
{aggregatedRanges.changes.map(
change =>
positions.has(change.id) && (
<ReviewPanelChange
docId={ranges!.docId}
key={change.id}
change={change}
top={positions.get(change.id)}
aggregate={aggregatedRanges.aggregates.get(change.id)}
hovered={hoveredEntry === change.id}
onEnter={handleEntryEnter}
onLeave={handleEntryLeave}
/>
)
)}
{aggregatedRanges.comments.map(
comment =>
positions.has(comment.id) && (
<ReviewPanelComment
docId={ranges!.docId}
key={comment.id}
comment={comment}
top={positions.get(comment.id)}
hovered={hoveredEntry === comment.id}
onEnter={handleEntryEnter}
onLeave={handleEntryLeave}
/>
)
)}
</div>
{onMoreCommentsBelowClick && (
<ReviewPanelMoreCommentsButton
onClick={onMoreCommentsBelowClick}
direction="downward"
/>
)}
</>
)
}
export default memo(ReviewPanelCurrentFile)

View File

@@ -0,0 +1,37 @@
import { FC, memo } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
const ReviewPanelDeleteCommentModal: FC<{
onHide: () => void
onDelete: () => void
title: string
message: string
}> = ({ onHide, onDelete, title, message }) => {
const { t } = useTranslation()
return (
<OLModal show onHide={onHide}>
<OLModalHeader>
<OLModalTitle>{title}</OLModalTitle>
</OLModalHeader>
<OLModalBody>{message}</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onHide}>
{t('cancel')}
</OLButton>
<OLButton variant="danger" onClick={onDelete}>
{t('delete')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(ReviewPanelDeleteCommentModal)

View File

@@ -0,0 +1,22 @@
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
function ReviewPanelEmptyState() {
const { t } = useTranslation()
return (
<div className="review-panel-empty-state">
<div className="review-panel-empty-state-inner">
<div className="review-panel-empty-state-comment-icon">
<MaterialIcon type="question_answer" />
</div>
<p>
<strong>{t('no_comments_or_suggestions')}</strong>
</p>
<p>{t('no_one_has_commented_or_left_any_suggestions_yet')}</p>
</div>
</div>
)
}
export default ReviewPanelEmptyState

View File

@@ -0,0 +1,27 @@
import { memo } from 'react'
import { buildName } from '../utils/build-name'
import { ReviewPanelUser } from '../../../../../types/review-panel/review-panel'
import { ChangesUser } from '../context/changes-users-context'
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
const ReviewPanelEntryUser = ({
user,
}: {
user?: ReviewPanelUser | ChangesUser
}) => {
const userName = buildName(user)
return (
<div className="review-panel-entry-user">
<span
className="review-panel-entry-user-color-badge"
style={{
backgroundColor: getBackgroundColorForUserId(user?.id),
}}
/>
{userName}
</div>
)
}
export default memo(ReviewPanelEntryUser)

View File

@@ -0,0 +1,216 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { AnyOperation } from '../../../../../types/change'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-context'
import { isSelectionWithinOp } from '../utils/is-selection-within-op'
import classNames from 'classnames'
import {
clearHighlightRanges,
highlightRanges,
} from '@/features/source-editor/extensions/ranges'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useLayoutContext } from '@/shared/context/layout-context'
import { EditorSelection } from '@codemirror/state'
import MaterialIcon from '@/shared/components/material-icon'
import { OFFSET_FOR_ENTRIES_ABOVE } from '../utils/position-items'
export const ReviewPanelEntry: FC<{
position: number
op: AnyOperation
docId: string
top?: number
className?: string
selectLineOnFocus?: boolean
hoverRanges?: boolean
disabled?: boolean
onEnterEntryIndicator?: () => void
onLeaveEntryIndicator?: () => void
entryIndicator?: 'comment' | 'edit'
}> = ({
children,
position,
top,
op,
className,
selectLineOnFocus = true,
docId,
hoverRanges = true,
disabled,
onEnterEntryIndicator,
onLeaveEntryIndicator,
entryIndicator,
}) => {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
const { openDocWithId, getCurrentDocumentId } = useEditorManagerContext()
const [selected, setSelected] = useState(false)
const [focused, setFocused] = useState(false)
const [textareaFocused, setTextareaFocused] = useState(false)
const { setReviewPanelOpen } = useLayoutContext()
const highlighted = isSelectionWithinOp(op, state.selection.main)
const entryRef = useRef<HTMLDivElement>(null)
const mousePressedRef = useRef(false)
const openReviewPanel = useCallback(() => {
setReviewPanelOpen(true)
}, [setReviewPanelOpen])
const selectEntry = useCallback(
event => {
setFocused(true)
if (event.target instanceof HTMLTextAreaElement) {
const entryBottom =
(entryRef.current?.offsetTop || 0) +
(entryRef.current?.offsetHeight || 0)
if (entryBottom > OFFSET_FOR_ENTRIES_ABOVE) {
// if the entry textarea is visible, no need to select the entry
// so that it doesn't scroll out of view as user types
setTextareaFocused(true)
return
}
}
if (mousePressedRef.current) {
return
}
setSelected(true)
if (!selectLineOnFocus) {
return
}
if (getCurrentDocumentId() !== docId) {
openDocWithId(docId, { gotoOffset: position, keepCurrentView: true })
} else {
setTimeout(() => {
view.dispatch({
selection: EditorSelection.cursor(position),
})
// scroll to line (centered)
const blockInfo = view.lineBlockAt(position)
const coordsAtPos = view.coordsAtPos(position)
const coordsAtLineStart = view.coordsAtPos(blockInfo.from)
let wrappedLineOffset = 0
if (coordsAtPos !== null && coordsAtLineStart !== null) {
wrappedLineOffset = coordsAtPos.top - coordsAtLineStart.top
}
const editorHeight = view.scrollDOM.getBoundingClientRect().height
view.scrollDOM.scrollTo({
top:
blockInfo.top -
editorHeight / 2 +
view.defaultLineHeight +
wrappedLineOffset,
behavior: 'smooth',
})
})
}
},
[
getCurrentDocumentId,
docId,
selectLineOnFocus,
view,
position,
openDocWithId,
]
)
// Clear op highlight on dismount
useEffect(() => {
return () => {
if (hoverRanges) {
setTimeout(() => {
view.dispatch(clearHighlightRanges(op))
})
}
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div
ref={entryRef}
onMouseDown={() => {
mousePressedRef.current = true
}}
onMouseUp={event => {
mousePressedRef.current = false
const isTextSelected = Boolean(window.getSelection()?.toString())
if (!isTextSelected && !selected) {
selectEntry(event)
}
}}
onFocus={selectEntry}
onBlur={() => {
mousePressedRef.current = false
setSelected(false)
setFocused(false)
setTextareaFocused(false)
}}
onMouseEnter={() => {
if (hoverRanges) {
view.dispatch(highlightRanges(op))
}
}}
onMouseLeave={() => {
if (hoverRanges) {
view.dispatch(clearHighlightRanges(op))
}
}}
role="button"
tabIndex={position + 1}
className={classNames(
'review-panel-entry',
{
// 'selected' is used to manually select an entry
// useful if the range is within range and you want to show the one outside the viewport
// it is not enough to just check isSelectionWithinOp for that
'review-panel-entry-selected': selected,
// 'focused' is set even when an entry was clicked but not selected (like clicking on a menu option)
// used to set z-index above other entries (since entries are not ordered the same way visually and in the DOM)
'review-panel-entry-focused': focused,
// 'highlighted' is set if the selection is within op but that doesn't necessarily mean it should be selected
// multiple entries can be highlighted at the same time
'review-panel-entry-highlighted': highlighted,
// 'textarea-focused' only changes entry styling (border, shadow etc)
// it doesnt change selected entry because that moves the cursor
// and repositions entries which can cause textarea to be scrolled out of view
'review-panel-entry-textarea-focused': textareaFocused,
'review-panel-entry-disabled': disabled,
},
className
)}
data-top={top}
data-pos={position}
style={{
position: top === undefined ? 'relative' : 'absolute',
visibility: top === undefined ? 'visible' : 'hidden',
transition: 'top .3s, left .1s, right .1s',
}}
>
{entryIndicator && (
<div
className="review-panel-entry-indicator"
onMouseEnter={onEnterEntryIndicator}
onMouseLeave={onLeaveEntryIndicator}
onMouseDown={openReviewPanel} // Using onMouseDown rather than onClick to guarantee that it fires before onFocus
role="button"
tabIndex={0}
>
<MaterialIcon
type={entryIndicator}
className="review-panel-entry-icon"
/>
</div>
)}
{children}
</div>
)
}

View File

@@ -0,0 +1,101 @@
import { FC, useCallback, useRef, useState } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import { PreventSelectingEntry } from './review-panel-prevent-selecting'
export const ExpandableContent: FC<{
className?: string
content: string
contentLimit?: number
newLineCharsLimit?: number
checkNewLines?: boolean
inline?: boolean
}> = ({
content,
className,
contentLimit = 50,
newLineCharsLimit = 3,
checkNewLines = true,
inline = false,
}) => {
const { t } = useTranslation()
const contentRef = useRef<HTMLDivElement>(null)
const [isExpanded, setIsExpanded] = useState(false)
const limit = checkNewLines
? Math.min(
contentLimit,
indexOfNthLine(content, newLineCharsLimit) ?? Infinity
)
: contentLimit
const isOverflowing = content.length > limit
const handleShowMore = useCallback(() => {
setIsExpanded(true)
contentRef.current?.dispatchEvent(
new CustomEvent('review-panel:position', { bubbles: true })
)
}, [])
const handleShowLess = useCallback(() => {
setIsExpanded(false)
contentRef.current?.dispatchEvent(
new CustomEvent('review-panel:position', { bubbles: true })
)
}, [])
return (
<>
<div
ref={contentRef}
className={classNames('review-panel-expandable-content', className)}
>
{isExpanded ? content : content.slice(0, limit)}
{isOverflowing && !isExpanded && '...'}
</div>
<div
className={classNames('review-panel-expandable-links', {
'review-panel-expandable-inline': inline,
})}
>
<PreventSelectingEntry>
{isExpanded ? (
<OLButton
variant="link"
className="btn-inline-link"
onClick={handleShowLess}
>
{t('show_less')}
</OLButton>
) : (
isOverflowing && (
<OLButton
variant="link"
className="btn-inline-link"
onClick={handleShowMore}
>
{t('show_more')}
</OLButton>
)
)}
</PreventSelectingEntry>
</div>
</>
)
}
function indexOfNthLine(content: string, n: number) {
if (n < 1) return null
let line = 0
for (let i = 0; i < content.length; i++) {
if (content[i] === '\n') {
line++
if (line === n) {
return i
}
}
}
return null
}

View File

@@ -0,0 +1,46 @@
import { FC, memo, useState } from 'react'
import { ReviewPanelResolvedThreadsButton } from './review-panel-resolved-threads-button'
import { ReviewPanelTrackChangesMenu } from './review-panel-track-changes-menu'
import ReviewPanelTrackChangesMenuButton from './review-panel-track-changes-menu-button'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { PanelHeading } from '@/shared/components/panel-heading'
import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled')
const ReviewPanelHeader: FC = () => {
const [trackChangesMenuExpanded, setTrackChangesMenuExpanded] =
useState(false)
const { setReviewPanelOpen } = useLayoutContext()
const { setIsOpen: setRailIsOpen } = useRailContext()
const { t } = useTranslation()
const newEditor = useIsNewEditorEnabled()
const handleClose = newEditor
? () => setRailIsOpen(false)
: () => setReviewPanelOpen(false)
return (
<div className="review-panel-header">
<PanelHeading title={t('review')} handleClose={handleClose}>
{isReviewerRoleEnabled && <ReviewPanelResolvedThreadsButton />}
</PanelHeading>
{!isReviewerRoleEnabled && (
<div className="review-panel-tools">
<ReviewPanelResolvedThreadsButton />
<ReviewPanelTrackChangesMenuButton
menuExpanded={trackChangesMenuExpanded}
setMenuExpanded={setTrackChangesMenuExpanded}
/>
</div>
)}
{trackChangesMenuExpanded && <ReviewPanelTrackChangesMenu />}
</div>
)
}
export default memo(ReviewPanelHeader)

View File

@@ -0,0 +1,155 @@
import { FC, useCallback, useState } from 'react'
import {
CommentId,
ReviewPanelCommentThreadMessage,
} from '../../../../../types/review-panel/review-panel'
import { useTranslation } from 'react-i18next'
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
import ReviewPanelCommentOptions from './review-panel-comment-options'
import { ExpandableContent } from './review-panel-expandable-content'
import ReviewPanelDeleteCommentModal from './review-panel-delete-comment-modal'
import { useUserContext } from '@/shared/context/user-context'
import ReviewPanelEntryUser from './review-panel-entry-user'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { PreventSelectingEntry } from './review-panel-prevent-selecting'
export const ReviewPanelMessage: FC<{
message: ReviewPanelCommentThreadMessage
hasReplies: boolean
isReply: boolean
onResolve?: () => Promise<void>
onEdit?: (commentId: CommentId, content: string) => Promise<void>
onDelete?: () => void
isThreadResolved: boolean
}> = ({
message,
isReply,
hasReplies,
onResolve,
onEdit,
onDelete,
isThreadResolved,
}) => {
const { t } = useTranslation()
const [editing, setEditing] = useState(false)
const [deleting, setDeleting] = useState(false)
const [content, setContent] = useState(message.content)
const user = useUserContext()
const permissions = usePermissionsContext()
const isCommentAuthor = user.id === message.user.id
const canEdit = isCommentAuthor && permissions.comment
const canResolve =
permissions.resolveAllComments ||
(permissions.resolveOwnComments && isCommentAuthor)
const canDelete = canResolve
const handleEditOption = useCallback(() => setEditing(true), [])
const showDeleteModal = useCallback(() => setDeleting(true), [])
const hideDeleteModal = useCallback(() => setDeleting(false), [])
const handleSubmit = useCallback(() => {
onEdit?.(message.id, content)
setEditing(false)
}, [content, message.id, onEdit])
const handleDelete = useCallback(() => {
onDelete?.()
setDeleting(false)
}, [onDelete])
return (
<div className="review-panel-comment">
<div className="review-panel-entry-header">
<div>
<ReviewPanelEntryUser user={message.user} />
<div className="review-panel-entry-time">
<FormatTimeBasedOnYear date={message.timestamp} />
</div>
</div>
<div className="review-panel-entry-actions">
{!editing && !isReply && !isThreadResolved && canResolve && (
<PreventSelectingEntry>
<OLTooltip
id="resolve-thread"
overlayProps={{ placement: 'bottom' }}
description={t('resolve_comment')}
tooltipProps={{ className: 'review-panel-tooltip' }}
>
<button
type="button"
tabIndex={0}
className="btn"
onClick={onResolve}
>
<MaterialIcon
type="check"
className="review-panel-entry-actions-icon"
accessibilityLabel={t('resolve_comment')}
/>
</button>
</OLTooltip>
</PreventSelectingEntry>
)}
{!editing && !isThreadResolved && (
<PreventSelectingEntry>
<ReviewPanelCommentOptions
canDelete={canDelete}
canEdit={canEdit}
onEdit={handleEditOption}
onDelete={showDeleteModal}
id={message.id}
/>
</PreventSelectingEntry>
)}
</div>
</div>
{editing ? (
<AutoExpandingTextArea
className="review-panel-comment-input review-panel-comment-edit"
onBlur={handleSubmit}
onChange={e => setContent(e.target.value)}
onKeyPress={e => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey &&
content
) {
e.preventDefault()
;(e.target as HTMLTextAreaElement).blur()
}
}}
value={content}
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
/>
) : (
<ExpandableContent
className="review-panel-comment-body"
contentLimit={100}
checkNewLines
content={message.content}
/>
)}
{deleting && (
<ReviewPanelDeleteCommentModal
onHide={hideDeleteModal}
onDelete={handleDelete}
title={hasReplies ? t('delete_comment_thread') : t('delete_comment')}
message={
hasReplies
? t('delete_comment_thread_message')
: t('delete_comment_message')
}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { FC, memo } from 'react'
import MaterialIcon from '@/shared/components/material-icon'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
const MoreCommentsButton: FC<{
onClick: () => void
direction: 'upward' | 'downward'
}> = ({ onClick, direction }) => {
const { t } = useTranslation()
return (
<div
className={classNames('review-panel-more-comments-button-container', {
downwards: direction === 'downward',
upwards: direction === 'upward',
})}
>
<OLButton variant="secondary" size="sm" onClick={onClick}>
<MaterialIcon type={`arrow_${direction}_alt`} />
{t('more_comments')}
</OLButton>
</div>
)
}
export default memo(MoreCommentsButton)

View File

@@ -0,0 +1,12 @@
import React, { FC, lazy, Suspense } from 'react'
import LoadingSpinner from '@/shared/components/loading-spinner'
const ReviewPanelContainer = lazy(() => import('./review-panel-container'))
export const ReviewPanelNew: FC = () => {
return (
<Suspense fallback={<LoadingSpinner delay={500} />}>
<ReviewPanelContainer />
</Suspense>
)
}

View File

@@ -0,0 +1,101 @@
import { FC, useMemo } from 'react'
import { MainDocument } from '../../../../../types/project-settings'
import { Ranges } from '../context/ranges-context'
import { ReviewPanelComment } from './review-panel-comment'
import { ReviewPanelChange } from './review-panel-change'
import {
isCommentOperation,
isDeleteChange,
isInsertChange,
} from '@/utils/operations'
import {
Change,
CommentOperation,
DeleteOperation,
EditOperation,
} from '../../../../../types/change'
import { canAggregate } from '../utils/can-aggregate'
import useOverviewFileCollapsed from '../hooks/use-overview-file-collapsed'
import { useThreadsContext } from '../context/threads-context'
import { CollapsibleFileHeader } from '@/shared/components/collapsible-file-header'
export const ReviewPanelOverviewFile: FC<{
doc: MainDocument
ranges: Ranges
}> = ({ doc, ranges }) => {
const { collapsed, toggleCollapsed } = useOverviewFileCollapsed(doc.doc.id)
const threads = useThreadsContext()
const { aggregates, changes } = useMemo(() => {
const changes: Change<EditOperation>[] = []
const aggregates: Map<string, Change<DeleteOperation>> = new Map()
let precedingChange: Change<EditOperation> | null = null
for (const change of ranges.changes) {
if (
precedingChange &&
isInsertChange(precedingChange) &&
isDeleteChange(change) &&
canAggregate(change, precedingChange)
) {
aggregates.set(precedingChange.id, change)
} else {
changes.push(change)
}
precedingChange = change
}
return { aggregates, changes }
}, [ranges])
const entries = useMemo(() => {
const unresolvedComments = ranges.comments.filter(comment => {
const thread = threads?.[comment.op.t]
return thread && thread.messages.length > 0 && !thread.resolved
})
return [...changes, ...unresolvedComments].sort((a, b) => a.op.p - b.op.p)
}, [changes, ranges.comments, threads])
if (entries.length === 0) {
return null
}
return (
<>
<div>
<CollapsibleFileHeader
name={doc.doc.name}
count={entries.length}
collapsed={collapsed}
toggleCollapsed={toggleCollapsed}
/>
{!collapsed && (
<div className="review-panel-overview-file-entries">
{entries.map(entry =>
isCommentOperation(entry.op) ? (
<ReviewPanelComment
key={entry.id}
comment={entry as Change<CommentOperation>}
docId={doc.doc.id}
hoverRanges={false}
/>
) : (
<ReviewPanelChange
key={entry.id}
change={entry as Change<EditOperation>}
aggregate={aggregates.get(entry.id)}
editable={false}
docId={doc.doc.id}
hoverRanges={false}
/>
)
)}
</div>
)}
</div>
<div className="review-panel-overfile-divider" />
</>
)
}

View File

@@ -0,0 +1,68 @@
import { FC, useMemo } from 'react'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { Ranges, useRangesContext } from '../context/ranges-context'
import { useTranslation } from 'react-i18next'
import { ReviewPanelOverviewFile } from './review-panel-overview-file'
import ReviewPanelEmptyState from './review-panel-empty-state'
import useProjectRanges from '../hooks/use-project-ranges'
export const ReviewPanelOverview: FC = () => {
const { t } = useTranslation()
const { docs } = useFileTreeData()
const docRanges = useRangesContext()
const { projectRanges, error } = useProjectRanges()
const rangesForDocs = useMemo(() => {
if (docs && docRanges && projectRanges) {
const rangesForDocs = new Map<string, Ranges>()
for (const doc of docs) {
const ranges =
doc.doc.id === docRanges.docId
? docRanges
: projectRanges.get(doc.doc.id)
if (ranges) {
rangesForDocs.set(doc.doc.id, ranges)
}
}
return rangesForDocs
}
}, [docRanges, docs, projectRanges])
const showEmptyState = useMemo((): boolean => {
if (!rangesForDocs) {
// data isn't loaded yet
return false
}
for (const ranges of rangesForDocs.values()) {
if (ranges.changes.length > 0 || ranges.comments.length > 0) {
return false
}
}
return true
}, [rangesForDocs])
return (
<div className="review-panel-overview">
{error && <div>{t('something_went_wrong')}</div>}
{showEmptyState && <ReviewPanelEmptyState />}
{docs && rangesForDocs && (
<div>
{docs.map(doc => {
const ranges = rangesForDocs.get(doc.doc.id)
return (
ranges && <ReviewPanelOverviewFile doc={doc} ranges={ranges} />
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,21 @@
const stopPropagation = (e: React.FocusEvent | React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
}
export const PreventSelectingEntry = ({
children,
}: {
children: React.ReactNode
}) => {
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
onMouseDown={stopPropagation}
onFocus={stopPropagation}
onMouseUp={stopPropagation}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { FC } from 'react'
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
import { useTranslation } from 'react-i18next'
import ReviewPanelEntryUser from './review-panel-entry-user'
const ReviewPanelResolvedMessage: FC<{
thread: ReviewPanelResolvedCommentThread
}> = ({ thread }) => {
const { t } = useTranslation()
return (
<div className="review-panel-comment">
<div className="review-panel-entry-header">
<div>
<ReviewPanelEntryUser user={thread.resolved_by_user} />
<div className="review-panel-entry-time">
<FormatTimeBasedOnYear date={thread.resolved_at} />
</div>
</div>
</div>
<div className="review-panel-comment-body review-panel-resolved-message">
<i>{t('marked_as_resolved')}</i>
</div>
</div>
)
}
export default ReviewPanelResolvedMessage

View File

@@ -0,0 +1,130 @@
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'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import { ExpandableContent } from './review-panel-expandable-content'
import { ReviewPanelCommentContent } from './review-panel-comment-content'
import { Change, CommentOperation } from '../../../../../types/change'
import classNames from 'classnames'
import { debugConsole } from '@/utils/debugging'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useUserContext } from '@/shared/context/user-context'
export const ReviewPanelResolvedThread: FC<{
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 permissions = usePermissionsContext()
const user = useUserContext()
const isCommentAuthor = user.id === comment.metadata?.user_id
const canDelete =
permissions.resolveAllComments ||
(permissions.resolveOwnComments && isCommentAuthor)
const canReopen = permissions.comment
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={classNames('review-panel-resolved-comment', {
'review-panel-resolved-disabled': processing,
})}
key={id}
>
<div className="review-panel-resolved-comment-header">
<div>
<Trans
i18nKey="from_filename"
components={[
// eslint-disable-next-line react/jsx-key
<span className="review-panel-resolved-comment-filename" />,
]}
values={{ filename: docName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
<div className="review-panel-resolved-comment-buttons">
{canReopen && (
<OLTooltip
id="reopen-thread"
overlayProps={{ placement: 'bottom' }}
description={t('reopen')}
>
<button
type="button"
className="btn"
onClick={handleReopenThread}
>
<MaterialIcon type="refresh" accessibilityLabel={t('reopen')} />
</button>
</OLTooltip>
)}
{canDelete && (
<OLTooltip
id="delete-thread"
overlayProps={{ placement: 'bottom' }}
description={t('delete')}
>
<button
type="button"
className="btn"
onClick={handleDeleteThread}
>
<MaterialIcon type="delete" accessibilityLabel={t('delete')} />
</button>
</OLTooltip>
)}
</div>
</div>
<div className="review-panel-resolved-comment-quoted-text">
<div className="review-panel-resolved-comment-quoted-text-label">
{t('quoted_text')}
</div>
<ExpandableContent
className="review-panel-resolved-comment-quoted-text-quote"
content={comment?.op.c}
checkNewLines
/>
</div>
<ReviewPanelCommentContent comment={comment} isResolved />
</div>
)
}

View File

@@ -0,0 +1,55 @@
import React, { FC, useRef, useState } from 'react'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { ReviewPanelResolvedThreadsMenu } from './review-panel-resolved-threads-menu'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import getMeta from '@/utils/meta'
export const ReviewPanelResolvedThreadsButton: FC = () => {
const [expanded, setExpanded] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const { t } = useTranslation()
return (
<>
<OLTooltip
id="resolved-comments"
overlayProps={{ placement: 'bottom' }}
description={t('resolved_comments')}
>
<button
className={
getMeta('ol-isReviewerRoleEnabled')
? 'review-panel-resolved-comments-toggle-reviewer-role'
: 'review-panel-resolved-comments-toggle'
}
ref={buttonRef}
onClick={() => setExpanded(true)}
>
<MaterialIcon type="inbox" />
</button>
</OLTooltip>
{expanded && (
<OLOverlay
show
onHide={() => setExpanded(false)}
transition={false}
container={this}
containerPadding={0}
placement="bottom"
rootClose
target={buttonRef.current}
>
<OLPopover
id="popover-resolved-threads"
className="review-panel-resolved-comments"
>
<ReviewPanelResolvedThreadsMenu />
</OLPopover>
</OLOverlay>
)}
</>
)
}

View File

@@ -0,0 +1,109 @@
import React, { FC, useMemo } from 'react'
import { useThreadsContext } from '../context/threads-context'
import { useTranslation } from 'react-i18next'
import { ReviewPanelResolvedThread } from './review-panel-resolved-thread'
import useProjectRanges from '../hooks/use-project-ranges'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { Change, CommentOperation } from '../../../../../types/change'
import { ThreadId } from '../../../../../types/review-panel/review-panel'
import LoadingSpinner from '@/shared/components/loading-spinner'
import OLBadge from '@/features/ui/components/ol/ol-badge'
export const ReviewPanelResolvedThreadsMenu: FC = () => {
const { t } = useTranslation()
const threads = useThreadsContext()
const { docs } = useFileTreeData()
const { projectRanges, loading } = useProjectRanges()
const docNameForThread = useMemo(() => {
const docNameForThread = new Map<string, string>()
for (const [docId, ranges] of projectRanges?.entries() ?? []) {
const docName = docs?.find(doc => doc.doc.id === docId)?.doc.name
if (docName !== undefined) {
for (const comment of ranges.comments) {
const threadId = comment.op.t
docNameForThread.set(threadId, docName)
}
}
}
return docNameForThread
}, [docs, projectRanges])
const allComments = useMemo(() => {
const allComments = new Map<string, Change<CommentOperation>>()
// eslint-disable-next-line no-unused-vars
for (const [_, ranges] of projectRanges?.entries() ?? []) {
for (const comment of ranges.comments) {
allComments.set(comment.op.t, comment)
}
}
return allComments
}, [projectRanges])
const resolvedThreads = useMemo(() => {
if (!threads) {
return []
}
const allResolvedThreads = []
for (const [id, thread] of Object.entries(threads)) {
if (thread.resolved) {
allResolvedThreads.push({ thread, id })
}
}
allResolvedThreads.sort((a, b) => {
return Date.parse(b.thread.resolved_at) - Date.parse(a.thread.resolved_at)
})
return allResolvedThreads.filter(thread => allComments.has(thread.id))
}, [threads, allComments])
if (loading) {
return <LoadingSpinner className="ms-auto me-auto" />
}
if (!resolvedThreads.length) {
return (
<div className="review-panel-resolved-comments-empty">
{t('no_resolved_comments')}
</div>
)
}
return (
<>
<div className="review-panel-resolved-comments-header">
<div className="review-panel-resolved-comments-label">
{t('resolved_comments')}
</div>
<OLBadge
bg="light"
text="dark"
className="review-panel-resolved-comments-count"
>
{resolvedThreads.length}
</OLBadge>
</div>
{resolvedThreads.map(thread => {
const comment = allComments.get(thread.id)
if (!comment) {
return null
}
return (
<ReviewPanelResolvedThread
key={thread.id}
id={thread.id as ThreadId}
comment={comment}
docName={docNameForThread.get(thread.id) ?? t('unknown')}
/>
)
})}
</>
)
}

View File

@@ -0,0 +1,40 @@
import { FC, memo } from 'react'
import classnames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import {
useReviewPanelViewActionsContext,
useReviewPanelViewContext,
} from '../context/review-panel-view-context'
const ReviewPanelTabs: FC = () => {
const subView = useReviewPanelViewContext()
const { setView: setSubView } = useReviewPanelViewActionsContext()
const { t } = useTranslation()
return (
<>
<button
className={classnames('review-panel-tab', {
'review-panel-tab-active': subView === 'cur_file',
})}
onClick={() => setSubView('cur_file')}
>
<MaterialIcon type="description" />
{t('current_file')}
</button>
<button
className={classnames('review-panel-tab', {
'review-panel-tab-active': subView === 'overview',
})}
onClick={() => setSubView('overview')}
>
<MaterialIcon type="list" />
{t('overview')}
</button>
</>
)
}
export default memo(ReviewPanelTabs)

View File

@@ -0,0 +1,60 @@
import { FC, memo, useState } from 'react'
import { Trans } from 'react-i18next'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import MaterialIcon from '@/shared/components/material-icon'
import { useProjectContext } from '@/shared/context/project-context'
import UpgradeTrackChangesModalLegacy from './upgrade-track-changes-modal-legacy'
import { send, sendMB } from '@/infrastructure/event-tracking'
const sendAnalytics = () => {
send('subscription-funnel', 'editor-click-feature', 'real-time-track-changes')
sendMB('paywall-prompt', {
'paywall-type': 'track-changes',
})
}
const ReviewPanelTrackChangesMenuButton: FC<{
menuExpanded: boolean
setMenuExpanded: React.Dispatch<React.SetStateAction<boolean>>
}> = ({ menuExpanded, setMenuExpanded }) => {
const project = useProjectContext()
const { wantTrackChanges } = useEditorManagerContext()
const [showModal, setShowModal] = useState(false)
const handleTrackChangesMenuExpand = () => {
if (project.features.trackChanges) {
setMenuExpanded(value => !value)
} else {
sendAnalytics()
setShowModal(true)
}
}
return (
<>
<button
className="track-changes-menu-button"
onClick={handleTrackChangesMenuExpand}
>
{wantTrackChanges && <div className="track-changes-indicator-circle" />}
{wantTrackChanges ? (
<Trans
i18nKey="track_changes_is_on"
components={{ strong: <strong /> }}
/>
) : (
<Trans
i18nKey="track_changes_is_off"
components={{ strong: <strong /> }}
/>
)}
<MaterialIcon type={menuExpanded ? 'expand_more' : 'chevron_right'} />
</button>
<UpgradeTrackChangesModalLegacy show={showModal} setShow={setShowModal} />
</>
)
}
export default memo(ReviewPanelTrackChangesMenuButton)

View File

@@ -0,0 +1,92 @@
import { FC } from 'react'
import TrackChangesToggle from '@/features/review-panel-new/components/track-changes-toggle'
import { useProjectContext } from '@/shared/context/project-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useTranslation } from 'react-i18next'
import {
useTrackChangesStateActionsContext,
useTrackChangesStateContext,
} from '../context/track-changes-state-context'
import { useChangesUsersContext } from '../context/changes-users-context'
import { buildName } from '../utils/build-name'
export const ReviewPanelTrackChangesMenu: FC = () => {
const { t } = useTranslation()
const permissions = usePermissionsContext()
const project = useProjectContext()
const trackChanges = useTrackChangesStateContext()
const { saveTrackChanges } = useTrackChangesStateActionsContext()
const changesUsers = useChangesUsersContext()
if (trackChanges === undefined || !changesUsers) {
return null
}
const { onForEveryone, onForGuests, onForMembers } = trackChanges
const canToggle = project.features.trackChanges && permissions.write
return (
<div className="rp-tc-state">
<div className="rp-tc-state-item">
<span className="rp-tc-state-item-name">{t('tc_everyone')}</span>
<TrackChangesToggle
id="track-changes-everyone"
description={t('track_changes_for_everyone')}
handleToggle={() =>
saveTrackChanges(onForEveryone ? { on_for: {} } : { on: true })
}
value={onForEveryone}
disabled={!canToggle}
/>
</div>
{[project.owner, ...project.members].map(member => {
const user = changesUsers.get(member._id) ?? member
const name = buildName(user)
const value = onForEveryone || onForMembers[member._id] === true
return (
<div key={member._id} className="rp-tc-state-item">
<span className="rp-tc-state-item-name">{name}</span>
<TrackChangesToggle
id={`track-changes-${member._id}`}
description={t('track_changes_for_x', { name })}
handleToggle={() => {
saveTrackChanges({
on_for: {
...onForMembers,
[member._id]: !value,
},
on_for_guests: onForGuests,
})
}}
value={value}
disabled={!canToggle || onForEveryone}
/>
</div>
)
})}
<div className="rp-tc-state-item">
<span className="rp-tc-state-item-name">{t('tc_guests')}</span>
<TrackChangesToggle
id="track-changes-guests"
description={t('track_changes_for_guests')}
handleToggle={() =>
saveTrackChanges({
on_for: onForMembers,
on_for_guests: !onForGuests,
})
}
value={onForGuests}
disabled={!canToggle || onForEveryone}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { FC, memo, useMemo } from 'react'
import ReviewPanelTabs from './review-panel-tabs'
import ReviewPanelHeader from './review-panel-header'
import ReviewPanelCurrentFile from './review-panel-current-file'
import { ReviewPanelOverview } from './review-panel-overview'
import classnames from 'classnames'
import { useReviewPanelStyles } from '@/features/review-panel-new/hooks/use-review-panel-styles'
import { useReviewPanelViewContext } from '../context/review-panel-view-context'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
const ReviewPanel: FC<{ mini?: boolean }> = ({ mini = false }) => {
const choosenSubView = useReviewPanelViewContext()
const newEditor = useIsNewEditorEnabled()
const activeSubView = useMemo(
() => (mini ? 'cur_file' : choosenSubView),
[choosenSubView, mini]
)
const style = useReviewPanelStyles(mini)
const className = classnames('review-panel-container', {
'review-panel-mini': mini,
'review-panel-subview-overview': activeSubView === 'overview',
})
return (
<div className={className} style={style}>
<div id="review-panel-inner" className="review-panel-inner">
{!newEditor && !mini && <ReviewPanelHeader />}
{activeSubView === 'cur_file' && <ReviewPanelCurrentFile />}
{activeSubView === 'overview' && <ReviewPanelOverview />}
<div className="review-panel-footer">
<ReviewPanelTabs />
</div>
</div>
</div>
)
}
export default memo(ReviewPanel)

View File

@@ -0,0 +1,277 @@
import {
CSSProperties,
FC,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import ReactDOM from 'react-dom'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-context'
import {
buildAddNewCommentRangeEffect,
reviewTooltipStateField,
} from '@/features/source-editor/extensions/review-tooltip'
import { EditorView, getTooltip } from '@codemirror/view'
import useViewerPermissions from '@/shared/hooks/use-viewer-permissions'
import usePreviousValue from '@/shared/hooks/use-previous-value'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useReviewPanelViewActionsContext } from '../context/review-panel-view-context'
import {
useRangesActionsContext,
useRangesContext,
} from '../context/ranges-context'
import { isInsertOperation } from '@/utils/operations'
import { isCursorNearViewportEdge } from '@/features/source-editor/utils/is-cursor-near-edge'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { numberOfChangesInSelection } from '../utils/changes-in-selection'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import classNames from 'classnames'
import useEventListener from '@/shared/hooks/use-event-listener'
import getMeta from '@/utils/meta'
import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled')
const TRACK_CHANGES_ON_WIDGET_HEIGHT = 25
const EDIT_MODE_SWITCH_WIDGET_HEIGHT = 40
const CM_LINE_RIGHT_PADDING = isReviewerRoleEnabled ? 8 : 2
const TOOLTIP_SHOW_DELAY = 120
const ReviewTooltipMenu: FC = () => {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
const isViewer = useViewerPermissions()
const [show, setShow] = useState(true)
const { setView } = useReviewPanelViewActionsContext()
const { setReviewPanelOpen } = useLayoutContext()
const { openTab: openRailTab } = useRailContext()
const newEditor = useIsNewEditorEnabled()
const tooltipState = state.field(reviewTooltipStateField, false)?.tooltip
const previousTooltipState = usePreviousValue(tooltipState)
useEffect(() => {
if (tooltipState !== null && previousTooltipState === null) {
setShow(true)
}
}, [tooltipState, previousTooltipState])
const addComment = useCallback(() => {
const { main } = view.state.selection
if (main.empty) {
return
}
if (newEditor) {
openRailTab('review-panel')
} else {
setReviewPanelOpen(true)
}
setView('cur_file')
const effects = isCursorNearViewportEdge(view, main.anchor)
? [
buildAddNewCommentRangeEffect(main),
EditorView.scrollIntoView(main.anchor, { y: 'center' }),
]
: [buildAddNewCommentRangeEffect(main)]
view.dispatch({ effects })
setShow(false)
}, [setReviewPanelOpen, setView, setShow, view, openRailTab, newEditor])
useEventListener('add-new-review-comment', addComment)
if (isViewer || !show || !tooltipState) {
return null
}
const tooltipView = getTooltip(view, tooltipState)
if (!tooltipView) {
return null
}
return ReactDOM.createPortal(
<ReviewTooltipMenuContent onAddComment={addComment} />,
tooltipView.dom
)
}
const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
onAddComment,
}) => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
const { reviewPanelOpen } = useLayoutContext()
const ranges = useRangesContext()
const { acceptChanges, rejectChanges } = useRangesActionsContext()
const { showGenericConfirmModal } = useModalsContext()
const { wantTrackChanges } = useEditorManagerContext()
const [tooltipStyle, setTooltipStyle] = useState<CSSProperties | undefined>()
const [visible, setVisible] = useState(false)
const changeIdsInSelection = useMemo(() => {
return (ranges?.changes ?? [])
.filter(({ op }) => {
const opFrom = op.p
const opLength = isInsertOperation(op) ? op.i.length : 0
const opTo = opFrom + opLength
const selection = state.selection.main
return opFrom >= selection.from && opTo <= selection.to
})
.map(({ id }) => id)
}, [ranges, state.selection.main])
const acceptChangesHandler = useCallback(() => {
const nChanges = numberOfChangesInSelection(
ranges,
view.state.selection.main
)
showGenericConfirmModal({
message: t('confirm_accept_selected_changes', { count: nChanges }),
title: t('accept_selected_changes'),
onConfirm: () => {
acceptChanges(...changeIdsInSelection)
},
primaryVariant: 'danger',
})
}, [
acceptChanges,
changeIdsInSelection,
ranges,
showGenericConfirmModal,
view,
t,
])
const rejectChangesHandler = useCallback(() => {
const nChanges = numberOfChangesInSelection(
ranges,
view.state.selection.main
)
showGenericConfirmModal({
message: t('confirm_reject_selected_changes', { count: nChanges }),
title: t('reject_selected_changes'),
onConfirm: () => {
rejectChanges(...changeIdsInSelection)
},
primaryVariant: 'danger',
})
}, [
showGenericConfirmModal,
t,
ranges,
view,
rejectChanges,
changeIdsInSelection,
])
const showChangesButtons = changeIdsInSelection.length > 0
useEffect(() => {
view.requestMeasure({
key: 'review-tooltip-outside-viewport',
read(view) {
const cursorCoords = view.coordsAtPos(view.state.selection.main.head)
if (!cursorCoords) {
return
}
const scrollDomRect = view.scrollDOM.getBoundingClientRect()
const contentDomRect = view.contentDOM.getBoundingClientRect()
const editorRightPos = contentDomRect.right - CM_LINE_RIGHT_PADDING
if (
cursorCoords.top > scrollDomRect.top &&
cursorCoords.top < scrollDomRect.bottom
) {
return
}
let widgetOffset = 0
if (isReviewerRoleEnabled) {
widgetOffset = EDIT_MODE_SWITCH_WIDGET_HEIGHT
} else if (wantTrackChanges && !reviewPanelOpen) {
widgetOffset = TRACK_CHANGES_ON_WIDGET_HEIGHT
}
return {
position: 'fixed' as const,
top: scrollDomRect.top + widgetOffset,
right: window.innerWidth - editorRightPos,
}
},
write(res) {
setTooltipStyle(res)
},
})
}, [view, reviewPanelOpen, wantTrackChanges])
useEffect(() => {
setVisible(false)
const timeout = setTimeout(() => {
setVisible(true)
}, TOOLTIP_SHOW_DELAY)
return () => {
clearTimeout(timeout)
}
}, [])
return (
<div
className={classNames('review-tooltip-menu', {
'review-tooltip-menu-visible': visible,
})}
style={tooltipStyle}
>
<button
className="review-tooltip-menu-button review-tooltip-add-comment-button"
onClick={onAddComment}
>
<MaterialIcon type="chat" />
{t('add_comment')}
</button>
{showChangesButtons && (
<>
<div className="review-tooltip-menu-divider" />
<OLTooltip
id="accept-all-changes"
description={t('accept_selected_changes')}
>
<button
className="review-tooltip-menu-button"
onClick={acceptChangesHandler}
>
<MaterialIcon type="check" />
</button>
</OLTooltip>
<OLTooltip
id="reject-all-changes"
description={t('reject_selected_changes')}
>
<button
className="review-tooltip-menu-button"
onClick={rejectChangesHandler}
>
<MaterialIcon type="clear" />
</button>
</OLTooltip>
</>
)}
</div>
)
}
export default ReviewTooltipMenu

View File

@@ -0,0 +1,36 @@
import { Trans } from 'react-i18next'
import { EditorView } from '@codemirror/view'
import classnames from 'classnames'
import { useCodeMirrorStateContext } from '@/features/source-editor/components/codemirror-context'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useCallback } from 'react'
function TrackChangesOnWidget() {
const { setReviewPanelOpen } = useLayoutContext()
const state = useCodeMirrorStateContext()
const darkTheme = state.facet(EditorView.darkTheme)
const openReviewPanel = useCallback(() => {
setReviewPanelOpen(true)
}, [setReviewPanelOpen])
return (
<div className="review-panel-in-editor-widgets">
<div className="review-panel-in-editor-widgets-inner">
<button
className={classnames('review-panel-track-changes-indicator', {
'review-panel-track-changes-indicator-on-dark': darkTheme,
})}
onClick={openReviewPanel}
>
<Trans
i18nKey="track_changes_is_on"
components={{ strong: <strong /> }}
/>
</button>
</div>
</div>
)
}
export default TrackChangesOnWidget

View File

@@ -0,0 +1,29 @@
import OLFormSwitch from '@/features/ui/components/ol/ol-form-switch'
type TrackChangesToggleProps = {
id: string
description: string
disabled: boolean
handleToggle: () => void
value: boolean
}
function TrackChangesToggle({
id,
description,
disabled,
handleToggle,
value,
}: TrackChangesToggleProps) {
return (
<OLFormSwitch
id={`input-switch-${id}`}
disabled={disabled}
onChange={handleToggle}
checked={value}
label={description}
/>
)
}
export default TrackChangesToggle

View File

@@ -0,0 +1,109 @@
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import { useUserContext } from '@/shared/context/user-context'
import teaserVideo from '../images/teaser-track-changes.mp4'
import teaserImage from '../images/teaser-track-changes.gif'
import { startFreeTrial, upgradePlan } from '@/main/account-upgrade'
import { memo } from 'react'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import MaterialIcon from '@/shared/components/material-icon'
type UpgradeTrackChangesModalProps = {
show: boolean
setShow: React.Dispatch<React.SetStateAction<boolean>>
}
function UpgradeTrackChangesModalLegacy({
show,
setShow,
}: UpgradeTrackChangesModalProps) {
const { t } = useTranslation()
const project = useProjectContext()
const user = useUserContext()
return (
<OLModal show={show} onHide={() => setShow(false)}>
<OLModalHeader closeButton>
<OLModalTitle>{t('upgrade_to_track_changes')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<div className="teaser-video-container">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video className="teaser-video" autoPlay loop>
<source src={teaserVideo} type="video/mp4" />
<img
src={teaserImage}
alt={t('demonstrating_track_changes_feature')}
/>
</video>
</div>
<h4 className="teaser-title">
{t('see_changes_in_your_documents_live')}
</h4>
<OLRow>
<OLCol lg={{ span: 10, offset: 1 }}>
<ul className="list-unstyled">
{[
t('track_any_change_in_real_time'),
t('review_your_peers_work'),
t('accept_or_reject_each_changes_individually'),
].map(translation => (
<li key={translation}>
<MaterialIcon type="check" className="align-text-bottom" />
&nbsp;{translation}
</li>
))}
</ul>
</OLCol>
</OLRow>
<p className="small">
{t('already_subscribed_try_refreshing_the_page')}
</p>
{project.owner && (
<div className="text-center">
{project.owner._id === user.id ? (
user.allowedFreeTrial ? (
<OLButton
variant="primary"
onClick={() => startFreeTrial('track-changes')}
>
{t('try_it_for_free')}
</OLButton>
) : (
<OLButton
variant="primary"
onClick={() => upgradePlan('project-sharing')}
>
{t('upgrade')}
</OLButton>
)
) : (
<p>
<strong>
{t(
'please_ask_the_project_owner_to_upgrade_to_track_changes'
)}
</strong>
</p>
)}
</div>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={() => setShow(false)}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(UpgradeTrackChangesModalLegacy)

View File

@@ -0,0 +1,104 @@
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import { useUserContext } from '@/shared/context/user-context'
import teaserVideo from '../images/teaser-track-changes.mp4'
import teaserImage from '../images/teaser-track-changes.gif'
import { startFreeTrial, upgradePlan } from '@/main/account-upgrade'
import { memo } from 'react'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import MaterialIcon from '@/shared/components/material-icon'
type UpgradeTrackChangesModalProps = {
show: boolean
setShow: React.Dispatch<React.SetStateAction<boolean>>
}
function UpgradeTrackChangesModal({
show,
setShow,
}: UpgradeTrackChangesModalProps) {
const { t } = useTranslation()
const project = useProjectContext()
const user = useUserContext()
return (
<OLModal show={show} onHide={() => setShow(false)}>
<OLModalHeader closeButton>
<OLModalTitle>{t('upgrade_to_review')}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="upgrade-track-changes-modal">
<div className="teaser-video-container">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video className="teaser-video" autoPlay loop>
<source src={teaserVideo} type="video/mp4" />
<img
src={teaserImage}
alt={t('demonstrating_track_changes_feature')}
/>
</video>
</div>
<h4 className="teaser-title">{t('get_real_time_track_changes')}</h4>
<OLRow>
<OLCol lg={{ span: 10, offset: 1 }}>
<ul className="list-unstyled">
{[
t('see_suggestions_from_collaborators'),
t('accept_or_reject_individual_edits'),
t('access_all_premium_features'),
].map(translation => (
<li key={translation}>
<MaterialIcon type="check" className="check-icon" />
<span>{translation}</span>
</li>
))}
</ul>
</OLCol>
</OLRow>
{project.owner && (
<div className="text-center">
{project.owner._id === user.id ? (
user.allowedFreeTrial ? (
<OLButton
variant="premium"
onClick={() => startFreeTrial('track-changes')}
>
{t('try_it_for_free')}
</OLButton>
) : (
<OLButton
variant="premium"
onClick={() => upgradePlan('project-sharing')}
>
{t('upgrade')}
</OLButton>
)
) : (
<p>
<strong>
{t(
'please_ask_the_project_owner_to_upgrade_to_track_changes'
)}
</strong>
</p>
)}
</div>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={() => setShow(false)}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(UpgradeTrackChangesModal)