diff --git a/editor/liveblocks.config.ts b/editor/liveblocks.config.ts index 4fa4820d29a4..a1150d640da9 100644 --- a/editor/liveblocks.config.ts +++ b/editor/liveblocks.config.ts @@ -19,6 +19,7 @@ export type Presence = { cursor: WindowPoint | null canvasScale: number | null canvasOffset: CanvasVector | null + following: string | null } export function initialPresence(): Presence { @@ -26,6 +27,7 @@ export function initialPresence(): Presence { cursor: null, canvasScale: null, canvasOffset: null, + following: null, } } diff --git a/editor/src/components/canvas/multiplayer-cursors.tsx b/editor/src/components/canvas/multiplayer-cursors.tsx index f3b0b000ceb7..0a0a41a386ed 100644 --- a/editor/src/components/canvas/multiplayer-cursors.tsx +++ b/editor/src/components/canvas/multiplayer-cursors.tsx @@ -46,6 +46,11 @@ export const MultiplayerPresence = React.memo(() => { (store) => store.editor.canvas.roundedCanvasOffset, 'MultiplayerPresence canvasOffset', ) + const mode = useEditorState( + Substores.restOfEditor, + (store) => store.editor.mode, + 'MultiplayerPresence mode', + ) useAddMyselfToCollaborators() @@ -53,9 +58,12 @@ export const MultiplayerPresence = React.memo(() => { if (!isLoggedIn(loginState)) { return } - // when the canvas is panned or zoomed, update the presence - updateMyPresence({ canvasScale, canvasOffset }) - }, [canvasScale, canvasOffset, updateMyPresence, loginState]) + updateMyPresence({ + canvasScale, + canvasOffset, + following: isFollowMode(mode) ? mode.playerId : null, + }) + }, [canvasScale, canvasOffset, updateMyPresence, loginState, mode]) React.useEffect(() => { // when the mouse moves over the canvas, update the presence cursor diff --git a/editor/src/components/user-bar.tsx b/editor/src/components/user-bar.tsx index 9b7b98c6e453..884b2c4e533d 100644 --- a/editor/src/components/user-bar.tsx +++ b/editor/src/components/user-bar.tsx @@ -3,6 +3,7 @@ import { useOthers, useSelf, useStatus, useStorage } from '../../liveblocks.conf import { getUserPicture, isLoggedIn } from '../common/user' import type { MultiplayerColor } from '../core/shared/multiplayer' import { + canFollowTarget, isDefaultAuth0AvatarURL, multiplayerColorFromIndex, multiplayerInitialsFromName, @@ -14,13 +15,16 @@ import { Substores, useEditorState } from './editor/store/store-hook' import { unless, when } from '../utils/react-conditionals' import { MultiplayerWrapper } from '../utils/multiplayer-wrapper' import { useDispatch } from './editor/store/dispatch-context' -import { switchEditorMode } from './editor/actions/action-creators' +import { showToast, switchEditorMode } from './editor/actions/action-creators' import type { EditorAction } from './editor/action-types' import { EditorModes, isFollowMode } from './editor/editor-modes' +import { notice } from './common/notice' import { useMyUserAndPresence } from '../core/commenting/comment-hooks' const MAX_VISIBLE_OTHER_PLAYERS = 4 +export const cannotFollowToastId = 'cannot-follow-toast-id' + export const UserBar = React.memo(() => { const loginState = useEditorState( Substores.userState, @@ -71,6 +75,7 @@ const MultiplayerUserBar = React.memo(() => { name: myUser.name, colorIndex: myUser.colorIndex, picture: myUser.avatar, + following: other.presence.following, })), ) @@ -88,15 +93,35 @@ const MultiplayerUserBar = React.memo(() => { ) const toggleFollowing = React.useCallback( - (id: string) => () => { - const newMode = - isFollowMode(mode) && mode.playerId === id - ? EditorModes.selectMode(null, false, 'none') - : EditorModes.followMode(id) - let actions: EditorAction[] = [switchEditorMode(newMode)] + (targetId: string) => () => { + let actions: EditorAction[] = [] + if ( + !canFollowTarget( + myUser.id, + targetId, + others.map((o) => o), + ) + ) { + actions.push( + showToast( + notice( + 'Cannot follow this player at the moment.', + 'WARNING', + false, + cannotFollowToastId, + ), + ), + ) + } else { + const newMode = + isFollowMode(mode) && mode.playerId === targetId + ? EditorModes.selectMode(null, false, 'none') + : EditorModes.followMode(targetId) + actions.push(switchEditorMode(newMode)) + } dispatch(actions) }, - [dispatch, mode], + [dispatch, mode, myUser, others], ) if (myUser.name == null) { diff --git a/editor/src/core/shared/multiplayer.spec.ts b/editor/src/core/shared/multiplayer.spec.ts index 7e4ad59a5112..2867c699a49e 100644 --- a/editor/src/core/shared/multiplayer.spec.ts +++ b/editor/src/core/shared/multiplayer.spec.ts @@ -1,4 +1,4 @@ -import { multiplayerInitialsFromName } from './multiplayer' +import { canFollowTarget, multiplayerInitialsFromName } from './multiplayer' describe('multiplayer', () => { describe('multiplayerInitialsFromName', () => { @@ -18,4 +18,39 @@ describe('multiplayer', () => { expect(multiplayerInitialsFromName('')).toEqual('XX') }) }) + + describe('canFollowTarget', () => { + it('can follow a single player', () => { + expect(canFollowTarget('foo', 'bar', [{ id: 'bar', following: null }])).toBe(true) + }) + it('can follow a player that follows another player', () => { + expect( + canFollowTarget('foo', 'bar', [ + { id: 'bar', following: 'baz' }, + { id: 'baz', following: null }, + ]), + ).toBe(true) + }) + it('can follow a player that follows another player indirectly', () => { + expect( + canFollowTarget('foo', 'bar', [ + { id: 'bar', following: 'baz' }, + { id: 'baz', following: 'qux' }, + { id: 'qux', following: null }, + ]), + ).toBe(true) + }) + it('cannot follow a player back', () => { + expect(canFollowTarget('foo', 'bar', [{ id: 'bar', following: 'foo' }])).toBe(false) + }) + it('cannot follow a player that has an indirect loop', () => { + expect( + canFollowTarget('foo', 'bar', [ + { id: 'bar', following: 'baz' }, + { id: 'baz', following: 'qux' }, + { id: 'qux', following: 'foo' }, + ]), + ).toBe(false) + }) + }) }) diff --git a/editor/src/core/shared/multiplayer.ts b/editor/src/core/shared/multiplayer.ts index 2dc780908257..a12c6d841fa8 100644 --- a/editor/src/core/shared/multiplayer.ts +++ b/editor/src/core/shared/multiplayer.ts @@ -107,6 +107,27 @@ export function isDefaultAuth0AvatarURL(s: string | null): boolean { ) } +export function canFollowTarget( + selfId: string, + targetId: string | null, + others: { id: string; following: string | null }[], +): boolean { + let followChain: Set = new Set() + + let id = targetId + while (id != null) { + if (followChain.has(id)) { + return false + } + followChain.add(id) + + const target = others.find((o) => o.id === id) + id = target?.following ?? null + } + + return !followChain.has(selfId) +} + export function projectIdToRoomId(projectId: string): string { return `project-room-${projectId}` }