fix(presence): fix additional avatars showing for presence (#1938)

This commit is contained in:
Waleed
2025-11-12 09:33:01 -08:00
committed by GitHub
parent af501347bb
commit c7560be282
6 changed files with 72 additions and 304 deletions

View File

@@ -2,5 +2,4 @@ export { DeployModal } from './deploy-modal/deploy-modal'
export { DeploymentControls } from './deployment-controls/deployment-controls'
export { ExportControls } from './export-controls/export-controls'
export { TemplateModal } from './template-modal/template-modal'
export { UserAvatarStack } from './user-avatar-stack/user-avatar-stack'
export { WebhookSettings } from './webhook-settings/webhook-settings'

View File

@@ -1,83 +0,0 @@
'use client'
import { type CSSProperties, useMemo } from 'react'
import Image from 'next/image'
import { Tooltip } from '@/components/emcn'
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
}
export function UserAvatar({
connectionId,
name,
color,
avatarUrl,
tooltipContent,
size = 'md',
index = 0,
}: AvatarProps) {
const { gradient } = useMemo(() => getPresenceColors(connectionId, color), [connectionId, color])
const sizeClass = {
sm: 'h-5 w-5 text-[10px]',
md: 'h-7 w-7 text-xs',
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} 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: hasAvatar ? undefined : gradient,
zIndex: 10 - index,
} as CSSProperties
}
>
{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>
)
if (tooltipContent) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{avatarElement}</Tooltip.Trigger>
<Tooltip.Content side='bottom' className='max-w-xs'>
{tooltipContent}
</Tooltip.Content>
</Tooltip.Root>
)
}
return avatarElement
}

View File

@@ -1,106 +0,0 @@
'use client'
import { useMemo } from 'react'
import { cn } from '@/lib/utils'
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'
interface User {
connectionId: string | number
name?: string
color?: string
info?: string
avatarUrl?: string | null
}
interface UserAvatarStackProps {
users?: User[]
maxVisible?: number
size?: 'sm' | 'md' | 'lg'
className?: string
}
export function UserAvatarStack({
users: propUsers,
maxVisible = 3,
size = 'md',
className = '',
}: UserAvatarStackProps) {
// Use presence data if no users are provided via props
const { users: presenceUsers } = usePresence()
const users = propUsers || presenceUsers
// Get operation error state from collaborative workflow
// Memoize the processed users to avoid unnecessary re-renders
const { visibleUsers, overflowCount } = useMemo(() => {
if (users.length === 0) {
return { visibleUsers: [], overflowCount: 0 }
}
const visible = users.slice(0, maxVisible)
const overflow = Math.max(0, users.length - maxVisible)
return {
visibleUsers: visible,
overflowCount: overflow,
}
}, [users, maxVisible])
// Determine spacing based on size
const spacingClass = {
sm: '-space-x-1',
md: '-space-x-1.5',
lg: '-space-x-2',
}[size]
const shouldShowAvatars = visibleUsers.length > 0
return (
<div className={`flex flex-col items-start ${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={
user.name ? (
<div className='text-center'>
<div className='font-medium'>{user.name}</div>
{user.info && (
<div className='mt-1 text-muted-foreground text-xs'>{user.info}</div>
)}
</div>
) : null
}
/>
))}
{overflowCount > 0 && (
<UserAvatar
connectionId='overflow-indicator'
name={`+${overflowCount}`}
size={size}
index={visibleUsers.length}
tooltipContent={
<div className='text-center'>
<div className='font-medium'>
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
</div>
<div className='mt-1 text-muted-foreground text-xs'>
{users.length} total online
</div>
</div>
}
/>
)}
</div>
)}
</div>
)
}

View File

@@ -1,63 +0,0 @@
'use client'
import { useMemo } from 'react'
import { useSession } from '@/lib/auth-client'
import { useSocket } from '@/contexts/socket-context'
interface SocketPresenceUser {
socketId: string
userId: string
userName: string
avatarUrl?: string | null
cursor?: { x: number; y: number } | null
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
}
type PresenceUser = {
connectionId: string | number
name?: string
color?: string
info?: string
avatarUrl?: string | null
}
interface UsePresenceReturn {
users: PresenceUser[]
currentUser: PresenceUser | null
isConnected: boolean
}
/**
* Hook for managing user presence in collaborative workflows using Socket.IO
* Uses the existing Socket context to get real presence data
* Filters out the current user so only other collaborators are shown
*/
export function usePresence(): UsePresenceReturn {
const { presenceUsers, isConnected } = useSocket()
const { data: session } = useSession()
const currentUserId = session?.user?.id
const users = useMemo(() => {
const uniqueUsers = new Map<string, SocketPresenceUser>()
presenceUsers.forEach((user) => {
uniqueUsers.set(user.userId, user)
})
return Array.from(uniqueUsers.values())
.filter((user) => user.userId !== currentUserId)
.map((user) => ({
connectionId: user.userId,
name: user.userName,
color: undefined,
info: user.selection?.type ? `Editing ${user.selection.type}` : undefined,
avatarUrl: user.avatarUrl,
}))
}, [presenceUsers, currentUserId])
return {
users,
currentUser: null,
isConnected,
}
}

View File

@@ -23,7 +23,6 @@ import {
TrainingControls,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
import { UserAvatarStack } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
@@ -2006,7 +2005,6 @@ const WorkflowContent = React.memo(() => {
<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='workflow-container h-full' />
<UserAvatarStack className='pointer-events-auto w-fit max-w-xs' />
</div>
<Panel />
<Terminal />
@@ -2020,8 +2018,6 @@ const WorkflowContent = React.memo(() => {
{/* Training Controls - for recording workflow edits */}
<TrainingControls />
<UserAvatarStack className='pointer-events-auto w-fit max-w-xs' />
<ReactFlow
nodes={nodes}
edges={edgesWithSelection}

View File

@@ -1,6 +1,6 @@
'use client'
import { type CSSProperties, useEffect, useMemo } from 'react'
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth-client'
@@ -17,9 +17,76 @@ interface AvatarsProps {
onPresenceChange?: (hasAvatars: boolean) => void
}
interface PresenceUser {
socketId: string
userId: string
userName?: string
avatarUrl?: string | null
}
interface UserAvatarProps {
user: PresenceUser
index: number
}
/**
* Individual user avatar with error handling for image loading.
* Falls back to colored circle with initials if image fails to load.
*/
function UserAvatar({ user, index }: UserAvatarProps) {
const [imageError, setImageError] = useState(false)
const color = getUserColor(user.userId)
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
const hasAvatar = Boolean(user.avatarUrl) && !imageError
// Reset error state when avatar URL changes
useEffect(() => {
setImageError(false)
}, [user.avatarUrl])
const avatarElement = (
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full font-semibold text-[7px] text-white'
style={
{
background: hasAvatar ? undefined : color,
zIndex: 10 - index,
} as CSSProperties
}
>
{hasAvatar && user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt={user.userName ? `${user.userName}'s avatar` : 'User avatar'}
fill
sizes='14px'
className='object-cover'
referrerPolicy='no-referrer'
unoptimized={user.avatarUrl.startsWith('http')}
onError={() => setImageError(true)}
/>
) : (
initials
)}
</div>
)
if (user.userName) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{avatarElement}</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<span>{user.userName}</span>
</Tooltip.Content>
</Tooltip.Root>
)
}
return avatarElement
}
/**
* Displays user avatars for presence in a workflow item.
* Consolidated logic from user-avatar-stack and user-avatar components.
* Only shows avatars for the currently active workflow.
*
* @param props - Component props
@@ -69,51 +136,9 @@ export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: Avatar
return (
<div className='-space-x-1 ml-[-8px] flex items-center'>
{visibleUsers.map((user, index) => {
const color = getUserColor(user.userId)
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
const hasAvatar = Boolean(user.avatarUrl)
const avatarElement = (
<div
key={user.socketId}
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full font-semibold text-[7px] text-white'
style={
{
background: hasAvatar ? undefined : color,
zIndex: 10 - index,
} as CSSProperties
}
>
{hasAvatar && user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt={user.userName ? `${user.userName}'s avatar` : 'User avatar'}
fill
sizes='14px'
className='object-cover'
referrerPolicy='no-referrer'
unoptimized={user.avatarUrl.startsWith('http')}
/>
) : (
initials
)}
</div>
)
if (user.userName) {
return (
<Tooltip.Root key={user.socketId}>
<Tooltip.Trigger asChild>{avatarElement}</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<span>{user.userName}</span>
</Tooltip.Content>
</Tooltip.Root>
)
}
return avatarElement
})}
{visibleUsers.map((user, index) => (
<UserAvatar key={user.socketId} user={user} index={index} />
))}
{overflowCount > 0 && (
<Tooltip.Root>