feat: [US-050] - Join/leave notifications
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
841a44112a
commit
ccb05e3a3e
2
prd.json
2
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"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
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 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.
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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<CRDTManager | null>(null)
|
||||
const isRemoteUpdateRef = useRef(false)
|
||||
const cursorThrottleRef = useRef<number>(0)
|
||||
const [collaborationNotifications, setCollaborationNotifications] = useState<CollaborationNotification[]>([])
|
||||
|
||||
// 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 && (
|
||||
<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>
|
||||
</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 = {
|
||||
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?.({
|
||||
|
|
|
|||
Loading…
Reference in New Issue