mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-30 09:18:01 -05:00
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:
committed by
GitHub
parent
a072e6d1d8
commit
eac358bc7c
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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]
|
||||
|
||||
82
apps/sim/lib/collaboration/presence-colors.ts
Normal file
82
apps/sim/lib/collaboration/presence-colors.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user