From f92fc1ad0130435f2faea3a40c25d3c795028d5a Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 15:42:07 -0300 Subject: [PATCH] feat: [US-046] - Presence indicators for active collaborators Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 12 ++-- src/app/editor/[projectId]/page.tsx | 10 +++ src/components/editor/PresenceAvatars.tsx | 65 +++++++++++++++++++ src/components/editor/Toolbar.tsx | 10 ++- src/lib/collaboration/realtime.ts | 29 ++++++++- 5 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 src/components/editor/PresenceAvatars.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 44b4c68..f755efe 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -27,13 +27,14 @@ import ConditionEditor from '@/components/editor/ConditionEditor' import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal' import { EditorProvider } from '@/components/editor/EditorContext' 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 type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' type FlowchartEditorProps = { projectId: string userId: string + userDisplayName: string isOwner: boolean initialData: FlowchartData needsMigration?: boolean @@ -206,7 +207,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) { } // 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 const nodeTypes: NodeTypes = useMemo( () => ({ @@ -238,12 +239,14 @@ function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMi const [validationIssues, setValidationIssues] = useState(null) const [warningNodeIds, setWarningNodeIds] = useState>(new Set()) const [connectionState, setConnectionState] = useState('disconnected') + const [presenceUsers, setPresenceUsers] = useState([]) const realtimeRef = useRef(null) // Connect to Supabase Realtime channel on mount, disconnect on unmount useEffect(() => { - const connection = new RealtimeConnection(projectId, userId, { + const connection = new RealtimeConnection(projectId, userId, userDisplayName, { onConnectionStateChange: setConnectionState, + onPresenceSync: setPresenceUsers, }) realtimeRef.current = connection connection.connect() @@ -252,7 +255,7 @@ function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMi connection.disconnect() realtimeRef.current = null } - }, [projectId, userId]) + }, [projectId, userId, userDisplayName]) const handleAddCharacter = useCallback( (name: string, color: string): string => { @@ -534,6 +537,7 @@ function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMi onProjectSettings={() => setShowSettings(true)} onShare={() => setShowShare(true)} connectionState={connectionState} + presenceUsers={presenceUsers} />
= 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 ( +
+ {visibleUsers.map((user) => { + const color = getUserColor(user.userId) + return ( +
+ {getInitials(user.displayName)} +
+ ) + })} + {overflow > 0 && ( +
1 ? 's' : ''}`} + > + +{overflow} +
+ )} +
+ ) +} diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index f283ff4..67d9345 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -1,6 +1,7 @@ 'use client' -import type { ConnectionState } from '@/lib/collaboration/realtime' +import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime' +import PresenceAvatars from './PresenceAvatars' type ToolbarProps = { onAddDialogue: () => void @@ -12,6 +13,7 @@ type ToolbarProps = { onProjectSettings: () => void onShare: () => void connectionState?: ConnectionState + presenceUsers?: PresenceUser[] } const connectionLabel: Record = { @@ -38,6 +40,7 @@ export default function Toolbar({ onProjectSettings, onShare, connectionState, + presenceUsers, }: ToolbarProps) { return (
@@ -66,6 +69,11 @@ export default function Toolbar({
+ {presenceUsers && presenceUsers.length > 0 && ( +
+ +
+ )} {connectionState && (
diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index 9be5547..1d038fc 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -3,9 +3,14 @@ import type { RealtimeChannel } from '@supabase/supabase-js' export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' +export type PresenceUser = { + userId: string + displayName: string +} + type RealtimeCallbacks = { onConnectionStateChange: (state: ConnectionState) => void - onPresenceSync?: (presences: Record) => void + onPresenceSync?: (users: PresenceUser[]) => void } const HEARTBEAT_INTERVAL_MS = 30_000 @@ -19,13 +24,15 @@ export class RealtimeConnection { private reconnectAttempts = 0 private projectId: string private userId: string + private displayName: string private callbacks: RealtimeCallbacks private isDestroyed = false private supabase = createClient() - constructor(projectId: string, userId: string, callbacks: RealtimeCallbacks) { + constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) { this.projectId = projectId this.userId = userId + this.displayName = displayName this.callbacks = callbacks } @@ -41,7 +48,18 @@ export class RealtimeConnection { .on('presence', { event: 'sync' }, () => { if (this.channel) { 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) => { @@ -52,6 +70,11 @@ export class RealtimeConnection { this.callbacks.onConnectionStateChange('connected') this.startHeartbeat() 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') { this.callbacks.onConnectionStateChange('reconnecting') this.scheduleReconnect()