feat(live-cursor): live cursor during collaboration (#1775)

* feat(live-cursor): collaborative cursor

* fix user avatar url rendering

* simplify presence

* fix env ts

* fix lint

* fix type mismatch
This commit is contained in:
Vikhyath Mondreti
2025-10-30 19:47:11 -07:00
committed by GitHub
parent a072e6d1d8
commit eac358bc7c
14 changed files with 342 additions and 80 deletions

View File

@@ -1,70 +1,30 @@
'use client'
import { type CSSProperties, useMemo } from 'react'
import Image from 'next/image'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { getPresenceColors } from '@/lib/collaboration/presence-colors'
interface AvatarProps {
connectionId: string | number
name?: string
color?: string
avatarUrl?: string | null
tooltipContent?: React.ReactNode | null
size?: 'sm' | 'md' | 'lg'
index?: number // Position in stack for z-index
}
// Color palette inspired by the app's design
const APP_COLORS = [
{ from: '#4F46E5', to: '#7C3AED' }, // indigo to purple
{ from: '#7C3AED', to: '#C026D3' }, // purple to fuchsia
{ from: '#EC4899', to: '#F97316' }, // pink to orange
{ from: '#14B8A6', to: '#10B981' }, // teal to emerald
{ from: '#6366F1', to: '#8B5CF6' }, // indigo to violet
{ from: '#F59E0B', to: '#F97316' }, // amber to orange
]
/**
* Generate a deterministic gradient based on a connection ID
*/
function generateGradient(connectionId: string | number): string {
// Convert connectionId to a number for consistent hashing
const numericId =
typeof connectionId === 'string'
? Math.abs(connectionId.split('').reduce((a, b) => a + b.charCodeAt(0), 0))
: connectionId
// Use the numeric ID to select a color pair from our palette
const colorPair = APP_COLORS[numericId % APP_COLORS.length]
// Add a slight rotation to the gradient based on connection ID for variety
const rotation = (numericId * 25) % 360
return `linear-gradient(${rotation}deg, ${colorPair.from}, ${colorPair.to})`
}
export function UserAvatar({
connectionId,
name,
color,
avatarUrl,
tooltipContent,
size = 'md',
index = 0,
}: AvatarProps) {
// Generate a deterministic gradient for this user based on connection ID
// Or use the provided color if available
const backgroundStyle = useMemo(() => {
if (color) {
// If a color is provided, create a gradient with it
const baseColor = color
const lighterShade = color.startsWith('#')
? `${color}dd` // Add transparency for a lighter shade effect
: color
const darkerShade = color.startsWith('#') ? color : color
return `linear-gradient(135deg, ${lighterShade}, ${darkerShade})`
}
// Otherwise, generate a gradient based on connectionId
return generateGradient(connectionId)
}, [connectionId, color])
const { gradient } = useMemo(() => getPresenceColors(connectionId, color), [connectionId, color])
// Determine avatar size
const sizeClass = {
@@ -73,20 +33,39 @@ export function UserAvatar({
lg: 'h-9 w-9 text-sm',
}[size]
const pixelSize = {
sm: 20,
md: 28,
lg: 36,
}[size]
const initials = name ? name.charAt(0).toUpperCase() : '?'
const hasAvatar = Boolean(avatarUrl)
const avatarElement = (
<div
className={`
${sizeClass} flex flex-shrink-0 cursor-default items-center justify-center rounded-full border-2 border-white font-semibold text-white shadow-sm `}
${sizeClass} relative flex flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full border-2 border-white font-semibold text-white shadow-sm `}
style={
{
background: backgroundStyle,
background: hasAvatar ? undefined : gradient,
zIndex: 10 - index, // Higher index = lower z-index for stacking effect
} as CSSProperties
}
>
{initials}
{hasAvatar && avatarUrl ? (
<Image
src={avatarUrl}
alt={name ? `${name}'s avatar` : 'User avatar'}
fill
sizes={`${pixelSize}px`}
className='object-cover'
referrerPolicy='no-referrer'
unoptimized={avatarUrl.startsWith('http')}
/>
) : (
initials
)}
</div>
)

View File

@@ -1,6 +1,7 @@
'use client'
import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { ConnectionStatus } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/connection-status/connection-status'
import { UserAvatar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar'
import { usePresence } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence'
@@ -11,6 +12,7 @@ interface User {
name?: string
color?: string
info?: string
avatarUrl?: string | null
}
interface UserAvatarStackProps {
@@ -55,21 +57,19 @@ export function UserAvatarStack({
lg: '-space-x-2',
}[size]
return (
<div className={`flex items-center gap-3 ${className}`}>
{/* Connection status - always check, shows when offline or operation errors */}
<ConnectionStatus isConnected={isConnected} hasOperationError={hasOperationError} />
const shouldShowAvatars = visibleUsers.length > 0
{/* Only show avatar stack when there are multiple users (>1) */}
{users.length > 1 && (
<div className={`flex items-center ${spacingClass}`}>
{/* Render visible user avatars */}
return (
<div className={`flex flex-col items-start gap-2 ${className}`}>
{shouldShowAvatars && (
<div className={cn('flex items-center px-2 py-1', spacingClass)}>
{visibleUsers.map((user, index) => (
<UserAvatar
key={user.connectionId}
connectionId={user.connectionId}
name={user.name}
color={user.color}
avatarUrl={user.avatarUrl}
size={size}
index={index}
tooltipContent={
@@ -85,10 +85,9 @@ export function UserAvatarStack({
/>
))}
{/* Render overflow indicator if there are more users */}
{overflowCount > 0 && (
<UserAvatar
connectionId='overflow-indicator' // Use a unique string identifier
connectionId='overflow-indicator'
name={`+${overflowCount}`}
size={size}
index={visibleUsers.length}
@@ -106,6 +105,8 @@ export function UserAvatarStack({
)}
</div>
)}
<ConnectionStatus isConnected={isConnected} hasOperationError={hasOperationError} />
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { memo, useMemo } from 'react'
import { useViewport } from 'reactflow'
import { useSession } from '@/lib/auth-client'
import { getPresenceColors } from '@/lib/collaboration/presence-colors'
import { useSocket } from '@/contexts/socket-context'
interface CursorPoint {
x: number
y: number
}
interface CursorRenderData {
id: string
name: string
cursor: CursorPoint
gradient: string
accentColor: string
}
const POINTER_OFFSET = {
x: 2,
y: 18,
}
const LABEL_BACKGROUND = 'rgba(15, 23, 42, 0.88)'
const CollaboratorCursorLayerComponent = () => {
const { presenceUsers } = useSocket()
const viewport = useViewport()
const session = useSession()
const currentUserId = session.data?.user?.id
const cursors = useMemo<CursorRenderData[]>(() => {
if (!presenceUsers.length) {
return []
}
return presenceUsers
.filter((user): user is typeof user & { cursor: CursorPoint } => Boolean(user.cursor))
.filter((user) => user.userId !== currentUserId)
.map((user) => {
const cursor = user.cursor
const name = user.userName?.trim() || 'Collaborator'
const { gradient, accentColor } = getPresenceColors(user.userId)
return {
id: user.socketId,
name,
cursor,
gradient,
accentColor,
}
})
}, [currentUserId, presenceUsers])
if (!cursors.length) {
return null
}
return (
<div className='pointer-events-none absolute inset-0 z-30 select-none'>
{cursors.map(({ id, name, cursor, gradient, accentColor }) => {
const x = cursor.x * viewport.zoom + viewport.x
const y = cursor.y * viewport.zoom + viewport.y
return (
<div
key={id}
className='pointer-events-none absolute'
style={{
transform: `translate3d(${x}px, ${y}px, 0)`,
transition: 'transform 0.12s ease-out',
}}
>
<div
className='relative'
style={{ transform: `translate(${-POINTER_OFFSET.x}px, ${-POINTER_OFFSET.y}px)` }}
>
<svg
width={20}
height={22}
viewBox='0 0 20 22'
className='drop-shadow-md'
style={{ fill: accentColor, stroke: 'white', strokeWidth: 1.25 }}
>
<path d='M1 0L1 17L6.2 12.5L10.5 21.5L13.7 19.8L9.4 10.7L18.5 10.7L1 0Z' />
</svg>
<div
className='absolute top-[-28px] left-4 flex items-center gap-2 rounded-full px-2 py-1 font-medium text-white text-xs shadow-lg'
style={{
background: LABEL_BACKGROUND,
border: `1px solid ${accentColor}`,
backdropFilter: 'blur(8px)',
}}
>
<span
className='h-2.5 w-2.5 rounded-full border border-white/60'
style={{ background: gradient }}
/>
<span>{name}</span>
</div>
</div>
</div>
)
})}
</div>
)
}
export const CollaboratorCursorLayer = memo(CollaboratorCursorLayerComponent)
CollaboratorCursorLayer.displayName = 'CollaboratorCursorLayer'

View File

@@ -8,7 +8,8 @@ interface SocketPresenceUser {
socketId: string
userId: string
userName: string
cursor?: { x: number; y: number }
avatarUrl?: string | null
cursor?: { x: number; y: number } | null
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
}
@@ -18,6 +19,7 @@ type PresenceUser = {
name?: string
color?: string
info?: string
avatarUrl?: string | null
}
interface UsePresenceReturn {
@@ -48,6 +50,7 @@ export function usePresence(): UsePresenceReturn {
name: user.userName,
color: undefined, // Let the avatar component generate colors
info: user.selection?.type ? `Editing ${user.selection.type}` : undefined,
avatarUrl: user.avatarUrl,
}))
}, [presenceUsers])

View File

@@ -15,6 +15,7 @@ import 'reactflow/dist/style.css'
import { createLogger } from '@/lib/logs/console/logger'
import { TriggerUtils } from '@/lib/workflows/triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { UserAvatarStack } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack'
import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar'
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
@@ -29,6 +30,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { CollaboratorCursorLayer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-presence/collaborator-cursor-layer'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
getNodeAbsolutePosition,
@@ -39,6 +41,7 @@ import {
updateNodeParent as updateNodeParentUtil,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { getBlock } from '@/blocks'
import { useSocket } from '@/contexts/socket-context'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
@@ -113,6 +116,7 @@ const WorkflowContent = React.memo(() => {
const params = useParams()
const router = useRouter()
const { project, getNodes, fitView } = useReactFlow()
const { emitCursorUpdate } = useSocket()
// Get workspace ID from the params
const workspaceId = params.workspaceId as string
@@ -1065,6 +1069,31 @@ const WorkflowContent = React.memo(() => {
]
)
const handleCanvasPointerMove = useCallback(
(event: React.PointerEvent<Element>) => {
const target = event.currentTarget as HTMLElement
const bounds = target.getBoundingClientRect()
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
})
emitCursorUpdate(position)
},
[project, emitCursorUpdate]
)
const handleCanvasPointerLeave = useCallback(() => {
emitCursorUpdate(null)
}, [emitCursorUpdate])
useEffect(() => {
return () => {
emitCursorUpdate(null)
}
}, [emitCursorUpdate])
// Handle drag over for ReactFlow canvas
const onDragOver = useCallback(
(event: React.DragEvent) => {
@@ -1937,6 +1966,9 @@ const WorkflowContent = React.memo(() => {
return (
<div className='flex h-screen w-full flex-col overflow-hidden'>
<div className='relative h-full w-full flex-1 transition-all duration-200'>
<div className='pointer-events-none absolute top-6 left-64 z-40 ml-4 sm:top-8 sm:ml-6'>
<UserAvatarStack className='pointer-events-auto w-fit max-w-xs' />
</div>
<div className='fixed top-0 right-0 z-10'>
<Panel />
</div>
@@ -1957,6 +1989,9 @@ const WorkflowContent = React.memo(() => {
return (
<div className='flex h-screen w-full flex-col overflow-hidden'>
<div className='relative h-full w-full flex-1 transition-all duration-200'>
<div className='pointer-events-none absolute top-6 left-64 z-40 ml-4 sm:top-8 sm:ml-6'>
<UserAvatarStack className='pointer-events-auto w-fit max-w-xs' />
</div>
<div className='fixed top-0 right-0 z-10'>
<Panel />
</div>
@@ -1999,6 +2034,8 @@ const WorkflowContent = React.memo(() => {
}}
onPaneClick={onPaneClick}
onEdgeClick={onEdgeClick}
onPointerMove={handleCanvasPointerMove}
onPointerLeave={handleCanvasPointerLeave}
elementsSelectable={true}
selectNodesOnDrag={false}
nodesConnectable={effectivePermissions.canEdit}
@@ -2021,6 +2058,7 @@ const WorkflowContent = React.memo(() => {
autoPanOnConnect={effectivePermissions.canEdit}
autoPanOnNodeDrag={effectivePermissions.canEdit}
>
<CollaboratorCursorLayer />
<Background
color='hsl(var(--workflow-dots))'
size={4}

View File

@@ -657,7 +657,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
if (createError) setCreateError(null)
}}
disabled={!allowPersonalApiKeys}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80 disabled:opacity-60 disabled:cursor-not-allowed'
className='h-8 disabled:cursor-not-allowed disabled:opacity-60 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Personal
</Button>

View File

@@ -97,7 +97,7 @@ export function CustomTools() {
<div className='flex items-center justify-between'>
<div>
<h2 className='font-semibold text-foreground text-lg'>Custom Tools</h2>
<p className='text-muted-foreground text-sm mt-1'>
<p className='mt-1 text-muted-foreground text-sm'>
Manage workspace-scoped custom tools for your agents
</p>
</div>
@@ -155,14 +155,14 @@ export function CustomTools() {
key={tool.id}
className='flex items-center justify-between gap-4 rounded-[8px] border bg-background p-4'
>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 mb-1'>
<code className='font-mono text-foreground text-sm font-medium'>
<div className='min-w-0 flex-1'>
<div className='mb-1 flex items-center gap-2'>
<code className='font-medium font-mono text-foreground text-sm'>
{tool.title}
</code>
</div>
{tool.schema?.function?.description && (
<p className='text-muted-foreground text-xs truncate'>
<p className='truncate text-muted-foreground text-xs'>
{tool.schema.function.description}
</p>
)}

View File

@@ -672,7 +672,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
</SelectTrigger>
<SelectContent align='start'>
<SelectGroup>
<SelectLabel className='px-3 py-1 text-muted-foreground text-[11px] uppercase'>
<SelectLabel className='px-3 py-1 text-[11px] text-muted-foreground uppercase'>
Workspace admins
</SelectLabel>
{workspaceAdmins.map((admin) => (

View File

@@ -26,7 +26,8 @@ interface PresenceUser {
socketId: string
userId: string
userName: string
cursor?: { x: number; y: number }
avatarUrl?: string | null
cursor?: { x: number; y: number } | null
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
}
@@ -52,7 +53,7 @@ interface SocketContextType {
) => void
emitVariableUpdate: (variableId: string, field: string, value: any, operationId?: string) => void
emitCursorUpdate: (cursor: { x: number; y: number }) => void
emitCursorUpdate: (cursor: { x: number; y: number } | null) => void
emitSelectionUpdate: (selection: { type: 'block' | 'edge' | 'none'; id?: string }) => void
// Event handlers for receiving real-time updates
onWorkflowOperation: (handler: (data: any) => void) => void
@@ -707,14 +708,23 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
// Cursor throttling optimized for database connection health
const lastCursorEmit = useRef(0)
const emitCursorUpdate = useCallback(
(cursor: { x: number; y: number }) => {
if (socket && currentWorkflowId) {
const now = performance.now()
// Reduced to 30fps (33ms) to reduce database load while maintaining smooth UX
if (now - lastCursorEmit.current >= 33) {
socket.emit('cursor-update', { cursor })
lastCursorEmit.current = now
}
(cursor: { x: number; y: number } | null) => {
if (!socket || !currentWorkflowId) {
return
}
const now = performance.now()
if (cursor === null) {
socket.emit('cursor-update', { cursor: null })
lastCursorEmit.current = now
return
}
// Reduced to 30fps (33ms) to reduce database load while maintaining smooth UX
if (now - lastCursorEmit.current >= 33) {
socket.emit('cursor-update', { cursor })
lastCursorEmit.current = now
}
},
[socket, currentWorkflowId]

View File

@@ -0,0 +1,82 @@
const APP_COLORS = [
{ from: '#4F46E5', to: '#7C3AED' }, // indigo to purple
{ from: '#7C3AED', to: '#C026D3' }, // purple to fuchsia
{ from: '#EC4899', to: '#F97316' }, // pink to orange
{ from: '#14B8A6', to: '#10B981' }, // teal to emerald
{ from: '#6366F1', to: '#8B5CF6' }, // indigo to violet
{ from: '#F59E0B', to: '#F97316' }, // amber to orange
]
interface PresenceColorPalette {
gradient: string
accentColor: string
baseColor: string
}
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/
function hashIdentifier(identifier: string | number): number {
if (typeof identifier === 'number' && Number.isFinite(identifier)) {
return Math.abs(Math.trunc(identifier))
}
if (typeof identifier === 'string') {
return Math.abs(Array.from(identifier).reduce((acc, char) => acc + char.charCodeAt(0), 0))
}
return 0
}
function withAlpha(hexColor: string, alpha: number): string {
if (!HEX_COLOR_REGEX.test(hexColor)) {
return hexColor
}
const normalized = hexColor.slice(1)
const expanded =
normalized.length === 3
? normalized
.split('')
.map((char) => `${char}${char}`)
.join('')
: normalized
const r = Number.parseInt(expanded.slice(0, 2), 16)
const g = Number.parseInt(expanded.slice(2, 4), 16)
const b = Number.parseInt(expanded.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${Math.min(Math.max(alpha, 0), 1)})`
}
function buildGradient(fromColor: string, toColor: string, rotationSeed: number): string {
const rotation = (rotationSeed * 25) % 360
return `linear-gradient(${rotation}deg, ${fromColor}, ${toColor})`
}
export function getPresenceColors(
identifier: string | number,
explicitColor?: string
): PresenceColorPalette {
const paletteIndex = hashIdentifier(identifier)
if (explicitColor) {
const normalizedColor = explicitColor.trim()
const lighterShade = HEX_COLOR_REGEX.test(normalizedColor)
? withAlpha(normalizedColor, 0.85)
: normalizedColor
return {
gradient: buildGradient(lighterShade, normalizedColor, paletteIndex),
accentColor: normalizedColor,
baseColor: lighterShade,
}
}
const colorPair = APP_COLORS[paletteIndex % APP_COLORS.length]
return {
gradient: buildGradient(colorPair.from, colorPair.to, paletteIndex),
accentColor: colorPair.to,
baseColor: colorPair.from,
}
}

View File

@@ -30,6 +30,7 @@ export function setupPresenceHandlers(
socketId: socket.id,
userId: session.userId,
userName: session.userName,
avatarUrl: session.avatarUrl,
cursor,
})
})
@@ -54,6 +55,7 @@ export function setupPresenceHandlers(
socketId: socket.id,
userId: session.userId,
userName: session.userName,
avatarUrl: session.avatarUrl,
selection,
})
})

View File

@@ -1,3 +1,5 @@
import { db, user } from '@sim/db'
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { getWorkflowState } from '@/socket-server/database/operations'
import type { AuthenticatedSocket } from '@/socket-server/middleware/auth'
@@ -80,6 +82,21 @@ export function setupWorkflowHandlers(
const room = roomManager.getWorkflowRoom(workflowId)!
room.activeConnections++
let avatarUrl = socket.userImage || null
if (!avatarUrl) {
try {
const [userRecord] = await db
.select({ image: user.image })
.from(user)
.where(eq(user.id, userId))
.limit(1)
avatarUrl = userRecord?.image ?? null
} catch (error) {
logger.warn('Failed to load user avatar for presence', { userId, error })
}
}
const userPresence: UserPresence = {
userId,
workflowId,
@@ -88,11 +105,16 @@ export function setupWorkflowHandlers(
joinedAt: Date.now(),
lastActivity: Date.now(),
role: userRole,
avatarUrl,
}
room.users.set(socket.id, userPresence)
roomManager.setWorkflowForSocket(socket.id, workflowId)
roomManager.setUserSession(socket.id, { userId, userName })
roomManager.setUserSession(socket.id, {
userId,
userName,
avatarUrl,
})
const workflowState = await getWorkflowState(workflowId)
socket.emit('workflow-state', workflowState)

View File

@@ -10,6 +10,7 @@ export interface AuthenticatedSocket extends Socket {
userName?: string
userEmail?: string
activeOrganizationId?: string
userImage?: string | null
}
// Enhanced authentication middleware
@@ -53,6 +54,7 @@ export async function authenticateSocket(socket: AuthenticatedSocket, next: any)
socket.userId = session.user.id
socket.userName = session.user.name || session.user.email || 'Unknown User'
socket.userEmail = session.user.email
socket.userImage = session.user.image || null
socket.activeOrganizationId = session.session.activeOrganizationId || undefined
next()

View File

@@ -31,6 +31,7 @@ export interface UserPresence {
role: string
cursor?: { x: number; y: number }
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
avatarUrl?: string | null
}
export interface WorkflowRoom {
@@ -43,7 +44,10 @@ export interface WorkflowRoom {
export class RoomManager {
private workflowRooms = new Map<string, WorkflowRoom>()
private socketToWorkflow = new Map<string, string>()
private userSessions = new Map<string, { userId: string; userName: string }>()
private userSessions = new Map<
string,
{ userId: string; userName: string; avatarUrl?: string | null }
>()
private io: Server
constructor(io: Server) {
@@ -237,11 +241,16 @@ export class RoomManager {
this.socketToWorkflow.set(socketId, workflowId)
}
getUserSession(socketId: string): { userId: string; userName: string } | undefined {
getUserSession(
socketId: string
): { userId: string; userName: string; avatarUrl?: string | null } | undefined {
return this.userSessions.get(socketId)
}
setUserSession(socketId: string, session: { userId: string; userName: string }): void {
setUserSession(
socketId: string,
session: { userId: string; userName: string; avatarUrl?: string | null }
): void {
this.userSessions.set(socketId, session)
}