[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:
parent
0e7af29085
commit
eb6aa9bbe4
3 changed files with 96 additions and 41 deletions
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue