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)

View File

@@ -0,0 +1,67 @@
import {
createContext,
FC,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { getJSON } from '@/infrastructure/fetch-json'
import { useProjectContext } from '@/shared/context/project-context'
import { UserId } from '../../../../../types/user'
import { useEditorContext } from '@/shared/context/editor-context'
import { debugConsole } from '@/utils/debugging'
import { captureException } from '@/infrastructure/error-reporter'
export type ChangesUser = {
id: UserId
email: string
first_name?: string
last_name?: string
}
export type ChangesUsers = Map<UserId, ChangesUser>
export const ChangesUsersContext = createContext<ChangesUsers | undefined>(
undefined
)
export const ChangesUsersProvider: FC = ({ children }) => {
const { _id: projectId, members, owner } = useProjectContext()
const { isRestrictedTokenMember } = useEditorContext()
const [changesUsers, setChangesUsers] = useState<ChangesUsers>()
useEffect(() => {
if (isRestrictedTokenMember) {
return
}
getJSON<ChangesUser[]>(`/project/${projectId}/changes/users`)
.then(data => setChangesUsers(new Map(data.map(item => [item.id, item]))))
.catch(error => {
debugConsole.error(error)
captureException(error)
})
}, [projectId, isRestrictedTokenMember])
// add the project owner and members to the changes users data
const value = useMemo(() => {
const value: ChangesUsers = new Map(changesUsers)
value.set(owner._id, { ...owner, id: owner._id })
for (const member of members) {
value.set(member._id, { ...member, id: member._id })
}
return value
}, [members, owner, changesUsers])
return (
<ChangesUsersContext.Provider value={value}>
{children}
</ChangesUsersContext.Provider>
)
}
export const useChangesUsersContext = () => {
return useContext(ChangesUsersContext)
}

View File

@@ -0,0 +1,197 @@
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import {
Change,
CommentOperation,
EditOperation,
} from '../../../../../types/change'
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'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import { throttle } from 'lodash'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
export type Ranges = {
docId: string
changes: Change<EditOperation>[]
comments: Change<CommentOperation>[]
}
export const RangesContext = createContext<Ranges | undefined>(undefined)
type RangesActions = {
acceptChanges: (...ids: string[]) => void
rejectChanges: (...ids: string[]) => void
}
const buildRanges = (currentDocument: DocumentContainer | null) => {
const ranges = currentDocument?.ranges
if (!ranges) {
return undefined
}
const dirtyState = ranges.getDirtyState()
ranges.resetDirtyState()
const changed = {
changes: new Set([
...Object.keys(dirtyState.change.added),
...Object.keys(dirtyState.change.moved),
...Object.keys(dirtyState.change.removed),
]),
comments: new Set([
...Object.keys(dirtyState.comment.added),
...Object.keys(dirtyState.comment.moved),
...Object.keys(dirtyState.comment.removed),
]),
}
return {
changes:
changed.changes.size > 0
? ranges.changes.map(change =>
changed.changes.has(change.id) ? { ...change } : change
)
: ranges.changes,
comments:
changed.comments.size > 0
? ranges.comments.map(comment =>
changed.comments.has(comment.id) ? { ...comment } : comment
)
: ranges.comments,
docId: currentDocument.doc_id,
}
}
const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
export const RangesProvider: FC = ({ children }) => {
const view = useCodeMirrorViewContext()
const { projectId } = useIdeReactContext()
const { currentDocument } = useEditorManagerContext()
const { socket } = useConnectionContext()
const [ranges, setRanges] = useState<Ranges | undefined>(() =>
buildRanges(currentDocument)
)
// rebuild the ranges when the current doc changes
useEffect(() => {
setRanges(buildRanges(currentDocument))
}, [currentDocument])
useEffect(() => {
if (currentDocument) {
const listener = throttle(
() => {
window.setTimeout(() => {
setRanges(buildRanges(currentDocument))
})
},
500,
{ leading: true, trailing: true }
)
// currentDocument.on('ranges:clear.cm6', listener)
currentDocument.on('ranges:redraw.cm6', listener)
currentDocument.on('ranges:dirty.cm6', listener)
return () => {
// currentDocument.off('ranges:clear.cm6')
currentDocument.off('ranges:redraw.cm6')
currentDocument.off('ranges:dirty.cm6')
}
}
}, [currentDocument])
// TODO: move this into DocumentContainer?
useEffect(() => {
if (currentDocument) {
const regenerateTrackChangesId = (doc: DocumentContainer) => {
if (doc.ranges) {
const inflight = doc.ranges.getIdSeed()
const pending = RangesTracker.generateIdSeed()
doc.ranges.setIdSeed(pending)
doc.setTrackChangesIdSeeds({ pending, inflight })
}
}
currentDocument.on('flipped_pending_to_inflight', () =>
regenerateTrackChangesId(currentDocument)
)
regenerateTrackChangesId(currentDocument)
return () => {
currentDocument.off('flipped_pending_to_inflight')
}
}
}, [currentDocument])
useSocketListener(
socket,
'accept-changes',
useCallback(
(docId: string, entryIds: string[]) => {
if (currentDocument?.ranges) {
if (docId === currentDocument.doc_id) {
currentDocument.ranges.removeChangeIds(entryIds)
setRanges(buildRanges(currentDocument))
}
}
},
[currentDocument]
)
)
const actions = useMemo(
() => ({
async acceptChanges(...ids: string[]) {
if (currentDocument?.ranges) {
const url = `/project/${projectId}/doc/${currentDocument.doc_id}/changes/accept`
await postJSON(url, { body: { change_ids: ids } })
currentDocument.ranges.removeChangeIds(ids)
setRanges(buildRanges(currentDocument))
}
},
rejectChanges(...ids: string[]) {
if (currentDocument?.ranges) {
view.dispatch(rejectChanges(view.state, currentDocument.ranges, ids))
}
},
}),
[currentDocument, projectId, view]
)
return (
<RangesActionsContext.Provider value={actions}>
<RangesContext.Provider value={ranges}>{children}</RangesContext.Provider>
</RangesActionsContext.Provider>
)
}
export const useRangesContext = () => {
return useContext(RangesContext)
}
export const useRangesActionsContext = () => {
const context = useContext(RangesActionsContext)
if (!context) {
throw new Error(
'useRangesActionsContext is only available inside RangesProvider'
)
}
return context
}

View File

@@ -0,0 +1,20 @@
import { FC } from 'react'
import { RangesProvider } from './ranges-context'
import { ChangesUsersProvider } from './changes-users-context'
import { TrackChangesStateProvider } from './track-changes-state-context'
import { ThreadsProvider } from './threads-context'
import { ReviewPanelViewProvider } from './review-panel-view-context'
export const ReviewPanelProviders: FC = ({ children }) => {
return (
<ReviewPanelViewProvider>
<ChangesUsersProvider>
<TrackChangesStateProvider>
<ThreadsProvider>
<RangesProvider>{children}</RangesProvider>
</ThreadsProvider>
</TrackChangesStateProvider>
</ChangesUsersProvider>
</ReviewPanelViewProvider>
)
}

View File

@@ -0,0 +1,54 @@
import {
createContext,
Dispatch,
FC,
SetStateAction,
useContext,
useMemo,
useState,
} from 'react'
export type View = 'cur_file' | 'overview'
export const ReviewPanelViewContext = createContext<View>('cur_file')
type ViewActions = {
setView: Dispatch<SetStateAction<View>>
}
const ReviewPanelViewActionsContext = createContext<ViewActions | undefined>(
undefined
)
export const ReviewPanelViewProvider: FC = ({ children }) => {
const [view, setView] = useState<View>('cur_file')
const actions = useMemo(
() => ({
setView,
}),
[setView]
)
return (
<ReviewPanelViewActionsContext.Provider value={actions}>
<ReviewPanelViewContext.Provider value={view}>
{children}
</ReviewPanelViewContext.Provider>
</ReviewPanelViewActionsContext.Provider>
)
}
export const useReviewPanelViewContext = () => {
return useContext(ReviewPanelViewContext)
}
export const useReviewPanelViewActionsContext = () => {
const context = useContext(ReviewPanelViewActionsContext)
if (!context) {
throw new Error(
'useViewActionsContext is only available inside ViewProvider'
)
}
return context
}

View File

@@ -0,0 +1,334 @@
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useProjectContext } from '@/shared/context/project-context'
import {
CommentId,
ReviewPanelCommentThreadMessage,
ThreadId,
} from '../../../../../types/review-panel/review-panel'
import { ReviewPanelCommentThread } from '../../../../../types/review-panel/comment-thread'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import { UserId } from '../../../../../types/user'
import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json'
import RangesTracker from '@overleaf/ranges-tracker'
import { CommentOperation } from '../../../../../types/change'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useEditorContext } from '@/shared/context/editor-context'
import { debugConsole } from '@/utils/debugging'
import { captureException } from '@/infrastructure/error-reporter'
export type Threads = Record<ThreadId, ReviewPanelCommentThread>
export const ThreadsContext = createContext<Threads | undefined>(undefined)
type ThreadsActions = {
addComment: (pos: number, text: string, content: string) => Promise<void>
resolveThread: (threadId: ThreadId) => Promise<void>
reopenThread: (threadId: ThreadId) => Promise<void>
deleteThread: (threadId: ThreadId) => Promise<void>
addMessage: (threadId: ThreadId, content: string) => Promise<void>
editMessage: (
threadId: ThreadId,
commentId: CommentId,
content: string
) => Promise<void>
deleteMessage: (threadId: ThreadId, commentId: CommentId) => Promise<void>
deleteOwnMessage: (threadId: ThreadId, commentId: CommentId) => Promise<void>
}
const ThreadsActionsContext = createContext<ThreadsActions | undefined>(
undefined
)
export const ThreadsProvider: FC = ({ children }) => {
const { _id: projectId } = useProjectContext()
const { currentDocument } = useEditorManagerContext()
const { isRestrictedTokenMember } = useEditorContext()
// const [error, setError] = useState<Error>()
const [data, setData] = useState<Threads>()
// load the initial threads data
useEffect(() => {
if (isRestrictedTokenMember) {
return
}
const abortController = new AbortController()
getJSON(`/project/${projectId}/threads`, {
signal: abortController.signal,
})
.then(data => {
setData(data)
})
.catch(error => {
debugConsole.error(error)
captureException(error)
// setError(error)
})
}, [projectId, isRestrictedTokenMember])
const { socket } = useConnectionContext()
useSocketListener(
socket,
'new-comment',
useCallback(
(
threadId: ThreadId,
comment: ReviewPanelCommentThreadMessage & { timestamp: number }
) => {
setData(value => {
if (value) {
const { submitting, ...thread } = value[threadId] ?? {
messages: [],
}
return {
...value,
[threadId]: {
...thread,
messages: [
...thread.messages,
{
...comment,
user: comment.user, // TODO
timestamp: new Date(comment.timestamp),
},
],
},
}
}
})
},
[]
)
)
useSocketListener(
socket,
'edit-message',
useCallback((threadId: ThreadId, commentId: CommentId, content: string) => {
setData(value => {
if (value) {
const thread = value[threadId] ?? { messages: [] }
return {
...value,
[threadId]: {
...thread,
messages: thread.messages.map(message =>
message.id === commentId ? { ...message, content } : message
),
},
}
}
})
}, [])
)
useSocketListener(
socket,
'delete-message',
useCallback((threadId: ThreadId, commentId: CommentId) => {
setData(value => {
if (value) {
const thread = value[threadId] ?? { messages: [] }
return {
...value,
[threadId]: {
...thread,
messages: thread.messages.filter(
message => message.id !== commentId
),
},
}
}
})
}, [])
)
useSocketListener(
socket,
'resolve-thread',
useCallback(
(
threadId: ThreadId,
user: { email: string; first_name: string; id: UserId }
) => {
setData(value => {
if (value) {
const thread = value[threadId] ?? { messages: [] }
return {
...value,
[threadId]: {
...thread,
resolved: true,
resolved_by_user: user, // TODO
resolved_at: new Date().toISOString(),
},
}
}
})
},
[]
)
)
useSocketListener(
socket,
'reopen-thread',
useCallback((threadId: ThreadId) => {
setData(value => {
if (value) {
const thread = value[threadId] ?? { messages: [] }
return {
...value,
[threadId]: {
...thread,
resolved: undefined,
resolved_by_user: undefined,
resolved_at: undefined,
},
}
}
})
}, [])
)
useSocketListener(
socket,
'delete-thread',
useCallback((threadId: ThreadId) => {
setData(value => {
if (value) {
const _value = { ...value }
delete _value[threadId]
return _value
}
})
}, [])
)
useSocketListener(
socket,
'new-comment-threads',
useCallback(threads => {
setData(prevState => {
const newThreads = { ...prevState }
for (const threadId of Object.keys(threads)) {
const thread = threads[threadId]
const newThreadData: ReviewPanelCommentThread = {
messages: [],
resolved: thread.resolved,
resolved_at: thread.resolved_at,
resolved_by_user_id: thread.resolved_by_user_id,
resolved_by_user: thread.resolved_by_user,
}
for (const message of thread.messages) {
newThreadData.messages.push({
...message,
timestamp: new Date(message.timestamp),
})
}
newThreads[threadId as ThreadId] = newThreadData
}
return newThreads
})
}, [])
)
const actions = useMemo(
() => ({
async addComment(pos: number, text: string, content: string) {
const threadId = RangesTracker.generateId() as ThreadId
await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
body: { content },
})
const op: CommentOperation = {
c: text,
p: pos,
t: threadId,
}
currentDocument?.submitOp(op)
},
async resolveThread(threadId: string) {
await postJSON(
`/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}/resolve`
)
},
async reopenThread(threadId: string) {
await postJSON(
`/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}/reopen`
)
},
async deleteThread(threadId: string) {
await deleteJSON(
`/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}`
)
currentDocument?.ranges?.removeCommentId(threadId)
},
async addMessage(threadId: ThreadId, content: string) {
await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
body: { content },
})
},
async editMessage(
threadId: ThreadId,
commentId: CommentId,
content: string
) {
await postJSON(
`/project/${projectId}/thread/${threadId}/messages/${commentId}/edit`,
{ body: { content } }
)
},
async deleteMessage(threadId: ThreadId, commentId: CommentId) {
await deleteJSON(
`/project/${projectId}/thread/${threadId}/messages/${commentId}`
)
},
async deleteOwnMessage(threadId: ThreadId, commentId: CommentId) {
await deleteJSON(
`/project/${projectId}/thread/${threadId}/own-messages/${commentId}`
)
},
}),
[currentDocument, projectId]
)
return (
<ThreadsActionsContext.Provider value={actions}>
<ThreadsContext.Provider value={data}>{children}</ThreadsContext.Provider>
</ThreadsActionsContext.Provider>
)
}
export const useThreadsContext = () => {
return useContext(ThreadsContext)
}
export const useThreadsActionsContext = () => {
const context = useContext(ThreadsActionsContext)
if (!context) {
throw new Error(
'useThreadsActionsContext is only available inside ThreadsProvider'
)
}
return context
}

View File

@@ -0,0 +1,193 @@
import { UserId } from '../../../../../types/user'
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { useProjectContext } from '@/shared/context/project-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useUserContext } from '@/shared/context/user-context'
import { postJSON } from '@/infrastructure/fetch-json'
import useEventListener from '@/shared/hooks/use-event-listener'
import { ProjectContextValue } from '@/shared/context/types/project-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import getMeta from '@/utils/meta'
export type TrackChangesState = {
onForEveryone: boolean
onForGuests: boolean
onForMembers: Record<UserId, boolean | undefined>
}
export const TrackChangesStateContext = createContext<
TrackChangesState | undefined
>(undefined)
type SaveTrackChangesRequestBody = {
on?: boolean
on_for?: Record<UserId, boolean | undefined>
on_for_guests?: boolean
}
type TrackChangesStateActions = {
saveTrackChanges: (trackChangesBody: SaveTrackChangesRequestBody) => void
saveTrackChangesForCurrentUser: (trackChanges: boolean) => void
}
const TrackChangesStateActionsContext = createContext<
TrackChangesStateActions | undefined
>(undefined)
export const TrackChangesStateProvider: FC = ({ children }) => {
const permissions = usePermissionsContext()
const { socket } = useConnectionContext()
const project = useProjectContext()
const user = useUserContext()
const { setWantTrackChanges } = useEditorManagerContext()
// TODO: update project.trackChangesState instead?
const [trackChangesValue, setTrackChangesValue] = useState<
ProjectContextValue['trackChangesState']
>(project.trackChangesState ?? false)
useSocketListener(socket, 'toggle-track-changes', setTrackChangesValue)
useEffect(() => {
setWantTrackChanges(
trackChangesValue === true ||
(trackChangesValue !== false &&
trackChangesValue[user.id ?? '__guests__'])
)
}, [setWantTrackChanges, trackChangesValue, user.id])
const trackChangesIsObject =
trackChangesValue !== true && trackChangesValue !== false
const onForEveryone = trackChangesValue === true
const onForGuests =
onForEveryone ||
(trackChangesIsObject && trackChangesValue.__guests__ === true)
const onForMembers = useMemo(() => {
const onForMembers: Record<UserId, boolean | undefined> = {}
if (trackChangesIsObject) {
for (const key of Object.keys(trackChangesValue)) {
if (key !== '__guests__') {
onForMembers[key as UserId] = trackChangesValue[key as UserId]
}
}
}
return onForMembers
}, [trackChangesIsObject, trackChangesValue])
const saveTrackChanges = useCallback(
async (trackChangesBody: SaveTrackChangesRequestBody) => {
postJSON(`/project/${project._id}/track_changes`, {
body: trackChangesBody,
})
},
[project._id]
)
const saveTrackChangesForCurrentUser = useCallback(
async (trackChanges: boolean) => {
if (user.id) {
if (getMeta('ol-isReviewerRoleEnabled')) {
saveTrackChanges({
on_for: {
...onForMembers,
[user.id]: trackChanges,
},
})
} else {
saveTrackChanges({
on_for: {
...onForMembers,
[user.id]: trackChanges,
},
on_for_guests: onForGuests,
})
}
}
},
[onForMembers, onForGuests, user.id, saveTrackChanges]
)
const actions = useMemo(
() => ({
saveTrackChanges,
saveTrackChangesForCurrentUser,
}),
[saveTrackChanges, saveTrackChangesForCurrentUser]
)
useEventListener(
'toggle-track-changes',
useCallback(() => {
if (
user.id &&
project.features.trackChanges &&
permissions.write &&
!onForEveryone
) {
const value = onForMembers[user.id]
if (getMeta('ol-isReviewerRoleEnabled')) {
actions.saveTrackChanges({
on_for: {
...onForMembers,
[user.id]: !value,
},
})
} else {
actions.saveTrackChanges({
on_for: {
...onForMembers,
[user.id]: !value,
},
on_for_guests: onForGuests,
})
}
}
}, [
actions,
onForMembers,
onForGuests,
onForEveryone,
permissions.write,
project.features.trackChanges,
user.id,
])
)
const value = useMemo(
() => ({ onForEveryone, onForGuests, onForMembers }),
[onForEveryone, onForGuests, onForMembers]
)
return (
<TrackChangesStateActionsContext.Provider value={actions}>
<TrackChangesStateContext.Provider value={value}>
{children}
</TrackChangesStateContext.Provider>
</TrackChangesStateActionsContext.Provider>
)
}
export const useTrackChangesStateContext = () => {
return useContext(TrackChangesStateContext)
}
export const useTrackChangesStateActionsContext = () => {
const context = useContext(TrackChangesStateActionsContext)
if (!context) {
throw new Error(
'useTrackChangesStateActionsContext is only available inside TrackChangesStateProvider'
)
}
return context
}

View File

@@ -0,0 +1,151 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-editor'
import {
Change,
CommentOperation,
EditOperation,
} from '../../../../../types/change'
import { DecorationSet, EditorView } from '@codemirror/view'
import { EditorSelection } from '@codemirror/state'
import _ from 'lodash'
import useReviewPanelLayout from './use-review-panel-layout'
const useMoreCommments = (
changes: Change<EditOperation>[],
comments: Change<CommentOperation>[],
newComments: DecorationSet
): {
onEntriesPositioned: () => void
onMoreCommentsAboveClick: null | (() => void)
onMoreCommentsBelowClick: null | (() => void)
} => {
const view = useCodeMirrorViewContext()
const { showPanel, mini } = useReviewPanelLayout()
const reviewPanelOpen = showPanel && !mini
const [positionAbove, setPositionAbove] = useState<number | null>(null)
const [positionBelow, setPositionBelow] = useState<number | null>(null)
const updateEntryPositions = useMemo(
() =>
_.debounce(
() =>
view.requestMeasure({
key: 'review-panel-more-comments',
read(view) {
const container = view.scrollDOM
if (!container || !reviewPanelOpen) {
return { positionAbove: null, positionBelow: null }
}
const containerTop = container.scrollTop
const containerBottom = containerTop + container.clientHeight
// First check for any entries in view by looking for the actual rendered entries
for (const entryElt of container.querySelectorAll<HTMLElement>(
'.review-panel-entry'
)) {
const entryTop = entryElt?.offsetTop ?? 0
const entryBottom = entryTop + (entryElt?.offsetHeight ?? 0)
if (entryBottom > containerTop && entryTop < containerBottom) {
// Some part of the entry is in view
return { positionAbove: null, positionBelow: null }
}
}
// Find the max and min positions in the visible part of the viewport
const visibleFrom = view.lineBlockAtHeight(containerTop).from
const visibleTo = view.lineBlockAtHeight(containerBottom).to
// Then go through the positions to find the first entry above and below the visible viewport.
// We can't use the rendered entries for this because only the entries that are in the viewport (or
// have been in the viewport during the current page view session) are actually rendered.
let firstEntryAbove: number | null = null
let firstEntryBelow: number | null = null
const updateFirstEntryAboveBelowPositions = (
position: number
) => {
if (visibleFrom === null || position < visibleFrom) {
firstEntryAbove = Math.max(firstEntryAbove ?? 0, position)
}
if (visibleTo === null || position > visibleTo) {
firstEntryBelow = Math.min(
firstEntryBelow ?? Number.MAX_VALUE,
position
)
}
}
for (const entry of [...changes, ...comments]) {
updateFirstEntryAboveBelowPositions(entry.op.p)
}
const cursor = newComments.iter()
while (cursor.value) {
updateFirstEntryAboveBelowPositions(cursor.from)
cursor.next()
}
return {
positionAbove: firstEntryAbove,
positionBelow: firstEntryBelow,
}
},
write({ positionAbove, positionBelow }) {
setPositionAbove(positionAbove)
setPositionBelow(positionBelow)
},
}),
200
),
[changes, comments, newComments, view, reviewPanelOpen]
)
useEffect(() => {
const scrollerElt = document.getElementsByClassName('cm-scroller')[0]
if (scrollerElt) {
scrollerElt.addEventListener('scroll', updateEntryPositions)
return () => {
scrollerElt.removeEventListener('scroll', updateEntryPositions)
}
}
}, [updateEntryPositions])
const onMoreCommentsClick = useCallback(
(position: number) => {
view.dispatch({
effects: EditorView.scrollIntoView(position, {
y: 'center',
}),
selection: EditorSelection.cursor(position),
})
},
[view]
)
const onMoreCommentsAboveClick = useCallback(() => {
if (positionAbove !== null) {
onMoreCommentsClick(positionAbove)
}
}, [positionAbove, onMoreCommentsClick])
const onMoreCommentsBelowClick = useCallback(() => {
if (positionBelow !== null) {
onMoreCommentsClick(positionBelow)
}
}, [positionBelow, onMoreCommentsClick])
return {
onEntriesPositioned: updateEntryPositions,
onMoreCommentsAboveClick:
positionAbove !== null ? onMoreCommentsAboveClick : null,
onMoreCommentsBelowClick:
positionBelow !== null ? onMoreCommentsBelowClick : null,
}
}
export default useMoreCommments

View File

@@ -0,0 +1,22 @@
import { useCallback } from 'react'
import { DocId } from '../../../../../types/project-settings'
import { useProjectContext } from '../../../shared/context/project-context'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
export default function useOverviewFileCollapsed(docId: DocId) {
const { _id: projectId } = useProjectContext()
const [collapsedDocs, setCollapsedDocs] = usePersistedState<
Record<DocId, boolean>
>(`docs_collapsed_state:${projectId}`, {}, false, true)
const toggleCollapsed = useCallback(() => {
setCollapsedDocs((collapsedDocs: Record<DocId, boolean>) => {
return {
...collapsedDocs,
[docId]: !collapsedDocs[docId],
}
})
}, [docId, setCollapsedDocs])
return { collapsed: collapsedDocs[docId], toggleCollapsed }
}

View File

@@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from 'react'
import { Ranges } from '../context/ranges-context'
import { useProjectContext } from '@/shared/context/project-context'
import { getJSON } from '@/infrastructure/fetch-json'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
export default function useProjectRanges() {
const { _id: projectId } = useProjectContext()
const [error, setError] = useState<Error>()
const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
const [loading, setLoading] = useState(true)
const { socket } = useConnectionContext()
useEffect(() => {
setLoading(true)
getJSON<{ id: string; ranges: Ranges }[]>(`/project/${projectId}/ranges`)
.then(data => {
setProjectRanges(
new Map(
data.map(item => [
item.id,
{
docId: item.id,
changes: item.ranges.changes ?? [],
comments: item.ranges.comments ?? [],
},
])
)
)
})
.catch(error => setError(error))
.finally(() => setLoading(false))
}, [projectId])
useSocketListener(
socket,
'accept-changes',
useCallback((docId: string, entryIds: string[]) => {
setProjectRanges(prevProjectRanges => {
if (!prevProjectRanges) {
return prevProjectRanges
}
const ranges = prevProjectRanges.get(docId)
if (!ranges) {
return prevProjectRanges
}
const updatedProjectRanges = new Map(prevProjectRanges)
updatedProjectRanges.set(docId, {
...ranges,
changes: ranges.changes.filter(
change => !entryIds.includes(change.id)
),
})
return updatedProjectRanges
})
}, [])
)
return { projectRanges, error, loading }
}

View File

@@ -0,0 +1,30 @@
import { useLayoutContext } from '@/shared/context/layout-context'
import { useRangesContext } from '../context/ranges-context'
import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
import { hasActiveRange } from '@/features/review-panel-new/utils/has-active-range'
import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
export default function useReviewPanelLayout(): {
showPanel: boolean
showHeader: boolean
mini: boolean
} {
const ranges = useRangesContext()
const threads = useThreadsContext()
const { selectedTab: selectedRailTab, isOpen: railIsOpen } = useRailContext()
const { reviewPanelOpen: reviewPanelOpenOldEditor } = useLayoutContext()
const newEditor = useIsNewEditorEnabled()
const reviewPanelOpen = newEditor
? selectedRailTab === 'review-panel' && railIsOpen
: reviewPanelOpenOldEditor
const hasCommentOrChange = hasActiveRange(ranges, threads)
const showPanel = reviewPanelOpen || !!hasCommentOrChange
const mini = !reviewPanelOpen
const showHeader = showPanel && !mini
return { showPanel, showHeader, mini }
}

View File

@@ -0,0 +1,53 @@
import { CSSProperties, useCallback, useEffect, useState } from 'react'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
import getMeta from '@/utils/meta'
export const useReviewPanelStyles = (mini: boolean) => {
const view = useCodeMirrorViewContext()
const [styles, setStyles] = useState<CSSProperties>({
'--review-panel-header-height': getMeta('ol-isReviewerRoleEnabled')
? '36px'
: '69px',
} as CSSProperties)
const updateScrollDomVariables = useCallback((element: HTMLDivElement) => {
const { top, bottom } = element.getBoundingClientRect()
setStyles(value => ({
...value,
'--review-panel-top': `${top}px`,
'--review-panel-bottom': `${bottom}px`,
}))
}, [])
const updateContentDomVariables = useCallback((element: HTMLDivElement) => {
const { height } = element.getBoundingClientRect()
setStyles(value => ({
...value,
'--review-panel-height': `${height}px`,
}))
}, [])
useEffect(() => {
if ('ResizeObserver' in window) {
const scrollDomObserver = new window.ResizeObserver(entries =>
updateScrollDomVariables(entries[0]?.target as HTMLDivElement)
)
scrollDomObserver.observe(view.scrollDOM)
const contentDomObserver = new window.ResizeObserver(entries =>
updateContentDomVariables(entries[0]?.target as HTMLDivElement)
)
contentDomObserver.observe(view.contentDOM)
return () => {
scrollDomObserver.disconnect()
contentDomObserver.disconnect()
}
}
}, [view, updateScrollDomVariables, updateContentDomVariables])
return styles
}

View File

@@ -0,0 +1,31 @@
import { useCallback, useState, Dispatch, SetStateAction } from 'react'
export default function useSubmittableTextInput(
handleSubmit: (
content: string,
setContent: Dispatch<SetStateAction<string>>
) => void
) {
const [content, setContent] = useState('')
const handleKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
if (content.trim().length > 0) {
handleSubmit(content, setContent)
}
}
},
[content, handleSubmit]
)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
},
[]
)
return { handleChange, handleKeyPress, content }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

View File

@@ -0,0 +1,15 @@
export const buildName = (user?: {
first_name?: string
last_name?: string
email?: string
}) => {
const name = [user?.first_name, user?.last_name].filter(Boolean).join(' ')
if (name) {
return name
}
if (user?.email) {
return user.email.split('@')[0]
}
return 'Unknown'
}

View File

@@ -0,0 +1,15 @@
import {
Change,
DeleteOperation,
InsertOperation,
} from '../../../../../types/change'
export const canAggregate = (
deletion: Change<DeleteOperation>,
insertion: Change<InsertOperation>
) =>
deletion.metadata?.user_id &&
// same user
deletion.metadata?.user_id === insertion.metadata?.user_id &&
// same position
deletion.op.p === insertion.op.p + insertion.op.i.length

View File

@@ -0,0 +1,44 @@
import { SelectionRange } from '@codemirror/state'
import { Ranges } from '@/features/review-panel-new/context/ranges-context'
import { isDeleteChange, isInsertChange } from '@/utils/operations'
import { canAggregate } from './can-aggregate'
import { Change, EditOperation } from '../../../../../types/change'
export function numberOfChangesInSelection(
ranges: Ranges | undefined,
selection: SelectionRange
) {
if (!ranges) {
return 0
}
let count = 0
let precedingChange: Change<EditOperation> | null = null
for (const change of ranges.changes) {
if (
precedingChange &&
isInsertChange(precedingChange) &&
isDeleteChange(change) &&
canAggregate(change, precedingChange)
) {
// only count once for the aggregated change
continue
} else if (
isInsertChange(change) &&
change.op.p >= selection.from &&
change.op.p + change.op.i.length <= selection.to
) {
count++
} else if (
isDeleteChange(change) &&
selection.from <= change.op.p &&
change.op.p <= selection.to
) {
count++
}
precedingChange = change
}
return count
}

View File

@@ -0,0 +1,25 @@
import { Ranges } from '@/features/review-panel-new/context/ranges-context'
import { Threads } from '@/features/review-panel-new/context/threads-context'
export const hasActiveRange = (
ranges: Ranges | undefined,
threads: Threads | undefined
): boolean | undefined => {
if (!ranges || !threads) {
// data isn't loaded yet
return undefined
}
if (ranges.changes.length > 0) {
// at least one tracked change
return true
}
for (const thread of Object.values(threads)) {
if (!thread.resolved) {
return true
}
}
return false
}

View File

@@ -0,0 +1,10 @@
import { AnyOperation } from '../../../../../types/change'
import { SelectionRange } from '@codemirror/state'
import { visibleTextLength } from '@/utils/operations'
export const isSelectionWithinOp = (
op: AnyOperation,
range: SelectionRange
): boolean => {
return range.to >= op.p && range.from <= op.p + visibleTextLength(op)
}

View File

@@ -0,0 +1,92 @@
import getMeta from '@/utils/meta'
import { debounce } from 'lodash'
export const OFFSET_FOR_ENTRIES_ABOVE = 70
const COLLAPSED_HEADER_HEIGHT = getMeta('ol-isReviewerRoleEnabled') ? 42 : 75
const GAP_BETWEEN_ENTRIES = 4
export const positionItems = debounce(
(
element: HTMLDivElement,
previousFocusedItemIndex: number | undefined,
docId: string
) => {
const items = Array.from(
element.querySelectorAll<HTMLDivElement>('.review-panel-entry')
)
items.sort((a, b) => Number(a.dataset.pos) - Number(b.dataset.pos))
if (!items.length) {
return
}
let activeItemIndex = items.findIndex(item =>
item.classList.contains('review-panel-entry-selected')
)
if (activeItemIndex === -1) {
// if entry was not selected manually
// check if there is an entry in selection and use that as the focused item
activeItemIndex = items.findIndex(item =>
item.classList.contains('review-panel-entry-highlighted')
)
}
if (activeItemIndex === -1) {
activeItemIndex = previousFocusedItemIndex || 0
}
const activeItem = items[activeItemIndex]
if (!activeItem) {
return
}
const activeItemTop = getTopPosition(activeItem, activeItemIndex === 0)
activeItem.style.top = `${activeItemTop}px`
activeItem.style.visibility = 'visible'
const focusedItemRect = activeItem.getBoundingClientRect()
// above the active item
let topLimit = activeItemTop
for (let i = activeItemIndex - 1; i >= 0; i--) {
const item = items[i]
const rect = item.getBoundingClientRect()
let top = getTopPosition(item, i === 0)
const bottom = top + rect.height
if (bottom > topLimit) {
top = topLimit - rect.height - GAP_BETWEEN_ENTRIES
}
item.style.top = `${top}px`
item.style.visibility = 'visible'
topLimit = top
}
// below the active item
let bottomLimit = activeItemTop + focusedItemRect.height
for (let i = activeItemIndex + 1; i < items.length; i++) {
const item = items[i]
const rect = item.getBoundingClientRect()
let top = getTopPosition(item, false)
if (top < bottomLimit) {
top = bottomLimit + GAP_BETWEEN_ENTRIES
}
item.style.top = `${top}px`
item.style.visibility = 'visible'
bottomLimit = top + rect.height
}
return {
docId,
activeItemIndex,
}
},
100,
{ leading: false, trailing: true, maxWait: 1000 }
)
function getTopPosition(item: HTMLDivElement, isFirstEntry: boolean) {
const offset = isFirstEntry ? 0 : OFFSET_FOR_ENTRIES_ABOVE
return Math.max(COLLAPSED_HEADER_HEIGHT + offset, Number(item.dataset.top))
}