Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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>
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,6 +12,7 @@ interface User {
name?: string
color?: string
info?: string
avatarUrl?: string | null
}

interface UserAvatarStackProps {
Expand Down Expand Up @@ -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={
Expand All @@ -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}
Expand All @@ -106,6 +105,8 @@ export function UserAvatarStack({
)}
</div>
)}

<ConnectionStatus isConnected={isConnected} hasOperationError={hasOperationError} />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand All @@ -18,6 +19,7 @@ type PresenceUser = {
name?: string
color?: string
info?: string
avatarUrl?: string | null
}

interface UsePresenceReturn {
Expand Down Expand Up @@ -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])

Expand Down
Loading