feat: [US-050] - Join/leave notifications
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
841a44112a
commit
ccb05e3a3e
@@ -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?.({
|
||||
|
||||
Reference in New Issue
Block a user