mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(presence): fix additional avatars showing for presence (#1938)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user