(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:
parent
6d4d9424df
commit
f864d0cfbd
7 changed files with 139 additions and 11 deletions
|
@ -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, }: {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 },
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in a new issue