Skip to content

Commit 9ed405c

Browse files
feat(presence): add user presence events (#583)
--------- Co-authored-by: Carolina Gonzalez <[email protected]>
1 parent 8c36794 commit 9ed405c

File tree

15 files changed

+632
-12
lines changed

15 files changed

+632
-12
lines changed

apps/kitchensink-react/src/AppRoutes.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {DocumentProjectionRoute} from './DocumentCollection/DocumentProjectionRo
1111
import {MultiResourceRoute} from './DocumentCollection/MultiResourceRoute'
1212
import {OrgDocumentExplorerRoute} from './DocumentCollection/OrgDocumentExplorerRoute'
1313
import {SearchRoute} from './DocumentCollection/SearchRoute'
14+
import {PresenceRoute} from './Presence/PresenceRoute'
1415
import {ProjectAuthHome} from './ProjectAuthentication/ProjectAuthHome'
1516
import {ProtectedRoute} from './ProtectedRoute'
1617
import {DashboardContextRoute} from './routes/DashboardContextRoute'
@@ -65,6 +66,10 @@ const documentCollectionRoutes = [
6566
path: 'experimental-resource-client',
6667
element: <ExperimentalResourceClientRoute />,
6768
},
69+
{
70+
path: 'presence',
71+
element: <PresenceRoute />,
72+
},
6873
]
6974

7075
const dashboardInteractionRoutes = [
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {ResourceProvider, usePresence, useProject} from '@sanity/sdk-react'
2+
import {JSX} from 'react'
3+
4+
import {devConfigs} from '../sanityConfigs'
5+
import {PresenceSecondProject} from './PresenceSecondProject'
6+
7+
export function PresenceRoute(): JSX.Element {
8+
const project = useProject()
9+
const {locations} = usePresence()
10+
11+
return (
12+
<div>
13+
<h1>Presence for {project?.id}</h1>
14+
<p>
15+
<a href={`https://test-studio.sanity.build/test/structure/`} target={project?.id}>
16+
View in Studio →
17+
</a>
18+
</p>
19+
<pre>{JSON.stringify(locations, null, 2)}</pre>
20+
<h1>Second Project Presence for {devConfigs[1].projectId}</h1>
21+
<p>
22+
<a
23+
href={`https://sdk-presence-example.sanity.studio/${devConfigs[2].dataset}/structure/`}
24+
target={devConfigs[2].dataset}
25+
>
26+
View in Studio →
27+
</a>
28+
</p>
29+
<ResourceProvider
30+
projectId={devConfigs[2].projectId}
31+
dataset={devConfigs[2].dataset}
32+
fallback={<p>Loading...</p>}
33+
>
34+
<PresenceSecondProject />
35+
</ResourceProvider>
36+
</div>
37+
)
38+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {usePresence, useProject} from '@sanity/sdk-react'
2+
import {JSX} from 'react'
3+
4+
export function PresenceSecondProject(): JSX.Element {
5+
const project = useProject()
6+
const {locations} = usePresence()
7+
8+
return (
9+
<div>
10+
<p>Project: {project?.id}</p>
11+
<pre>{JSON.stringify(locations, null, 2)}</pre>
12+
</div>
13+
)
14+
}

apps/kitchensink-react/src/sanityConfigs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export const devConfigs: SanityConfig[] = [
99
projectId: 'd45jg133',
1010
dataset: 'production',
1111
},
12+
{
13+
projectId: 'v28v5k8m',
14+
dataset: 'production',
15+
},
1216
]
1317

1418
export const e2eConfigs: SanityConfig[] = [

packages/core/src/_exports/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ export {type JsonMatch} from '../document/patchOperations'
104104
export {type DocumentPermissionsResult, type PermissionDeniedReason} from '../document/permissions'
105105
export type {FavoriteStatusResponse} from '../favorites/favorites'
106106
export {getFavoritesState, resolveFavoritesState} from '../favorites/favorites'
107+
export {getPresence} from '../presence/presenceStore'
108+
export type {
109+
DisconnectEvent,
110+
PresenceLocation,
111+
RollCallEvent,
112+
StateEvent,
113+
TransportEvent,
114+
UserPresence,
115+
} from '../presence/types'
107116
export {getPreviewState, type GetPreviewStateOptions} from '../preview/getPreviewState'
108117
export type {PreviewStoreState, PreviewValue, ValuePending} from '../preview/previewStore'
109118
export {resolvePreview, type ResolvePreviewOptions} from '../preview/resolvePreview'

packages/core/src/presence/bifurTransport.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {type BifurClient, fromUrl} from '@sanity/bifur-client'
22
import {type SanityClient} from '@sanity/client'
3-
import {EMPTY, type Observable} from 'rxjs'
4-
import {map, share} from 'rxjs/operators'
3+
import {EMPTY, fromEvent, type Observable} from 'rxjs'
4+
import {map, share, switchMap} from 'rxjs/operators'
55

66
import {
77
type BifurTransportOptions,
@@ -98,5 +98,11 @@ export const createBifurTransport = (options: BifurTransportOptions): PresenceTr
9898
}
9999
}
100100

101+
if (typeof window !== 'undefined') {
102+
fromEvent(window, 'beforeunload')
103+
.pipe(switchMap(() => dispatchMessage({type: 'disconnect'})))
104+
.subscribe()
105+
}
106+
101107
return [incomingEvents$.pipe(share()), dispatchMessage]
102108
}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import {type SanityClient} from '@sanity/client'
2+
import {delay, firstValueFrom, of, Subject} from 'rxjs'
3+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4+
5+
import {getTokenState} from '../auth/authStore'
6+
import {getClient} from '../client/clientStore'
7+
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
8+
import {type SanityUser} from '../users/types'
9+
import {getUserState} from '../users/usersStore'
10+
import {createBifurTransport} from './bifurTransport'
11+
import {getPresence} from './presenceStore'
12+
import {type PresenceLocation, type TransportEvent} from './types'
13+
14+
vi.mock('../auth/authStore')
15+
vi.mock('../client/clientStore')
16+
vi.mock('../users/usersStore')
17+
vi.mock('./bifurTransport')
18+
19+
describe('presenceStore', () => {
20+
let instance: SanityInstance
21+
let mockClient: SanityClient
22+
let mockTokenState: Subject<string | null>
23+
let mockIncomingEvents: Subject<TransportEvent>
24+
let mockDispatchMessage: ReturnType<typeof vi.fn>
25+
let mockGetUserState: ReturnType<typeof vi.fn>
26+
27+
const mockUser: SanityUser = {
28+
sanityUserId: 'u123',
29+
profile: {
30+
id: 'user-1',
31+
displayName: 'Test User',
32+
33+
provider: 'google',
34+
createdAt: '2023-01-01T00:00:00Z',
35+
},
36+
memberships: [],
37+
}
38+
39+
beforeEach(() => {
40+
vi.clearAllMocks()
41+
42+
// Mock crypto.randomUUID
43+
Object.defineProperty(global, 'crypto', {
44+
value: {
45+
randomUUID: vi.fn(() => 'test-session-id'),
46+
},
47+
})
48+
49+
mockClient = {
50+
withConfig: vi.fn().mockReturnThis(),
51+
} as unknown as SanityClient
52+
53+
mockTokenState = new Subject<string | null>()
54+
mockIncomingEvents = new Subject<TransportEvent>()
55+
mockDispatchMessage = vi.fn(() => of(undefined))
56+
57+
vi.mocked(getClient).mockReturnValue(mockClient)
58+
vi.mocked(getTokenState).mockReturnValue({
59+
observable: mockTokenState.asObservable(),
60+
getCurrent: vi.fn(),
61+
subscribe: vi.fn(),
62+
})
63+
64+
vi.mocked(createBifurTransport).mockReturnValue([
65+
mockIncomingEvents.asObservable(),
66+
mockDispatchMessage,
67+
])
68+
69+
mockGetUserState = vi.fn(() => of(mockUser))
70+
vi.mocked(getUserState).mockImplementation(mockGetUserState)
71+
72+
instance = createSanityInstance({projectId: 'test-project', dataset: 'test-dataset'})
73+
})
74+
75+
afterEach(() => {
76+
instance.dispose()
77+
})
78+
79+
describe('getPresence', () => {
80+
it('creates bifur transport with correct parameters', () => {
81+
getPresence(instance)
82+
83+
expect(createBifurTransport).toHaveBeenCalledWith({
84+
client: mockClient,
85+
token$: expect.any(Object),
86+
sessionId: 'test-session-id',
87+
})
88+
})
89+
90+
it('sends rollCall message on initialization', () => {
91+
getPresence(instance)
92+
93+
expect(mockDispatchMessage).toHaveBeenCalledWith({type: 'rollCall'})
94+
})
95+
96+
it('returns empty array when no users present', () => {
97+
const source = getPresence(instance)
98+
expect(source.getCurrent()).toEqual([])
99+
})
100+
101+
it('handles state events from other users', async () => {
102+
const source = getPresence(instance)
103+
104+
// Subscribe to initialize the store
105+
const unsubscribe = source.subscribe(() => {})
106+
107+
// Wait a bit for initialization
108+
await firstValueFrom(of(null).pipe(delay(10)))
109+
110+
const locations: PresenceLocation[] = [
111+
{
112+
type: 'document',
113+
documentId: 'doc-1',
114+
path: ['title'],
115+
lastActiveAt: '2023-01-01T12:00:00Z',
116+
},
117+
]
118+
119+
mockIncomingEvents.next({
120+
type: 'state',
121+
userId: 'user-1',
122+
sessionId: 'other-session',
123+
timestamp: '2023-01-01T12:00:00Z',
124+
locations,
125+
})
126+
127+
// Wait for processing
128+
await firstValueFrom(of(null).pipe(delay(20)))
129+
130+
const presence = source.getCurrent()
131+
expect(presence).toHaveLength(1)
132+
expect(presence[0].sessionId).toBe('other-session')
133+
expect(presence[0].locations).toEqual(locations)
134+
135+
unsubscribe()
136+
})
137+
138+
it('ignores events from own session', async () => {
139+
const source = getPresence(instance)
140+
const unsubscribe = source.subscribe(() => {})
141+
142+
await firstValueFrom(of(null).pipe(delay(10)))
143+
144+
mockIncomingEvents.next({
145+
type: 'state',
146+
userId: 'user-1',
147+
sessionId: 'test-session-id', // Same as our session
148+
timestamp: '2023-01-01T12:00:00Z',
149+
locations: [],
150+
})
151+
152+
await firstValueFrom(of(null).pipe(delay(20)))
153+
154+
const presence = source.getCurrent()
155+
expect(presence).toHaveLength(0)
156+
157+
unsubscribe()
158+
})
159+
160+
it('handles disconnect events', async () => {
161+
const source = getPresence(instance)
162+
const unsubscribe = source.subscribe(() => {})
163+
164+
await firstValueFrom(of(null).pipe(delay(10)))
165+
166+
// First add a user
167+
mockIncomingEvents.next({
168+
type: 'state',
169+
userId: 'user-1',
170+
sessionId: 'other-session',
171+
timestamp: '2023-01-01T12:00:00Z',
172+
locations: [],
173+
})
174+
175+
await firstValueFrom(of(null).pipe(delay(20)))
176+
expect(source.getCurrent()).toHaveLength(1)
177+
178+
// Then disconnect them
179+
mockIncomingEvents.next({
180+
type: 'disconnect',
181+
userId: 'user-1',
182+
sessionId: 'other-session',
183+
timestamp: '2023-01-01T12:01:00Z',
184+
})
185+
186+
await firstValueFrom(of(null).pipe(delay(20)))
187+
expect(source.getCurrent()).toHaveLength(0)
188+
189+
unsubscribe()
190+
})
191+
192+
it('fetches user data for present users', async () => {
193+
const source = getPresence(instance)
194+
const unsubscribe = source.subscribe(() => {})
195+
196+
await firstValueFrom(of(null).pipe(delay(10)))
197+
198+
mockIncomingEvents.next({
199+
type: 'state',
200+
userId: 'user-1',
201+
sessionId: 'other-session',
202+
timestamp: '2023-01-01T12:00:00Z',
203+
locations: [
204+
{
205+
type: 'document',
206+
documentId: 'doc-1',
207+
path: ['title'],
208+
lastActiveAt: '2023-01-01T12:00:00Z',
209+
},
210+
],
211+
})
212+
213+
await firstValueFrom(of(null).pipe(delay(50)))
214+
215+
expect(getUserState).toHaveBeenCalledWith(instance, {
216+
userId: 'user-1',
217+
resourceType: 'project',
218+
projectId: 'test-project',
219+
})
220+
221+
unsubscribe()
222+
})
223+
224+
it('handles presence events correctly', async () => {
225+
const source = getPresence(instance)
226+
const unsubscribe = source.subscribe(() => {})
227+
228+
await firstValueFrom(of(null).pipe(delay(10)))
229+
230+
mockIncomingEvents.next({
231+
type: 'state',
232+
userId: 'test-user',
233+
sessionId: 'other-session',
234+
timestamp: '2023-01-01T12:00:00Z',
235+
locations: [],
236+
})
237+
238+
await firstValueFrom(of(null).pipe(delay(50)))
239+
240+
const presence = source.getCurrent()
241+
expect(presence).toHaveLength(1)
242+
expect(presence[0].sessionId).toBe('other-session')
243+
244+
unsubscribe()
245+
})
246+
})
247+
})

0 commit comments

Comments
 (0)