@sanity/sdk-react 2.18.0 → 2.19.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/dist/index.d.ts +143 -16
- package/dist/index.js +135 -2
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/_exports/sdk-react.ts +5 -0
- package/src/hooks/presence/usePresence.ts +14 -1
- package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
- package/src/hooks/presence/usePresenceForDocument.ts +104 -0
- package/src/hooks/presence/useReportPresence.test.tsx +202 -0
- package/src/hooks/presence/useReportPresence.ts +170 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isMediaLibraryResource,
|
|
3
|
+
type PresenceSelection,
|
|
4
|
+
reportPresence,
|
|
5
|
+
type ReportPresenceOptions,
|
|
6
|
+
} from '@sanity/sdk'
|
|
7
|
+
import {type Path} from '@sanity/types'
|
|
8
|
+
import {useEffect, useMemo, useRef} from 'react'
|
|
9
|
+
|
|
10
|
+
import {type DocumentHandle} from '../../config/handles'
|
|
11
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
12
|
+
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
13
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Field focus moves at human speed, so a second between announcements is plenty.
|
|
17
|
+
*/
|
|
18
|
+
const FOCUS_THROTTLE_MS = 1000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A caret that trails a second behind reads as broken rather than as latency, so
|
|
22
|
+
* reporting a `selection` announces more often. The Studio uses a flat 1000ms for
|
|
23
|
+
* both, which is why its remote carets feel sluggish.
|
|
24
|
+
*/
|
|
25
|
+
const SELECTION_THROTTLE_MS = 250
|
|
26
|
+
|
|
27
|
+
/** @beta */
|
|
28
|
+
export interface UseReportPresenceOptions extends DocumentHandle {
|
|
29
|
+
/**
|
|
30
|
+
* The focused field path. Omit it for document-level presence. Keyed and numeric
|
|
31
|
+
* segments are supported, so array items and Portable Text spans can be
|
|
32
|
+
* addressed.
|
|
33
|
+
*/
|
|
34
|
+
path?: Path
|
|
35
|
+
|
|
36
|
+
/** The Portable Text caret, when the focused field is a Portable Text field. */
|
|
37
|
+
selection?: PresenceSelection
|
|
38
|
+
|
|
39
|
+
/** Overrides the throttle interval. Mainly useful in tests. */
|
|
40
|
+
throttleMs?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Announces that the current user is in a document, so that other clients in the
|
|
45
|
+
* same project and dataset can show them.
|
|
46
|
+
*
|
|
47
|
+
* Writing presence is opt-in. Reading it with `usePresenceForDocument` or
|
|
48
|
+
* `usePresence` never announces anything, and this hook is the only thing that
|
|
49
|
+
* makes an app visible to others. That includes the Studio, which shares the same
|
|
50
|
+
* presence room and will show these users in its navbar and field indicators.
|
|
51
|
+
*
|
|
52
|
+
* Announcements are throttled, collapsed over a short window, and then repeated
|
|
53
|
+
* every 30 seconds while the user is idle. That repeat is what tells peers the
|
|
54
|
+
* session is still alive, so the intended usage is to mount this hook for as long
|
|
55
|
+
* as the user is in the document. On unmount the location is cleared, leaving the
|
|
56
|
+
* user present in the app but not in any particular document.
|
|
57
|
+
*
|
|
58
|
+
* The perspective decides which specific document is reported: the draft under
|
|
59
|
+
* `drafts`, the published document under `published`, a version under a release.
|
|
60
|
+
* It is taken from `ResourceProvider` unless you pass one on the handle. That
|
|
61
|
+
* matters for interoperability, because the Studio's field indicators compare the
|
|
62
|
+
* exact id its form is on, so a mismatch shows your user at document level while
|
|
63
|
+
* never lighting up a field.
|
|
64
|
+
*
|
|
65
|
+
* Presence is scoped to a single project and dataset. It is not a list of everyone
|
|
66
|
+
* signed in to your organization.
|
|
67
|
+
*
|
|
68
|
+
* @example Document-level presence
|
|
69
|
+
* ```tsx
|
|
70
|
+
* function DocumentEditor({documentId, documentType}: DocumentHandle) {
|
|
71
|
+
* useReportPresence({documentId, documentType})
|
|
72
|
+
* return <Editor />
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @example Presence in a release version
|
|
77
|
+
* ```tsx
|
|
78
|
+
* // The document id stays plain; the perspective selects the version.
|
|
79
|
+
* useReportPresence({documentId, documentType, perspective: {releaseName: 'autumn'}})
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @example Field-level presence
|
|
83
|
+
* ```tsx
|
|
84
|
+
* function TitleField({documentId, documentType}: DocumentHandle) {
|
|
85
|
+
* const [focused, setFocused] = useState(false)
|
|
86
|
+
* useReportPresence({documentId, documentType, path: focused ? ['title'] : undefined})
|
|
87
|
+
* return <input onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} />
|
|
88
|
+
* }
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* @beta
|
|
92
|
+
*/
|
|
93
|
+
export function useReportPresence(options: UseReportPresenceOptions): void {
|
|
94
|
+
const {path, selection, throttleMs, ...handle} = options
|
|
95
|
+
|
|
96
|
+
const normalizedOptions = useNormalizedResourceOptions(handle)
|
|
97
|
+
if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'useReportPresence() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sanityInstance = useSanityInstance()
|
|
104
|
+
trackHookUsage(sanityInstance, 'useReportPresence')
|
|
105
|
+
|
|
106
|
+
const {resource, perspective} = normalizedOptions
|
|
107
|
+
const {documentId, liveEdit} = options
|
|
108
|
+
|
|
109
|
+
const interval = throttleMs ?? (selection ? SELECTION_THROTTLE_MS : FOCUS_THROTTLE_MS)
|
|
110
|
+
|
|
111
|
+
// Compared by value, because callers write `path={['title']}` inline and a fresh
|
|
112
|
+
// array identity every render must not mean a fresh announcement every render.
|
|
113
|
+
const locationKey = useMemo(
|
|
114
|
+
() =>
|
|
115
|
+
JSON.stringify([documentId, path ?? [], selection ?? null, perspective ?? null, liveEdit]),
|
|
116
|
+
[documentId, path, selection, perspective, liveEdit],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Rebuilt from the key rather than from the props, so the effect below can
|
|
120
|
+
// depend on it honestly instead of suppressing the exhaustive-deps rule.
|
|
121
|
+
const location = useMemo<ReportPresenceOptions>(() => {
|
|
122
|
+
const [id, parsedPath, parsedSelection, parsedPerspective, parsedLiveEdit] = JSON.parse(
|
|
123
|
+
locationKey,
|
|
124
|
+
) as [string, Path, PresenceSelection, ReportPresenceOptions['perspective'] | null, boolean?]
|
|
125
|
+
return {
|
|
126
|
+
documentId: id,
|
|
127
|
+
// Forwarded rather than resolved here: core turns the perspective into the
|
|
128
|
+
// specific document id, so the read and write sides cannot drift apart.
|
|
129
|
+
...(parsedPerspective ? {perspective: parsedPerspective} : {}),
|
|
130
|
+
...(parsedLiveEdit ? {liveEdit: parsedLiveEdit} : {}),
|
|
131
|
+
...(parsedPath.length > 0 ? {path: parsedPath} : {}),
|
|
132
|
+
...(parsedSelection ? {selection: parsedSelection} : {}),
|
|
133
|
+
}
|
|
134
|
+
}, [locationKey])
|
|
135
|
+
|
|
136
|
+
const lastSentAt = useRef(0)
|
|
137
|
+
const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
// Captured in this effect's closure rather than read from a ref, so nothing is
|
|
141
|
+
// written during render. A newer location re-runs the effect, which clears the
|
|
142
|
+
// pending timeout below and schedules the newer value instead.
|
|
143
|
+
const send = () => {
|
|
144
|
+
lastSentAt.current = Date.now()
|
|
145
|
+
reportPresence(sanityInstance, {
|
|
146
|
+
...(resource ? {resource} : {}),
|
|
147
|
+
locations: [location],
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const elapsed = Date.now() - lastSentAt.current
|
|
152
|
+
if (elapsed >= interval) {
|
|
153
|
+
send()
|
|
154
|
+
} else {
|
|
155
|
+
// Trailing edge, so the position the user settled on is the one announced.
|
|
156
|
+
clearTimeout(pending.current)
|
|
157
|
+
pending.current = setTimeout(send, interval - elapsed)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return () => clearTimeout(pending.current)
|
|
161
|
+
}, [location, interval, sanityInstance, resource])
|
|
162
|
+
|
|
163
|
+
// Kept separate from the throttled effect so it runs on unmount only, rather
|
|
164
|
+
// than every time the reported location changes.
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
return () => {
|
|
167
|
+
reportPresence(sanityInstance, {...(resource ? {resource} : {}), locations: []})
|
|
168
|
+
}
|
|
169
|
+
}, [sanityInstance, resource])
|
|
170
|
+
}
|