(1/2) Cursor Chat - Presence (#1487)

This PR adds support for seeing **another user**'s chat messages.

It's part 1 of two PRs relating to Cursor Chat.
And it's needed for the much bigger part 2:
https://github.com/tldraw/brivate/pull/1981

# Presence

You can see another person's chat messages!

![2023-06-02 at 17 42 33 - Blush
Capybara](https://github.com/tldraw/tldraw/assets/15892272/8f3efb5f-9c05-459c-aa7e-24842be75e58)

If they have a name, it gets popped on top.

![2023-06-02 at 17 45 34 - Sapphire
Meerkat](https://github.com/tldraw/tldraw/assets/15892272/749bd924-c1f5-419b-a028-1fafe1b61292)

That's it!
With this PR, there's no way of actually *typing* your chat messages.
That comes with the [next
one](https://github.com/tldraw/brivate/pull/1981)!

# Admin

### To-do

- [x] Store chat message
- [x] Allow overflowing chat
- [x] Presence for chat message
- [x] Display chat message to others

### Change Type

- [x] `minor` — New Feature

### Test Plan

To test this, I recommend checking out both `lu/cursor-chat` branches,
and opening two browser sessions in the same shared project.

1. In one session, type some cursor chat by pressing the Enter key while
on the canvas (and typing).
2. On the other session, check that you can see the chat message appear.
3. Repeat this while being both named, and unnamed.

I recommend just focusing on the visible presense in this PR.
The [other PR](https://github.com/tldraw/brivate/pull/1981) is where we
can focus about how we _input_ the cursor chat.

### Release Notes

- [dev] Added support for cursor chat presence.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Lu Wilson 2023-06-15 16:10:08 +01:00 committed by GitHub
parent 21377c0f22
commit 3bbb34eba8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 238 additions and 39 deletions

View file

@ -1,15 +1,16 @@
/* eslint-disable no-inner-declarations */
import { InstancePresenceRecordType, Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/editor.css'
import '@tldraw/tldraw/ui.css'
import { useRef } from 'react'
const SHOW_MOVING_CURSOR = true
const CURSOR_SPEED = 0.5
const CIRCLE_RADIUS = 100
const UPDATE_FPS = 60
const USER_NAME = 'huppy da arrow'
const MOVING_CURSOR_SPEED = 0.25 // 0 is stopped, 1 is full send
const MOVING_CURSOR_RADIUS = 100
const CURSOR_CHAT_MESSAGE = 'Hey, I think this is just great.'
export default function UserPresenceExample() {
const rTimeout = useRef<any>(-1)
const rRaf = useRef<any>(-1)
return (
<div className="tldraw__editor">
<Tldraw
@ -22,39 +23,62 @@ export default function UserPresenceExample() {
id: InstancePresenceRecordType.createId(editor.store.id),
currentPageId: editor.currentPageId,
userId: 'peer-1',
userName: 'Peer 1',
userName: USER_NAME,
cursor: { x: 0, y: 0, type: 'default', rotation: 0 },
chatMessage: CURSOR_CHAT_MESSAGE,
})
editor.store.put([peerPresence])
// Make the fake user's cursor rotate in a circle
if (rTimeout.current) {
clearTimeout(rTimeout.current)
}
const raf = rRaf.current
cancelAnimationFrame(raf)
if (MOVING_CURSOR_SPEED > 0 || CURSOR_CHAT_MESSAGE) {
function loop() {
let cursor = peerPresence.cursor
let chatMessage = peerPresence.chatMessage
if (SHOW_MOVING_CURSOR) {
rTimeout.current = setInterval(() => {
const k = 1000 / CURSOR_SPEED
const now = Date.now()
const t = (now % k) / k
// rotate in a circle
if (MOVING_CURSOR_SPEED > 0) {
const k = 1000 / MOVING_CURSOR_SPEED
const t = (now % k) / k
cursor = {
...peerPresence.cursor,
x: 150 + Math.cos(t * Math.PI * 2) * MOVING_CURSOR_RADIUS,
y: 150 + Math.sin(t * Math.PI * 2) * MOVING_CURSOR_RADIUS,
}
}
if (CURSOR_CHAT_MESSAGE) {
const k = 1000
const t = (now % (k * 3)) / k
chatMessage =
t < 1
? ''
: t > 2
? CURSOR_CHAT_MESSAGE
: CURSOR_CHAT_MESSAGE.slice(0, Math.ceil((t - 1) * CURSOR_CHAT_MESSAGE.length))
}
editor.store.put([
{
...peerPresence,
cursor: {
...peerPresence.cursor,
x: 150 + Math.cos(t * Math.PI * 2) * CIRCLE_RADIUS,
y: 150 + Math.sin(t * Math.PI * 2) * CIRCLE_RADIUS,
},
cursor,
chatMessage,
lastActivityTimestamp: now,
},
])
}, 1000 / UPDATE_FPS)
rRaf.current = requestAnimationFrame(loop)
}
rRaf.current = requestAnimationFrame(loop)
} else {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
rTimeout.current = setInterval(() => {
rRaf.current = setInterval(() => {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
}, 1000)
}

View file

@ -46,6 +46,7 @@
"action.leave-shared-project": "Leave shared project",
"action.new-project": "New project",
"action.new-shared-project": "New shared project",
"action.open-cursor-chat": "Cursor chat",
"action.open-file": "Open file",
"action.pack": "Pack",
"action.paste": "Paste",
@ -278,6 +279,7 @@
"shortcuts-dialog.tools": "Tools",
"shortcuts-dialog.transform": "Transform",
"shortcuts-dialog.view": "View",
"shortcuts-dialog.collaboration": "Collaboration",
"home-project-dialog.title": "Home project",
"home-project-dialog.description": "This is your local home project. It's just for you!",
"rename-project-dialog.title": "Rename project",
@ -339,5 +341,6 @@
"vscode.file-open.backup": "Backup",
"vscode.file-open.backup-saved": "Backup saved",
"vscode.file-open.backup-failed": "Backup failed: this is not a .tldr file.",
"vscode.file-open.dont-show-again": "Don't ask again"
"vscode.file-open.dont-show-again": "Don't ask again",
"cursor-chat.type-to-chat": "Type to chat..."
}

View file

@ -62,6 +62,27 @@ https://alex.dytry.ch/toys/palette/?palette=%7B%22families%22:%5B%22black%22,%22
/* These cursor values get programmatically overridden */
/* They're just here to help your editor autocomplete */
--tl-cursor: var(--tl-default-svg);
--tl-cursor-none: none;
--tl-cursor-default: default;
--tl-cursor-pointer: pointer;
--tl-cursor-cross: crosshair;
--tl-cursor-move: move;
--tl-cursor-grab: grab;
--tl-cursor-grabbing: grabbing;
--tl-cursor-text: text;
--tl-cursor-resize-edge: ew-resize;
--tl-cursor-resize-corner: nesw-resize;
--tl-cursor-ew-resize: ew-resize;
--tl-cursor-ns-resize: ns-resize;
--tl-cursor-nesw-resize: nesw-resize;
--tl-cursor-nwse-resize: nwse-resize;
--tl-cursor-rotate: pointer;
--tl-cursor-nwse-rotate: pointer;
--tl-cursor-nesw-rotate: pointer;
--tl-cursor-senw-rotate: pointer;
--tl-cursor-swne-rotate: pointer;
--tl-cursor-zoom-in: zoom-in;
--tl-cursor-zoom-out: zoom-out;
--tl-scale: calc(1 / var(--tl-zoom));
--tl-font-draw: 'tldraw_draw', sans-serif;
--tl-font-sans: 'tldraw_sans', sans-serif;
@ -746,7 +767,6 @@ input,
position: absolute;
}
/* Rounded corners */
.tl-nametag {
position: absolute;
top: 16px;
@ -754,15 +774,84 @@ input,
width: fit-content;
height: fit-content;
max-width: 120px;
color: var(--color-selected-contrast);
padding: 3px 6px;
white-space: nowrap;
position: absolute;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 10px;
padding: 2px 6px;
font-size: 12px;
font-family: var(--font-family);
border-radius: var(--radius-2);
color: var(--color-selected-contrast);
}
.tl-nametag-title {
position: absolute;
top: -2px;
left: 13px;
width: fit-content;
height: fit-content;
padding: 0px 6px;
max-width: 120px;
white-space: nowrap;
position: absolute;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-family: var(--font-family);
text-shadow: var(--tl-text-outline);
color: var(--color-selected-contrast);
}
.tl-nametag-chat {
position: absolute;
top: 16px;
left: 13px;
width: fit-content;
height: fit-content;
color: var(--color-selected-contrast);
white-space: nowrap;
position: absolute;
padding: 3px 6px;
font-size: 12px;
font-family: var(--font-family);
opacity: 1;
border-radius: var(--radius-2);
}
.tl-cursor-chat {
position: absolute;
color: var(--color-selected-contrast);
white-space: nowrap;
padding: 3px 6px;
font-size: 12px;
font-family: var(--font-family);
pointer-events: none;
z-index: var(--layer-cursor);
margin-top: 16px;
margin-left: 13px;
opacity: 1;
border: none;
user-select: text;
border-radius: var(--radius-2);
}
.tl-cursor-chat::selection {
background: var(--color-selected);
color: var(--color-selected-contrast);
text-shadow: none;
}
.tl-cursor-chat-fade {
/* Setting to zero causes it to immediately disappear */
/* Setting to near-zero causes it to fade out gradually */
opacity: 0.0001;
transition: opacity 5s ease-in-out;
}
.tl-cursor-chat::placeholder {
color: var(--color-selected-contrast);
opacity: 0.7;
}
/* -------------------------------------------------- */

View file

@ -10,23 +10,37 @@ export type TLCursorComponent = (props: {
zoom: number
color?: string
name: string | null
chatMessage: string
}) => any | null
const _Cursor: TLCursorComponent = ({ className, zoom, point, color, name }) => {
const rDiv = useRef<HTMLDivElement>(null)
useTransform(rDiv, point?.x, point?.y, 1 / zoom)
const _Cursor: TLCursorComponent = ({ className, zoom, point, color, name, chatMessage }) => {
const rCursor = useRef<HTMLDivElement>(null)
useTransform(rCursor, point?.x, point?.y, 1 / zoom)
if (!point) return null
return (
<div ref={rDiv} className={classNames('tl-overlays__item', className)}>
<div ref={rCursor} className={classNames('tl-overlays__item', className)}>
<svg className="tl-cursor">
<use href="#cursor" color={color} />
</svg>
{name !== null && name !== '' && (
<div className="tl-nametag" style={{ backgroundColor: color }}>
{name}
</div>
{chatMessage ? (
<>
{name && (
<div className="tl-nametag-title" style={{ color }}>
{name}
</div>
)}
<div className="tl-nametag-chat" style={{ backgroundColor: color }}>
{chatMessage}
</div>
</>
) : (
name && (
<div className="tl-nametag" style={{ backgroundColor: color }}>
{name}
</div>
)
)}
</div>
)

View file

@ -33,7 +33,7 @@ const Collaborator = track(function Collaborator({ userId }: { userId: string })
// if the collaborator is on another page, ignore them
if (latestPresence.currentPageId !== editor.currentPageId) return null
const { brush, scribble, selectedIds, userName, cursor, color } = latestPresence
const { brush, scribble, selectedIds, userName, cursor, color, chatMessage } = latestPresence
// Add a little padding to the top-left of the viewport
// so that the cursor doesn't get cut off
@ -63,6 +63,7 @@ const Collaborator = track(function Collaborator({ userId }: { userId: string })
color={color}
zoom={zoomLevel}
name={userName !== 'New User' ? userName : null}
chatMessage={chatMessage}
/>
) : CollaboratorHint ? (
<CollaboratorHint

View file

@ -941,6 +941,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented)
brush: Box2dModel | null;
// (undocumented)
chatMessage: string;
// (undocumented)
currentPageId: TLPageId;
// (undocumented)
cursor: TLCursor;
@ -949,6 +951,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented)
followingUserId: null | string;
// (undocumented)
isChatting: boolean;
// (undocumented)
isDebugMode: boolean;
// (undocumented)
isFocusMode: boolean;
@ -1007,6 +1011,8 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
z: number;
};
// (undocumented)
chatMessage: string;
// (undocumented)
color: string;
// (undocumented)
currentPageId: TLPageId;

View file

@ -43,6 +43,7 @@ export const createPresenceStateDerivation =
},
lastActivityTimestamp: pointer.lastActivityTimestamp,
screenBounds: instance.screenBounds,
chatMessage: instance.chatMessage,
})
})
}

View file

@ -1110,6 +1110,30 @@ describe('hoist opacity', () => {
})
})
describe('Adds chat message to presence', () => {
const { up, down } = instancePresenceMigrations.migrators[3]
test('up adds the chatMessage property', () => {
expect(up({})).toEqual({ chatMessage: '' })
})
test('down removes the chatMessage property', () => {
expect(down({ chatMessage: '' })).toEqual({})
})
})
describe('Adds chat properties to instance', () => {
const { up, down } = instanceMigrations.migrators[14]
test('up adds the chatMessage property', () => {
expect(up({})).toEqual({ chatMessage: '', isChatting: false })
})
test('down removes the chatMessage property', () => {
expect(down({ chatMessage: '', isChatting: true })).toEqual({})
})
})
describe('Removes does resize from embed', () => {
const { up, down } = embedShapeMigrations.migrators[2]
test('up works as expected', () => {

View file

@ -44,6 +44,10 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
exportBackground: boolean
screenBounds: Box2dModel
zoomBrush: Box2dModel | null
chatMessage: string
isChatting: boolean
isPenMode: boolean
isGridMode: boolean
}
@ -87,6 +91,8 @@ export const instanceTypeValidator: T.Validator<TLInstance> = T.model(
exportBackground: T.boolean,
screenBounds: T.boxModel,
zoomBrush: T.boxModel.nullable(),
chatMessage: T.string,
isChatting: T.boolean,
isPenMode: T.boolean,
isGridMode: T.boolean,
})
@ -106,13 +112,14 @@ const Versions = {
RemoveUserId: 11,
AddIsPenModeAndIsGridMode: 12,
HoistOpacity: 13,
AddChat: 14,
} as const
export { Versions as instanceTypeVersions }
/** @public */
export const instanceMigrations = defineMigrations({
currentVersion: Versions.HoistOpacity,
currentVersion: Versions.AddChat,
migrators: {
[Versions.AddTransparentExportBgs]: {
up: (instance: TLInstance) => {
@ -281,6 +288,14 @@ export const instanceMigrations = defineMigrations({
}
},
},
[Versions.AddChat]: {
up: (instance: TLInstance) => {
return { ...instance, chatMessage: '', isChatting: false }
},
down: ({ chatMessage: _, isChatting: __, ...instance }: TLInstance) => {
return instance
},
},
},
})
@ -321,6 +336,8 @@ export const InstanceRecordType = createRecordType<TLInstance>('instance', {
isToolLocked: false,
screenBounds: { x: 0, y: 0, w: 1080, h: 720 },
zoomBrush: null,
chatMessage: '',
isChatting: false,
isGridMode: false,
isPenMode: false,
})

View file

@ -27,6 +27,7 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
type: TLCursor['type']
rotation: number
}
chatMessage: string
}
/** @public */
@ -59,18 +60,20 @@ export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.mode
currentPageId: idValidator<TLPageId>('page'),
brush: T.boxModel.nullable(),
scribble: scribbleValidator.nullable(),
chatMessage: T.string,
})
)
const Versions = {
AddScribbleDelay: 1,
RemoveInstanceId: 2,
AddChatMessage: 3,
} as const
export { Versions as instancePresenceVersions }
export const instancePresenceMigrations = defineMigrations({
currentVersion: Versions.RemoveInstanceId,
currentVersion: Versions.AddChatMessage,
migrators: {
[Versions.AddScribbleDelay]: {
up: (instance) => {
@ -95,6 +98,14 @@ export const instancePresenceMigrations = defineMigrations({
return { ...instance, instanceId: TLINSTANCE_ID }
},
},
[Versions.AddChatMessage]: {
up: (instance) => {
return { ...instance, chatMessage: '' }
},
down: ({ chatMessage: _, ...instance }) => {
return instance
},
},
},
})
@ -130,4 +141,5 @@ export const InstancePresenceRecordType = createRecordType<TLInstancePresence>(
selectedIds: [],
brush: null,
scribble: null,
chatMessage: '',
}))

File diff suppressed because one or more lines are too long

View file

@ -81,6 +81,7 @@ export interface TLUiEventMap {
'toggle-reduce-motion': null
'exit-pen-mode': null
'stop-following': null
'open-cursor-chat': null
}
type Join<T, K> = K extends null

View file

@ -50,6 +50,7 @@ export type TLUiTranslationKey =
| 'action.leave-shared-project'
| 'action.new-project'
| 'action.new-shared-project'
| 'action.open-cursor-chat'
| 'action.open-file'
| 'action.pack'
| 'action.paste'
@ -282,6 +283,7 @@ export type TLUiTranslationKey =
| 'shortcuts-dialog.tools'
| 'shortcuts-dialog.transform'
| 'shortcuts-dialog.view'
| 'shortcuts-dialog.collaboration'
| 'home-project-dialog.title'
| 'home-project-dialog.description'
| 'rename-project-dialog.title'
@ -344,3 +346,4 @@ export type TLUiTranslationKey =
| 'vscode.file-open.backup-saved'
| 'vscode.file-open.backup-failed'
| 'vscode.file-open.dont-show-again'
| 'cursor-chat.type-to-chat'

View file

@ -50,6 +50,7 @@ export const DEFAULT_TRANSLATION = {
'action.leave-shared-project': 'Leave shared project',
'action.new-project': 'New project',
'action.new-shared-project': 'New shared project',
'action.open-cursor-chat': 'Cursor chat',
'action.open-file': 'Open file',
'action.pack': 'Pack',
'action.paste': 'Paste',
@ -285,6 +286,7 @@ export const DEFAULT_TRANSLATION = {
'shortcuts-dialog.tools': 'Tools',
'shortcuts-dialog.transform': 'Transform',
'shortcuts-dialog.view': 'View',
'shortcuts-dialog.collaboration': 'Collaboration',
'home-project-dialog.title': 'Home project',
'home-project-dialog.description': "This is your local home project. It's just for you!",
'rename-project-dialog.title': 'Rename project',
@ -354,4 +356,5 @@ export const DEFAULT_TRANSLATION = {
'vscode.file-open.backup-saved': 'Backup saved',
'vscode.file-open.backup-failed': 'Backup failed: this is not a .tldr file.',
'vscode.file-open.dont-show-again': "Don't ask again",
'cursor-chat.type-to-chat': 'Type to chat...',
}

View file

@ -3,6 +3,7 @@
--layer-menus: 400;
--layer-overlays: 500;
--layer-toasts: 650;
--layer-cursor: 700;
}
/* --------------------- Layout --------------------- */