UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

199 lines (163 loc) • 6.25 kB
import {type ListenEvent, type ListenOptions, type SanityClient} from '@sanity/client' import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' import {catchError, of} from 'rxjs' import {type ReleaseId} from '../../perspective/types' import {getPublishedId} from '../../util' import {type CommentDocument, type Loadable} from '../types' import {commentsReducer, type CommentsReducerAction, type CommentsReducerState} from './reducer' type DocumentId = string type TransactionId = string export interface CommentsStoreOptions { client: SanityClient | null documentId: string onLatestTransactionIdReceived: (documentId: DocumentId) => void transactionsIdMap: Map<DocumentId, TransactionId> releaseId?: ReleaseId } interface CommentsStoreReturnType extends Loadable<CommentDocument[]> { dispatch: React.Dispatch<CommentsReducerAction> } const INITIAL_STATE: CommentsReducerState = { comments: {}, } const LISTEN_OPTIONS: ListenOptions = { events: ['welcome', 'mutation', 'reconnect'], includeResult: true, includeAllVersions: true, visibility: 'query', tag: 'comments-store', } export const SORT_FIELD = '_createdAt' export const SORT_ORDER = 'desc' const QUERY_FILTERS = [`_type == "comment"`, `target.document._ref == $documentId`] const VERSION_FILTER = `target.documentVersionId==$documentVersionId` const NO_VERSION_FILTER = `!defined(target.documentVersionId)` const QUERY_PROJECTION = `{ _createdAt, _id, authorId, contentSnapshot, context, lastEditedAt, message, parentCommentId, reactions, status, target, threadId }` // Newest comments first const QUERY_SORT_ORDER = `order(${SORT_FIELD} ${SORT_ORDER})` export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreReturnType { const {client, documentId, onLatestTransactionIdReceived, transactionsIdMap, releaseId} = opts const filters = [...QUERY_FILTERS, releaseId ? VERSION_FILTER : NO_VERSION_FILTER].join(' && ') const query = useMemo(() => `*[${filters}] ${QUERY_PROJECTION} | ${QUERY_SORT_ORDER}`, [filters]) const [state, dispatch] = useReducer(commentsReducer, INITIAL_STATE) const [loading, setLoading] = useState<boolean>(client !== null) const [error, setError] = useState<Error | null>(null) const didInitialFetch = useRef<boolean>(false) const params = useMemo( () => ({ documentId: getPublishedId(documentId), ...(releaseId ? {documentVersionId: releaseId} : {}), }), [documentId, releaseId], ) const initialFetch = useCallback(async () => { if (!client) { setLoading(false) return } try { const res = await client.fetch(query, params) dispatch({type: 'COMMENTS_SET', comments: res}) setLoading(false) } catch (err) { setError(err) } }, [client, params, query]) const handleListenerEvent = useCallback( async (event: ListenEvent<Record<string, CommentDocument>>) => { // Fetch all comments on initial connection if (event.type === 'welcome' && !didInitialFetch.current) { setLoading(true) await initialFetch() setLoading(false) didInitialFetch.current = true } // The reconnect event means that we are trying to reconnect to the realtime listener. // In this case we set loading to true to indicate that we're trying to // reconnect. Once a connection has been established, the welcome event // will be received and we'll fetch all comments again (above) if (event.type === 'reconnect') { setLoading(true) didInitialFetch.current = false } // 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_RECEIVED', payload: nextComment, }) } } if (event.transition === 'disappear') { dispatch({type: 'COMMENT_DELETED', id: event.documentId}) } if (event.transition === 'update') { const updatedComment = event.result as CommentDocument | undefined const id = event.result?._id || '' const transactionId = event.transactionId const latestTransactionId = transactionsIdMap.get(id) const isLatestTransaction = transactionId === latestTransactionId // If we have a transaction id stored for the received comment id, but the // received transaction id is not the latest, we don't want to update the // comment in the store. This is to avoid that the UI is updated with an old // transaction id when multiple transactions are started in a short time span. if (!isLatestTransaction && latestTransactionId) return if (updatedComment) { dispatch({ type: 'COMMENT_UPDATED', payload: updatedComment, }) // If the received transaction id is the latest, we'll call the // `onLatestTransactionIdReceived` callback to let the parent consumer // know that the transaction id has been received. if (isLatestTransaction) { onLatestTransactionIdReceived(id) } } } } }, [initialFetch, onLatestTransactionIdReceived, transactionsIdMap], ) const listener$ = useMemo(() => { if (!client) return of() const events$ = client.observable.listen(`*[${filters}]`, params, LISTEN_OPTIONS).pipe( catchError((err) => { setError(err) return of(err) }), ) return events$ }, [client, filters, params]) useEffect(() => { const sub = listener$.subscribe(handleListenerEvent) return () => { sub?.unsubscribe() } }, [handleListenerEvent, listener$]) // Transform comments object to array const commentsAsArray = useMemo(() => Object.values(state.comments), [state.comments]) return { data: commentsAsArray, dispatch, error, loading, } }