(1/2) Timeout collaborator cursors (#1525)

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 <steveruizok@gmail.com>
This commit is contained in:
Lu Wilson 2023-06-15 16:48:47 +01:00 committed by GitHub
parent 6d4d9424df
commit f864d0cfbd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 139 additions and 11 deletions

View file

@ -375,6 +375,7 @@ export class Editor extends EventEmitter<TLEventMap> {
ease?: (t: number) => number; ease?: (t: number) => number;
}): this; }): this;
animateToShape(shapeId: TLShapeId, opts?: TLAnimationOptions): this; animateToShape(shapeId: TLShapeId, opts?: TLAnimationOptions): this;
animateToUser(userId: string): void;
get animationSpeed(): number; get animationSpeed(): number;
// @internal (undocumented) // @internal (undocumented)
annotateError(error: unknown, { origin, willCrashApp, tags, extras, }: { annotateError(error: unknown, { origin, willCrashApp, tags, extras, }: {

View file

@ -1,4 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { track } from 'signia-react' import { track } from 'signia-react'
import { COLLABORATOR_CHECK_INTERVAL, COLLABORATOR_TIMEOUT } from '../constants'
import { useEditor } from '../hooks/useEditor' import { useEditor } from '../hooks/useEditor'
import { useEditorComponents } from '../hooks/useEditorComponents' import { useEditorComponents } from '../hooks/useEditorComponents'
import { usePeerIds } from '../hooks/usePeerIds' import { usePeerIds } from '../hooks/usePeerIds'
@ -28,8 +30,34 @@ const Collaborator = track(function Collaborator({ userId }: { userId: string })
} = useEditorComponents() } = useEditorComponents()
const latestPresence = usePresence(userId) 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 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 the collaborator is on another page, ignore them
if (latestPresence.currentPageId !== editor.currentPageId) return null if (latestPresence.currentPageId !== editor.currentPageId) return null

View file

@ -273,3 +273,17 @@ export const BLACKLISTED_PROPS = new Set([
'url', 'url',
'growY', '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

View file

@ -86,6 +86,7 @@ import {
ANIMATION_MEDIUM_MS, ANIMATION_MEDIUM_MS,
BLACKLISTED_PROPS, BLACKLISTED_PROPS,
COARSE_DRAG_DISTANCE, COARSE_DRAG_DISTANCE,
COLLABORATOR_TIMEOUT,
DEFAULT_ANIMATION_OPTIONS, DEFAULT_ANIMATION_OPTIONS,
DRAG_DISTANCE, DRAG_DISTANCE,
FOLLOW_CHASE_PAN_SNAP, FOLLOW_CHASE_PAN_SNAP,
@ -95,6 +96,7 @@ import {
FOLLOW_CHASE_ZOOM_UNSNAP, FOLLOW_CHASE_ZOOM_UNSNAP,
GRID_INCREMENT, GRID_INCREMENT,
HAND_TOOL_FRICTION, HAND_TOOL_FRICTION,
INTERNAL_POINTER_IDS,
MAJOR_NUDGE_FACTOR, MAJOR_NUDGE_FACTOR,
MAX_PAGES, MAX_PAGES,
MAX_SHAPES_PER_PAGE, MAX_SHAPES_PER_PAGE,
@ -3802,17 +3804,18 @@ export class Editor extends EventEmitter<TLEventMap> {
previousScreenPoint.setTo(currentScreenPoint) previousScreenPoint.setTo(currentScreenPoint)
previousPagePoint.setTo(currentPagePoint) previousPagePoint.setTo(currentPagePoint)
const px = (sx - screenBounds.x) / cz - cx
const py = (sy - screenBounds.y) / cz - cy
currentScreenPoint.set(sx, sy) 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 this.inputs.isPen = info.type === 'pointer' && info.isPen
// Reset velocity on pointer down // Reset velocity on pointer down
if (info.name === '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 // todo: We only have to do this if there are multiple users in the document
@ -3822,7 +3825,12 @@ export class Editor extends EventEmitter<TLEventMap> {
typeName: 'pointer', typeName: 'pointer',
x: currentPagePoint.x, x: currentPagePoint.x,
y: currentPagePoint.y, 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<TLEventMap> {
target: 'canvas', target: 'canvas',
name: 'pointer_move', name: 'pointer_move',
point: currentScreenPoint, point: currentScreenPoint,
pointerId: 0, pointerId: INTERNAL_POINTER_IDS.CAMERA_MOVE,
ctrlKey: this.inputs.ctrlKey, ctrlKey: this.inputs.ctrlKey,
altKey: this.inputs.altKey, altKey: this.inputs.altKey,
shiftKey: this.inputs.shiftKey, shiftKey: this.inputs.shiftKey,
@ -9013,6 +9021,60 @@ export class Editor extends EventEmitter<TLEventMap> {
return this 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. * Start viewport-following a user.
* *
@ -9021,9 +9083,6 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
startFollowingUser(userId: string) { 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', () => ({ const leaderPresences = this.store.query.records('instance_presence', () => ({
userId: { eq: userId }, userId: { eq: userId },
})) }))

View file

@ -951,6 +951,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented) // (undocumented)
followingUserId: null | string; followingUserId: null | string;
// (undocumented) // (undocumented)
highlightedUserIds: string[];
// (undocumented)
isChatting: boolean; isChatting: boolean;
// (undocumented) // (undocumented)
isDebugMode: boolean; isDebugMode: boolean;

View file

@ -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', () => { describe('Adds chat message to presence', () => {
const { up, down } = instancePresenceMigrations.migrators[3] const { up, down } = instancePresenceMigrations.migrators[3]

View file

@ -33,6 +33,7 @@ export type TLInstancePropsForNextShape = Pick<TLShapeProps, TLStyleType>
export interface TLInstance extends BaseRecord<'instance', TLInstanceId> { export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
currentPageId: TLPageId currentPageId: TLPageId
followingUserId: string | null followingUserId: string | null
highlightedUserIds: string[]
brush: Box2dModel | null brush: Box2dModel | null
opacityForNextShape: TLOpacityType opacityForNextShape: TLOpacityType
propsForNextShape: TLInstancePropsForNextShape propsForNextShape: TLInstancePropsForNextShape
@ -66,6 +67,7 @@ export const instanceTypeValidator: T.Validator<TLInstance> = T.model(
id: idValidator<TLInstanceId>('instance'), id: idValidator<TLInstanceId>('instance'),
currentPageId: pageIdValidator, currentPageId: pageIdValidator,
followingUserId: T.string.nullable(), followingUserId: T.string.nullable(),
highlightedUserIds: T.arrayOf(T.string),
brush: T.boxModel.nullable(), brush: T.boxModel.nullable(),
opacityForNextShape: opacityValidator, opacityForNextShape: opacityValidator,
propsForNextShape: T.object({ propsForNextShape: T.object({
@ -113,13 +115,14 @@ const Versions = {
AddIsPenModeAndIsGridMode: 12, AddIsPenModeAndIsGridMode: 12,
HoistOpacity: 13, HoistOpacity: 13,
AddChat: 14, AddChat: 14,
AddHighlightedUserIds: 15,
} as const } as const
export { Versions as instanceTypeVersions } export { Versions as instanceTypeVersions }
/** @public */ /** @public */
export const instanceMigrations = defineMigrations({ export const instanceMigrations = defineMigrations({
currentVersion: Versions.AddChat, currentVersion: Versions.AddHighlightedUserIds,
migrators: { migrators: {
[Versions.AddTransparentExportBgs]: { [Versions.AddTransparentExportBgs]: {
up: (instance: TLInstance) => { up: (instance: TLInstance) => {
@ -296,6 +299,14 @@ export const instanceMigrations = defineMigrations({
return instance return instance
}, },
}, },
[Versions.AddHighlightedUserIds]: {
up: (instance: TLInstance) => {
return { ...instance, highlightedUserIds: [] }
},
down: ({ highlightedUserIds: _, ...instance }: TLInstance) => {
return instance
},
},
}, },
}) })
@ -307,6 +318,7 @@ export const InstanceRecordType = createRecordType<TLInstance>('instance', {
}).withDefaultProperties( }).withDefaultProperties(
(): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({ (): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({
followingUserId: null, followingUserId: null,
highlightedUserIds: [],
opacityForNextShape: 1, opacityForNextShape: 1,
propsForNextShape: { propsForNextShape: {
color: 'black', color: 'black',