Selaa lähdekoodia

Merge pull request #13573 from overleaf/ii-review-panel-migration-comment-entry

[web] Create comment entries

GitOrigin-RevId: 7f3fbe672d18d57a0f5e683e5456ea79ed295e2d
ilkin-overleaf 3 vuotta sitten
vanhempi
sitoutus
9e8be31bdf

+ 5 - 0
services/web/frontend/extracted-translations.json

@@ -58,6 +58,7 @@
   "are_you_affiliated_with_an_institution": "",
   "are_you_getting_an_undefined_control_sequence_error": "",
   "are_you_still_at": "",
+  "are_you_sure": "",
   "ascending": "",
   "ask_proj_owner_to_upgrade_for_full_history": "",
   "ask_proj_owner_to_upgrade_for_longer_compiles": "",
@@ -425,6 +426,7 @@
   "history_view_a11y_description": "",
   "history_view_all": "",
   "history_view_labels": "",
+  "hit_enter_to_reply": "",
   "hotkey_add_a_comment": "",
   "hotkey_autocomplete_menu": "",
   "hotkey_beginning_of_document": "",
@@ -621,6 +623,7 @@
   "new_to_latex_look_at": "",
   "newsletter": "",
   "next_payment_of_x_collectected_on_y": "",
+  "no_comments": "",
   "no_existing_password": "",
   "no_folder": "",
   "no_image_files_found": "",
@@ -797,11 +800,13 @@
   "replace_from_computer": "",
   "replace_from_project_files": "",
   "replace_from_url": "",
+  "reply": "",
   "repository_name": "",
   "republish": "",
   "resend": "",
   "resend_confirmation_email": "",
   "resending_confirmation_email": "",
+  "resolve": "",
   "resolved_comments": "",
   "restore_file": "",
   "restoring": "",

+ 23 - 5
services/web/frontend/js/features/source-editor/components/review-panel/current-file-container.tsx

@@ -1,3 +1,4 @@
+import { useMemo } from 'react'
 import ChangeEntry from './entries/change-entry'
 import AggregateChangeEntry from './entries/aggregate-change-entry'
 import CommentEntry from './entries/comment-entry'
@@ -5,9 +6,12 @@ import AddCommentEntry from './entries/add-comment-entry'
 import BulkActionsEntry from './entries/bulk-actions-entry'
 import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
 import useCodeMirrorContentHeight from '../../hooks/use-codemirror-content-height'
+import { ReviewPanelEntry } from '../../../../../../types/review-panel/entry'
+import { ThreadId } from '../../../../../../types/review-panel/review-panel'
 
 function CurrentFileContainer() {
-  const { entries, openDocId, permissions } = useReviewPanelValueContext()
+  const { commentThreads, entries, openDocId, permissions, loadingThreads } =
+    useReviewPanelValueContext()
   const contentHeight = useCodeMirrorContentHeight()
 
   console.log('Review panel got content height', contentHeight)
@@ -15,6 +19,12 @@ function CurrentFileContainer() {
   const currentDocEntries =
     openDocId && openDocId in entries ? entries[openDocId] : undefined
 
+  const objectEntries = useMemo(() => {
+    return Object.entries(currentDocEntries || {}) as Array<
+      [ThreadId, ReviewPanelEntry]
+    >
+  }, [currentDocEntries])
+
   return (
     <div
       id="review-panel-current-file"
@@ -26,8 +36,8 @@ function CurrentFileContainer() {
         className="rp-entry-list-inner"
         style={{ height: `${contentHeight}px` }}
       >
-        {currentDocEntries &&
-          Object.entries(currentDocEntries).map(([id, entry]) => {
+        {openDocId &&
+          objectEntries.map(([id, entry]) => {
             if (!entry.visible) {
               return null
             }
@@ -40,8 +50,16 @@ function CurrentFileContainer() {
               return <AggregateChangeEntry key={id} />
             }
 
-            if (entry.type === 'comment') {
-              return <CommentEntry key={id} />
+            if (entry.type === 'comment' && !loadingThreads) {
+              return (
+                <CommentEntry
+                  key={id}
+                  docId={openDocId}
+                  entry={entry}
+                  entryId={id}
+                  threads={commentThreads}
+                />
+              )
             }
 
             if (entry.type === 'add-comment' && permissions.comment) {

+ 190 - 2
services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx

@@ -1,7 +1,195 @@
+import { useState, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
 import EntryContainer from './entry-container'
+import Comment from './comment'
+import EntryActions from './entry-actions'
+import AutoExpandingTextArea, {
+  resetHeight,
+} from '../../../../../shared/components/auto-expanding-text-area'
+import Icon from '../../../../../shared/components/icon'
+import {
+  useReviewPanelUpdaterFnsContext,
+  useReviewPanelValueContext,
+} from '../../../context/review-panel/review-panel-context'
+import classnames from 'classnames'
+import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
+import {
+  DocId,
+  ReviewPanelCommentThreads,
+  ThreadId,
+} from '../../../../../../../types/review-panel/review-panel'
 
-function CommentEntry() {
-  return <EntryContainer>Comment entry</EntryContainer>
+type CommentEntryProps = {
+  docId: DocId
+  entry: ReviewPanelCommentEntry
+  entryId: ThreadId
+  threads: ReviewPanelCommentThreads
+}
+
+function CommentEntry({ docId, entry, entryId, threads }: CommentEntryProps) {
+  const { t } = useTranslation()
+  const {
+    permissions,
+    gotoEntry,
+    toggleReviewPanel,
+    resolveComment,
+    submitReply,
+    handleLayoutChange,
+  } = useReviewPanelValueContext()
+  const { setEntryHover } = useReviewPanelUpdaterFnsContext()
+
+  const [replyContent, setReplyContent] = useState('')
+  const [animating, setAnimating] = useState(false)
+  const entryDivRef = useRef<HTMLDivElement | null>(null)
+
+  const thread =
+    entry.thread_id in threads ? threads[entry.thread_id] : undefined
+
+  const handleEntryClick = (e: React.MouseEvent<HTMLDivElement>) => {
+    const target = e.target as Element
+    if (
+      [
+        'rp-entry',
+        'rp-comment-loaded',
+        'rp-comment-content',
+        'rp-comment-reply',
+        'rp-entry-metadata',
+      ].some(className => [...target.classList].includes(className))
+    ) {
+      gotoEntry(docId, entry.offset)
+    }
+  }
+
+  const handleAnimateAndCallOnResolve = () => {
+    setAnimating(true)
+
+    if (entryDivRef.current) {
+      entryDivRef.current.style.top = '0'
+    }
+
+    setTimeout(() => {
+      resolveComment(docId, entryId)
+    }, 350)
+  }
+
+  const handleCommentReplyKeyPress = (
+    e: React.KeyboardEvent<HTMLTextAreaElement>
+  ) => {
+    if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
+      e.preventDefault()
+
+      if (replyContent.length) {
+        ;(e.target as HTMLTextAreaElement).blur()
+        submitReply(entry, replyContent)
+        setReplyContent('')
+        resetHeight(e)
+      }
+    }
+  }
+
+  const handleOnReply = () => {
+    if (replyContent.length) {
+      submitReply(entry, replyContent)
+      setReplyContent('')
+    }
+  }
+
+  if (!thread) {
+    return null
+  }
+
+  return (
+    <EntryContainer>
+      {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
+      <div
+        className={classnames('rp-comment-wrapper', {
+          'rp-comment-wrapper-resolving': animating,
+        })}
+        onMouseEnter={() => setEntryHover(true)}
+        onMouseLeave={() => setEntryHover(false)}
+        onClick={handleEntryClick}
+      >
+        <div
+          className="rp-entry-callout rp-entry-callout-comment"
+          style={{
+            top: entry.screenPos
+              ? entry.screenPos.y + entry.screenPos.height - 1 + 'px'
+              : undefined,
+          }}
+        />
+        {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
+        <div
+          className={classnames('rp-entry-indicator', {
+            'rp-entry-indicator-focused': entry.focused,
+          })}
+          style={{
+            top: entry.screenPos ? `${entry.screenPos.y}px` : undefined,
+          }}
+          onClick={toggleReviewPanel}
+        >
+          <Icon type="comment" />
+        </div>
+        <div
+          className={classnames('rp-entry', 'rp-entry-comment', {
+            'rp-entry-focused': entry.focused,
+            'rp-entry-comment-resolving': animating,
+          })}
+          style={{
+            top: entry.screenPos ? `${entry.screenPos.y}px` : undefined,
+            visibility: entry.visible ? 'visible' : 'hidden',
+          }}
+          ref={entryDivRef}
+        >
+          {!thread.submitting && (!thread || thread.messages.length === 0) && (
+            <div className="rp-loading">{t('no_comments')}</div>
+          )}
+          <div className="rp-comment-loaded">
+            {thread.messages.map(comment => (
+              <Comment
+                key={comment.id}
+                thread={thread}
+                threadId={entry.thread_id}
+                comment={comment}
+              />
+            ))}
+          </div>
+          {thread.submitting && (
+            <div className="rp-loading">
+              <Icon type="spinner" spin />
+            </div>
+          )}
+          {permissions.comment && (
+            <div className="rp-comment-reply">
+              <AutoExpandingTextArea
+                className="rp-comment-input"
+                onChange={e => setReplyContent(e.target.value)}
+                onKeyPress={handleCommentReplyKeyPress}
+                onClick={e => e.stopPropagation()}
+                onResize={handleLayoutChange}
+                placeholder={t('hit_enter_to_reply')}
+                value={replyContent}
+              />
+            </div>
+          )}
+          <EntryActions>
+            {permissions.comment && permissions.write && (
+              <EntryActions.Button onClick={handleAnimateAndCallOnResolve}>
+                <Icon type="inbox" /> {t('resolve')}
+              </EntryActions.Button>
+            )}
+            {permissions.comment && (
+              <EntryActions.Button
+                onClick={handleOnReply}
+                disabled={!replyContent.length}
+              >
+                <Icon type="reply" /> {t('reply')}
+              </EntryActions.Button>
+            )}
+          </EntryActions>
+        </div>
+      </div>
+    </EntryContainer>
+  )
 }
 
 export default CommentEntry

+ 119 - 0
services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx

@@ -0,0 +1,119 @@
+import { useTranslation } from 'react-i18next'
+import { useState } from 'react'
+import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area'
+import { formatTime } from '../../../../utils/format-date'
+import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
+import {
+  ReviewPanelCommentThread,
+  ReviewPanelCommentThreadMessage,
+  ThreadId,
+} from '../../../../../../../types/review-panel/review-panel'
+
+type CommentProps = {
+  thread: ReviewPanelCommentThread
+  threadId: ThreadId
+  comment: ReviewPanelCommentThreadMessage
+}
+
+function Comment({ thread, threadId, comment }: CommentProps) {
+  const { t } = useTranslation()
+  const { deleteComment, handleLayoutChange, saveEdit } =
+    useReviewPanelValueContext()
+  const [deleting, setDeleting] = useState(false)
+  const [editing, setEditing] = useState(false)
+
+  const handleConfirmDelete = () => {
+    setDeleting(true)
+    handleLayoutChange()
+  }
+
+  const handleDoDelete = () => {
+    setDeleting(false)
+    deleteComment(threadId, comment.id)
+    handleLayoutChange()
+  }
+
+  const handleCancelDelete = () => {
+    setDeleting(false)
+    handleLayoutChange()
+  }
+
+  const handleStartEditing = () => {
+    setEditing(true)
+    handleLayoutChange()
+  }
+
+  const handleSaveEditOnEnter = (
+    e: React.KeyboardEvent<HTMLTextAreaElement>
+  ) => {
+    if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
+      e.preventDefault()
+      handleSaveEdit(e)
+    }
+  }
+
+  const handleSaveEdit = (
+    e:
+      | React.FocusEvent<HTMLTextAreaElement>
+      | React.KeyboardEvent<HTMLTextAreaElement>
+  ) => {
+    setEditing(false)
+    saveEdit(threadId, comment.id, (e.target as HTMLTextAreaElement).value)
+  }
+
+  return (
+    <div className="rp-comment">
+      <p className="rp-comment-content">
+        {editing ? (
+          <AutoExpandingTextArea
+            className="rp-comment-input"
+            onKeyPress={handleSaveEditOnEnter}
+            onBlur={handleSaveEdit}
+            onClick={e => e.stopPropagation()}
+            onResize={handleLayoutChange}
+          />
+        ) : (
+          <>
+            <span
+              className="rp-entry-user"
+              style={{ color: `hsl(${comment.user.hue}, 70%, 40%` }}
+            >
+              {comment.user.name}:
+            </span>
+            &nbsp;
+            {comment.content}
+          </>
+        )}
+      </p>
+      {!editing && (
+        <div className="rp-entry-metadata">
+          {!deleting && formatTime(comment.timestamp, 'MMM d, y h:mm a')}
+          {comment.user.isSelf && !deleting && (
+            <span className="rp-comment-actions">
+              &nbsp;&bull;&nbsp;
+              <button onClick={handleStartEditing}>{t('edit')}</button>
+              {thread.messages.length > 1 && (
+                <>
+                  &nbsp;&bull;&nbsp;
+                  <button onClick={handleConfirmDelete}>{t('delete')}</button>
+                </>
+              )}
+            </span>
+          )}
+          {comment.user.isSelf && deleting && (
+            <span className="rp-confim-delete">
+              {t('are_you_sure')}&nbsp;&bull;&nbsp;
+              <button type="button" onClick={handleDoDelete}>
+                {t('delete')}
+              </button>
+              &nbsp;&bull;&nbsp;
+              <button onClick={handleCancelDelete}>{t('cancel')}</button>
+            </span>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default Comment

+ 19 - 0
services/web/frontend/js/features/source-editor/components/review-panel/entries/entry-actions.tsx

@@ -0,0 +1,19 @@
+import classnames from 'classnames'
+
+function EntryActions({
+  className,
+  ...rest
+}: React.ComponentPropsWithoutRef<'div'>) {
+  return <div className={classnames('rp-entry-actions', className)} {...rest} />
+}
+
+EntryActions.Button = function EntryActionsButton({
+  className,
+  ...rest
+}: React.ComponentPropsWithoutRef<'button'>) {
+  return (
+    <button className={classnames('rp-entry-button', className)} {...rest} />
+  )
+}
+
+export default EntryActions

+ 54 - 2
services/web/frontend/js/features/source-editor/context/review-panel/hooks/use-angular-review-panel-state.ts

@@ -1,20 +1,30 @@
-import { useMemo, useCallback } from 'react'
+import { useState, useMemo, useCallback } from 'react'
 import useScopeValue from '../../../../../shared/hooks/use-scope-value'
+import useScopeEventEmitter from '../../../../../shared/hooks/use-scope-event-emitter'
 import { ReviewPanelState } from '../types/review-panel-state'
 import { sendMB } from '../../../../../infrastructure/event-tracking'
 import * as ReviewPanel from '../types/review-panel-state'
 import { SubView } from '../../../../../../../types/review-panel/review-panel'
+import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
 
 function useAngularReviewPanelState(): ReviewPanelState {
+  const emitLayoutChange = useScopeEventEmitter('review-panel:layout', false)
+
   const [subView, setSubView] = useScopeValue<ReviewPanel.Value<'subView'>>(
     'reviewPanel.subView'
   )
   const [collapsed, setCollapsed] = useScopeValue<
     ReviewPanel.Value<'collapsed'>
   >('reviewPanel.overview.docsCollapsedState')
+  const [commentThreads] = useScopeValue<ReviewPanel.Value<'commentThreads'>>(
+    'reviewPanel.commentThreads',
+    true
+  )
   const [entries] = useScopeValue<ReviewPanel.Value<'entries'>>(
     'reviewPanel.entries'
   )
+  const [loadingThreads] =
+    useScopeValue<ReviewPanel.Value<'loadingThreads'>>('loadingThreads')
 
   const [permissions] =
     useScopeValue<ReviewPanel.Value<'permissions'>>('permissions')
@@ -50,6 +60,14 @@ function useAngularReviewPanelState(): ReviewPanelState {
   const [trackChangesForGuestsAvailable] = useScopeValue<
     ReviewPanel.Value<'trackChangesForGuestsAvailable'>
   >('reviewPanel.trackChangesForGuestsAvailable')
+  const [resolveComment] =
+    useScopeValue<ReviewPanel.Value<'resolveComment'>>('resolveComment')
+  const [deleteComment] =
+    useScopeValue<ReviewPanel.Value<'deleteComment'>>('deleteComment')
+  const [gotoEntry] = useScopeValue<ReviewPanel.Value<'gotoEntry'>>('gotoEntry')
+  const [saveEdit] = useScopeValue<ReviewPanel.Value<'saveEdit'>>('saveEdit')
+  const [submitReplyAngular] =
+    useScopeValue<(entry: ReviewPanelCommentEntry) => void>('submitReply')
 
   const [formattedProjectMembers] = useScopeValue<
     ReviewPanel.Value<'formattedProjectMembers'>
@@ -66,12 +84,36 @@ function useAngularReviewPanelState(): ReviewPanelState {
     [setSubView]
   )
 
+  const handleLayoutChange = useCallback(() => {
+    window.requestAnimationFrame(() => {
+      emitLayoutChange()
+    })
+  }, [emitLayoutChange])
+
+  const submitReply = useCallback(
+    (entry: ReviewPanelCommentEntry, replyContent: string) => {
+      submitReplyAngular({ ...entry, replyContent })
+    },
+    [submitReplyAngular]
+  )
+
+  const [entryHover, setEntryHover] = useState(false)
+
   const values = useMemo<ReviewPanelState['values']>(
     () => ({
       collapsed,
+      commentThreads,
+      deleteComment,
       entries,
+      entryHover,
+      gotoEntry,
+      handleLayoutChange,
+      loadingThreads,
       permissions,
+      resolveComment,
+      saveEdit,
       shouldCollapse,
+      submitReply,
       subView,
       wantTrackChanges,
       openDocId,
@@ -87,9 +129,18 @@ function useAngularReviewPanelState(): ReviewPanelState {
     }),
     [
       collapsed,
+      commentThreads,
+      deleteComment,
       entries,
+      entryHover,
+      gotoEntry,
+      handleLayoutChange,
+      loadingThreads,
       permissions,
+      resolveComment,
+      saveEdit,
       shouldCollapse,
+      submitReply,
       subView,
       wantTrackChanges,
       openDocId,
@@ -108,10 +159,11 @@ function useAngularReviewPanelState(): ReviewPanelState {
   const updaterFns = useMemo<ReviewPanelState['updaterFns']>(
     () => ({
       handleSetSubview,
+      setEntryHover,
       setCollapsed,
       setShouldCollapse,
     }),
-    [handleSetSubview, setCollapsed, setShouldCollapse]
+    [handleSetSubview, setCollapsed, setEntryHover, setShouldCollapse]
   )
 
   return { values, updaterFns }

+ 18 - 0
services/web/frontend/js/features/source-editor/context/review-panel/types/review-panel-state.ts

@@ -1,16 +1,33 @@
 import {
+  CommentId,
   DocId,
+  ReviewPanelCommentThreads,
   ReviewPanelEntries,
   ReviewPanelPermissions,
   SubView,
+  ThreadId,
 } from '../../../../../../../types/review-panel/review-panel'
+import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
 
 export interface ReviewPanelState {
   values: {
     collapsed: Record<string, boolean>
+    commentThreads: ReviewPanelCommentThreads
+    deleteComment: (threadId: ThreadId, commentId: CommentId) => void
     entries: ReviewPanelEntries
+    entryHover: boolean
+    gotoEntry: (docId: DocId, entryOffset: number) => void
+    handleLayoutChange: () => void
+    loadingThreads: boolean
     permissions: ReviewPanelPermissions
+    resolveComment: (docId: DocId, entryId: ThreadId) => void
+    saveEdit: (
+      threadId: ThreadId,
+      commentId: CommentId,
+      content: string
+    ) => void
     shouldCollapse: boolean
+    submitReply: (entry: ReviewPanelCommentEntry, replyContent: string) => void
     subView: SubView
     wantTrackChanges: boolean
     openDocId: DocId | null
@@ -32,6 +49,7 @@ export interface ReviewPanelState {
   }
   updaterFns: {
     handleSetSubview: (subView: SubView) => void
+    setEntryHover: React.Dispatch<React.SetStateAction<boolean>>
     setCollapsed: React.Dispatch<
       React.SetStateAction<ReviewPanelState['values']['collapsed']>
     >

+ 73 - 0
services/web/frontend/js/shared/components/auto-expanding-text-area.tsx

@@ -0,0 +1,73 @@
+import { useEffect, useRef } from 'react'
+import { callFnsInSequence } from '../../utils/functions'
+import { MergeAndOverride } from '../../../../types/utils'
+
+export const resetHeight = (
+  e:
+    | React.ChangeEvent<HTMLTextAreaElement>
+    | React.KeyboardEvent<HTMLTextAreaElement>
+) => {
+  const el = e.target as HTMLTextAreaElement
+
+  window.requestAnimationFrame(() => {
+    const curHeight = el.offsetHeight
+    const fitHeight = el.scrollHeight
+    // clear height if text area is empty
+    if (!el.value.length) {
+      el.style.removeProperty('height')
+    }
+    // otherwise expand to fit text
+    else if (fitHeight > curHeight) {
+      el.style.height = `${fitHeight}px`
+    }
+  })
+}
+
+type AutoExpandingTextAreaProps = MergeAndOverride<
+  React.ComponentProps<'textarea'>,
+  {
+    onResize?: () => void
+  }
+>
+
+function AutoExpandingTextArea({
+  onChange,
+  onResize,
+  ...rest
+}: AutoExpandingTextAreaProps) {
+  const ref = useRef<HTMLTextAreaElement>(null)
+
+  useEffect(() => {
+    if (!ref.current || !onResize || !('ResizeObserver' in window)) {
+      return
+    }
+
+    let isFirstResize = true
+
+    const resizeObserver = new ResizeObserver(() => {
+      // Ignore the resize that is triggered when the element is first
+      // inserted into the DOM
+      if (isFirstResize) {
+        isFirstResize = false
+      } else {
+        onResize()
+      }
+    })
+
+    resizeObserver.observe(ref.current)
+
+    return () => {
+      resizeObserver.disconnect()
+    }
+  }, [onResize])
+
+  return (
+    <textarea
+      onChange={callFnsInSequence(onChange, resetHeight)}
+      {...rest}
+      ref={ref}
+    />
+  )
+}
+
+export default AutoExpandingTextArea

+ 14 - 0
services/web/frontend/stylesheets/app/editor/review-panel.less

@@ -1294,4 +1294,18 @@ button when (@is-overleaf-light = true) {
     height: 0;
     transition: height 150ms;
   }
+
+  .rp-entry-metadata {
+    button {
+      padding: 0;
+      border: 0;
+      background-color: transparent;
+      color: @ol-blue;
+
+      &:hover,
+      &:focus {
+        text-decoration: underline;
+      }
+    }
+  }
 }

+ 23 - 0
services/web/test/frontend/features/review-panel/review-panel.spec.tsx

@@ -166,4 +166,27 @@ describe('<ReviewPanel />', function () {
       })
     })
   })
+
+  describe('comment entries', function () {
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('shows threads and comments', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('edits comment', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('deletes comment', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('cancels comment editing', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('cancels comment deletion', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('adds new comment (replies) to a thread', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('resolves comment', function () {})
+  })
 })

+ 5 - 2
services/web/types/review-panel/entry.ts

@@ -1,3 +1,5 @@
+import { ThreadId } from './review-panel'
+
 interface ReviewPanelEntryScreenPos {
   y: number
   height: number
@@ -8,14 +10,15 @@ interface ReviewPanelBaseEntry {
   visible: boolean
 }
 
-interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
+export interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
   type: 'comment'
   content: string
   entry_ids: string[]
   focused: boolean
   offset: number
   screenPos: ReviewPanelEntryScreenPos
-  thread_id: string
+  thread_id: ThreadId
+  replyContent?: string // angular specific
 }
 
 interface ReviewPanelInsertEntry extends ReviewPanelBaseEntry {

+ 36 - 1
services/web/types/review-panel/review-panel.ts

@@ -10,7 +10,42 @@ export interface ReviewPanelPermissions {
   comment: boolean
 }
 
-export type ReviewPanelDocEntries = Record<string, ReviewPanelEntry>
+export type ThreadId = Brand<string, 'ThreadId'>
+type ReviewPanelDocEntries = Record<ThreadId, ReviewPanelEntry>
 
 export type DocId = Brand<string, 'DocId'>
 export type ReviewPanelEntries = Record<DocId, ReviewPanelDocEntries>
+
+type UserId = Brand<string, 'UserId'>
+
+interface ReviewPanelUser {
+  avatar_text: string
+  email: string
+  hue: number
+  id: UserId
+  isSelf: boolean
+  name: string
+}
+
+export type CommentId = Brand<string, 'CommentId'>
+export interface ReviewPanelCommentThreadMessage {
+  content: string
+  id: CommentId
+  timestamp: number
+  user: ReviewPanelUser
+  user_id: UserId
+}
+
+export interface ReviewPanelCommentThread {
+  messages: Array<ReviewPanelCommentThreadMessage>
+  // resolved: boolean
+  // resolved_at: number
+  // resolved_by_user_id: string
+  // resolved_by_user: ReviewPanelUser
+  submitting?: boolean // angular specific (to be made into a local state)
+}
+
+export type ReviewPanelCommentThreads = Record<
+  ThreadId,
+  ReviewPanelCommentThread
+>