feat: [US-046] - Presence indicators for active collaborators
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7278b7527f
commit
f92fc1ad01
|
|
@ -27,13 +27,14 @@ import ConditionEditor from '@/components/editor/ConditionEditor'
|
||||||
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
|
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
|
||||||
import { EditorProvider } from '@/components/editor/EditorContext'
|
import { EditorProvider } from '@/components/editor/EditorContext'
|
||||||
import Toast from '@/components/Toast'
|
import Toast from '@/components/Toast'
|
||||||
import { RealtimeConnection, type ConnectionState } from '@/lib/collaboration/realtime'
|
import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime'
|
||||||
import ShareModal from '@/components/editor/ShareModal'
|
import ShareModal from '@/components/editor/ShareModal'
|
||||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
type FlowchartEditorProps = {
|
type FlowchartEditorProps = {
|
||||||
projectId: string
|
projectId: string
|
||||||
userId: string
|
userId: string
|
||||||
|
userDisplayName: string
|
||||||
isOwner: boolean
|
isOwner: boolean
|
||||||
initialData: FlowchartData
|
initialData: FlowchartData
|
||||||
needsMigration?: boolean
|
needsMigration?: boolean
|
||||||
|
|
@ -206,7 +207,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inner component that uses useReactFlow hook
|
// Inner component that uses useReactFlow hook
|
||||||
function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMigration }: FlowchartEditorProps) {
|
function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, initialData, needsMigration }: FlowchartEditorProps) {
|
||||||
// Define custom node types - memoized to prevent re-renders
|
// Define custom node types - memoized to prevent re-renders
|
||||||
const nodeTypes: NodeTypes = useMemo(
|
const nodeTypes: NodeTypes = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -238,12 +239,14 @@ function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMi
|
||||||
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
|
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
|
||||||
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
|
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
||||||
|
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
|
||||||
const realtimeRef = useRef<RealtimeConnection | null>(null)
|
const realtimeRef = useRef<RealtimeConnection | null>(null)
|
||||||
|
|
||||||
// Connect to Supabase Realtime channel on mount, disconnect on unmount
|
// Connect to Supabase Realtime channel on mount, disconnect on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const connection = new RealtimeConnection(projectId, userId, {
|
const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
|
||||||
onConnectionStateChange: setConnectionState,
|
onConnectionStateChange: setConnectionState,
|
||||||
|
onPresenceSync: setPresenceUsers,
|
||||||
})
|
})
|
||||||
realtimeRef.current = connection
|
realtimeRef.current = connection
|
||||||
connection.connect()
|
connection.connect()
|
||||||
|
|
@ -252,7 +255,7 @@ function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMi
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
realtimeRef.current = null
|
realtimeRef.current = null
|
||||||
}
|
}
|
||||||
}, [projectId, userId])
|
}, [projectId, userId, userDisplayName])
|
||||||
|
|
||||||
const handleAddCharacter = useCallback(
|
const handleAddCharacter = useCallback(
|
||||||
(name: string, color: string): string => {
|
(name: string, color: string): string => {
|
||||||
|
|
@ -534,6 +537,7 @@ function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMi
|
||||||
onProjectSettings={() => setShowSettings(true)}
|
onProjectSettings={() => setShowSettings(true)}
|
||||||
onShare={() => setShowShare(true)}
|
onShare={() => setShowShare(true)}
|
||||||
connectionState={connectionState}
|
connectionState={connectionState}
|
||||||
|
presenceUsers={presenceUsers}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,15 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch user's display name for presence
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('display_name')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const userDisplayName = profile?.display_name || user.email || 'Anonymous'
|
||||||
|
|
||||||
// Try to load as owner first
|
// Try to load as owner first
|
||||||
const { data: ownedProject } = await supabase
|
const { data: ownedProject } = await supabase
|
||||||
.from('projects')
|
.from('projects')
|
||||||
|
|
@ -102,6 +111,7 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
<FlowchartEditor
|
<FlowchartEditor
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
|
userDisplayName={userDisplayName}
|
||||||
isOwner={isOwner}
|
isOwner={isOwner}
|
||||||
initialData={flowchartData}
|
initialData={flowchartData}
|
||||||
needsMigration={needsMigration}
|
needsMigration={needsMigration}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { PresenceUser } from '@/lib/collaboration/realtime'
|
||||||
|
|
||||||
|
type PresenceAvatarsProps = {
|
||||||
|
users: PresenceUser[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 5
|
||||||
|
|
||||||
|
// Generate a consistent color from a user ID hash
|
||||||
|
function getUserColor(userId: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < userId.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
|
||||||
|
}
|
||||||
|
const colors = [
|
||||||
|
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||||
|
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||||
|
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||||
|
]
|
||||||
|
return colors[Math.abs(hash) % colors.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initials from a display name (first letter of first two words)
|
||||||
|
function getInitials(displayName: string): string {
|
||||||
|
const parts = displayName.trim().split(/\s+/)
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||||
|
}
|
||||||
|
return displayName.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PresenceAvatars({ users }: PresenceAvatarsProps) {
|
||||||
|
if (users.length === 0) return null
|
||||||
|
|
||||||
|
const visibleUsers = users.slice(0, MAX_VISIBLE)
|
||||||
|
const overflow = users.length - MAX_VISIBLE
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center -space-x-2">
|
||||||
|
{visibleUsers.map((user) => {
|
||||||
|
const color = getUserColor(user.userId)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={user.userId}
|
||||||
|
title={user.displayName}
|
||||||
|
className="relative flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-[10px] font-semibold text-white dark:border-zinc-800"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{getInitials(user.displayName)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{overflow > 0 && (
|
||||||
|
<div
|
||||||
|
className="relative flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-zinc-400 text-[10px] font-semibold text-white dark:border-zinc-800 dark:bg-zinc-500"
|
||||||
|
title={`${overflow} more collaborator${overflow > 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
+{overflow}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { ConnectionState } from '@/lib/collaboration/realtime'
|
import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime'
|
||||||
|
import PresenceAvatars from './PresenceAvatars'
|
||||||
|
|
||||||
type ToolbarProps = {
|
type ToolbarProps = {
|
||||||
onAddDialogue: () => void
|
onAddDialogue: () => void
|
||||||
|
|
@ -12,6 +13,7 @@ type ToolbarProps = {
|
||||||
onProjectSettings: () => void
|
onProjectSettings: () => void
|
||||||
onShare: () => void
|
onShare: () => void
|
||||||
connectionState?: ConnectionState
|
connectionState?: ConnectionState
|
||||||
|
presenceUsers?: PresenceUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionLabel: Record<ConnectionState, string> = {
|
const connectionLabel: Record<ConnectionState, string> = {
|
||||||
|
|
@ -38,6 +40,7 @@ export default function Toolbar({
|
||||||
onProjectSettings,
|
onProjectSettings,
|
||||||
onShare,
|
onShare,
|
||||||
connectionState,
|
connectionState,
|
||||||
|
presenceUsers,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
|
@ -66,6 +69,11 @@ export default function Toolbar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{presenceUsers && presenceUsers.length > 0 && (
|
||||||
|
<div className="mr-2">
|
||||||
|
<PresenceAvatars users={presenceUsers} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{connectionState && (
|
{connectionState && (
|
||||||
<div className="flex items-center gap-1.5 mr-2" title={connectionLabel[connectionState]}>
|
<div className="flex items-center gap-1.5 mr-2" title={connectionLabel[connectionState]}>
|
||||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${connectionColor[connectionState]}${connectionState === 'reconnecting' ? ' animate-pulse' : ''}`} />
|
<span className={`inline-block h-2.5 w-2.5 rounded-full ${connectionColor[connectionState]}${connectionState === 'reconnecting' ? ' animate-pulse' : ''}`} />
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,14 @@ import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||||
|
|
||||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
|
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
|
||||||
|
|
||||||
|
export type PresenceUser = {
|
||||||
|
userId: string
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
|
||||||
type RealtimeCallbacks = {
|
type RealtimeCallbacks = {
|
||||||
onConnectionStateChange: (state: ConnectionState) => void
|
onConnectionStateChange: (state: ConnectionState) => void
|
||||||
onPresenceSync?: (presences: Record<string, unknown[]>) => void
|
onPresenceSync?: (users: PresenceUser[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 30_000
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
||||||
|
|
@ -19,13 +24,15 @@ export class RealtimeConnection {
|
||||||
private reconnectAttempts = 0
|
private reconnectAttempts = 0
|
||||||
private projectId: string
|
private projectId: string
|
||||||
private userId: string
|
private userId: string
|
||||||
|
private displayName: string
|
||||||
private callbacks: RealtimeCallbacks
|
private callbacks: RealtimeCallbacks
|
||||||
private isDestroyed = false
|
private isDestroyed = false
|
||||||
private supabase = createClient()
|
private supabase = createClient()
|
||||||
|
|
||||||
constructor(projectId: string, userId: string, callbacks: RealtimeCallbacks) {
|
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
|
||||||
this.projectId = projectId
|
this.projectId = projectId
|
||||||
this.userId = userId
|
this.userId = userId
|
||||||
|
this.displayName = displayName
|
||||||
this.callbacks = callbacks
|
this.callbacks = callbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +48,18 @@ export class RealtimeConnection {
|
||||||
.on('presence', { event: 'sync' }, () => {
|
.on('presence', { event: 'sync' }, () => {
|
||||||
if (this.channel) {
|
if (this.channel) {
|
||||||
const state = this.channel.presenceState()
|
const state = this.channel.presenceState()
|
||||||
this.callbacks.onPresenceSync?.(state)
|
const users: PresenceUser[] = []
|
||||||
|
for (const [key, presences] of Object.entries(state)) {
|
||||||
|
if (key === this.userId) continue // Exclude own presence
|
||||||
|
const presence = presences[0] as { userId?: string; displayName?: string } | undefined
|
||||||
|
if (presence?.userId) {
|
||||||
|
users.push({
|
||||||
|
userId: presence.userId,
|
||||||
|
displayName: presence.displayName || 'Anonymous',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.callbacks.onPresenceSync?.(users)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.subscribe(async (status) => {
|
.subscribe(async (status) => {
|
||||||
|
|
@ -52,6 +70,11 @@ export class RealtimeConnection {
|
||||||
this.callbacks.onConnectionStateChange('connected')
|
this.callbacks.onConnectionStateChange('connected')
|
||||||
this.startHeartbeat()
|
this.startHeartbeat()
|
||||||
await this.createSession()
|
await this.createSession()
|
||||||
|
// Track presence with user info
|
||||||
|
await this.channel?.track({
|
||||||
|
userId: this.userId,
|
||||||
|
displayName: this.displayName,
|
||||||
|
})
|
||||||
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
||||||
this.callbacks.onConnectionStateChange('reconnecting')
|
this.callbacks.onConnectionStateChange('reconnecting')
|
||||||
this.scheduleReconnect()
|
this.scheduleReconnect()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue