Skip to content

Commit

Permalink
fix(comments): lost comment message while document is reconnecting
Browse files Browse the repository at this point in the history
  • Loading branch information
hermanwikner committed Mar 7, 2024
1 parent 3afe5a2 commit 542257c
Show file tree
Hide file tree
Showing 10 changed files with 151 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {type DocumentLayoutProps} from 'sanity'
import {useDocumentPane} from '../../..'
import {COMMENTS_INSPECTOR_NAME} from '../../../panes/document/constants'
import {
CommentsAuthoringPathProvider,
CommentsEnabledProvider,
CommentsProvider,
CommentsSelectedPathProvider,
Expand Down Expand Up @@ -43,7 +44,9 @@ function CommentsDocumentLayoutInner(props: DocumentLayoutProps) {
isCommentsOpen={inspector?.name === COMMENTS_INSPECTOR_NAME}
onCommentsOpen={handleOpenCommentsInspector}
>
<CommentsSelectedPathProvider>{props.renderDefault(props)}</CommentsSelectedPathProvider>
<CommentsSelectedPathProvider>
<CommentsAuthoringPathProvider>{props.renderDefault(props)}</CommentsAuthoringPathProvider>
</CommentsSelectedPathProvider>
</CommentsProvider>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import styled, {css} from 'styled-components'
import {
applyCommentsFieldAttr,
type CommentCreatePayload,
type CommentMessage,
type CommentsUIMode,
isTextSelectionComment,
useComments,
useCommentsAuthoringPath,
useCommentsEnabled,
useCommentsScroll,
useCommentsSelectedPath,
Expand All @@ -22,6 +24,14 @@ import {
import {COMMENTS_HIGHLIGHT_HUE_KEY} from '../../src/constants'
import {CommentsFieldButton} from './CommentsFieldButton'

// When the form is temporarily set to `readOnly` while reconnecting, the form
// will be re-rendered and any comment that is being authored will be lost.
// To avoid this, we cache the comment message in a map and restore it when the
// field is re-rendered.
const messageCache = new Map<string, CommentMessage>()

const EMPTY_ARRAY: [] = []

const HIGHLIGHT_BLOCK_VARIANTS: Variants = {
initial: {
opacity: 0,
Expand Down Expand Up @@ -74,8 +84,6 @@ function CommentFieldInner(
},
) {
const {mode} = props
const [open, setOpen] = useState<boolean>(false)
const [value, setValue] = useState<PortableTextBlock[] | null>(null)

const currentUser = useCurrentUser()
const {element: boundaryElement} = useBoundaryElement()
Expand All @@ -94,18 +102,28 @@ function CommentFieldInner(
} = useComments()
const {upsellData, handleOpenDialog} = useCommentsUpsell()
const {selectedPath, setSelectedPath} = useCommentsSelectedPath()
const {authoringPath, setAuthoringPath} = useCommentsAuthoringPath()
const {scrollToGroup} = useCommentsScroll({
boundaryElement,
})

const fieldTitle = useMemo(() => getSchemaTypeTitle(props.schemaType), [props.schemaType])
const stringPath = useMemo(() => PathUtils.toString(props.path), [props.path])

// Use the cached value if it exists as the initial value
const cachedValue = messageCache.get(stringPath) || null

const [value, setValue] = useState<PortableTextBlock[] | null>(cachedValue)

// If the path of the field matches the authoring path, the comment input should be open.
const isOpen = useMemo(() => authoringPath === stringPath, [authoringPath, stringPath])

// Determine if the current field is selected
const isSelected = useMemo(() => {
if (!isCommentsOpen) return false
if (selectedPath?.origin === 'form' || selectedPath?.origin === 'url') return false
return selectedPath?.fieldPath === PathUtils.toString(props.path)
}, [isCommentsOpen, props.path, selectedPath?.fieldPath, selectedPath?.origin])
return selectedPath?.fieldPath === stringPath
}, [isCommentsOpen, selectedPath?.fieldPath, selectedPath?.origin, stringPath])

const isInlineCommentThread = useMemo(() => {
return comments.data.open
Expand All @@ -115,17 +133,21 @@ function CommentFieldInner(

// Total number of comments for the current field
const count = useMemo(() => {
const stringPath = PathUtils.toString(props.path)

const commentsCount = comments.data.open
.map((c) => (c.fieldPath === stringPath ? c.commentsCount : 0))
.reduce((acc, val) => acc + val, 0)

return commentsCount || 0
}, [comments.data.open, props.path])
}, [comments.data.open, stringPath])

const hasComments = Boolean(count > 0)

const resetMessageValue = useCallback(() => {
// Reset the value and remove the message from the cache
setValue(null)
messageCache.delete(stringPath)
}, [stringPath])

const handleClick = useCallback(() => {
// When clicking a comment button when the field has comments, we want to:
if (hasComments) {
Expand All @@ -134,8 +156,9 @@ function CommentFieldInner(
setStatus('open')
}

// 2. Close the comment input if it's open
setOpen(false)
// 2. Ensure that the authoring path is reset when clicking
// the comment button when the field has comments.
setAuthoringPath(null)

// 3. Open the comments inspector
onCommentsOpen?.()
Expand Down Expand Up @@ -171,19 +194,24 @@ function CommentFieldInner(
return
}

// Else, toggle the comment input open/closed
setOpen((v) => !v)
// If the field is open (i.e. the authoring path is set to the current field)
// we close the field by resetting the authoring path. If the field is not open,
// we set the authoring path to the current field so that the comment form is opened.
setAuthoringPath(isOpen ? null : stringPath)
}, [
comments.data.open,
handleOpenDialog,
hasComments,
isOpen,
mode,
onCommentsOpen,
props.path,
scrollToGroup,
setAuthoringPath,
setSelectedPath,
setStatus,
status,
stringPath,
upsellData,
])

Expand All @@ -200,7 +228,7 @@ function CommentFieldInner(
status: 'open',
threadId: newThreadId,
// New comments have no reactions
reactions: [],
reactions: EMPTY_ARRAY,
}

// Execute the create mutation
Expand All @@ -216,8 +244,7 @@ function CommentFieldInner(
setStatus('open')
}

// Reset the value
setValue(null)
resetMessageValue()

// Scroll to the thread
setSelectedPath({
Expand All @@ -232,14 +259,23 @@ function CommentFieldInner(
onCommentsOpen,
operation,
props.path,
resetMessageValue,
scrollToGroup,
setSelectedPath,
setStatus,
status,
value,
])

const handleDiscard = useCallback(() => setValue(null), [])
const handleClose = useCallback(() => setAuthoringPath(null), [setAuthoringPath])

const handleOnChange = useCallback(
(nextValue: CommentMessage) => {
setValue(nextValue)
messageCache.set(stringPath, nextValue)
},
[stringPath],
)

const internalComments: FieldProps['__internal_comments'] = useMemo(
() => ({
Expand All @@ -250,29 +286,31 @@ function CommentFieldInner(
fieldTitle={fieldTitle}
isCreatingDataset={isCreatingDataset}
mentionOptions={mentionOptions}
onChange={setValue}
onChange={handleOnChange}
onClick={handleClick}
onClose={handleClose}
onCommentAdd={handleCommentAdd}
onDiscard={handleDiscard}
open={open}
setOpen={setOpen}
onDiscard={resetMessageValue}
open={isOpen}
value={value}
/>
),
hasComments,
isAddingComment: open,
isAddingComment: isOpen,
}),
[
currentUser,
count,
fieldTitle,
isCreatingDataset,
mentionOptions,
handleOnChange,
handleClick,
handleClose,
handleCommentAdd,
handleDiscard,
open,
resetMessageValue,
isOpen,
value,
isCreatingDataset,
hasComments,
],
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ interface CommentsFieldButtonProps {
mentionOptions: UserListWithPermissionsHookValue
onChange: (value: PortableTextBlock[]) => void
onClick?: () => void
onClose: () => void
onCommentAdd: () => void
onDiscard: () => void
onInputKeyDown?: (event: React.KeyboardEvent<Element>) => void
open: boolean
setOpen: (open: boolean) => void
value: CommentMessage
}

Expand All @@ -56,11 +56,11 @@ export function CommentsFieldButton(props: CommentsFieldButtonProps) {
mentionOptions,
onChange,
onClick,
onClose,
onCommentAdd,
onDiscard,
onInputKeyDown,
open,
setOpen,
value,
} = props
const {t} = useTranslation(commentsLocaleNamespace)
Expand All @@ -73,9 +73,9 @@ export function CommentsFieldButton(props: CommentsFieldButtonProps) {

const closePopover = useCallback(() => {
if (!open) return
setOpen(false)
onClose()
addCommentButtonElement?.focus()
}, [addCommentButtonElement, open, setOpen])
}, [addCommentButtonElement, open, onClose])

const handleSubmit = useCallback(() => {
onCommentAdd()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {createContext} from 'react'

import {type CommentsAuthoringPathContextValue} from './types'

/**
* @beta
* @hidden
*/
export const CommentsAuthoringPathContext = createContext<CommentsAuthoringPathContextValue | null>(
null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {type ReactNode, useCallback, useMemo, useState} from 'react'

import {CommentsAuthoringPathContext} from './CommentsAuthoringPathContext'
import {type CommentsAuthoringPathContextValue} from './types'

interface CommentsAuthoringPathProviderProps {
children: ReactNode
}

/**
* @beta
* @hidden
* This provider keeps track of the path that the user is currently authoring a comment for.
* This is needed to make sure that we consistently keep the editor open when the user is
* authoring a comment. The state is kept in a context to make sure that it is preserved
* across re-renders. If this state was kept in a component, it would be reset every time
* the component re-renders, for example, when the form is temporarily set to `readOnly`
* while reconnecting.
*/
export function CommentsAuthoringPathProvider(props: CommentsAuthoringPathProviderProps) {
const {children} = props
const [authoringPath, setAuthoringPath] = useState<string | null>(null)

const handleSetAuthoringPath = useCallback((nextAuthoringPath: string | null) => {
setAuthoringPath(nextAuthoringPath)
}, [])

const value = useMemo(
(): CommentsAuthoringPathContextValue => ({
authoringPath,
setAuthoringPath: handleSetAuthoringPath,
}),
[authoringPath, handleSetAuthoringPath],
)

return (
<CommentsAuthoringPathContext.Provider value={value}>
{children}
</CommentsAuthoringPathContext.Provider>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './CommentsAuthoringPathContext'
export * from './CommentsAuthoringPathProvider'
export * from './types'
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* @beta
* @hidden
*/
export interface CommentsAuthoringPathContextValue {
setAuthoringPath: (nextAuthoringPath: string | null) => void
authoringPath: string | null
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './authoring-path'
export * from './comments'
export * from './enabled'
export * from './intent'
Expand Down
1 change: 1 addition & 0 deletions packages/sanity/src/structure/comments/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './use-comment-operations'
export * from './useComments'
export * from './useCommentsAuthoringPath'
export * from './useCommentsEnabled'
export * from './useCommentsIntent'
export * from './useCommentsOnboarding'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {useContext} from 'react'

import {CommentsAuthoringPathContext, type CommentsAuthoringPathContextValue} from '../context'

/**
* @beta
* @hidden
*/
export function useCommentsAuthoringPath(): CommentsAuthoringPathContextValue {
const value = useContext(CommentsAuthoringPathContext)

if (!value) {
throw new Error('useCommentsAuthoringPath: missing context value')
}

return value
}

0 comments on commit 542257c

Please sign in to comment.