[improvement] More nuanced cursor state (#1682)

This PR adds some more nuance to collaborator cursors.

Rather than being timed out or not timed out, a collaborator can now be
`active`, `idle` or `inactive`.

We calculate this based on the difference between the time that has
elapsed since the user's last activity timestamp.

After 3 seconds of inactivity, they go `idle`.
After sixty seconds of inactivity, they are `inactive`.
After any activity, they become `active` again.

When a user is `active`, we always show their cursor.
When a user is `idle`, we hide their cursor if they're following us,
unless they're highlighted
When a user is `inactive`, we hide their cursor unless they're
highlighted.

### Change Type

- [x] `minor`

### Test Plan

1. Find a friend and experiment with inactive times
2. Join a room that includes an inactive cursors; they should be hidden
on load
3. Have people follow you; do their timeouts feel natural?

### Release Notes

- Improve cursor timeouts and hiding logic.
This commit is contained in:
Steve Ruiz 2023-06-30 13:46:07 +01:00 committed by GitHub
parent 0e7af29085
commit eb6aa9bbe4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 96 additions and 41 deletions

View file

@ -1,6 +1,11 @@
import { track } from '@tldraw/state' import { track } from '@tldraw/state'
import { TLInstancePresence } from '@tldraw/tlschema'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { COLLABORATOR_CHECK_INTERVAL, COLLABORATOR_TIMEOUT } from '../constants' import {
COLLABORATOR_CHECK_INTERVAL,
COLLABORATOR_IDLE_TIMEOUT,
COLLABORATOR_INACTIVE_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'
@ -11,15 +16,61 @@ export const LiveCollaborators = track(function Collaborators() {
return ( return (
<> <>
{peerIds.map((id) => ( {peerIds.map((id) => (
<Collaborator key={id} userId={id} /> <CollaboratorGuard key={id} collaboratorId={id} />
))} ))}
</> </>
) )
}) })
const Collaborator = track(function Collaborator({ userId }: { userId: string }) { const CollaboratorGuard = track(function CollaboratorGuard({
collaboratorId,
}: {
collaboratorId: string
}) {
const editor = useEditor()
const presence = usePresence(collaboratorId)
const collaboratorState = useCollaboratorState(presence)
if (!(presence && presence.currentPageId === editor.currentPageId)) {
// No need to render if we don't have a presence or if they're on a different page
return null
}
switch (collaboratorState) {
case 'inactive': {
const { followingUserId, highlightedUserIds } = editor.instanceState
// If they're inactive and unless we're following them or they're highlighted, hide them
if (!(followingUserId === presence.userId || highlightedUserIds.includes(presence.userId))) {
return null
}
break
}
case 'idle': {
const { highlightedUserIds } = editor.instanceState
// If they're idle and following us and unless they have a chat message or are highlighted, hide them
if (
presence.followingUserId === editor.user.id &&
!(presence.chatMessage || highlightedUserIds.includes(presence.userId))
) {
return null
}
break
}
case 'active': {
// If they're active, show them
break
}
}
return <Collaborator latestPresence={presence} />
})
const Collaborator = track(function Collaborator({
latestPresence,
}: {
latestPresence: TLInstancePresence
}) {
const editor = useEditor() const editor = useEditor()
const { viewportPageBounds, zoomLevel } = editor
const { const {
CollaboratorBrush, CollaboratorBrush,
@ -29,40 +80,9 @@ const Collaborator = track(function Collaborator({ userId }: { userId: string })
CollaboratorShapeIndicator, CollaboratorShapeIndicator,
} = useEditorComponents() } = useEditorComponents()
const latestPresence = usePresence(userId) const { viewportPageBounds, zoomLevel } = editor
const { userId, chatMessage, brush, scribble, selectedIds, userName, cursor, color } =
const [isTimedOut, setIsTimedOut] = useState(false) latestPresence
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 &&
!latestPresence.chatMessage &&
!editor.instanceState.highlightedUserIds.includes(userId)
)
return null
// if the collaborator is on another page, ignore them
if (latestPresence.currentPageId !== editor.currentPageId) return null
const { brush, scribble, selectedIds, userName, cursor, color, chatMessage } = latestPresence
// Add a little padding to the top-left of the viewport // Add a little padding to the top-left of the viewport
// so that the cursor doesn't get cut off // so that the cursor doesn't get cut off
@ -127,3 +147,35 @@ const Collaborator = track(function Collaborator({ userId }: { userId: string })
</> </>
) )
}) })
function getStateFromElapsedTime(elapsed: number) {
return elapsed > COLLABORATOR_INACTIVE_TIMEOUT
? 'inactive'
: elapsed > COLLABORATOR_IDLE_TIMEOUT
? 'idle'
: 'active'
}
function useCollaboratorState(latestPresence: TLInstancePresence | null) {
const rLastActivityTimestamp = useRef(latestPresence?.lastActivityTimestamp ?? -1)
const [state, setState] = useState<'active' | 'idle' | 'inactive'>(() =>
getStateFromElapsedTime(Date.now() - rLastActivityTimestamp.current)
)
useEffect(() => {
const interval = setInterval(() => {
setState(getStateFromElapsedTime(Date.now() - rLastActivityTimestamp.current))
}, COLLABORATOR_CHECK_INTERVAL)
return () => clearInterval(interval)
}, [])
if (latestPresence) {
// We can do this on every render, it's free and cheaper than an effect
// remember, there can be lots and lots of cursors moving around all the time
rLastActivityTimestamp.current = latestPresence.lastActivityTimestamp
}
return state
}

View file

@ -95,7 +95,10 @@ export const GRID_STEPS = [
] ]
/** @internal */ /** @internal */
export const COLLABORATOR_TIMEOUT = 3000 export const COLLABORATOR_INACTIVE_TIMEOUT = 60000
/** @internal */
export const COLLABORATOR_IDLE_TIMEOUT = 3000
/** @internal */ /** @internal */
export const COLLABORATOR_CHECK_INTERVAL = 1200 export const COLLABORATOR_CHECK_INTERVAL = 1200

View file

@ -87,7 +87,7 @@ import {
CAMERA_MAX_RENDERING_INTERVAL, CAMERA_MAX_RENDERING_INTERVAL,
CAMERA_MOVING_TIMEOUT, CAMERA_MOVING_TIMEOUT,
COARSE_DRAG_DISTANCE, COARSE_DRAG_DISTANCE,
COLLABORATOR_TIMEOUT, COLLABORATOR_IDLE_TIMEOUT,
DEFAULT_ANIMATION_OPTIONS, DEFAULT_ANIMATION_OPTIONS,
DRAG_DISTANCE, DRAG_DISTANCE,
FOLLOW_CHASE_PAN_SNAP, FOLLOW_CHASE_PAN_SNAP,
@ -3150,7 +3150,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (index < 0) return if (index < 0) return
highlightedUserIds.splice(index, 1) highlightedUserIds.splice(index, 1)
this.updateInstanceState({ highlightedUserIds }) this.updateInstanceState({ highlightedUserIds })
}, COLLABORATOR_TIMEOUT) }, COLLABORATOR_IDLE_TIMEOUT)
}) })
} }