developing #10
2
prd.json
2
prd.json
|
|
@ -341,7 +341,7 @@
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 19,
|
"priority": 19,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "Dependencies: US-045, US-046"
|
"notes": "Dependencies: US-045, US-046"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
16
progress.txt
16
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 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
|
- 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.
|
- `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
|
- 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.
|
- 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.
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import { RealtimeConnection, type ConnectionState, type PresenceUser, type Remot
|
||||||
import RemoteCursors from '@/components/editor/RemoteCursors'
|
import RemoteCursors from '@/components/editor/RemoteCursors'
|
||||||
import { CRDTManager } from '@/lib/collaboration/crdt'
|
import { CRDTManager } from '@/lib/collaboration/crdt'
|
||||||
import ShareModal from '@/components/editor/ShareModal'
|
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'
|
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
// LocalStorage key prefix for draft saves
|
// LocalStorage key prefix for draft saves
|
||||||
|
|
@ -303,6 +304,15 @@ function randomHexColor(): string {
|
||||||
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
|
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
|
// Compute auto-migration of existing free-text values to character/variable definitions
|
||||||
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
||||||
if (!shouldMigrate) {
|
if (!shouldMigrate) {
|
||||||
|
|
@ -514,6 +524,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
const crdtRef = useRef<CRDTManager | null>(null)
|
const crdtRef = useRef<CRDTManager | null>(null)
|
||||||
const isRemoteUpdateRef = useRef(false)
|
const isRemoteUpdateRef = useRef(false)
|
||||||
const cursorThrottleRef = useRef<number>(0)
|
const cursorThrottleRef = useRef<number>(0)
|
||||||
|
const [collaborationNotifications, setCollaborationNotifications] = useState<CollaborationNotification[]>([])
|
||||||
|
|
||||||
// Initialize CRDT manager and connect to Supabase Realtime channel on mount
|
// Initialize CRDT manager and connect to Supabase Realtime channel on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -556,6 +567,18 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
|
const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
|
||||||
onConnectionStateChange: setConnectionState,
|
onConnectionStateChange: setConnectionState,
|
||||||
onPresenceSync: setPresenceUsers,
|
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) => {
|
onCursorUpdate: (cursor) => {
|
||||||
setRemoteCursors((prev) => {
|
setRemoteCursors((prev) => {
|
||||||
const existing = prev.findIndex((c) => c.userId === cursor.userId)
|
const existing = prev.findIndex((c) => c.userId === cursor.userId)
|
||||||
|
|
@ -644,6 +667,10 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
)
|
)
|
||||||
}, [presenceUsers])
|
}, [presenceUsers])
|
||||||
|
|
||||||
|
const handleDismissNotification = useCallback((id: string) => {
|
||||||
|
setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleAddCharacter = useCallback(
|
const handleAddCharacter = useCallback(
|
||||||
(name: string, color: string): string => {
|
(name: string, color: string): string => {
|
||||||
const id = nanoid()
|
const id = nanoid()
|
||||||
|
|
@ -1333,6 +1360,17 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
onClose={() => setToastMessage(null)}
|
onClose={() => setToastMessage(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{collaborationNotifications.length > 0 && (
|
||||||
|
<div className="fixed bottom-4 left-4 z-50 flex flex-col gap-2">
|
||||||
|
{collaborationNotifications.map((notification) => (
|
||||||
|
<CollaborationToast
|
||||||
|
key={notification.id}
|
||||||
|
notification={notification}
|
||||||
|
onDismiss={handleDismissNotification}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</EditorProvider>
|
</EditorProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-zinc-800 px-4 py-2.5 text-sm font-medium text-white shadow-lg dark:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: notification.color }}
|
||||||
|
/>
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,8 @@ export type RemoteCursor = {
|
||||||
type RealtimeCallbacks = {
|
type RealtimeCallbacks = {
|
||||||
onConnectionStateChange: (state: ConnectionState) => void
|
onConnectionStateChange: (state: ConnectionState) => void
|
||||||
onPresenceSync?: (users: PresenceUser[]) => void
|
onPresenceSync?: (users: PresenceUser[]) => void
|
||||||
|
onPresenceJoin?: (user: PresenceUser) => void
|
||||||
|
onPresenceLeave?: (user: PresenceUser) => void
|
||||||
onChannelSubscribed?: (channel: RealtimeChannel) => void
|
onChannelSubscribed?: (channel: RealtimeChannel) => void
|
||||||
onCursorUpdate?: (cursor: RemoteCursor) => void
|
onCursorUpdate?: (cursor: RemoteCursor) => void
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +78,28 @@ export class RealtimeConnection {
|
||||||
this.callbacks.onPresenceSync?.(users)
|
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 }) => {
|
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
|
||||||
if (payload.userId === this.userId) return
|
if (payload.userId === this.userId) return
|
||||||
this.callbacks.onCursorUpdate?.({
|
this.callbacks.onCursorUpdate?.({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue