@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.2
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 +163 -0
- package/dist/_exports/_internal.d.ts.map +1 -0
- package/dist/_exports/_internal.js +332 -0
- package/dist/_exports/_internal.js.map +1 -0
- 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-CtE9bLHW.js +754 -0
- package/dist/authStore-CtE9bLHW.js.map +1 -0
- package/dist/authStore-DKFX8fn7.d.ts +3179 -0
- package/dist/authStore-DKFX8fn7.d.ts.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 -2332
- package/dist/index.js +4171 -4332
- 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-DPf9sN3V.js +1346 -0
- package/dist/queryStore-DPf9sN3V.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-Dq_qCkeI.js +127 -0
- package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
- package/dist/version-BChjsi4u.js +9 -0
- package/dist/version-BChjsi4u.js.map +1 -0
- package/package.json +47 -52
- package/src/_exports/_internal.ts +27 -0
- package/src/_exports/agent.ts +19 -0
- package/src/_exports/comlink.ts +16 -0
- package/src/_exports/index.ts +125 -64
- package/src/access/checkPermissions.test.ts +55 -0
- package/src/access/checkPermissions.ts +54 -0
- package/src/agent/agentActions.test.ts +16 -60
- package/src/agent/agentActions.ts +25 -38
- package/src/applications/applications.test-d.ts +88 -0
- package/src/applications/applications.test.ts +130 -0
- package/src/applications/applications.ts +367 -0
- package/src/auth/authLogger.ts +30 -0
- package/src/auth/authStore.test.ts +194 -13
- package/src/auth/authStore.ts +58 -26
- package/src/auth/getOrganizationVerificationState.test.ts +18 -17
- package/src/auth/getOrganizationVerificationState.ts +4 -4
- package/src/auth/handleAuthCallback.test.ts +35 -1
- package/src/auth/handleAuthCallback.ts +25 -6
- package/src/auth/logout.test.ts +75 -2
- package/src/auth/logout.ts +23 -4
- package/src/auth/refreshStampedToken.test.ts +219 -469
- package/src/auth/refreshStampedToken.ts +79 -195
- package/src/auth/studioAuth.ts +1 -1
- package/src/auth/studioModeAuth.ts +2 -1
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
- package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
- package/src/client/clientStore.test.ts +62 -37
- package/src/client/clientStore.ts +45 -75
- package/src/client/liveEvents.test.ts +151 -0
- package/src/client/liveEvents.ts +107 -0
- package/src/comlink/controller/actions/destroyController.test.ts +4 -1
- package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
- package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
- package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
- package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
- package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
- package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
- package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
- package/src/comlink/node/actions/releaseNode.test.ts +7 -4
- package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
- package/src/comlink/node/getNodeState.test.ts +1 -1
- package/src/comlink/node/getNodeState.ts +2 -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/__tests__/handles.test.ts +18 -12
- package/src/config/handles.ts +25 -7
- package/src/config/sanityConfig.ts +40 -82
- package/src/datasets/datasets.test.ts +2 -2
- package/src/datasets/datasets.ts +10 -4
- package/src/document/actions.test.ts +115 -33
- package/src/document/actions.ts +212 -15
- package/src/document/applyDocumentActions.test.ts +35 -13
- package/src/document/applyDocumentActions.ts +30 -9
- package/src/document/documentConstants.ts +15 -0
- package/src/document/documentStore.concurrency.test.ts +1306 -0
- package/src/document/documentStore.test.ts +753 -273
- package/src/document/documentStore.ts +197 -75
- package/src/document/events.test.ts +57 -2
- package/src/document/events.ts +82 -26
- package/src/document/listen.test.ts +56 -0
- package/src/document/listen.ts +74 -21
- package/src/document/listenerEventOperators.test.ts +311 -0
- package/src/document/listenerEventOperators.ts +217 -0
- package/src/document/patchOperations.test.ts +76 -2
- package/src/document/patchOperations.ts +60 -1
- package/src/document/permissions.test.ts +107 -60
- package/src/document/permissions.ts +44 -19
- package/src/document/processActions/create.ts +138 -0
- package/src/document/processActions/delete.ts +100 -0
- package/src/document/processActions/discard.ts +63 -0
- package/src/document/processActions/edit.ts +154 -0
- package/src/document/processActions/processActions.ts +218 -0
- package/src/document/processActions/publish.ts +120 -0
- package/src/document/processActions/releaseArchive.ts +77 -0
- package/src/document/processActions/releaseCreate.ts +59 -0
- package/src/document/processActions/releaseDelete.ts +65 -0
- package/src/document/processActions/releaseEdit.ts +37 -0
- package/src/document/processActions/releasePublish.ts +45 -0
- package/src/document/processActions/releaseSchedule.ts +87 -0
- package/src/document/processActions/releaseUtil.ts +31 -0
- package/src/document/processActions/shared.ts +308 -0
- package/src/document/processActions/unpublish.ts +85 -0
- package/src/document/processActions.test.ts +1129 -121
- package/src/document/reducers.test.ts +772 -64
- package/src/document/reducers.ts +205 -51
- package/src/document/resourceRules.test.ts +178 -0
- package/src/document/resourceRules.ts +117 -0
- package/src/document/sharedListener.test.ts +16 -14
- package/src/document/sharedListener.ts +24 -8
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +50 -0
- package/src/favorites/favorites.test.ts +5 -6
- package/src/installations/installations.test-d.ts +73 -0
- package/src/installations/installations.test.ts +62 -0
- package/src/installations/installations.ts +206 -0
- package/src/organization/organization.test-d.ts +36 -0
- package/src/organization/organization.test.ts +138 -0
- package/src/organization/organization.ts +138 -0
- package/src/organizations/organizations.test-d.ts +56 -0
- package/src/organizations/organizations.test.ts +150 -0
- package/src/organizations/organizations.ts +107 -0
- package/src/presence/bifurTransport.test.ts +201 -9
- package/src/presence/bifurTransport.ts +98 -14
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +915 -24
- package/src/presence/presenceStore.ts +576 -104
- package/src/presence/types.ts +175 -3
- package/src/preview/previewProjectionUtils.test.ts +9 -22
- package/src/preview/previewProjectionUtils.ts +7 -3
- package/src/preview/types.ts +0 -15
- package/src/project/project.test-d.ts +37 -0
- package/src/project/project.test.ts +157 -12
- package/src/project/project.ts +138 -25
- package/src/projection/getProjectionState.test.ts +18 -20
- package/src/projection/getProjectionState.ts +23 -3
- package/src/projection/projectionQuery.ts +5 -5
- package/src/projection/projectionStore.test.ts +1 -9
- package/src/projection/resolveProjection.test.ts +1 -2
- package/src/projection/resolveProjection.ts +16 -0
- package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
- package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
- package/src/projects/projects.test-d.ts +32 -0
- package/src/projects/projects.test.ts +108 -42
- package/src/projects/projects.ts +63 -27
- package/src/query/queryStore.test.ts +259 -123
- package/src/query/queryStore.ts +85 -69
- package/src/query/reducers.test.ts +0 -42
- package/src/query/reducers.ts +2 -18
- package/src/releases/getPerspectiveState.test.ts +17 -23
- package/src/releases/getPerspectiveState.ts +8 -20
- package/src/releases/observeReleases.test.ts +127 -0
- package/src/releases/observeReleases.ts +102 -0
- package/src/releases/releasesStore.test.ts +100 -53
- package/src/releases/releasesStore.ts +69 -44
- package/src/releases/utils/sortReleases.test.ts +38 -2
- package/src/releases/utils/sortReleases.ts +15 -10
- package/src/store/createActionBinder.test.ts +109 -90
- package/src/store/createActionBinder.ts +36 -28
- package/src/store/createSanityInstance.test.ts +15 -13
- package/src/store/createSanityInstance.ts +14 -2
- package/src/store/createStateSourceAction.test.ts +64 -2
- package/src/store/createStateSourceAction.ts +38 -43
- package/src/store/createStoreInstance.test.ts +4 -2
- package/src/store/fetcherStore.test-d.ts +121 -0
- package/src/store/fetcherStore.test.ts +299 -0
- package/src/store/fetcherStore.ts +801 -0
- package/src/telemetry/environment.test.ts +119 -0
- package/src/telemetry/environment.ts +92 -0
- package/src/telemetry/events.ts +42 -0
- package/src/telemetry/initTelemetry.test.ts +432 -0
- package/src/telemetry/initTelemetry.ts +218 -0
- package/src/telemetry/telemetryManager.test.ts +327 -0
- package/src/telemetry/telemetryManager.ts +199 -0
- package/src/users/reducers.test.ts +3 -1
- package/src/users/reducers.ts +5 -6
- package/src/users/types.ts +4 -4
- package/src/users/usersStore.test.ts +16 -12
- package/src/users/usersStore.ts +5 -1
- package/src/utils/buildQuery.test.ts +14 -0
- package/src/utils/buildQuery.ts +17 -0
- package/src/utils/createFetcherStore.test.ts +7 -5
- package/src/utils/createFetcherStore.ts +8 -5
- package/src/utils/getStagingApiHost.test.ts +21 -0
- package/src/utils/getStagingApiHost.ts +14 -0
- package/src/utils/ids.test.ts +1 -29
- package/src/utils/ids.ts +0 -10
- package/src/utils/includeShape.ts +36 -0
- package/src/utils/isImportError.test.ts +72 -0
- package/src/utils/isImportError.ts +34 -0
- package/src/utils/logger.test.ts +12 -0
- package/src/utils/logger.ts +8 -3
- package/src/utils/object.test.ts +95 -0
- package/src/utils/object.ts +142 -0
- package/src/utils/setCleanupTimeout.ts +37 -0
- package/src/document/processActions.ts +0 -707
- package/src/utils/defineIntent.test.ts +0 -477
- package/src/utils/defineIntent.ts +0 -244
|
@@ -1,27 +1,183 @@
|
|
|
1
|
+
import {DocumentId, getPublishedId} from '@sanity/id-utils'
|
|
2
|
+
import {type Path} from '@sanity/types'
|
|
1
3
|
import {createSelector} from 'reselect'
|
|
2
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
auditTime,
|
|
6
|
+
catchError,
|
|
7
|
+
combineLatest,
|
|
8
|
+
distinctUntilChanged,
|
|
9
|
+
EMPTY,
|
|
10
|
+
filter,
|
|
11
|
+
first,
|
|
12
|
+
map,
|
|
13
|
+
merge,
|
|
14
|
+
type Observable,
|
|
15
|
+
of,
|
|
16
|
+
Subscription,
|
|
17
|
+
switchMap,
|
|
18
|
+
timer,
|
|
19
|
+
withLatestFrom,
|
|
20
|
+
} from 'rxjs'
|
|
3
21
|
|
|
4
22
|
import {getTokenState} from '../auth/authStore'
|
|
5
23
|
import {getClient} from '../client/clientStore'
|
|
6
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
type DocumentResource,
|
|
26
|
+
isCanvasResource,
|
|
27
|
+
isDatasetResource,
|
|
28
|
+
isMediaLibraryResource,
|
|
29
|
+
} from '../config/sanityConfig'
|
|
30
|
+
import {getEditingDocumentId} from '../document/util'
|
|
7
31
|
import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
|
|
8
|
-
import {
|
|
32
|
+
import {type SanityInstance} from '../store/createSanityInstance'
|
|
33
|
+
import {
|
|
34
|
+
createStateSourceAction,
|
|
35
|
+
type SelectorContext,
|
|
36
|
+
type StateSource,
|
|
37
|
+
} from '../store/createStateSourceAction'
|
|
9
38
|
import {defineStore, type StoreContext} from '../store/defineStore'
|
|
10
39
|
import {type SanityUser} from '../users/types'
|
|
11
40
|
import {getUserState} from '../users/usersStore'
|
|
41
|
+
import {createLogger} from '../utils/logger'
|
|
12
42
|
import {createBifurTransport} from './bifurTransport'
|
|
13
|
-
import {
|
|
43
|
+
import {startsWithPath} from './paths'
|
|
44
|
+
import {
|
|
45
|
+
type DocumentPresence,
|
|
46
|
+
type DocumentPresenceOptions,
|
|
47
|
+
type PresenceLocation,
|
|
48
|
+
type ReportPresenceOptions,
|
|
49
|
+
type TransportEvent,
|
|
50
|
+
type TransportMessage,
|
|
51
|
+
type UserPresence,
|
|
52
|
+
type WirePresenceLocation,
|
|
53
|
+
} from './types'
|
|
54
|
+
|
|
55
|
+
const logger = createLogger('presence')
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Used for the canvas organization lookup only. The socket itself is pinned to
|
|
59
|
+
* `2022-06-30` in `bifurTransport`, because that is the version Bifur compares
|
|
60
|
+
* against when deciding whether a connection may authenticate over RPC rather
|
|
61
|
+
* than requiring a JWT at upgrade. Do not align these two.
|
|
62
|
+
*/
|
|
63
|
+
const PRESENCE_API_VERSION = '2026-03-30'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How often the current user re-announces while idle. Matches the Studio so the
|
|
67
|
+
* two agree on how long a quiet peer should be trusted.
|
|
68
|
+
*/
|
|
69
|
+
const REPORT_MIN_INTERVAL = 30_000
|
|
70
|
+
|
|
71
|
+
/** Collapses a burst of focus changes into a single announcement. */
|
|
72
|
+
const REPORT_AUDIT_TIME = 200
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How long a session may go without announcing before it is dropped. Bifur
|
|
76
|
+
* publishes nothing when a peer's socket dies, so a peer that crashes, is
|
|
77
|
+
* force-quit, or loses power would otherwise stay visible forever.
|
|
78
|
+
*
|
|
79
|
+
* Peers re-announce every 30 seconds, so this allows three missed announcements.
|
|
80
|
+
*/
|
|
81
|
+
const SESSION_TTL = 90_000
|
|
82
|
+
|
|
83
|
+
/** How often to look for sessions that have gone quiet. */
|
|
84
|
+
const SWEEP_INTERVAL = 15_000
|
|
85
|
+
|
|
86
|
+
type PresenceSession = {
|
|
87
|
+
userId: string
|
|
88
|
+
locations: PresenceLocation[]
|
|
89
|
+
/**
|
|
90
|
+
* When we last heard from this session, on our own clock. Used for expiry in
|
|
91
|
+
* preference to the sender's `lastActiveAt`, which is subject to clock skew:
|
|
92
|
+
* a peer whose clock runs slow would otherwise be expired immediately, and one
|
|
93
|
+
* whose clock runs fast would never be expired at all.
|
|
94
|
+
*/
|
|
95
|
+
lastSeenAt: number
|
|
96
|
+
}
|
|
14
97
|
|
|
15
98
|
type PresenceStoreState = {
|
|
16
|
-
locations: Map<string,
|
|
99
|
+
locations: Map<string, PresenceSession>
|
|
17
100
|
users: Record<string, SanityUser | undefined>
|
|
101
|
+
organizationId?: string
|
|
102
|
+
/**
|
|
103
|
+
* Set when the canvas organization lookup fails. Without it, consumers waiting
|
|
104
|
+
* on `organizationId` would wait forever.
|
|
105
|
+
*/
|
|
106
|
+
organizationIdError?: unknown
|
|
107
|
+
/**
|
|
108
|
+
* Where this client says it is. `undefined` means the app has never reported,
|
|
109
|
+
* which keeps it silent: reading presence must not make an app start
|
|
110
|
+
* broadcasting. An empty array is different, and means "here, but not in any
|
|
111
|
+
* particular document".
|
|
112
|
+
*/
|
|
113
|
+
localLocations?: WirePresenceLocation[]
|
|
18
114
|
}
|
|
19
115
|
|
|
20
116
|
const getInitialState = (): PresenceStoreState => ({
|
|
21
|
-
locations: new Map<string,
|
|
117
|
+
locations: new Map<string, PresenceSession>(),
|
|
22
118
|
users: {},
|
|
23
119
|
})
|
|
24
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Sends a message without letting a failure tear down the stream it was sent on.
|
|
123
|
+
*
|
|
124
|
+
* This matters most for the announce pipeline, which carries the idle heartbeat,
|
|
125
|
+
* roll-call responses, and the re-announce on reconnect. Those share one chain, so
|
|
126
|
+
* an unhandled error from a single rejected RPC would silence this client
|
|
127
|
+
* permanently while it carried on reading presence normally. A dropped socket
|
|
128
|
+
* errors the in-flight request, which is exactly the case where staying alive
|
|
129
|
+
* matters, since the reconnect that follows is what makes us visible again.
|
|
130
|
+
*
|
|
131
|
+
* No retry is needed here. The 30 second heartbeat sends again on its own, and a
|
|
132
|
+
* recovered connection triggers an immediate re-announce.
|
|
133
|
+
*/
|
|
134
|
+
const sendSafely = (
|
|
135
|
+
dispatch: (message: TransportMessage) => Observable<void>,
|
|
136
|
+
message: TransportMessage,
|
|
137
|
+
): Observable<void> =>
|
|
138
|
+
dispatch(message).pipe(
|
|
139
|
+
catchError((error: unknown) => {
|
|
140
|
+
logger.warn('Failed to send presence message', {messageType: message.type, error})
|
|
141
|
+
return EMPTY
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Reduces a stream to a bare trigger that cannot fail.
|
|
147
|
+
*
|
|
148
|
+
* The announce pipeline combines three inputs with `merge`, which propagates an
|
|
149
|
+
* error from any one of them. Since that one pipeline carries the idle heartbeat,
|
|
150
|
+
* roll-call responses, and the re-announce on reconnect, a single failing input
|
|
151
|
+
* would silence this client for good. Losing one trigger is survivable; losing
|
|
152
|
+
* all three is not.
|
|
153
|
+
*/
|
|
154
|
+
const asTrigger = (source$: Observable<unknown>, name: string): Observable<void> =>
|
|
155
|
+
source$.pipe(
|
|
156
|
+
map(() => undefined),
|
|
157
|
+
catchError((error: unknown) => {
|
|
158
|
+
logger.error('Presence announce trigger failed', {trigger: name, error})
|
|
159
|
+
return EMPTY
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
/** Ignores `lastActiveAt`, which changes on every report even when nothing moved. */
|
|
164
|
+
const locationKey = ({documentId, path, selection}: WirePresenceLocation) =>
|
|
165
|
+
JSON.stringify([documentId, path, selection ?? null])
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Compares reported locations by value. Key order within a caller-supplied
|
|
169
|
+
* `selection` could in principle differ and read as a change, which costs one
|
|
170
|
+
* extra announcement and nothing else.
|
|
171
|
+
*/
|
|
172
|
+
function isEqualLocations(
|
|
173
|
+
a: WirePresenceLocation[] | undefined,
|
|
174
|
+
b: WirePresenceLocation[] | undefined,
|
|
175
|
+
): boolean {
|
|
176
|
+
if (a === b) return true
|
|
177
|
+
if (!a || !b || a.length !== b.length) return false
|
|
178
|
+
return a.every((location, index) => locationKey(location) === locationKey(b[index]))
|
|
179
|
+
}
|
|
180
|
+
|
|
25
181
|
/** @public */
|
|
26
182
|
export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
|
|
27
183
|
name: 'presence',
|
|
@@ -33,29 +189,34 @@ export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
|
|
|
33
189
|
key: {resource},
|
|
34
190
|
} = context
|
|
35
191
|
|
|
36
|
-
// Presence is only supported for dataset resources
|
|
37
192
|
if (isMediaLibraryResource(resource)) {
|
|
38
|
-
throw new Error(
|
|
39
|
-
'Presence is not supported for media library resources. Presence tracking requires a dataset resource with a projectId.',
|
|
40
|
-
)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (isCanvasResource(resource)) {
|
|
44
|
-
throw new Error(
|
|
45
|
-
'Presence is not supported for canvas resources. Presence tracking requires a dataset resource with a projectId.',
|
|
46
|
-
)
|
|
193
|
+
throw new Error('Presence is not supported for media library resources.')
|
|
47
194
|
}
|
|
48
195
|
|
|
196
|
+
// A fresh id per store, deliberately not persisted. Reusing an id across
|
|
197
|
+
// page loads would collide whenever a tab inherits another's session
|
|
198
|
+
// storage (`window.open`, or a same-origin iframe), making two live clients
|
|
199
|
+
// filter each other out as self. Stale sessions are handled by the
|
|
200
|
+
// disconnect on unload, with the expiry sweep above as the backstop.
|
|
49
201
|
const sessionId = crypto.randomUUID()
|
|
50
202
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
203
|
+
// Dataset resources must use the project hostname so the socket URL is project-specific.
|
|
204
|
+
// Canvas resources use the global API endpoint via the resource config.
|
|
205
|
+
const client = isDatasetResource(resource)
|
|
206
|
+
? getClient(instance, {
|
|
207
|
+
apiVersion: PRESENCE_API_VERSION,
|
|
208
|
+
projectId: resource.projectId,
|
|
209
|
+
dataset: resource.dataset,
|
|
210
|
+
useProjectHostname: true,
|
|
211
|
+
})
|
|
212
|
+
: getClient(instance, {
|
|
213
|
+
apiVersion: PRESENCE_API_VERSION,
|
|
214
|
+
resource,
|
|
215
|
+
})
|
|
55
216
|
|
|
56
217
|
const token$ = getTokenState(instance).observable.pipe(distinctUntilChanged())
|
|
57
218
|
|
|
58
|
-
const [incomingEvents$, dispatch] = createBifurTransport({
|
|
219
|
+
const [incomingEvents$, dispatch, connections$, unload$] = createBifurTransport({
|
|
59
220
|
client,
|
|
60
221
|
token$,
|
|
61
222
|
sessionId,
|
|
@@ -63,110 +224,173 @@ export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
|
|
|
63
224
|
|
|
64
225
|
const subscription = new Subscription()
|
|
65
226
|
|
|
227
|
+
// Subscribed before the roll call below, deliberately. The transport
|
|
228
|
+
// establishes its listener when this stream is subscribed, and a roll call
|
|
229
|
+
// sent before we are listening would have its answers arrive to no one.
|
|
66
230
|
subscription.add(
|
|
67
|
-
incomingEvents$.subscribe(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
231
|
+
incomingEvents$.subscribe({
|
|
232
|
+
next: (event: TransportEvent) => {
|
|
233
|
+
if ('sessionId' in event && event.sessionId === sessionId) {
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (event.type === 'state') {
|
|
238
|
+
state.set('presence/state', (prevState: PresenceStoreState) => {
|
|
239
|
+
const newLocations = new Map(prevState.locations)
|
|
240
|
+
newLocations.set(event.sessionId, {
|
|
241
|
+
userId: event.userId,
|
|
242
|
+
locations: event.locations,
|
|
243
|
+
lastSeenAt: Date.now(),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
...prevState,
|
|
248
|
+
locations: newLocations,
|
|
249
|
+
}
|
|
78
250
|
})
|
|
251
|
+
} else if (event.type === 'disconnect') {
|
|
252
|
+
state.set('presence/disconnect', (prevState: PresenceStoreState) => {
|
|
253
|
+
const newLocations = new Map(prevState.locations)
|
|
254
|
+
newLocations.delete(event.sessionId)
|
|
255
|
+
return {...prevState, locations: newLocations}
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
// The transport re-establishes its listener on every live connection, so
|
|
260
|
+
// this should not fire. Handled rather than left to become an unhandled
|
|
261
|
+
// error, which is how presence used to die silently and for good.
|
|
262
|
+
error: (error: unknown) => {
|
|
263
|
+
logger.error('Presence event stream failed', {error})
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
)
|
|
79
267
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
})
|
|
91
|
-
}
|
|
268
|
+
// Subscribing keeps the socket alive and reconnects it with backoff. Each
|
|
269
|
+
// emission is a freshly live connection: anything we accumulated is now
|
|
270
|
+
// suspect, so start over and ask everyone to announce themselves again.
|
|
271
|
+
subscription.add(
|
|
272
|
+
connections$.subscribe(() => {
|
|
273
|
+
state.set('presence/reset', (prevState) => ({
|
|
274
|
+
...prevState,
|
|
275
|
+
locations: new Map<string, PresenceSession>(),
|
|
276
|
+
}))
|
|
277
|
+
sendSafely(dispatch, {type: 'rollCall'}).subscribe()
|
|
92
278
|
}),
|
|
93
279
|
)
|
|
94
280
|
|
|
95
|
-
|
|
281
|
+
// Restarted per connection so a long reconnect backoff does not expire
|
|
282
|
+
// everyone while we are offline — reconnecting clears the map anyway.
|
|
283
|
+
//
|
|
284
|
+
// Uses an rxjs timer rather than `setCleanupTimeout`, despite the guidance in
|
|
285
|
+
// core-package-conventions, because this is a recurring sweep and not a
|
|
286
|
+
// cleanup timer. Unref'ing it would not help anyway: a live presence
|
|
287
|
+
// subscription holds an open WebSocket, and that is what keeps a Node process
|
|
288
|
+
// alive. Anything running server-side should not subscribe to presence at all.
|
|
289
|
+
subscription.add(
|
|
290
|
+
connections$.pipe(switchMap(() => timer(SWEEP_INTERVAL, SWEEP_INTERVAL))).subscribe(() => {
|
|
291
|
+
state.set('presence/expire', (prevState) => {
|
|
292
|
+
const cutoff = Date.now() - SESSION_TTL
|
|
293
|
+
const stale = [...prevState.locations].filter(([, s]) => s.lastSeenAt < cutoff)
|
|
294
|
+
if (stale.length === 0) return prevState
|
|
96
295
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
296
|
+
const newLocations = new Map(prevState.locations)
|
|
297
|
+
for (const [staleSessionId] of stale) newLocations.delete(staleSessionId)
|
|
298
|
+
return {...prevState, locations: newLocations}
|
|
299
|
+
})
|
|
300
|
+
}),
|
|
301
|
+
)
|
|
103
302
|
|
|
104
|
-
|
|
105
|
-
|
|
303
|
+
subscription.add(
|
|
304
|
+
unload$.pipe(switchMap(() => sendSafely(dispatch, {type: 'disconnect'}))).subscribe(),
|
|
305
|
+
)
|
|
106
306
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
({
|
|
118
|
-
id: userId,
|
|
119
|
-
displayName: 'Unknown user',
|
|
120
|
-
name: 'Unknown user',
|
|
121
|
-
email: '',
|
|
122
|
-
} as unknown as SanityUser),
|
|
123
|
-
sessionId,
|
|
124
|
-
locations: locs,
|
|
125
|
-
}
|
|
126
|
-
})
|
|
127
|
-
},
|
|
128
|
-
)
|
|
307
|
+
// Announce where we are. A location change, a peer asking for a roll call, or
|
|
308
|
+
// a reconnect all restart `timer(0, ...)`, so we announce immediately and then
|
|
309
|
+
// every 30s while idle. That idle tick is what tells peers we are still here,
|
|
310
|
+
// and is what their expiry sweep measures against.
|
|
311
|
+
const localLocations$ = state.observable.pipe(
|
|
312
|
+
map((s) => s.localLocations),
|
|
313
|
+
// Compared by value, not by reference. The Studio compares by reference and
|
|
314
|
+
// so never dedupes, because its form rebuilds the array on every call.
|
|
315
|
+
distinctUntilChanged(isEqualLocations),
|
|
316
|
+
)
|
|
129
317
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
.filter((id): id is string => !!id),
|
|
142
|
-
),
|
|
143
|
-
distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => v === b[i])),
|
|
318
|
+
const rollCallRequests$ = incomingEvents$.pipe(
|
|
319
|
+
// Bifur echoes our own roll call back to us, since we are subscribed to the
|
|
320
|
+
// same topic we published to. Answering it would be a pointless round trip.
|
|
321
|
+
filter((event) => event.type === 'rollCall' && event.sessionId !== sessionId),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
subscription.add(
|
|
325
|
+
merge(
|
|
326
|
+
asTrigger(localLocations$, 'locationChange'),
|
|
327
|
+
asTrigger(rollCallRequests$, 'rollCall'),
|
|
328
|
+
asTrigger(connections$, 'connection'),
|
|
144
329
|
)
|
|
330
|
+
.pipe(
|
|
331
|
+
switchMap(() => timer(0, REPORT_MIN_INTERVAL)),
|
|
332
|
+
withLatestFrom(localLocations$),
|
|
333
|
+
map(([, locations]) => locations),
|
|
334
|
+
// Stay silent until the app has reported at least once.
|
|
335
|
+
filter((locations): locations is WirePresenceLocation[] => locations !== undefined),
|
|
336
|
+
auditTime(REPORT_AUDIT_TIME),
|
|
337
|
+
switchMap((locations) => sendSafely(dispatch, {type: 'state', locations})),
|
|
338
|
+
)
|
|
339
|
+
.subscribe(),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
// Resolve display names for everyone we can see.
|
|
343
|
+
//
|
|
344
|
+
// This belongs to the store rather than to one state source: it is driven by
|
|
345
|
+
// which user ids are in state, and every selector needs it. It used to hang off
|
|
346
|
+
// `getPresence`'s `onSubscribe`, which meant anything reading presence another
|
|
347
|
+
// way saw only "Unknown user".
|
|
348
|
+
const userIds$ = state.observable.pipe(
|
|
349
|
+
map((s) =>
|
|
350
|
+
Array.from(s.locations.values())
|
|
351
|
+
.map((l) => l.userId)
|
|
352
|
+
.filter((id): id is string => !!id),
|
|
353
|
+
),
|
|
354
|
+
distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => v === b[i])),
|
|
355
|
+
)
|
|
145
356
|
|
|
146
|
-
|
|
357
|
+
// For canvas resources, wait for the organizationId to arrive. A failed lookup
|
|
358
|
+
// resolves to `undefined` rather than never emitting, so one failed request
|
|
359
|
+
// does not leave every user permanently unresolved. Dataset resources emit
|
|
360
|
+
// immediately so the stream is never blocked.
|
|
361
|
+
const organizationId$: Observable<string | undefined> = isCanvasResource(resource)
|
|
362
|
+
? state.observable.pipe(
|
|
363
|
+
filter((s) => s.organizationId !== undefined || s.organizationIdError !== undefined),
|
|
364
|
+
first(),
|
|
365
|
+
map((s) => s.organizationId),
|
|
366
|
+
)
|
|
367
|
+
: of(undefined)
|
|
368
|
+
|
|
369
|
+
subscription.add(
|
|
370
|
+
combineLatest([userIds$, organizationId$])
|
|
147
371
|
.pipe(
|
|
148
|
-
switchMap((userIds) => {
|
|
372
|
+
switchMap(([userIds, organizationId]) => {
|
|
149
373
|
if (userIds.length === 0) {
|
|
150
374
|
return of([])
|
|
151
375
|
}
|
|
376
|
+
// Without an organization there is nothing to scope the lookup to, so
|
|
377
|
+
// skip the request rather than making one that cannot succeed.
|
|
378
|
+
if (!isDatasetResource(resource) && !organizationId) {
|
|
379
|
+
return of([])
|
|
380
|
+
}
|
|
152
381
|
const userObservables = userIds.map((userId) =>
|
|
153
|
-
getUserState(
|
|
382
|
+
getUserState(instance, {
|
|
154
383
|
userId,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
? context.key.resource.projectId
|
|
159
|
-
: undefined,
|
|
384
|
+
...(isDatasetResource(resource)
|
|
385
|
+
? {resourceType: 'project', projectId: resource.projectId}
|
|
386
|
+
: {resourceType: 'organization', organizationId}),
|
|
160
387
|
}).pipe(filter((v): v is NonNullable<typeof v> => !!v)),
|
|
161
388
|
)
|
|
162
389
|
return combineLatest(userObservables)
|
|
163
390
|
}),
|
|
164
391
|
)
|
|
165
392
|
.subscribe((users) => {
|
|
166
|
-
|
|
167
|
-
return
|
|
168
|
-
}
|
|
169
|
-
context.state.set('presence/users', (prevState) => ({
|
|
393
|
+
state.set('presence/users', (prevState) => ({
|
|
170
394
|
...prevState,
|
|
171
395
|
users: {
|
|
172
396
|
...prevState.users,
|
|
@@ -178,8 +402,256 @@ export const getPresence = bindActionByResource(
|
|
|
178
402
|
}, {}),
|
|
179
403
|
},
|
|
180
404
|
}))
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
|
|
405
|
+
}),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
// Canvas resources need the organizationId to resolve users — fetch it once from the canvas endpoint
|
|
409
|
+
if (isCanvasResource(resource)) {
|
|
410
|
+
const globalClient = getClient(instance, {apiVersion: PRESENCE_API_VERSION})
|
|
411
|
+
subscription.add(
|
|
412
|
+
globalClient.observable
|
|
413
|
+
.request<{organizationId: string}>({
|
|
414
|
+
uri: `/canvases/${resource.canvasId}`,
|
|
415
|
+
tag: 'canvases.get',
|
|
416
|
+
})
|
|
417
|
+
.subscribe({
|
|
418
|
+
next: ({organizationId}) => {
|
|
419
|
+
state.set('presence/organizationId', (prev) => ({...prev, organizationId}))
|
|
420
|
+
},
|
|
421
|
+
error: (organizationIdError: unknown) => {
|
|
422
|
+
state.set('presence/organizationIdError', (prev) => ({...prev, organizationIdError}))
|
|
423
|
+
},
|
|
424
|
+
}),
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return () => {
|
|
429
|
+
sendSafely(dispatch, {type: 'disconnect'}).subscribe()
|
|
430
|
+
subscription.unsubscribe()
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const selectLocations = (state: PresenceStoreState) => state.locations
|
|
436
|
+
const selectUsers = (state: PresenceStoreState) => state.users
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Stands in for a participant whose profile has not resolved yet, or cannot be
|
|
440
|
+
* resolved. This must be a structurally valid `SanityUser`: consumers read
|
|
441
|
+
* `user.profile`, and a partial object would throw there on first render.
|
|
442
|
+
*/
|
|
443
|
+
const createUnresolvedUser = (userId: string): SanityUser => ({
|
|
444
|
+
sanityUserId: userId,
|
|
445
|
+
profile: {
|
|
446
|
+
id: userId,
|
|
447
|
+
displayName: 'Unknown user',
|
|
448
|
+
email: '',
|
|
449
|
+
provider: '',
|
|
450
|
+
createdAt: '',
|
|
451
|
+
},
|
|
452
|
+
memberships: [],
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
const selectPresence = createSelector(
|
|
456
|
+
selectLocations,
|
|
457
|
+
selectUsers,
|
|
458
|
+
(locations, users): UserPresence[] => {
|
|
459
|
+
return Array.from(locations.entries()).map(([sessionId, {userId, locations: locs}]) => ({
|
|
460
|
+
user: users[userId] || createUnresolvedUser(userId),
|
|
461
|
+
sessionId,
|
|
462
|
+
locations: locs,
|
|
463
|
+
}))
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Results per state object, then per query.
|
|
469
|
+
*
|
|
470
|
+
* `createStateSourceAction` runs its selector afresh on every `getCurrent()`, and
|
|
471
|
+
* `getCurrent` is what React's `useSyncExternalStore` calls as its snapshot. A
|
|
472
|
+
* selector that builds a new array each call therefore sends React into an
|
|
473
|
+
* infinite render loop, which is why the store docs point at memoized selectors.
|
|
474
|
+
*
|
|
475
|
+
* A single `reselect` memo slot is not enough here, because the selector is
|
|
476
|
+
* parameterised per document and callers watching different documents would
|
|
477
|
+
* evict each other. Keyed on the state object, which is replaced on every change,
|
|
478
|
+
* so entries are collected once that state is unreachable.
|
|
479
|
+
*/
|
|
480
|
+
const documentPresenceCache = new WeakMap<PresenceStoreState, Map<string, DocumentPresence[]>>()
|
|
481
|
+
|
|
482
|
+
/** Flattens the session map down to one document. */
|
|
483
|
+
function selectDocumentPresence(
|
|
484
|
+
state: PresenceStoreState,
|
|
485
|
+
options: DocumentPresenceOptions,
|
|
486
|
+
): DocumentPresence[] {
|
|
487
|
+
const cacheKey = JSON.stringify([
|
|
488
|
+
getEditingDocumentId(options),
|
|
489
|
+
options.path ?? null,
|
|
490
|
+
options.excludeVersions ?? false,
|
|
491
|
+
])
|
|
492
|
+
|
|
493
|
+
let perState = documentPresenceCache.get(state)
|
|
494
|
+
if (!perState) {
|
|
495
|
+
perState = new Map<string, DocumentPresence[]>()
|
|
496
|
+
documentPresenceCache.set(state, perState)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const cached = perState.get(cacheKey)
|
|
500
|
+
if (cached) return cached
|
|
501
|
+
|
|
502
|
+
const computed = computeDocumentPresence(state, options)
|
|
503
|
+
perState.set(cacheKey, computed)
|
|
504
|
+
return computed
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The id a query and a location are compared on. Exact when `excludeVersions` is
|
|
509
|
+
* set, otherwise normalized so a draft, its published version, and any release
|
|
510
|
+
* version count as one document.
|
|
511
|
+
*/
|
|
512
|
+
const scopeId = (documentId: string, excludeVersions: boolean | undefined): string =>
|
|
513
|
+
excludeVersions ? documentId : getPublishedId(DocumentId(documentId))
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Declared `string[]`, actually a `Path`. See the note on
|
|
517
|
+
* {@link PresenceLocation.path}; `DocumentPresence.path` reports it honestly.
|
|
518
|
+
*/
|
|
519
|
+
const pathOf = (location: PresenceLocation): Path => location.path as unknown as Path
|
|
520
|
+
|
|
521
|
+
function matchesQuery(
|
|
522
|
+
location: PresenceLocation,
|
|
523
|
+
target: string,
|
|
524
|
+
{path, excludeVersions}: DocumentPresenceOptions,
|
|
525
|
+
): boolean {
|
|
526
|
+
if (scopeId(location.documentId, excludeVersions) !== target) return false
|
|
527
|
+
return !path || startsWithPath(path, pathOf(location))
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function toDocumentPresence(
|
|
531
|
+
users: PresenceStoreState['users'],
|
|
532
|
+
sessionId: string,
|
|
533
|
+
userId: string,
|
|
534
|
+
location: PresenceLocation,
|
|
535
|
+
): DocumentPresence {
|
|
536
|
+
return {
|
|
537
|
+
user: users[userId] || createUnresolvedUser(userId),
|
|
538
|
+
sessionId,
|
|
539
|
+
documentId: location.documentId,
|
|
540
|
+
path: pathOf(location),
|
|
541
|
+
lastActiveAt: location.lastActiveAt,
|
|
542
|
+
...(location.selection === undefined ? {} : {selection: location.selection}),
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function computeDocumentPresence(
|
|
547
|
+
state: PresenceStoreState,
|
|
548
|
+
options: DocumentPresenceOptions,
|
|
549
|
+
): DocumentPresence[] {
|
|
550
|
+
// Resolved before scoping, and identically to the write side, or a query for a
|
|
551
|
+
// document would not match the client reporting it.
|
|
552
|
+
const target = scopeId(getEditingDocumentId(options), options.excludeVersions)
|
|
553
|
+
|
|
554
|
+
return Array.from(state.locations).flatMap(([sessionId, session]) =>
|
|
555
|
+
session.locations
|
|
556
|
+
.filter((location) => matchesQuery(location, target, options))
|
|
557
|
+
.map((location) => toDocumentPresence(state.users, sessionId, session.userId, location)),
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const _getPresence = bindActionByResource(
|
|
562
|
+
presenceStore,
|
|
563
|
+
createStateSourceAction({
|
|
564
|
+
selector: (context: SelectorContext<PresenceStoreState>): UserPresence[] =>
|
|
565
|
+
selectPresence(context.state),
|
|
184
566
|
}),
|
|
185
567
|
)
|
|
568
|
+
|
|
569
|
+
/** @beta */
|
|
570
|
+
export function getPresence(
|
|
571
|
+
instance: SanityInstance,
|
|
572
|
+
params?: {resource?: DocumentResource},
|
|
573
|
+
): StateSource<UserPresence[]> {
|
|
574
|
+
// bit of a hack to support the old bound action by dataset
|
|
575
|
+
// in reality, this will always be passed a resource
|
|
576
|
+
return _getPresence(instance, params ?? {})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const _getDocumentPresence = bindActionByResource(
|
|
580
|
+
presenceStore,
|
|
581
|
+
createStateSourceAction({
|
|
582
|
+
selector: (
|
|
583
|
+
{state}: SelectorContext<PresenceStoreState>,
|
|
584
|
+
options: {resource?: DocumentResource} & DocumentPresenceOptions,
|
|
585
|
+
): DocumentPresence[] => selectDocumentPresence(state, options),
|
|
586
|
+
}),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Presence within a single document, flattened to one entry per participant per
|
|
591
|
+
* location so it can be rendered directly against a field.
|
|
592
|
+
*
|
|
593
|
+
* Reading presence never announces anything. Use `reportPresence` to make the
|
|
594
|
+
* current user visible to others.
|
|
595
|
+
*
|
|
596
|
+
* @param instance - the Sanity instance
|
|
597
|
+
* @param params - the document to look at, its perspective, an optional `path` to
|
|
598
|
+
* narrow to a field subtree, and `excludeVersions` to compare document ids exactly
|
|
599
|
+
*
|
|
600
|
+
* @beta
|
|
601
|
+
*/
|
|
602
|
+
export function getDocumentPresence(
|
|
603
|
+
instance: SanityInstance,
|
|
604
|
+
params: {resource?: DocumentResource} & DocumentPresenceOptions,
|
|
605
|
+
): StateSource<DocumentPresence[]> {
|
|
606
|
+
return _getDocumentPresence(instance, params)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const _reportPresence = bindActionByResource(
|
|
610
|
+
presenceStore,
|
|
611
|
+
(
|
|
612
|
+
{state}: StoreContext<PresenceStoreState, BoundResourceKey>,
|
|
613
|
+
{locations}: {resource?: DocumentResource; locations: ReportPresenceOptions[]},
|
|
614
|
+
) => {
|
|
615
|
+
const lastActiveAt = new Date().toISOString()
|
|
616
|
+
const wireLocations: WirePresenceLocation[] = locations.map((location) => ({
|
|
617
|
+
type: 'document',
|
|
618
|
+
// The specific document being edited, not the canonical id. Other clients,
|
|
619
|
+
// the Studio included, compare exact ids for field-level presence.
|
|
620
|
+
documentId: getEditingDocumentId(location),
|
|
621
|
+
path: location.path ?? [],
|
|
622
|
+
lastActiveAt,
|
|
623
|
+
...(location.selection === undefined ? {} : {selection: location.selection}),
|
|
624
|
+
}))
|
|
625
|
+
|
|
626
|
+
state.set('presence/report', (prevState) => ({...prevState, localLocations: wireLocations}))
|
|
627
|
+
},
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Announces where the current user is, so other clients in the same project and
|
|
632
|
+
* dataset can show them. Reading presence never announces anything, so this is
|
|
633
|
+
* the only way an app becomes visible to others.
|
|
634
|
+
*
|
|
635
|
+
* Call it again whenever the user moves. Announcements are collapsed over a short
|
|
636
|
+
* window and then repeated every 30 seconds while idle, which is what tells peers
|
|
637
|
+
* the session is still alive.
|
|
638
|
+
*
|
|
639
|
+
* Which specific document each location resolves to depends on its perspective, and
|
|
640
|
+
* that matters for interoperability: other clients, the Studio included, compare
|
|
641
|
+
* exact document ids for field-level presence. Pass the perspective you are editing
|
|
642
|
+
* under rather than building a draft or version id yourself.
|
|
643
|
+
*
|
|
644
|
+
* @param instance - the Sanity instance
|
|
645
|
+
* @param params - the resource to announce on, plus the locations to report.
|
|
646
|
+
* Pass one location with a `documentId` for document-level presence, add a
|
|
647
|
+
* `path` for field-level presence, and pass an empty array to appear present
|
|
648
|
+
* without being in any document.
|
|
649
|
+
*
|
|
650
|
+
* @beta
|
|
651
|
+
*/
|
|
652
|
+
export function reportPresence(
|
|
653
|
+
instance: SanityInstance,
|
|
654
|
+
params: {resource?: DocumentResource; locations: ReportPresenceOptions[]},
|
|
655
|
+
): void {
|
|
656
|
+
return _reportPresence(instance, params)
|
|
657
|
+
}
|