@sanity/sdk 3.0.0-rc.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/_exports/_internal.d.ts +71 -3
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +332 -24
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +65 -0
- package/dist/_exports/agent.d.ts.map +1 -0
- package/dist/_exports/agent.js +71 -0
- package/dist/_exports/agent.js.map +1 -0
- package/dist/_exports/comlink.d.ts +118 -0
- package/dist/_exports/comlink.d.ts.map +1 -0
- package/dist/_exports/comlink.js +69 -0
- package/dist/_exports/comlink.js.map +1 -0
- package/dist/authStore-C0o1NOxW.d.ts +3165 -0
- package/dist/authStore-C0o1NOxW.d.ts.map +1 -0
- package/dist/authStore-CtE9bLHW.js +754 -0
- package/dist/authStore-CtE9bLHW.js.map +1 -0
- package/dist/clientStore-BtE2-q2i.js +90 -0
- package/dist/clientStore-BtE2-q2i.js.map +1 -0
- package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
- package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
- package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
- package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
- package/dist/getEnv-D5RGkQLu.js +8 -0
- package/dist/getEnv-D5RGkQLu.js.map +1 -0
- package/dist/getNodeState-CR3__B2D.js +67 -0
- package/dist/getNodeState-CR3__B2D.js.map +1 -0
- package/dist/index.d.ts +3 -2335
- package/dist/index.js +4048 -3878
- package/dist/index.js.map +1 -1
- package/dist/logger-DAtJb5RQ.js +173 -0
- package/dist/logger-DAtJb5RQ.js.map +1 -0
- package/dist/object-CxXlSVg7.js +83 -0
- package/dist/object-CxXlSVg7.js.map +1 -0
- package/dist/queryStore-C7yMHPUI.js +1346 -0
- package/dist/queryStore-C7yMHPUI.js.map +1 -0
- package/dist/rolldown-runtime-vyAXikos.js +9 -0
- package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
- package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
- package/dist/telemetryManager-UIEiMtC0.js +127 -0
- package/dist/telemetryManager-UIEiMtC0.js.map +1 -0
- package/dist/version-D8RP_Q59.js +9 -0
- package/dist/version-D8RP_Q59.js.map +1 -0
- package/package.json +11 -17
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/agent.ts +19 -0
- package/src/_exports/comlink.ts +16 -0
- package/src/_exports/index.ts +45 -90
- package/src/applications/applications.test-d.ts +5 -0
- package/src/applications/applications.ts +5 -2
- package/src/auth/authMode.test.ts +0 -25
- package/src/auth/authMode.ts +3 -6
- package/src/auth/authStore.test.ts +1 -1
- package/src/auth/authStore.ts +0 -1
- package/src/comments/addonDatasetStore.test.ts +215 -0
- package/src/comments/addonDatasetStore.ts +249 -0
- package/src/comments/buildCommentThreads.test.ts +83 -0
- package/src/comments/buildCommentThreads.ts +54 -0
- package/src/comments/commentActions.test.ts +540 -0
- package/src/comments/commentActions.ts +555 -0
- package/src/comments/commentFieldPath.test.ts +40 -0
- package/src/comments/commentFieldPath.ts +44 -0
- package/src/comments/commentsConstants.ts +80 -0
- package/src/comments/commentsStore.test.ts +419 -0
- package/src/comments/commentsStore.ts +483 -0
- package/src/comments/normalizeComment.test.ts +116 -0
- package/src/comments/normalizeComment.ts +45 -0
- package/src/comments/observeComments.test.ts +193 -0
- package/src/comments/observeComments.ts +113 -0
- package/src/comments/reducers.test.ts +277 -0
- package/src/comments/reducers.ts +316 -0
- package/src/comments/types.ts +249 -0
- package/src/comments/weakenReferences.test.ts +47 -0
- package/src/comments/weakenReferences.ts +29 -0
- package/src/config/sanityConfig.ts +3 -75
- package/src/datasets/datasets.test.ts +44 -9
- package/src/datasets/datasets.ts +24 -23
- package/src/document/applyDocumentActions.test.ts +8 -12
- package/src/document/documentConstants.ts +17 -0
- package/src/document/documentStore.test.ts +173 -19
- package/src/document/documentStore.ts +36 -5
- package/src/document/patchOperations.test.ts +27 -0
- package/src/document/patchOperations.ts +10 -1
- package/src/document/permissions.test.ts +3 -1
- package/src/document/reducers.test.ts +357 -0
- package/src/document/reducers.ts +125 -27
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/favorites/favorites.test.ts +64 -24
- package/src/favorites/favorites.ts +73 -22
- package/src/installations/installations.test-d.ts +5 -0
- package/src/installations/installations.ts +4 -0
- package/src/presence/bifurTransport.test.ts +155 -3
- package/src/presence/bifurTransport.ts +79 -12
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +822 -2
- package/src/presence/presenceStore.ts +518 -102
- package/src/presence/types.ts +175 -3
- package/src/preview/types.ts +0 -25
- package/src/projection/projectionStore.test.ts +0 -8
- package/src/projection/types.ts +0 -8
- package/src/query/queryStore.ts +7 -6
- package/src/store/createActionBinder.test.ts +51 -29
- package/src/store/createActionBinder.ts +16 -62
- package/src/store/createSanityInstance.test.ts +0 -68
- package/src/store/createSanityInstance.ts +0 -63
- package/src/store/createStateSourceAction.ts +1 -1
- package/src/store/fetcherStore.test.ts +23 -1
- package/src/store/fetcherStore.ts +3 -3
- package/src/users/reducers.test.ts +0 -3
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
- package/dist/_chunks-es/_internal.js +0 -134
- package/dist/_chunks-es/_internal.js.map +0 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
- package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
- package/dist/_chunks-es/telemetryManager.js +0 -93
- package/dist/_chunks-es/telemetryManager.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/src/applications/userApplications.test.ts +0 -126
- package/src/applications/userApplications.ts +0 -261
- package/src/preview/getPreviewState.test.ts +0 -137
- package/src/preview/getPreviewState.ts +0 -69
- package/src/preview/resolvePreview.test.ts +0 -64
- package/src/preview/resolvePreview.ts +0 -43
- package/src/utils/createFetcherStore.test.ts +0 -171
- package/src/utils/createFetcherStore.ts +0 -284
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import {DocumentId, getPublishedId} from '@sanity/id-utils'
|
|
2
|
+
import {type Path} from '@sanity/types'
|
|
3
|
+
import {
|
|
4
|
+
catchError,
|
|
5
|
+
distinctUntilChanged,
|
|
6
|
+
EMPTY,
|
|
7
|
+
first,
|
|
8
|
+
firstValueFrom,
|
|
9
|
+
groupBy,
|
|
10
|
+
map,
|
|
11
|
+
mergeMap,
|
|
12
|
+
NEVER,
|
|
13
|
+
Observable,
|
|
14
|
+
pairwise,
|
|
15
|
+
race,
|
|
16
|
+
startWith,
|
|
17
|
+
switchMap,
|
|
18
|
+
tap,
|
|
19
|
+
} from 'rxjs'
|
|
20
|
+
|
|
21
|
+
import {type DocumentHandle} from '../config/sanityConfig'
|
|
22
|
+
import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
|
|
23
|
+
import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
|
|
24
|
+
import {type SanityInstance} from '../store/createSanityInstance'
|
|
25
|
+
import {
|
|
26
|
+
createStateSourceAction,
|
|
27
|
+
type SelectorContext,
|
|
28
|
+
type StateSource,
|
|
29
|
+
} from '../store/createStateSourceAction'
|
|
30
|
+
import {type StoreState} from '../store/createStoreState'
|
|
31
|
+
import {defineStore, type StoreContext} from '../store/defineStore'
|
|
32
|
+
import {insecureRandomId} from '../utils/ids'
|
|
33
|
+
import {setCleanupTimeout} from '../utils/setCleanupTimeout'
|
|
34
|
+
import {observeAddonDatasetClient} from './addonDatasetStore'
|
|
35
|
+
import {buildCommentThreads} from './buildCommentThreads'
|
|
36
|
+
import {toCommentFieldPath} from './commentFieldPath'
|
|
37
|
+
import {COMMENTS_STATE_CLEAR_DELAY} from './commentsConstants'
|
|
38
|
+
import {normalizeComment} from './normalizeComment'
|
|
39
|
+
import {type CommentsEvent, observeComments} from './observeComments'
|
|
40
|
+
import {
|
|
41
|
+
addSubscriber,
|
|
42
|
+
clearPendingTransaction,
|
|
43
|
+
type CommentsKeyParts,
|
|
44
|
+
type CommentsStoreState,
|
|
45
|
+
getCommentsKey,
|
|
46
|
+
parseCommentsKey,
|
|
47
|
+
receiveComment,
|
|
48
|
+
removeCommentById,
|
|
49
|
+
removeSubscriber,
|
|
50
|
+
setComments,
|
|
51
|
+
setCommentsError,
|
|
52
|
+
} from './reducers'
|
|
53
|
+
import {type Comment, type CommentStatus, type CommentThread, type StoredComment} from './types'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which comments to read.
|
|
57
|
+
* @beta
|
|
58
|
+
*/
|
|
59
|
+
export interface CommentsOptions extends DocumentHandle {
|
|
60
|
+
/**
|
|
61
|
+
* Narrow to one field. Omit to get every comment on the document.
|
|
62
|
+
*/
|
|
63
|
+
fieldPath?: string | Path
|
|
64
|
+
/** Narrow to open or resolved threads. Omit for both. */
|
|
65
|
+
status?: CommentStatus
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @beta */
|
|
69
|
+
export interface ResolveCommentsOptions extends CommentsOptions {
|
|
70
|
+
signal?: AbortSignal
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fields on {@link CommentsOptions} that are deliberately left out of the key
|
|
75
|
+
* below, because neither changes which comments an option set addresses.
|
|
76
|
+
*
|
|
77
|
+
* `source` is the deprecated alias for `resource` and is folded into it before
|
|
78
|
+
* a key is ever built, so keying on both would give one list two keys.
|
|
79
|
+
* `liveEdit` describes the document rather than its comments, which hang off
|
|
80
|
+
* the published id whether or not drafts exist.
|
|
81
|
+
*/
|
|
82
|
+
type CommentsKeyIrrelevantField = 'source' | 'liveEdit'
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A stable string standing for one set of read options.
|
|
86
|
+
*
|
|
87
|
+
* Only React needs this: it holds one state source steady across renders and
|
|
88
|
+
* defers swapping to a new one while the previous list is still on screen.
|
|
89
|
+
* `fieldPath` is normalised on the way in, so a path array and the equivalent
|
|
90
|
+
* string address the same list.
|
|
91
|
+
*
|
|
92
|
+
* @internal
|
|
93
|
+
*/
|
|
94
|
+
export function getCommentsOptionsKey(options: CommentsOptions): string {
|
|
95
|
+
return JSON.stringify({
|
|
96
|
+
documentId: options.documentId,
|
|
97
|
+
documentType: options.documentType,
|
|
98
|
+
projectId: options.projectId,
|
|
99
|
+
dataset: options.dataset,
|
|
100
|
+
resource: options.resource,
|
|
101
|
+
perspective: options.perspective,
|
|
102
|
+
fieldPath: options.fieldPath === undefined ? undefined : toCommentFieldPath(options.fieldPath),
|
|
103
|
+
status: options.status,
|
|
104
|
+
// The `Record` half makes a new field on `CommentsOptions` a compile
|
|
105
|
+
// error here unless it is listed above or named as irrelevant. Left to
|
|
106
|
+
// `satisfies CommentsOptions` alone, a forgotten field would just be
|
|
107
|
+
// absent from the key, and a reader would keep the list it had while the
|
|
108
|
+
// caller thought it had asked for a different one.
|
|
109
|
+
} satisfies CommentsOptions &
|
|
110
|
+
Record<Exclude<keyof CommentsOptions, CommentsKeyIrrelevantField>, unknown>)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @internal */
|
|
114
|
+
export function parseCommentsOptionsKey(key: string): CommentsOptions {
|
|
115
|
+
return JSON.parse(key) as CommentsOptions
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Which comment list an option set addresses.
|
|
120
|
+
*
|
|
121
|
+
* Comments hang off the published id, so a draft and its published document
|
|
122
|
+
* share one list. A release keeps its own.
|
|
123
|
+
*/
|
|
124
|
+
export function toCommentsKeyParts(
|
|
125
|
+
instance: SanityInstance,
|
|
126
|
+
options: CommentsOptions,
|
|
127
|
+
): CommentsKeyParts {
|
|
128
|
+
const perspective = options.perspective ?? instance.config.perspective
|
|
129
|
+
return {
|
|
130
|
+
documentId: getPublishedId(DocumentId(options.documentId)),
|
|
131
|
+
...(isReleasePerspective(perspective) ? {documentVersionId: perspective.releaseName} : {}),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function applyEvent(
|
|
136
|
+
state: StoreState<CommentsStoreState>,
|
|
137
|
+
key: string,
|
|
138
|
+
event: CommentsEvent,
|
|
139
|
+
): void {
|
|
140
|
+
switch (event.type) {
|
|
141
|
+
case 'snapshot':
|
|
142
|
+
state.set('setComments', setComments(key, event.comments))
|
|
143
|
+
return
|
|
144
|
+
case 'appear':
|
|
145
|
+
state.set('receiveComment', receiveComment(key, event.comment))
|
|
146
|
+
return
|
|
147
|
+
case 'disappear':
|
|
148
|
+
state.set('removeComment', removeCommentById(event.commentId))
|
|
149
|
+
return
|
|
150
|
+
case 'error':
|
|
151
|
+
state.set('setCommentsError', setCommentsError(key, event.error))
|
|
152
|
+
return
|
|
153
|
+
case 'update': {
|
|
154
|
+
const pending = state.get().pendingTransactions[event.comment._id]
|
|
155
|
+
|
|
156
|
+
// Our own writes come back through the listener. When a later transaction
|
|
157
|
+
// is already in flight for this comment, an echo of an earlier one would
|
|
158
|
+
// undo it on screen, so drop it and wait for the one we are expecting.
|
|
159
|
+
if (pending && pending !== event.transactionId) return
|
|
160
|
+
|
|
161
|
+
state.set('receiveComment', receiveComment(key, event.comment))
|
|
162
|
+
if (pending) {
|
|
163
|
+
state.set('clearPendingTransaction', clearPendingTransaction(event.comment._id))
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const watchSubscribedDocuments = ({
|
|
170
|
+
state,
|
|
171
|
+
instance,
|
|
172
|
+
key,
|
|
173
|
+
}: StoreContext<CommentsStoreState, BoundResourceKey>) => {
|
|
174
|
+
const client$ = observeAddonDatasetClient(instance, {resource: key.resource})
|
|
175
|
+
|
|
176
|
+
return state.observable
|
|
177
|
+
.pipe(
|
|
178
|
+
map((current) => new Set(Object.keys(current.entries))),
|
|
179
|
+
distinctUntilChanged(
|
|
180
|
+
(a, b) => a.size === b.size && Array.from(b).every((entry) => a.has(entry)),
|
|
181
|
+
),
|
|
182
|
+
startWith(new Set<string>()),
|
|
183
|
+
pairwise(),
|
|
184
|
+
mergeMap(([previous, current]) => [
|
|
185
|
+
...Array.from(current)
|
|
186
|
+
.filter((entry) => !previous.has(entry))
|
|
187
|
+
.map((entry) => ({key: entry, added: true})),
|
|
188
|
+
...Array.from(previous)
|
|
189
|
+
.filter((entry) => !current.has(entry))
|
|
190
|
+
.map((entry) => ({key: entry, added: false})),
|
|
191
|
+
]),
|
|
192
|
+
groupBy((event) => event.key),
|
|
193
|
+
mergeMap((group$) =>
|
|
194
|
+
group$.pipe(
|
|
195
|
+
switchMap((event) => {
|
|
196
|
+
if (!event.added) return EMPTY
|
|
197
|
+
|
|
198
|
+
const {documentId, documentVersionId} = parseCommentsKey(group$.key)
|
|
199
|
+
|
|
200
|
+
return client$.pipe(
|
|
201
|
+
switchMap((client) => {
|
|
202
|
+
if (!client) {
|
|
203
|
+
// A dataset that does not exist holds no comments. Settling on
|
|
204
|
+
// an empty list matters: leaving it unset would suspend
|
|
205
|
+
// readers forever on a project nobody has commented in.
|
|
206
|
+
state.set('setComments', setComments(group$.key, []))
|
|
207
|
+
return EMPTY
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return observeComments({client, documentId, documentVersionId}).pipe(
|
|
211
|
+
tap((commentsEvent) => applyEvent(state, group$.key, commentsEvent)),
|
|
212
|
+
// Keep following the addon client after one listener fails.
|
|
213
|
+
// A token refresh or reconnect can then supply a new client.
|
|
214
|
+
catchError((error: unknown) => {
|
|
215
|
+
state.set('setCommentsError', setCommentsError(group$.key, error))
|
|
216
|
+
return EMPTY
|
|
217
|
+
}),
|
|
218
|
+
)
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
.subscribe({error: (error: unknown) => state.set('setError', {error})})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const commentsStore = defineStore<CommentsStoreState, BoundResourceKey>({
|
|
229
|
+
name: 'Comments',
|
|
230
|
+
getInitialState: () => ({
|
|
231
|
+
entries: {},
|
|
232
|
+
pendingCreates: {},
|
|
233
|
+
pendingTransactions: {},
|
|
234
|
+
}),
|
|
235
|
+
initialize: (context) => {
|
|
236
|
+
const subscription = watchSubscribedDocuments(context)
|
|
237
|
+
return () => subscription.unsubscribe()
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Filtered lists and their threads, cached against the comment map they came
|
|
243
|
+
* from.
|
|
244
|
+
*
|
|
245
|
+
* Selectors run on every store change, and a fresh array each time would make
|
|
246
|
+
* `useSyncExternalStore` re-render whenever anything anywhere in the store
|
|
247
|
+
* moved. Keying on the comment map means a change to some other document, or
|
|
248
|
+
* to an unrelated part of the state, hands back the identical array. Entries
|
|
249
|
+
* die with the map they belong to.
|
|
250
|
+
*/
|
|
251
|
+
const normalizedCache = new WeakMap<object, Comment[]>()
|
|
252
|
+
const filteredCache = new WeakMap<object, Map<string, Comment[]>>()
|
|
253
|
+
const threadCache = new WeakMap<object, Map<string, CommentThread[]>>()
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The stored map turned into what consumers see, newest first.
|
|
257
|
+
*
|
|
258
|
+
* Cached alongside the filters below rather than done per read, so the
|
|
259
|
+
* normalised objects keep their identity for as long as the stored map does.
|
|
260
|
+
*/
|
|
261
|
+
function normalizeAll(commentsById: Record<string, StoredComment>): Comment[] {
|
|
262
|
+
const cached = normalizedCache.get(commentsById)
|
|
263
|
+
if (cached) return cached
|
|
264
|
+
|
|
265
|
+
// Newest first, matching the query's order. The store keys comments by id, so
|
|
266
|
+
// the order has to be reapplied here.
|
|
267
|
+
const normalized = Object.values(commentsById)
|
|
268
|
+
.map(normalizeComment)
|
|
269
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
270
|
+
|
|
271
|
+
normalizedCache.set(commentsById, normalized)
|
|
272
|
+
return normalized
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function filterComments(
|
|
276
|
+
all: Comment[],
|
|
277
|
+
fieldPath: string | undefined,
|
|
278
|
+
status: CommentStatus | undefined,
|
|
279
|
+
): Comment[] {
|
|
280
|
+
let byFilter = filteredCache.get(all)
|
|
281
|
+
if (!byFilter) {
|
|
282
|
+
byFilter = new Map()
|
|
283
|
+
filteredCache.set(all, byFilter)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// `\0` stands in for "no field filter", which is not the same as `''`.
|
|
287
|
+
const cacheKey = `${fieldPath ?? '\0'}|${status ?? ''}`
|
|
288
|
+
const cached = byFilter.get(cacheKey)
|
|
289
|
+
if (cached) return cached
|
|
290
|
+
|
|
291
|
+
const filtered = all.filter((comment) => {
|
|
292
|
+
if (status && comment.status !== status) return false
|
|
293
|
+
if (fieldPath === undefined) return true
|
|
294
|
+
return comment.fieldPath === fieldPath
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
byFilter.set(cacheKey, filtered)
|
|
298
|
+
return filtered
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function selectComments(
|
|
302
|
+
{state, instance}: SelectorContext<CommentsStoreState>,
|
|
303
|
+
options: CommentsOptions,
|
|
304
|
+
): Comment[] | undefined {
|
|
305
|
+
if (state.error) throw state.error
|
|
306
|
+
|
|
307
|
+
const entry = state.entries[getCommentsKey(toCommentsKeyParts(instance, options))]
|
|
308
|
+
if (entry?.error) throw entry.error
|
|
309
|
+
if (!entry?.comments) return undefined
|
|
310
|
+
|
|
311
|
+
return filterComments(
|
|
312
|
+
normalizeAll(entry.comments),
|
|
313
|
+
options.fieldPath === undefined ? undefined : toCommentFieldPath(options.fieldPath),
|
|
314
|
+
options.status,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function selectCommentThreads(
|
|
319
|
+
context: SelectorContext<CommentsStoreState>,
|
|
320
|
+
options: CommentsOptions,
|
|
321
|
+
): CommentThread[] | undefined {
|
|
322
|
+
const comments = selectComments(context, {...options, fieldPath: undefined, status: undefined})
|
|
323
|
+
if (comments === undefined) return undefined
|
|
324
|
+
|
|
325
|
+
let byFilter = threadCache.get(comments)
|
|
326
|
+
if (!byFilter) {
|
|
327
|
+
byFilter = new Map()
|
|
328
|
+
threadCache.set(comments, byFilter)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const fieldPath =
|
|
332
|
+
options.fieldPath === undefined ? undefined : toCommentFieldPath(options.fieldPath)
|
|
333
|
+
const cacheKey = `${fieldPath ?? '\0'}|${options.status ?? ''}`
|
|
334
|
+
const cached = byFilter.get(cacheKey)
|
|
335
|
+
if (cached) return cached
|
|
336
|
+
|
|
337
|
+
const threads = buildCommentThreads(comments).filter((thread) => {
|
|
338
|
+
if (options.status && thread.parentComment.status !== options.status) return false
|
|
339
|
+
if (fieldPath === undefined) return true
|
|
340
|
+
return thread.fieldPath === fieldPath
|
|
341
|
+
})
|
|
342
|
+
byFilter.set(cacheKey, threads)
|
|
343
|
+
return threads
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Every comment on a document, newest first.
|
|
348
|
+
*
|
|
349
|
+
* `undefined` until the first snapshot arrives. Replies are included; use
|
|
350
|
+
* {@link getCommentThreadsState} to read them grouped.
|
|
351
|
+
*
|
|
352
|
+
* @beta
|
|
353
|
+
*/
|
|
354
|
+
export const getCommentsState: (
|
|
355
|
+
instance: SanityInstance,
|
|
356
|
+
options: CommentsOptions,
|
|
357
|
+
) => StateSource<Comment[] | undefined> = bindActionByResource(
|
|
358
|
+
commentsStore,
|
|
359
|
+
createStateSourceAction({
|
|
360
|
+
selector: selectComments,
|
|
361
|
+
onSubscribe: ({state, instance}, options: CommentsOptions) => {
|
|
362
|
+
const key = getCommentsKey(toCommentsKeyParts(instance, options))
|
|
363
|
+
const subscriptionId = insecureRandomId()
|
|
364
|
+
state.set('addSubscriber', addSubscriber(key, subscriptionId))
|
|
365
|
+
|
|
366
|
+
return () => {
|
|
367
|
+
setCleanupTimeout(
|
|
368
|
+
() => state.set('removeSubscriber', removeSubscriber(key, subscriptionId)),
|
|
369
|
+
COMMENTS_STATE_CLEAR_DELAY,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Threads on a document, newest thread first, each with its replies oldest first.
|
|
378
|
+
*
|
|
379
|
+
* A thread's `status` and `fieldPath` come from its first comment, so filtering
|
|
380
|
+
* by either selects whole threads rather than individual replies.
|
|
381
|
+
*
|
|
382
|
+
* @beta
|
|
383
|
+
*/
|
|
384
|
+
export const getCommentThreadsState: (
|
|
385
|
+
instance: SanityInstance,
|
|
386
|
+
options: CommentsOptions,
|
|
387
|
+
) => StateSource<CommentThread[] | undefined> = bindActionByResource(
|
|
388
|
+
commentsStore,
|
|
389
|
+
createStateSourceAction({
|
|
390
|
+
selector: selectCommentThreads,
|
|
391
|
+
onSubscribe: ({state, instance}, options: CommentsOptions) => {
|
|
392
|
+
const key = getCommentsKey(toCommentsKeyParts(instance, options))
|
|
393
|
+
const subscriptionId = insecureRandomId()
|
|
394
|
+
state.set('addSubscriber', addSubscriber(key, subscriptionId))
|
|
395
|
+
|
|
396
|
+
return () => {
|
|
397
|
+
setCleanupTimeout(
|
|
398
|
+
() => state.set('removeSubscriber', removeSubscriber(key, subscriptionId)),
|
|
399
|
+
COMMENTS_STATE_CLEAR_DELAY,
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Waits for a document's comments to load.
|
|
408
|
+
*
|
|
409
|
+
* Holds a subscriber only while resolving, so a component that suspends on this
|
|
410
|
+
* and then errors before mounting does not strand the list. Throw the promise
|
|
411
|
+
* for Suspense, then read through {@link getCommentsState}.
|
|
412
|
+
*
|
|
413
|
+
* @beta
|
|
414
|
+
*/
|
|
415
|
+
export const resolveComments: (
|
|
416
|
+
instance: SanityInstance,
|
|
417
|
+
options: ResolveCommentsOptions,
|
|
418
|
+
) => Promise<Comment[]> = bindActionByResource(
|
|
419
|
+
commentsStore,
|
|
420
|
+
(
|
|
421
|
+
{state, instance}: StoreContext<CommentsStoreState, BoundResourceKey>,
|
|
422
|
+
{signal, ...options}: ResolveCommentsOptions,
|
|
423
|
+
) => resolveList(state, instance, options, signal, getCommentsState),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Waits for a document's comments to load, grouped into threads.
|
|
428
|
+
* @beta
|
|
429
|
+
*/
|
|
430
|
+
export const resolveCommentThreads: (
|
|
431
|
+
instance: SanityInstance,
|
|
432
|
+
options: ResolveCommentsOptions,
|
|
433
|
+
) => Promise<CommentThread[]> = bindActionByResource(
|
|
434
|
+
commentsStore,
|
|
435
|
+
(
|
|
436
|
+
{state, instance}: StoreContext<CommentsStoreState, BoundResourceKey>,
|
|
437
|
+
{signal, ...options}: ResolveCommentsOptions,
|
|
438
|
+
) => resolveList(state, instance, options, signal, getCommentThreadsState),
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
function resolveList<T>(
|
|
442
|
+
state: StoreState<CommentsStoreState>,
|
|
443
|
+
instance: SanityInstance,
|
|
444
|
+
options: CommentsOptions,
|
|
445
|
+
signal: AbortSignal | undefined,
|
|
446
|
+
getState: (i: SanityInstance, o: CommentsOptions) => StateSource<T | undefined>,
|
|
447
|
+
): Promise<T> {
|
|
448
|
+
const key = getCommentsKey(toCommentsKeyParts(instance, options))
|
|
449
|
+
const {getCurrent} = getState(instance, options)
|
|
450
|
+
|
|
451
|
+
// Loading is driven by subscribers, so without one here nothing would ever
|
|
452
|
+
// fetch and this promise would never settle. Holding it only for the duration
|
|
453
|
+
// of the resolve also means a component that suspends and then errors before
|
|
454
|
+
// mounting does not leave a subscriber-less entry behind holding its error.
|
|
455
|
+
const subscriptionId = insecureRandomId()
|
|
456
|
+
state.set('addSubscriber', addSubscriber(key, subscriptionId))
|
|
457
|
+
|
|
458
|
+
const release = () => state.set('removeSubscriber', removeSubscriber(key, subscriptionId))
|
|
459
|
+
|
|
460
|
+
const aborted$ = signal
|
|
461
|
+
? new Observable<never>((observer) => {
|
|
462
|
+
const listener = () => {
|
|
463
|
+
// Release now rather than after the delay: when this was the only
|
|
464
|
+
// reader, dropping the key tears down the listener immediately, which
|
|
465
|
+
// is the point of aborting.
|
|
466
|
+
release()
|
|
467
|
+
observer.error(new DOMException('The operation was aborted.', 'AbortError'))
|
|
468
|
+
}
|
|
469
|
+
signal.addEventListener('abort', listener)
|
|
470
|
+
return () => signal.removeEventListener('abort', listener)
|
|
471
|
+
})
|
|
472
|
+
: NEVER
|
|
473
|
+
|
|
474
|
+
const resolved$ = state.observable.pipe(
|
|
475
|
+
map(() => getCurrent()),
|
|
476
|
+
first((value): value is T => value !== undefined),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
const promise = firstValueFrom(race([resolved$, aborted$]))
|
|
480
|
+
const releaseLater = () => setCleanupTimeout(release, COMMENTS_STATE_CLEAR_DELAY)
|
|
481
|
+
promise.then(releaseLater, releaseLater)
|
|
482
|
+
return promise
|
|
483
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {normalizeComment} from './normalizeComment'
|
|
4
|
+
import {type StoredComment} from './types'
|
|
5
|
+
|
|
6
|
+
function stored(overrides: Partial<StoredComment> = {}): StoredComment {
|
|
7
|
+
return {
|
|
8
|
+
_type: 'comment',
|
|
9
|
+
_id: 'comment-1',
|
|
10
|
+
_createdAt: '2026-01-01T00:00:00Z',
|
|
11
|
+
_rev: 'rev-1',
|
|
12
|
+
authorId: 'user-1',
|
|
13
|
+
message: null,
|
|
14
|
+
threadId: 'thread-1',
|
|
15
|
+
status: 'open',
|
|
16
|
+
reactions: null,
|
|
17
|
+
target: {
|
|
18
|
+
documentType: 'author',
|
|
19
|
+
document: {
|
|
20
|
+
_dataset: 'production',
|
|
21
|
+
_projectId: 'p',
|
|
22
|
+
_ref: 'doc-1',
|
|
23
|
+
_type: 'crossDatasetReference',
|
|
24
|
+
_weak: true,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
...overrides,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('normalizeComment', () => {
|
|
32
|
+
it('maps a stored comment to the public shape', () => {
|
|
33
|
+
// Asserted in full. This mapping is the seam that lets storage change
|
|
34
|
+
// without the public shape moving, so every field is deliberate.
|
|
35
|
+
expect(normalizeComment(stored())).toEqual({
|
|
36
|
+
id: 'comment-1',
|
|
37
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
38
|
+
authorId: 'user-1',
|
|
39
|
+
message: null,
|
|
40
|
+
threadId: 'thread-1',
|
|
41
|
+
status: 'open',
|
|
42
|
+
documentId: 'doc-1',
|
|
43
|
+
documentType: 'author',
|
|
44
|
+
fieldPath: '',
|
|
45
|
+
reactions: [],
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('leaves out what the stored comment does not have', () => {
|
|
50
|
+
const normalized = normalizeComment(stored())
|
|
51
|
+
|
|
52
|
+
// Absent rather than present-and-undefined, so a deep equality check in a
|
|
53
|
+
// consumer's test does not have to know about keys it never set.
|
|
54
|
+
expect('parentCommentId' in normalized).toBe(false)
|
|
55
|
+
expect('lastEditedAt' in normalized).toBe(false)
|
|
56
|
+
expect('selection' in normalized).toBe(false)
|
|
57
|
+
expect('contentSnapshot' in normalized).toBe(false)
|
|
58
|
+
expect('state' in normalized).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('lifts the field path out of the target', () => {
|
|
62
|
+
const comment = stored({
|
|
63
|
+
target: {
|
|
64
|
+
documentType: 'author',
|
|
65
|
+
path: {field: 'body[_key=="intro"].content'},
|
|
66
|
+
document: {_ref: 'doc-1', _type: 'reference', _weak: true},
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(normalizeComment(comment).fieldPath).toBe('body[_key=="intro"].content')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('lifts a text selection out of the target', () => {
|
|
74
|
+
const selection = {type: 'text' as const, value: [{_key: 'b1', text: 'marked'}]}
|
|
75
|
+
const comment = stored({
|
|
76
|
+
target: {
|
|
77
|
+
documentType: 'author',
|
|
78
|
+
path: {field: 'body', selection},
|
|
79
|
+
document: {_ref: 'doc-1', _type: 'reference', _weak: true},
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
expect(normalizeComment(comment).selection).toEqual(selection)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('drops the array key from reactions', () => {
|
|
87
|
+
const comment = stored({
|
|
88
|
+
reactions: [{_key: 'user-2-:+1:', shortName: ':+1:', userId: 'user-2', addedAt: 'then'}],
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(normalizeComment(comment).reactions).toEqual([
|
|
92
|
+
{shortName: ':+1:', userId: 'user-2', addedAt: 'then'},
|
|
93
|
+
])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('leaves the author out when the document does not carry one', () => {
|
|
97
|
+
// The organization store records identity server-side rather than on the
|
|
98
|
+
// document, and types it as doubly optional, so there are comments with no
|
|
99
|
+
// author to map. Emitting `''` would look like a user id.
|
|
100
|
+
const comment = stored()
|
|
101
|
+
delete (comment as {authorId?: string}).authorId
|
|
102
|
+
|
|
103
|
+
expect('authorId' in normalizeComment(comment)).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('carries the local create state through', () => {
|
|
107
|
+
const error = new Error('nope')
|
|
108
|
+
const comment = stored({_state: {type: 'createError', error}})
|
|
109
|
+
|
|
110
|
+
expect(normalizeComment(comment).state).toEqual({type: 'createError', error})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('keeps replies pointed at their parent', () => {
|
|
114
|
+
expect(normalizeComment(stored({parentCommentId: 'parent-1'})).parentCommentId).toBe('parent-1')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {type Comment, type StoredComment} from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turns a stored comment into the shape consumers see.
|
|
5
|
+
*
|
|
6
|
+
* This is the whole seam between the storage format and the public API. When
|
|
7
|
+
* comments move to the organization-level store, the three fields that differ
|
|
8
|
+
* are handled here and nothing downstream changes:
|
|
9
|
+
*
|
|
10
|
+
* - the author moves from `authorId` to `_system.createdBy`
|
|
11
|
+
* - `target.document` becomes a global document reference, so `documentId` is
|
|
12
|
+
* parsed out of a `resourceType:resourceId:documentId` string rather than
|
|
13
|
+
* read off `_ref`
|
|
14
|
+
* - `_type` goes from `comment` to `sanity.comment`, and stops being exposed
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeComment(stored: StoredComment): Comment {
|
|
19
|
+
const {target} = stored
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
id: stored._id,
|
|
23
|
+
createdAt: stored._createdAt,
|
|
24
|
+
// Omitted rather than emptied when absent, so a consumer can tell "no
|
|
25
|
+
// author recorded" from a user whose id happens to be falsy.
|
|
26
|
+
...(stored.authorId ? {authorId: stored.authorId} : {}),
|
|
27
|
+
message: stored.message,
|
|
28
|
+
threadId: stored.threadId,
|
|
29
|
+
...(stored.parentCommentId ? {parentCommentId: stored.parentCommentId} : {}),
|
|
30
|
+
status: stored.status,
|
|
31
|
+
...(stored.lastEditedAt ? {lastEditedAt: stored.lastEditedAt} : {}),
|
|
32
|
+
documentId: target.document._ref,
|
|
33
|
+
documentType: target.documentType,
|
|
34
|
+
fieldPath: target.path?.field ?? '',
|
|
35
|
+
...(target.path?.selection ? {selection: target.path.selection} : {}),
|
|
36
|
+
...(stored.contentSnapshot === undefined ? {} : {contentSnapshot: stored.contentSnapshot}),
|
|
37
|
+
// Dropped: the stored `_key`, which only exists to address the array item.
|
|
38
|
+
reactions: (stored.reactions ?? []).map(({shortName, userId, addedAt}) => ({
|
|
39
|
+
shortName,
|
|
40
|
+
userId,
|
|
41
|
+
addedAt,
|
|
42
|
+
})),
|
|
43
|
+
...(stored._state ? {state: stored._state} : {}),
|
|
44
|
+
}
|
|
45
|
+
}
|