Skip to content

Commit 590bcf7

Browse files
authored
Merge pull request #912 from nextcloud/fix/follow-user
fix: follow user
2 parents 7bc45b2 + 0e72d38 commit 590bcf7

File tree

8 files changed

+216
-178
lines changed

8 files changed

+216
-178
lines changed

playwright/e2e/follow.spec.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { Buffer } from 'buffer'
7+
import { expect } from '@playwright/test'
8+
import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
9+
import { test } from '../support/fixtures/random-user'
10+
import {
11+
createUserShare,
12+
openFilesApp,
13+
resolveStoredFileName,
14+
openWhiteboardFromFiles,
15+
} from '../support/utils'
16+
17+
test.beforeEach(async ({ page }) => {
18+
await openFilesApp(page)
19+
})
20+
21+
test('following a collaborator requests viewport sync', async ({ page, browser }) => {
22+
test.setTimeout(150000)
23+
const boardName = `Follow board ${Date.now()}`
24+
25+
await page.getByRole('button', { name: 'New' }).click()
26+
await page.getByRole('menuitem', { name: 'New whiteboard' }).click()
27+
const nameField = page.getByRole('textbox', { name: /name/i })
28+
if (await nameField.count()) {
29+
await nameField.fill(boardName)
30+
} else {
31+
await page.keyboard.type(boardName)
32+
}
33+
await page.getByRole('button', { name: 'Create' }).click()
34+
await openFilesApp(page)
35+
const storedName = await resolveStoredFileName(page, boardName)
36+
37+
const followerUser = await createRandomUser()
38+
await createUserShare(page, {
39+
fileName: storedName,
40+
shareWith: followerUser.userId,
41+
permissions: 15,
42+
})
43+
44+
await openWhiteboardFromFiles(page, storedName)
45+
await expect(page.locator('.network-status')).toHaveCount(0, { timeout: 30000 })
46+
47+
const baseOrigin = new URL(await page.url()).origin
48+
const followerContext = await browser.newContext({
49+
baseURL: `${baseOrigin}/index.php/`,
50+
storageState: undefined,
51+
})
52+
const followerPage = await followerContext.newPage()
53+
54+
const requestViewportFrames: string[] = []
55+
followerPage.on('websocket', (socket) => {
56+
if (!socket.url().includes('/socket.io/')) {
57+
return
58+
}
59+
60+
socket.on('framesent', (payload) => {
61+
const raw = typeof payload === 'string'
62+
? payload
63+
: (payload as { payload?: unknown; data?: unknown }).payload
64+
|| (payload as { payload?: unknown; data?: unknown }).data
65+
|| payload
66+
if (typeof raw !== 'string' && !Buffer.isBuffer(raw) && !(raw instanceof ArrayBuffer)) {
67+
return
68+
}
69+
const frame = typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf-8')
70+
if (!frame.startsWith('42')) {
71+
return
72+
}
73+
try {
74+
const event = JSON.parse(frame.slice(2))
75+
if (Array.isArray(event) && event[0] === 'request-viewport') {
76+
requestViewportFrames.push(frame)
77+
}
78+
} catch {
79+
return
80+
}
81+
})
82+
})
83+
84+
await login(followerPage.request, followerUser)
85+
await openWhiteboardFromFiles(followerPage, storedName, { preferSharedView: true })
86+
await expect(followerPage.locator('.network-status')).toHaveCount(0, { timeout: 30000 })
87+
88+
const collaboratorEntry = followerPage.locator('.UserList__collaborator:not(.is-current-user)')
89+
await expect(collaboratorEntry).toHaveCount(1, { timeout: 30000 })
90+
await collaboratorEntry.first().click()
91+
92+
await expect.poll(() => requestViewportFrames.length, {
93+
timeout: 20000,
94+
interval: 500,
95+
}).toBeGreaterThan(0)
96+
97+
await followerPage.close()
98+
await followerContext.close()
99+
})

src/App.tsx

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { useTimer } from './hooks/useTimer'
3838
import { TimerOverlay } from './components/Timer'
3939
import { useCollaborationStore } from './stores/useCollaborationStore'
4040
import { useElementCreatorTracking } from './hooks/useElementCreatorTracking'
41+
import { useFollowedUser } from './hooks/useFollowedUser'
4142
import { CreatorDisplay } from './components/CreatorDisplay'
4243
import { useCreatorDisplayStore } from './stores/useCreatorDisplayStore'
4344
import type { ExcalidrawElement } from '@nextcloud/excalidraw/dist/types/excalidraw/element/types'
@@ -148,38 +149,7 @@ export default function App({
148149
// Creator tracking
149150
const creatorDisplaySettings = useCreatorDisplayStore(state => state.settings)
150151
useElementCreatorTracking({ excalidrawAPI, enabled: true })
151-
152-
// Expose followUser globally for recording agent access
153-
useEffect(() => {
154-
// Create a followUser function that accesses the collaboration store directly
155-
window.followUser = (userId: string) => {
156-
if (!excalidrawAPI) {
157-
console.warn('[Collaboration] Cannot follow user: Excalidraw API not available')
158-
return
159-
}
160-
161-
const currentSocket = useCollaborationStore.getState().socket
162-
if (!currentSocket?.connected) {
163-
logger.warn('[Collaboration] Cannot follow user: Socket not connected')
164-
return
165-
}
166-
167-
// Set the followed user ID in the collaboration store
168-
useCollaborationStore.setState({ followedUserId: userId })
169-
logger.info(`[Collaboration] Recording agent now following user: ${userId}`)
170-
171-
// Debug: Log current collaboration store state
172-
const state = useCollaborationStore.getState()
173-
logger.debug('[Collaboration] Current collaboration store state:', {
174-
followedUserId: state.followedUserId,
175-
socketConnected: state.socket?.connected,
176-
status: state.status,
177-
})
178-
}
179-
return () => {
180-
delete window.followUser
181-
}
182-
}, [excalidrawAPI])
152+
useFollowedUser({ excalidrawAPI, fileId: normalizedFileId })
183153

184154
useEffect(() => {
185155
const handleVideoError = (e: Event) => {

src/hooks/useFollowedUser.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { useCallback, useEffect } from 'react'
7+
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types'
8+
import type { OnUserFollowedPayload } from '@nextcloud/excalidraw/dist/types/excalidraw/types'
9+
import { useCollaborationStore } from '../stores/useCollaborationStore'
10+
import logger from '../utils/logger'
11+
12+
type UseFollowedUserOptions = {
13+
excalidrawAPI: ExcalidrawImperativeAPI | null
14+
fileId: number
15+
}
16+
17+
export function useFollowedUser({ excalidrawAPI, fileId }: UseFollowedUserOptions) {
18+
const handleUserFollow = useCallback((payload: OnUserFollowedPayload) => {
19+
const targetUserId = payload.userToFollow?.socketId
20+
if (!targetUserId) {
21+
logger.warn('[Collaboration] Invalid follow payload', payload)
22+
return
23+
}
24+
25+
if (payload.action === 'FOLLOW') {
26+
useCollaborationStore.setState({ followedUserId: targetUserId })
27+
const { socket } = useCollaborationStore.getState()
28+
if (socket?.connected && Number.isFinite(fileId)) {
29+
socket.emit('request-viewport', {
30+
fileId: fileId.toString(),
31+
userId: targetUserId,
32+
})
33+
}
34+
logger.info(`[Collaboration] Following user from UI: ${targetUserId}`)
35+
return
36+
}
37+
38+
const currentFollowed = useCollaborationStore.getState().followedUserId
39+
if (currentFollowed === targetUserId) {
40+
useCollaborationStore.setState({ followedUserId: null })
41+
logger.info(`[Collaboration] Stopped following user from UI: ${targetUserId}`)
42+
}
43+
}, [fileId])
44+
45+
useEffect(() => {
46+
if (!excalidrawAPI) {
47+
return
48+
}
49+
50+
const unsubscribe = excalidrawAPI.onUserFollow(handleUserFollow)
51+
return () => {
52+
if (typeof unsubscribe === 'function') {
53+
unsubscribe()
54+
}
55+
}
56+
}, [excalidrawAPI, handleUserFollow])
57+
58+
// Expose followUser globally for recording agent access
59+
useEffect(() => {
60+
if (!excalidrawAPI) {
61+
return
62+
}
63+
64+
window.followUser = (userId: string) => {
65+
if (!excalidrawAPI) {
66+
logger.warn('[Collaboration] Cannot follow user: Excalidraw API not available')
67+
return
68+
}
69+
70+
const currentSocket = useCollaborationStore.getState().socket
71+
if (!currentSocket?.connected) {
72+
logger.warn('[Collaboration] Cannot follow user: Socket not connected')
73+
return
74+
}
75+
76+
useCollaborationStore.setState({ followedUserId: userId })
77+
logger.info(`[Collaboration] Recording agent now following user: ${userId}`)
78+
79+
const state = useCollaborationStore.getState()
80+
logger.debug('[Collaboration] Current collaboration store state:', {
81+
followedUserId: state.followedUserId,
82+
socketConnected: state.socket?.connected,
83+
status: state.status,
84+
})
85+
}
86+
return () => {
87+
delete window.followUser
88+
}
89+
}, [excalidrawAPI])
90+
}

0 commit comments

Comments
 (0)