From f864d0cfbdb067851d3b65a0e9b839f8eb1a375d Mon Sep 17 00:00:00 2001 From: Lu Wilson Date: Thu, 15 Jun 2023 16:48:47 +0100 Subject: [PATCH] (1/2) Timeout collaborator cursors (#1525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a timeout to collaborator cursors. It's part 1 of two PRs. The second one is smaller: https://github.com/tldraw/brivate/pull/2053 # What is this? After three seconds of inactivity, collaborator cursors disappear. ![2023-06-08 at 10 42 43 - Moccasin Flamingo](https://github.com/tldraw/tldraw/assets/15892272/93e463aa-0329-4ecb-ada1-4c38b36a655b) If you're following someone, you can always see their cursor. ![2023-06-08 at 10 45 42 - Olive Crayfish](https://github.com/tldraw/tldraw/assets/15892272/11e8d85a-18a8-4976-85c5-d14f3841c296) # Is there anything else? The PR also adds support for the brivate PR: https://github.com/tldraw/brivate/pull/2053 # Admin ### Change Type - [x] `minor` — New Feature ### Test Plan You probably need to test this locally, as we don't do multiplayer previews on this repo yet. 1. Open the same shared project in two browser sessions. 2. Move around the cursor in one session, while able to see it from the other. 3. Stop moving the cursor. 4. Make sure that the cursor disappears on the other session after 3 seconds. 5. Move the cursor again, and make sure it reappears it. 6. Make sure that viewport-following the user makes the cursor show permanently. ### Release Notes - Brought back cursor timeouts. Collaborator cursors now disappear after 3 seconds of inactivity. --------- Co-authored-by: Steve Ruiz --- packages/editor/api-report.md | 1 + .../src/lib/components/LiveCollaborators.tsx | 28 +++++++ packages/editor/src/lib/constants.ts | 14 ++++ packages/editor/src/lib/editor/Editor.ts | 79 ++++++++++++++++--- packages/tlschema/api-report.md | 2 + packages/tlschema/src/migrations.test.ts | 12 +++ packages/tlschema/src/records/TLInstance.ts | 14 +++- 7 files changed, 139 insertions(+), 11 deletions(-) diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index ab33e1423..68d6bc210 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -375,6 +375,7 @@ export class Editor extends EventEmitter { ease?: (t: number) => number; }): this; animateToShape(shapeId: TLShapeId, opts?: TLAnimationOptions): this; + animateToUser(userId: string): void; get animationSpeed(): number; // @internal (undocumented) annotateError(error: unknown, { origin, willCrashApp, tags, extras, }: { diff --git a/packages/editor/src/lib/components/LiveCollaborators.tsx b/packages/editor/src/lib/components/LiveCollaborators.tsx index 23d41b6f2..62d44d36d 100644 --- a/packages/editor/src/lib/components/LiveCollaborators.tsx +++ b/packages/editor/src/lib/components/LiveCollaborators.tsx @@ -1,4 +1,6 @@ +import { useEffect, useRef, useState } from 'react' import { track } from 'signia-react' +import { COLLABORATOR_CHECK_INTERVAL, COLLABORATOR_TIMEOUT } from '../constants' import { useEditor } from '../hooks/useEditor' import { useEditorComponents } from '../hooks/useEditorComponents' import { usePeerIds } from '../hooks/usePeerIds' @@ -28,8 +30,34 @@ const Collaborator = track(function Collaborator({ userId }: { userId: string }) } = useEditorComponents() const latestPresence = usePresence(userId) + + const [isTimedOut, setIsTimedOut] = useState(false) + const rLastSeen = useRef(-1) + + useEffect(() => { + const interval = setInterval(() => { + setIsTimedOut(Date.now() - rLastSeen.current > COLLABORATOR_TIMEOUT) + }, COLLABORATOR_CHECK_INTERVAL) + + return () => clearInterval(interval) + }, []) + if (!latestPresence) return null + // We can do this on every render, it's free and would be the same as running a useEffect with a dependency on the timestamp + rLastSeen.current = latestPresence.lastActivityTimestamp + + // If the user has timed out + // ... and we're not following them + // ... and they're not highlighted + // then we'll hide the contributor + if ( + isTimedOut && + editor.instanceState.followingUserId !== userId && + !editor.instanceState.highlightedUserIds.includes(userId) + ) + return null + // if the collaborator is on another page, ignore them if (latestPresence.currentPageId !== editor.currentPageId) return null diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index dfef18e5d..0e7a69c81 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -273,3 +273,17 @@ export const BLACKLISTED_PROPS = new Set([ 'url', 'growY', ]) + +/** @internal */ +export const COLLABORATOR_TIMEOUT = 3000 + +/** @internal */ +export const COLLABORATOR_CHECK_INTERVAL = 1200 + +/** + * Negative pointer ids are reserved for internal use. + * + * @internal */ +export const INTERNAL_POINTER_IDS = { + CAMERA_MOVE: -10, +} as const diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 18ebfeef1..79826d97f 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -86,6 +86,7 @@ import { ANIMATION_MEDIUM_MS, BLACKLISTED_PROPS, COARSE_DRAG_DISTANCE, + COLLABORATOR_TIMEOUT, DEFAULT_ANIMATION_OPTIONS, DRAG_DISTANCE, FOLLOW_CHASE_PAN_SNAP, @@ -95,6 +96,7 @@ import { FOLLOW_CHASE_ZOOM_UNSNAP, GRID_INCREMENT, HAND_TOOL_FRICTION, + INTERNAL_POINTER_IDS, MAJOR_NUDGE_FACTOR, MAX_PAGES, MAX_SHAPES_PER_PAGE, @@ -3802,17 +3804,18 @@ export class Editor extends EventEmitter { previousScreenPoint.setTo(currentScreenPoint) previousPagePoint.setTo(currentPagePoint) - const px = (sx - screenBounds.x) / cz - cx - const py = (sy - screenBounds.y) / cz - cy - currentScreenPoint.set(sx, sy) - currentPagePoint.set(px, py, sz ?? 0.5) + currentPagePoint.set( + (sx - screenBounds.x) / cz - cx, + (sy - screenBounds.y) / cz - cy, + sz ?? 0.5 + ) this.inputs.isPen = info.type === 'pointer' && info.isPen // Reset velocity on pointer down if (info.name === 'pointer_down') { - this.inputs.pointerVelocity = new Vec2d() + this.inputs.pointerVelocity.set(0, 0) } // todo: We only have to do this if there are multiple users in the document @@ -3822,7 +3825,12 @@ export class Editor extends EventEmitter { typeName: 'pointer', x: currentPagePoint.x, y: currentPagePoint.y, - lastActivityTimestamp: Date.now(), + lastActivityTimestamp: + // If our pointer moved only because we're following some other user, then don't + // update our last activity timestamp; otherwise, update it to the current timestamp. + info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE + ? this.store.get(TLPOINTER_ID)?.lastActivityTimestamp ?? Date.now() + : Date.now(), }, ]) } @@ -8391,7 +8399,7 @@ export class Editor extends EventEmitter { target: 'canvas', name: 'pointer_move', point: currentScreenPoint, - pointerId: 0, + pointerId: INTERNAL_POINTER_IDS.CAMERA_MOVE, ctrlKey: this.inputs.ctrlKey, altKey: this.inputs.altKey, shiftKey: this.inputs.shiftKey, @@ -9013,6 +9021,60 @@ export class Editor extends EventEmitter { return this } + /** + * Animate the camera to a user's cursor position. + * This also briefly show the user's cursor if it's not currently visible. + * + * @param userId - The id of the user to aniamte to. + * @public + */ + animateToUser(userId: string) { + const presences = this.store.query.records('instance_presence', () => ({ + userId: { eq: userId }, + })) + + const presence = [...presences.value] + .sort((a, b) => { + return a.lastActivityTimestamp - b.lastActivityTimestamp + }) + .pop() + + if (!presence) return + + this.batch(() => { + // If we're following someone, stop following them + if (this.instanceState.followingUserId !== null) { + this.stopFollowingUser() + } + + // If we're not on the same page, move to the page they're on + const isOnSamePage = presence.currentPageId === this.currentPageId + if (!isOnSamePage) { + this.setCurrentPageId(presence.currentPageId) + } + + // Only animate the camera if the user is on the same page as us + const options = isOnSamePage ? { duration: 500 } : undefined + + const position = presence.cursor + + this.centerOnPoint(position.x, position.y, options) + + // Highlight the user's cursor + const { highlightedUserIds } = this.instanceState + this.updateInstanceState({ highlightedUserIds: [...highlightedUserIds, userId] }) + + // Unhighlight the user's cursor after a few seconds + setTimeout(() => { + const highlightedUserIds = [...this.instanceState.highlightedUserIds] + const index = highlightedUserIds.indexOf(userId) + if (index < 0) return + highlightedUserIds.splice(index, 1) + this.updateInstanceState({ highlightedUserIds }) + }, COLLABORATOR_TIMEOUT) + }) + } + /** * Start viewport-following a user. * @@ -9021,9 +9083,6 @@ export class Editor extends EventEmitter { * @public */ startFollowingUser(userId: string) { - // Currently, we get the leader's viewport page bounds from their user presence. - // This is a placeholder until the ephemeral PR lands. - // After that, we'll be able to get the required data from their instance presence instead. const leaderPresences = this.store.query.records('instance_presence', () => ({ userId: { eq: userId }, })) diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index 1bd61fb38..04bba1081 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -951,6 +951,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> { // (undocumented) followingUserId: null | string; // (undocumented) + highlightedUserIds: string[]; + // (undocumented) isChatting: boolean; // (undocumented) isDebugMode: boolean; diff --git a/packages/tlschema/src/migrations.test.ts b/packages/tlschema/src/migrations.test.ts index edbbfd3d3..0ac137c8e 100644 --- a/packages/tlschema/src/migrations.test.ts +++ b/packages/tlschema/src/migrations.test.ts @@ -1110,6 +1110,18 @@ describe('hoist opacity', () => { }) }) +describe('Adds highlightedUserIds to instance', () => { + const { up, down } = instanceMigrations.migrators[instanceTypeVersions.AddHighlightedUserIds] + + test('up works as expected', () => { + expect(up({})).toEqual({ highlightedUserIds: [] }) + }) + + test('down works as expected', () => { + expect(down({ highlightedUserIds: [] })).toEqual({}) + }) +}) + describe('Adds chat message to presence', () => { const { up, down } = instancePresenceMigrations.migrators[3] diff --git a/packages/tlschema/src/records/TLInstance.ts b/packages/tlschema/src/records/TLInstance.ts index b952619b8..3ec1a5826 100644 --- a/packages/tlschema/src/records/TLInstance.ts +++ b/packages/tlschema/src/records/TLInstance.ts @@ -33,6 +33,7 @@ export type TLInstancePropsForNextShape = Pick export interface TLInstance extends BaseRecord<'instance', TLInstanceId> { currentPageId: TLPageId followingUserId: string | null + highlightedUserIds: string[] brush: Box2dModel | null opacityForNextShape: TLOpacityType propsForNextShape: TLInstancePropsForNextShape @@ -66,6 +67,7 @@ export const instanceTypeValidator: T.Validator = T.model( id: idValidator('instance'), currentPageId: pageIdValidator, followingUserId: T.string.nullable(), + highlightedUserIds: T.arrayOf(T.string), brush: T.boxModel.nullable(), opacityForNextShape: opacityValidator, propsForNextShape: T.object({ @@ -113,13 +115,14 @@ const Versions = { AddIsPenModeAndIsGridMode: 12, HoistOpacity: 13, AddChat: 14, + AddHighlightedUserIds: 15, } as const export { Versions as instanceTypeVersions } /** @public */ export const instanceMigrations = defineMigrations({ - currentVersion: Versions.AddChat, + currentVersion: Versions.AddHighlightedUserIds, migrators: { [Versions.AddTransparentExportBgs]: { up: (instance: TLInstance) => { @@ -296,6 +299,14 @@ export const instanceMigrations = defineMigrations({ return instance }, }, + [Versions.AddHighlightedUserIds]: { + up: (instance: TLInstance) => { + return { ...instance, highlightedUserIds: [] } + }, + down: ({ highlightedUserIds: _, ...instance }: TLInstance) => { + return instance + }, + }, }, }) @@ -307,6 +318,7 @@ export const InstanceRecordType = createRecordType('instance', { }).withDefaultProperties( (): Omit => ({ followingUserId: null, + highlightedUserIds: [], opacityForNextShape: 1, propsForNextShape: { color: 'black',