diff --git a/dev/test-studio/schema/debug/comments.ts b/dev/test-studio/schema/debug/comments.ts
index bfac01f9312..3846857aa40 100644
--- a/dev/test-studio/schema/debug/comments.ts
+++ b/dev/test-studio/schema/debug/comments.ts
@@ -36,13 +36,6 @@ export const commentsDebug = defineType({
},
],
},
- {
- name: 'image',
- type: 'image',
- title: 'Image title',
- hidden: ({document}) => Boolean(document?.hideFields),
- description: DESCRIPTION,
- },
{
type: 'array',
name: 'arrayOfObjects',
@@ -111,5 +104,12 @@ export const commentsDebug = defineType({
},
],
},
+ {
+ name: 'image',
+ type: 'image',
+ title: 'Image title',
+ hidden: ({document}) => Boolean(document?.hideFields),
+ description: DESCRIPTION,
+ },
],
})
diff --git a/packages/sanity/src/core/comments/__workshop__/CommentDeleteDialogStory.tsx b/packages/sanity/src/core/comments/__workshop__/CommentDeleteDialogStory.tsx
new file mode 100644
index 00000000000..b1eed81b61c
--- /dev/null
+++ b/packages/sanity/src/core/comments/__workshop__/CommentDeleteDialogStory.tsx
@@ -0,0 +1,20 @@
+import React from 'react'
+import {useAction, useBoolean} from '@sanity/ui-workshop'
+import {CommentDeleteDialog} from '../components'
+
+export default function CommentDeleteDialogStory() {
+ const isParent = useBoolean('Is parent', false, 'Props') || false
+ const error = useBoolean('Error', false, 'Props') || false
+ const loading = useBoolean('Loading', false, 'Props') || false
+
+ return (
+
+ )
+}
diff --git a/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx b/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx
index 2a6d946abe9..9bd42213eee 100644
--- a/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx
+++ b/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx
@@ -156,6 +156,9 @@ export default function CommentsListStory() {
onEdit={handleEdit}
onReply={handleReplySubmit}
status={status}
+ onCreateRetry={() => {
+ // ...
+ }}
onNewThreadCreate={() => {
// ...
}}
diff --git a/packages/sanity/src/core/comments/__workshop__/CommentsProviderStory.tsx b/packages/sanity/src/core/comments/__workshop__/CommentsProviderStory.tsx
index 09726b09112..69e1c1df5e6 100644
--- a/packages/sanity/src/core/comments/__workshop__/CommentsProviderStory.tsx
+++ b/packages/sanity/src/core/comments/__workshop__/CommentsProviderStory.tsx
@@ -46,6 +46,9 @@ function Inner() {
onNewThreadCreate={create.execute}
onReply={create.execute}
status="open"
+ onCreateRetry={() => {
+ // ...
+ }}
/>
)
}
diff --git a/packages/sanity/src/core/comments/__workshop__/index.ts b/packages/sanity/src/core/comments/__workshop__/index.ts
index 9c0ab1e89c7..b63922ba9a3 100644
--- a/packages/sanity/src/core/comments/__workshop__/index.ts
+++ b/packages/sanity/src/core/comments/__workshop__/index.ts
@@ -30,5 +30,10 @@ export default defineScope({
title: 'MentionsMenu',
component: lazy(() => import('./MentionsMenuStory')),
},
+ {
+ name: 'comment-delete-dialog',
+ title: 'CommentDeleteDialog',
+ component: lazy(() => import('./CommentDeleteDialogStory')),
+ },
],
})
diff --git a/packages/sanity/src/core/comments/components/CommentDeleteDialog.tsx b/packages/sanity/src/core/comments/components/CommentDeleteDialog.tsx
index a943b3aa7d7..371d788a992 100644
--- a/packages/sanity/src/core/comments/components/CommentDeleteDialog.tsx
+++ b/packages/sanity/src/core/comments/components/CommentDeleteDialog.tsx
@@ -1,5 +1,6 @@
import {Dialog, Grid, Button, Stack, Text} from '@sanity/ui'
import React, {useCallback} from 'react'
+import {TextWithTone} from '../../components'
const DIALOG_COPY: Record<
'thread' | 'comment',
@@ -60,10 +61,14 @@ export function CommentDeleteDialog(props: CommentDeleteDialogProps) {
}
>
- {error && Something went wrong}
-
-
+
{body}
+
+ {error && (
+
+ An error occurred while deleting the comment. Please try again.
+
+ )}
)
diff --git a/packages/sanity/src/core/comments/components/avatars/SpacerAvatar.tsx b/packages/sanity/src/core/comments/components/avatars/SpacerAvatar.tsx
index fed96082ac4..232080e8dd9 100644
--- a/packages/sanity/src/core/comments/components/avatars/SpacerAvatar.tsx
+++ b/packages/sanity/src/core/comments/components/avatars/SpacerAvatar.tsx
@@ -1,7 +1,9 @@
import React from 'react'
+export const AVATAR_HEIGHT = 25
+
const INLINE_STYLE: React.CSSProperties = {
- minWidth: 25,
+ minWidth: AVATAR_HEIGHT,
}
/**
diff --git a/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx b/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx
index 5cd91691e32..040f0b781c6 100644
--- a/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx
+++ b/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx
@@ -43,6 +43,9 @@ export function CommentThreadLayout(props: CommentThreadLayoutProps) {
} = props
const createNewThreadInputRef = useRef(null)
const [displayNewThreadInput, setDisplayNewThreadInput] = useState(false)
+ const [newThreadButtonElement, setNewThreadButtonElement] = useState(
+ null,
+ )
const onCreateNewThreadClick = useCallback(() => {
setDisplayNewThreadInput(true)
@@ -75,8 +78,11 @@ export function CommentThreadLayout(props: CommentThreadLayoutProps) {
onNewThreadCreate?.(nextComment)
setDisplayNewThreadInput(false)
+
+ // When the new thread is created, we focus the button again
+ newThreadButtonElement?.focus()
},
- [onNewThreadCreate, path],
+ [newThreadButtonElement, onNewThreadCreate, path],
)
return (
@@ -114,6 +120,7 @@ export function CommentThreadLayout(props: CommentThreadLayoutProps) {
mode="bleed"
onClick={onCreateNewThreadClick}
padding={2}
+ ref={setNewThreadButtonElement}
/>
)}
diff --git a/packages/sanity/src/core/comments/components/list/CommentsList.tsx b/packages/sanity/src/core/comments/components/list/CommentsList.tsx
index 033f43bf644..d18a4dece8a 100644
--- a/packages/sanity/src/core/comments/components/list/CommentsList.tsx
+++ b/packages/sanity/src/core/comments/components/list/CommentsList.tsx
@@ -47,6 +47,7 @@ export interface CommentsListProps {
error: Error | null
loading: boolean
mentionOptions: MentionOptionsHookValue
+ onCreateRetry: (id: string) => void
onDelete: (id: string) => void
onEdit: (id: string, payload: CommentEditPayload) => void
onNewThreadCreate: (payload: CommentCreatePayload) => void
@@ -78,6 +79,7 @@ export const CommentsList = forwardRef(fu
error,
loading,
mentionOptions,
+ onCreateRetry,
onDelete,
onEdit,
onNewThreadCreate,
@@ -202,12 +204,18 @@ export const CommentsList = forwardRef(fu
// We use slice() to avoid mutating the original array.
const replies = item.replies.slice().reverse()
+ const canReply =
+ status === 'open' &&
+ item.parentComment._state?.type !== 'createError' &&
+ item.parentComment._state?.type !== 'createRetrying'
+
return (
void
onDelete: (id: string) => void
onEdit: (id: string, payload: CommentEditPayload) => void
onPathFocus?: (path: Path) => void
@@ -60,6 +61,7 @@ export function CommentsListItem(props: CommentsListItemProps) {
canReply,
currentUser,
mentionOptions,
+ onCreateRetry,
onDelete,
onEdit,
onPathFocus,
@@ -137,67 +139,77 @@ export function CommentsListItem(props: CommentsListItemProps) {
return (
-
-
-
+
+
+
+
+
+ {showCollapseButton && !didExpand.current && (
+
+
+
+
+
+ )}
+
+ {splicedReplies.map((reply) => (
+
-
- {showCollapseButton && !didExpand.current && (
-
-
-
-
-
- )}
-
- {splicedReplies.map((reply) => (
-
-
-
- ))}
-
- {canReply && (
-
- )}
-
+ ))}
+
+ {canReply && (
+
+ )}
diff --git a/packages/sanity/src/core/comments/components/list/CommentsListItemLayout.tsx b/packages/sanity/src/core/comments/components/list/CommentsListItemLayout.tsx
index 8a76e4b95fc..b3224043ded 100644
--- a/packages/sanity/src/core/comments/components/list/CommentsListItemLayout.tsx
+++ b/packages/sanity/src/core/comments/components/list/CommentsListItemLayout.tsx
@@ -22,6 +22,7 @@ import {
Box,
TooltipDelayGroupProvider,
TooltipDelayGroupProviderProps,
+ MenuButtonProps,
} from '@sanity/ui'
import React, {useCallback, useRef, useState} from 'react'
import {CurrentUser, Path} from '@sanity/types'
@@ -31,7 +32,7 @@ import * as PathUtils from '@sanity/util/paths'
import {TimeAgoOpts, useTimeAgo} from '../../../hooks'
import {useUser} from '../../../store'
import {CommentMessageSerializer} from '../pte'
-import {CommentInput} from '../pte/comment-input'
+import {CommentInput, CommentInputHandle} from '../pte/comment-input'
import {
CommentDocument,
CommentEditPayload,
@@ -43,15 +44,29 @@ import {FLEX_GAP} from '../constants'
import {useDidUpdate} from '../../../form'
import {useCommentHasChanged} from '../../helpers'
import {TextTooltip} from '../TextTooltip'
-import {CommentsAvatar, SpacerAvatar} from '../avatars'
+import {AVATAR_HEIGHT, CommentsAvatar, SpacerAvatar} from '../avatars'
const TOOLTIP_GROUP_DELAY: TooltipDelayGroupProviderProps['delay'] = {open: 500}
const SKELETON_INLINE_STYLE: React.CSSProperties = {width: '50%'}
+const POPOVER_PROPS: MenuButtonProps['popover'] = {placement: 'bottom-end'}
const TimeText = styled(Text)`
min-width: max-content;
`
+const InnerStack = styled(Stack)`
+ transition: opacity 200ms ease;
+
+ &[data-muted='true'] {
+ transition: unset;
+ opacity: 0.5;
+ }
+`
+
+const ErrorFlex = styled(Flex)`
+ min-height: ${AVATAR_HEIGHT}px;
+`
+
const FloatingLayer = styled(Layer)(({theme}) => {
const {space} = theme.sanity
@@ -65,6 +80,13 @@ const FloatingLayer = styled(Layer)(({theme}) => {
`
})
+const RetryCardButton = styled(Card)`
+ // Add not on hover
+ &:not(:hover) {
+ background-color: transparent;
+ }
+`
+
const FloatingCard = styled(Card)(({theme}) => {
const {space} = theme.sanity
@@ -111,8 +133,11 @@ interface CommentsListItemLayoutProps {
canEdit?: boolean
comment: CommentDocument
currentUser: CurrentUser
+ hasError?: boolean
isParent?: boolean
+ isRetrying?: boolean
mentionOptions: MentionOptionsHookValue
+ onCreateRetry?: (id: string) => void
onDelete: (id: string) => void
onEdit: (id: string, message: CommentEditPayload) => void
onPathFocus?: (path: Path) => void
@@ -127,8 +152,11 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) {
canEdit,
comment,
currentUser,
+ hasError,
isParent,
+ isRetrying,
mentionOptions,
+ onCreateRetry,
onDelete,
onEdit,
onPathFocus,
@@ -146,6 +174,8 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) {
const hasChanges = useCommentHasChanged(value)
+ const commentInputRef = useRef(null)
+
const createdDate = _createdAt ? new Date(_createdAt) : new Date()
const createdTimeAgo = useTimeAgo(createdDate, TIME_AGO_OPTS)
const formattedCreatedAt = format(createdDate, 'PPPPp')
@@ -155,6 +185,12 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) {
const handleMenuOpen = useCallback(() => setMenuOpen(true), [])
const handleMenuClose = useCallback(() => setMenuOpen(false), [])
+ const displayError = hasError || isRetrying
+
+ const handleCreateRetry = useCallback(() => {
+ onCreateRetry?.(_id)
+ }, [_id, onCreateRetry])
+
const cancelEdit = useCallback(() => {
setIsEditing(false)
setValue(startMessage.current)
@@ -200,6 +236,7 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) {
useClickOutside(() => {
if (!hasChanges) {
cancelEdit()
+ commentInputRef.current?.blur()
}
}, [rootElement])
@@ -218,138 +255,173 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) {
return (
-
- {avatar}
-
-
-
- {name}
-
-
-
- {createdTimeAgo}
-
-
- {formattedLastEditAt && (
-
- (edited)
-
+
+
+ {avatar}
+
+
+
+ {name}
+
+ {!displayError && (
+
+
+ {createdTimeAgo}
+
+
+ {formattedLastEditAt && (
+
+ (edited)
+
+ )}
+
)}
-
- {!isEditing && (
-
-
-
- {isParent && (
- <>
- {onPathFocus && (
-
+ {!isEditing && !displayError && (
+
+
+
+ {isParent && (
+ <>
+ {onPathFocus && (
+
+
+
+ )}
+
+ {onStatusChange && (
+
+
+
+ )}
+ >
+ )}
+
+ {canDelete && canEdit && (
+
-
- )}
+ }
+ onOpen={handleMenuOpen}
+ onClose={handleMenuClose}
+ menu={
+
+ }
+ popover={POPOVER_PROPS}
+ />
+ )}
+
+
+
+ )}
+
- {onStatusChange && (
-
-
-
- )}
- >
- )}
-
- {canDelete && canEdit && (
-
- }
- onOpen={handleMenuOpen}
- onClose={handleMenuClose}
- menu={
-
- }
- popover={{placement: 'bottom-end'}}
- />
- )}
-
-
-
+ {isEditing && (
+
+
+
+
+
+
+
)}
-
- {isEditing && (
-
-
+ {!isEditing && (
+
+
-
-
-
-
- )}
+
+
+ )}
+
- {!isEditing && (
-
+ {displayError && (
+
-
-
+
+
+ {hasError && 'Failed to send.'}
+ {isRetrying && 'Posting...'}
+
+
+
+
+
+ Retry
+
+
+
+
+
)}
)
diff --git a/packages/sanity/src/core/comments/components/pte/comment-input/CommentInput.tsx b/packages/sanity/src/core/comments/components/pte/comment-input/CommentInput.tsx
index b759300460c..76796f21e8a 100644
--- a/packages/sanity/src/core/comments/components/pte/comment-input/CommentInput.tsx
+++ b/packages/sanity/src/core/comments/components/pte/comment-input/CommentInput.tsx
@@ -24,6 +24,7 @@ interface CommentInputProps {
}
export interface CommentInputHandle {
+ blur: () => void
focus: () => void
scrollTo: () => void
}
@@ -86,6 +87,11 @@ export const CommentInput = forwardRef(
PortableTextEditor.focus(editorRef.current)
}
},
+ blur() {
+ if (editorRef?.current) {
+ PortableTextEditor.blur(editorRef.current)
+ }
+ },
scrollTo: scrollToEditor,
}
},
diff --git a/packages/sanity/src/core/comments/components/pte/comment-input/Editable.tsx b/packages/sanity/src/core/comments/components/pte/comment-input/Editable.tsx
index 9fecf99758d..c6de55af357 100644
--- a/packages/sanity/src/core/comments/components/pte/comment-input/Editable.tsx
+++ b/packages/sanity/src/core/comments/components/pte/comment-input/Editable.tsx
@@ -27,22 +27,26 @@ const EditableWrapStack = styled(Stack)(() => {
`
})
-export const StyledPopover = styled(Popover)(() => {
+export const StyledPopover = styled(Popover)(({theme}) => {
+ const {space, radius} = theme.sanity
+
return css`
- // Position the Popover relative to the @
- transform: translateY(6px);
- &[data-placement='top-end'] {
- transform: translateY(-12px);
+ &[data-placement='bottom'] {
+ transform: translateY(${space[1]}px);
+ }
+
+ &[data-placement='top'] {
+ transform: translateY(-${space[1]}px);
}
[data-ui='Popover__wrapper'] {
- border-radius: ${({theme}) => theme.sanity.radius[3]}px;
+ border-radius: ${radius[3]}px;
display: flex;
flex-direction: column;
- width: 300px; // todo: improve
overflow: clip;
overflow: hidden;
position: relative;
+ width: 300px; // todo: improve
}
`
})
@@ -167,19 +171,20 @@ export function Editable(props: EditableProps) {
/>
}
disabled={!mentionsMenuOpen}
- fallbackPlacements={['bottom-end', 'top-end']}
+ fallbackPlacements={['bottom', 'top']}
open={mentionsMenuOpen}
- placement="bottom-end"
+ placement="bottom"
portal
ref={setPopoverElement}
referenceElement={cursorElement}
/>
+
undefined,
mentionOptions: EMPTY_MENTION_OPTIONS,
remove: noopOperation,
setStatus: noop,
@@ -80,9 +82,11 @@ export const CommentsProvider = memo(function CommentsProvider(props: CommentsPr
return
})
+const EMPTY = {}
+
function CommentsProviderInner(props: Omit) {
const {children, documentValue} = props
- const {_id: documentId, _type: documentType} = documentValue || {}
+ const {_id: documentId, _type: documentType} = documentValue || EMPTY
const [status, setStatus] = useState('open')
@@ -102,17 +106,48 @@ function CommentsProviderInner(props: Omit) {
schemaType,
workspace: workspaceName,
- // The following callbacks are used to update the local state
- // when a comment is created, updated or deleted to make the
- // UI feel more responsive.
+ // The following callbacks runs when the comment operations are executed.
+ // They are used to update the local state of the comments immediately after
+ // a comment operation has been executed. This is done to avoid waiting for
+ // the real time listener to update the comments and make the UI feel more
+ // responsive. The comment will be updated again when we receive an mutation
+ // event from the real time listener.
+
onCreate: (payload) => {
- dispatch({type: 'COMMENT_ADDED', result: payload})
+ // If the comment we try to create already exists in the local state and has
+ // the 'createError' state, we know that we are retrying a comment creation.
+ // In that case, we want to change the state to 'createRetrying'.
+ const hasError = data?.find((c) => c._id === payload._id)?._state?.type === 'createError'
+
+ dispatch({
+ type: 'COMMENT_ADDED',
+ payload: {
+ ...payload,
+ _state: hasError ? {type: 'createRetrying'} : undefined,
+ },
+ })
+ },
+
+ // When an error occurs during comment creation, we update the comment state
+ // to `createError`. This will make the comment appear in the UI as a comment
+ // that failed to be created. The user can then retry the comment creation.
+ onCreateError: (id, err) => {
+ dispatch({
+ type: 'COMMENT_UPDATED',
+ payload: {
+ _id: id,
+ _state: {
+ error: err,
+ type: 'createError',
+ },
+ },
+ })
},
onEdit: (id, payload) => {
dispatch({
type: 'COMMENT_UPDATED',
- result: {
+ payload: {
_id: id,
...payload,
},
@@ -122,7 +157,7 @@ function CommentsProviderInner(props: Omit) {
onUpdate: (id, payload) => {
dispatch({
type: 'COMMENT_UPDATED',
- result: {
+ payload: {
_id: id,
...payload,
},
@@ -133,8 +168,15 @@ function CommentsProviderInner(props: Omit) {
const threadItemsByStatus = useMemo(() => {
if (!schemaType || !currentUser) return EMPTY_COMMENTS_DATA
+ // Since we only make one query to get all comments using the order `_createdAt desc` – we
+ // can't know for sure that the comments added through the real time listener will be in the
+ // correct order. In order to avoid that comments are out of order, we make an additional
+ // sort here. The comments can be out of order if e.g a comment creation fails and is retried
+ // later.
+ const sorted = orderBy(data, ['_createdAt'], ['desc'])
+
const threadItems = buildCommentThreadItems({
- comments: data || EMPTY_ARRAY,
+ comments: sorted || EMPTY_ARRAY,
schemaType,
currentUser,
documentValue,
@@ -146,11 +188,14 @@ function CommentsProviderInner(props: Omit) {
}
}, [currentUser, data, documentValue, schemaType])
+ const getComment = useCallback((id: string) => data?.find((c) => c._id === id), [data])
+
const ctxValue = useMemo(
() =>
({
status,
setStatus,
+ getComment,
comments: {
data: threadItemsByStatus,
error,
@@ -172,6 +217,7 @@ function CommentsProviderInner(props: Omit) {
}) satisfies CommentsContextValue,
[
error,
+ getComment,
loading,
mentionOptions,
operation.create,
diff --git a/packages/sanity/src/core/comments/hooks/useCommentOperations.ts b/packages/sanity/src/core/comments/hooks/useCommentOperations.ts
index 77768833f65..2f2050dd146 100644
--- a/packages/sanity/src/core/comments/hooks/useCommentOperations.ts
+++ b/packages/sanity/src/core/comments/hooks/useCommentOperations.ts
@@ -26,6 +26,7 @@ interface CommentOperationsHookOptions {
workspace: string
onCreate?: (comment: CommentPostPayload) => void
+ onCreateError: (id: string, error: Error) => void
onEdit?: (id: string, comment: CommentEditPayload) => void
onRemove?: (id: string) => void
onUpdate?: (id: string, comment: Partial) => void
@@ -40,12 +41,13 @@ export function useCommentOperations(
documentId,
documentType,
onCreate,
+ onCreateError,
onEdit,
onRemove,
onUpdate,
projectId,
workspace,
- } = opts || {}
+ } = opts
const client = useCommentsClient()
const authorId = currentUser?.id
@@ -58,7 +60,10 @@ export function useCommentOperations(
const handleCreate = useCallback(
async (comment: CommentCreatePayload) => {
const nextComment = {
- _id: uuid(),
+ // The comment payload might already have an id if, for example, the comment was created
+ // but the request failed. In that case, we'll reuse the id when retrying to
+ // create the comment.
+ _id: comment?.id || uuid(),
_type: 'comment',
authorId: authorId || '', // improve
lastEditedAt: undefined,
@@ -66,6 +71,7 @@ export function useCommentOperations(
parentCommentId: comment.parentCommentId,
status: comment.status,
threadId: comment.threadId,
+
context: {
payload: {
workspace,
@@ -90,7 +96,13 @@ export function useCommentOperations(
onCreate?.(nextComment)
- await client.create(nextComment)
+ try {
+ await client.create(nextComment)
+ } catch (err) {
+ onCreateError?.(nextComment._id, err)
+
+ throw err
+ }
},
[
authorId,
@@ -99,6 +111,7 @@ export function useCommentOperations(
documentId,
documentType,
onCreate,
+ onCreateError,
projectId,
title,
toolName,
@@ -137,6 +150,8 @@ export function useCommentOperations(
async (id: string, comment: Partial) => {
onUpdate?.(id, comment)
+ // If the update contains a status, we'll update the status of all replies
+ // to the comment as well.
if (comment.status) {
await Promise.all([
client
@@ -151,6 +166,7 @@ export function useCommentOperations(
return
}
+ // Else we'll just update the comment itself
await client.patch(id).set(comment).commit()
},
[client, onUpdate],
@@ -159,9 +175,9 @@ export function useCommentOperations(
const operation = useMemo(
() => ({
create: handleCreate,
- update: handleUpdate,
- remove: handleRemove,
edit: handleEdit,
+ remove: handleRemove,
+ update: handleUpdate,
}),
[handleCreate, handleRemove, handleEdit, handleUpdate],
)
diff --git a/packages/sanity/src/core/comments/store/reducer.ts b/packages/sanity/src/core/comments/store/reducer.ts
index b3febf336ef..a775f5c4f5a 100644
--- a/packages/sanity/src/core/comments/store/reducer.ts
+++ b/packages/sanity/src/core/comments/store/reducer.ts
@@ -1,7 +1,7 @@
import {CommentDocument, CommentPostPayload} from '../types'
interface CommentAddedAction {
- result: CommentDocument | CommentPostPayload
+ payload: CommentDocument | CommentPostPayload
type: 'COMMENT_ADDED'
}
@@ -11,7 +11,7 @@ interface CommentDeletedAction {
}
interface CommentUpdatedAction {
- result: CommentDocument | Partial
+ payload: CommentDocument | Partial
type: 'COMMENT_UPDATED'
}
@@ -60,8 +60,37 @@ export function commentsReducer(
}
case 'COMMENT_ADDED': {
- const nextCommentResult = action.result as CommentDocument
- const nextComment = {[nextCommentResult._id]: nextCommentResult}
+ const nextCommentResult = action.payload as CommentDocument
+
+ const nextCommentValue = nextCommentResult satisfies CommentDocument
+
+ const nextComment = {
+ [nextCommentResult._id]: {
+ ...state.comments[nextCommentResult._id],
+ ...nextCommentValue,
+ _state: nextCommentResult._state || undefined,
+ // If the comment is created optimistically, it won't have a createdAt date.
+ // In that case, we'll use the current date.
+ // The correct date will be set when the comment is created on the server
+ // and the comment is received in the realtime listener.
+ _createdAt: nextCommentResult._createdAt || new Date().toISOString(),
+ } satisfies CommentDocument,
+ }
+
+ const commentExists = state.comments && state.comments[nextCommentResult._id]
+
+ // The comment might already exist in the store if an optimistic update
+ // has been performed but the post request failed. In that case we want
+ // to merge the new comment with the existing one.
+ if (commentExists) {
+ return {
+ ...state,
+ comments: {
+ ...nextComment,
+ ...state.comments,
+ },
+ }
+ }
const nextComments = {
...nextComment,
@@ -92,7 +121,7 @@ export function commentsReducer(
}
case 'COMMENT_UPDATED': {
- const updatedComment = action.result
+ const updatedComment = action.payload
const id = updatedComment._id as string
return {
diff --git a/packages/sanity/src/core/comments/store/useCommentsStore.ts b/packages/sanity/src/core/comments/store/useCommentsStore.ts
index 4335d2b747f..8edf5dda552 100644
--- a/packages/sanity/src/core/comments/store/useCommentsStore.ts
+++ b/packages/sanity/src/core/comments/store/useCommentsStore.ts
@@ -48,13 +48,12 @@ const QUERY = `*[${QUERY_FILTERS.join(' && ')}] ${QUERY_PROJECTION} | ${QUERY_SO
export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreReturnType {
const client = useCommentsClient()
- const documentId = getPublishedId(opts.documentId)
const [state, dispatch] = useReducer(commentsReducer, INITIAL_STATE)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
- const params = useMemo(() => ({documentId}), [documentId])
+ const params = useMemo(() => ({documentId: getPublishedId(opts.documentId)}), [opts.documentId])
const initialFetch = useCallback(async () => {
try {
@@ -67,6 +66,7 @@ export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreRetur
const handleListenerEvent = useCallback(
async (event: ListenEvent>) => {
+ // Fetch all comments on initial connection
if (event.type === 'welcome') {
setLoading(true)
await initialFetch()
@@ -78,12 +78,17 @@ export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreRetur
await initialFetch()
}
+ // Handle mutations (create, update, delete) from the realtime listener
+ // and update the comments store accordingly
if (event.type === 'mutation') {
if (event.transition === 'appear') {
const nextComment = event.result as CommentDocument | undefined
if (nextComment) {
- dispatch({type: 'COMMENT_ADDED', result: nextComment})
+ dispatch({
+ type: 'COMMENT_ADDED',
+ payload: nextComment,
+ })
}
}
@@ -93,8 +98,12 @@ export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreRetur
if (event.transition === 'update') {
const updatedComment = event.result as CommentDocument | undefined
+
if (updatedComment) {
- dispatch({type: 'COMMENT_UPDATED', result: updatedComment})
+ dispatch({
+ type: 'COMMENT_ADDED',
+ payload: updatedComment,
+ })
}
}
}
@@ -106,7 +115,7 @@ export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreRetur
const events$ = client.observable.listen(QUERY, params, LISTEN_OPTIONS).pipe(
catchError((err) => {
setError(err)
- return of()
+ return of(err)
}),
)
diff --git a/packages/sanity/src/core/comments/types.ts b/packages/sanity/src/core/comments/types.ts
index 90d01216cb0..8108d729b9a 100644
--- a/packages/sanity/src/core/comments/types.ts
+++ b/packages/sanity/src/core/comments/types.ts
@@ -54,6 +54,8 @@ export interface CommentThreadItem {
* @hidden
*/
export interface CommentsContextValue {
+ getComment: (id: string) => CommentDocument | undefined
+
comments: {
data: {
open: CommentThreadItem[]
@@ -114,6 +116,24 @@ interface CommentContext {
}
}
+interface CommentCreateRetryingState {
+ type: 'createRetrying'
+}
+
+interface CommentCreateFailedState {
+ type: 'createError'
+ error: Error
+}
+
+/**
+ * The state is used to track the state of the comment (e.g. if it failed to be created, etc.)
+ * It is a local value and is not stored on the server.
+ * When there's no state, the comment is considered to be in a "normal" state (e.g. created successfully).
+ *
+ * The state value is primarily used to update the UI. That is, to show an error message or retry button.
+ */
+type CommentState = CommentCreateFailedState | CommentCreateRetryingState | undefined
+
/**
* @beta
* @hidden
@@ -139,6 +159,8 @@ export interface CommentDocument {
context?: CommentContext
+ _state?: CommentState
+
target: {
path: CommentPath
documentType: string
@@ -157,17 +179,18 @@ export interface CommentDocument {
* @beta
* @hidden
*/
-export type CommentPostPayload = Omit
+export type CommentPostPayload = Omit
/**
* @beta
* @hidden
*/
export interface CommentCreatePayload {
- message: CommentMessage
- status: CommentStatus
fieldPath: string
+ id?: string
+ message: CommentMessage
parentCommentId: string | undefined
+ status: CommentStatus
threadId: string
}
diff --git a/packages/sanity/src/desk/comments/inspector/CommentsInspector.tsx b/packages/sanity/src/desk/comments/inspector/CommentsInspector.tsx
index 83da83acfab..f5e3721ac4a 100644
--- a/packages/sanity/src/desk/comments/inspector/CommentsInspector.tsx
+++ b/packages/sanity/src/desk/comments/inspector/CommentsInspector.tsx
@@ -38,7 +38,8 @@ export function CommentsInspector(props: DocumentInspectorProps) {
const params = useUnique(paneRouter.params) || (EMPTY_PARAMS as Partial<{comment?: string}>)
const commentIdParamRef = useRef(params?.comment)
- const {comments, create, edit, mentionOptions, remove, update, status, setStatus} = useComments()
+ const {comments, create, edit, mentionOptions, remove, update, status, setStatus, getComment} =
+ useComments()
const currentComments = useMemo(() => comments.data[status], [comments, status])
@@ -56,6 +57,23 @@ export function CommentsInspector(props: DocumentInspectorProps) {
}
}, [comments.loading])
+ const handleCreateRetry = useCallback(
+ (id: string) => {
+ const comment = getComment(id)
+ if (!comment) return
+
+ create.execute({
+ fieldPath: comment.target.path.field,
+ id: comment._id,
+ message: comment.message,
+ parentCommentId: comment.parentCommentId,
+ status: comment.status,
+ threadId: comment.threadId,
+ })
+ },
+ [create, getComment],
+ )
+
const closeDeleteDialog = useCallback(() => {
if (deleteLoading) return
setShowDeleteDialog(false)
@@ -152,6 +170,7 @@ export function CommentsInspector(props: DocumentInspectorProps) {
error={comments.error}
loading={comments.loading}
mentionOptions={mentionOptions}
+ onCreateRetry={handleCreateRetry}
onDelete={onDeleteStart}
onEdit={handleEdit}
onNewThreadCreate={handleNewThreadCreate}