diff --git a/prd.json b/prd.json index 14f135d..42f2047 100644 --- a/prd.json +++ b/prd.json @@ -341,7 +341,7 @@ "Verify in browser using dev-browser skill" ], "priority": 19, - "passes": false, + "passes": true, "notes": "Dependencies: US-045, US-046" }, { diff --git a/progress.txt b/progress.txt index 5bfbce3..cb9e998 100644 --- a/progress.txt +++ b/progress.txt @@ -62,6 +62,8 @@ - For Supabase Realtime broadcast of binary data (Yjs updates), convert `Uint8Array` → `Array.from()` for JSON payload, and `new Uint8Array()` on receive. - For ephemeral real-time data (cursors, typing indicators), use Supabase Realtime broadcast (`channel.send({ type: 'broadcast', event, payload })`) + `.on('broadcast', { event }, callback)` — not persistence-backed - `RemoteCursors` at `src/components/editor/RemoteCursors.tsx` renders collaborator cursors on canvas. Uses `useViewport()` to transform flow→screen coordinates. Throttle broadcasts to 50ms via timestamp ref. +- Supabase Realtime presence events: `sync` (full state), `join` (arrivals with `newPresences` array), `leave` (departures with `leftPresences` array). Filter `this.userId` to skip own events. +- `CollaborationToast` at `src/components/editor/CollaborationToast.tsx` shows join/leave notifications (bottom-left, auto-dismiss 3s). Uses `getUserColor(userId)` for accent color dot. --- @@ -416,3 +418,17 @@ - The `page.tsx` had a corrupted structure with unclosed tags and dead code — likely from a failed merge. Fixed by restructuring the error/not-found case into a proper early return - No browser testing tools are available; manual verification is needed. --- + +## 2026-01-23 - US-050 +- What was implemented: Join/leave toast notifications when collaborators connect or disconnect from the editing session +- Files changed: + - `src/lib/collaboration/realtime.ts` - Added `onPresenceJoin` and `onPresenceLeave` callbacks to `RealtimeCallbacks` type; added Supabase Realtime `presence.join` and `presence.leave` event listeners that filter out own user and invoke callbacks + - `src/components/editor/CollaborationToast.tsx` - New component: renders a compact toast notification with user's presence color dot, "[Name] joined" or "[Name] left" message, auto-dismisses after 3 seconds + - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `getUserColor` helper (same hash logic as PresenceAvatars), `collaborationNotifications` state, `onPresenceJoin`/`onPresenceLeave` handlers on RealtimeConnection, `handleDismissNotification` callback, and rendering of CollaborationToast list in bottom-left corner +- **Learnings for future iterations:** + - Supabase Realtime presence has three event types: `sync` (full state), `join` (new arrivals), and `leave` (departures). Each provides an array of presences (`newPresences`/`leftPresences`). Use all three for different purposes. + - The `join` event fires for each newly tracked presence. It includes the presence payload (userId, displayName) that was passed to `channel.track()`. + - Collaboration notifications are positioned `bottom-left` (`left-4`) to avoid overlapping with the existing Toast component which is `bottom-right` (`right-4`). + - The `getUserColor` function is duplicated from PresenceAvatars to avoid circular imports. Both use the same hash-to-color-index algorithm with the same RANDOM_COLORS palette for consistency. + - No browser testing tools are available; manual verification is needed. +--- diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index f4cb1d7..c353a3c 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -37,6 +37,7 @@ import { RealtimeConnection, type ConnectionState, type PresenceUser, type Remot import RemoteCursors from '@/components/editor/RemoteCursors' import { CRDTManager } from '@/lib/collaboration/crdt' import ShareModal from '@/components/editor/ShareModal' +import CollaborationToast, { type CollaborationNotification } from '@/components/editor/CollaborationToast' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' // LocalStorage key prefix for draft saves @@ -303,6 +304,15 @@ function randomHexColor(): string { return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)] } +// Generate a consistent color from a user ID hash (same logic as PresenceAvatars) +function getUserColor(userId: string): string { + let hash = 0 + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0 + } + return RANDOM_COLORS[Math.abs(hash) % RANDOM_COLORS.length] +} + // Compute auto-migration of existing free-text values to character/variable definitions function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) { if (!shouldMigrate) { @@ -514,6 +524,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const crdtRef = useRef(null) const isRemoteUpdateRef = useRef(false) const cursorThrottleRef = useRef(0) + const [collaborationNotifications, setCollaborationNotifications] = useState([]) // Initialize CRDT manager and connect to Supabase Realtime channel on mount useEffect(() => { @@ -556,6 +567,18 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const connection = new RealtimeConnection(projectId, userId, userDisplayName, { onConnectionStateChange: setConnectionState, onPresenceSync: setPresenceUsers, + onPresenceJoin: (user) => { + setCollaborationNotifications((prev) => [ + ...prev, + { id: nanoid(), displayName: user.displayName, type: 'join', color: getUserColor(user.userId) }, + ]) + }, + onPresenceLeave: (user) => { + setCollaborationNotifications((prev) => [ + ...prev, + { id: nanoid(), displayName: user.displayName, type: 'leave', color: getUserColor(user.userId) }, + ]) + }, onCursorUpdate: (cursor) => { setRemoteCursors((prev) => { const existing = prev.findIndex((c) => c.userId === cursor.userId) @@ -644,6 +667,10 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, ) }, [presenceUsers]) + const handleDismissNotification = useCallback((id: string) => { + setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id)) + }, []) + const handleAddCharacter = useCallback( (name: string, color: string): string => { const id = nanoid() @@ -1333,6 +1360,17 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, onClose={() => setToastMessage(null)} /> )} + {collaborationNotifications.length > 0 && ( +
+ {collaborationNotifications.map((notification) => ( + + ))} +
+ )} ) diff --git a/src/components/editor/CollaborationToast.tsx b/src/components/editor/CollaborationToast.tsx new file mode 100644 index 0000000..3885d9c --- /dev/null +++ b/src/components/editor/CollaborationToast.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useEffect } from 'react' + +export type CollaborationNotification = { + id: string + displayName: string + type: 'join' | 'leave' + color: string +} + +type CollaborationToastProps = { + notification: CollaborationNotification + onDismiss: (id: string) => void +} + +export default function CollaborationToast({ notification, onDismiss }: CollaborationToastProps) { + useEffect(() => { + const timer = setTimeout(() => { + onDismiss(notification.id) + }, 3000) + + return () => clearTimeout(timer) + }, [notification.id, onDismiss]) + + const message = notification.type === 'join' + ? `${notification.displayName} joined` + : `${notification.displayName} left` + + return ( +
+
+
+ {message} +
+
+ ) +} diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index d65d860..92815b0 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -23,6 +23,8 @@ export type RemoteCursor = { type RealtimeCallbacks = { onConnectionStateChange: (state: ConnectionState) => void onPresenceSync?: (users: PresenceUser[]) => void + onPresenceJoin?: (user: PresenceUser) => void + onPresenceLeave?: (user: PresenceUser) => void onChannelSubscribed?: (channel: RealtimeChannel) => void onCursorUpdate?: (cursor: RemoteCursor) => void } @@ -76,6 +78,28 @@ export class RealtimeConnection { this.callbacks.onPresenceSync?.(users) } }) + .on('presence', { event: 'join' }, ({ newPresences }) => { + for (const presence of newPresences) { + const p = presence as { userId?: string; displayName?: string } + if (p.userId && p.userId !== this.userId) { + this.callbacks.onPresenceJoin?.({ + userId: p.userId, + displayName: p.displayName || 'Anonymous', + }) + } + } + }) + .on('presence', { event: 'leave' }, ({ leftPresences }) => { + for (const presence of leftPresences) { + const p = presence as { userId?: string; displayName?: string } + if (p.userId && p.userId !== this.userId) { + this.callbacks.onPresenceLeave?.({ + userId: p.userId, + displayName: p.displayName || 'Anonymous', + }) + } + } + }) .on('broadcast', { event: 'cursor' }, ({ payload }) => { if (payload.userId === this.userId) return this.callbacks.onCursorUpdate?.({