Files
supabase/apps/ui-library/public/r/realtime-cursor-react.json
T
Eduardo Gurgel 06fa29dbb8 feat(ui-library): improve realtime blocks (#39157)
* fix(ui library): fix presence room to clear stack on subscription error

* fix(ui library): fix realtime chat on subscription error

* feat(ui library): improve realtime cursors joining, leaving and disconnecting

Presence is used now to react to cursors joining and leaving.

* When a cursor joins all the other cursors broadcast their last
position (if available). This avoids an empty page when joining if cursors are not
moving
* When a cursor leaves the room their respective cursor is removed

And finally the cursor position is broadcasted only the channel is
successfully subscribed. Otherwise it might try to send POST requests to
broadcast outside the websocket

* chore(ui library): update registry & llms

* chore: prettier

* fix(ui-library): fix missing username on cursor

* chore(ui-library): npm run build
2025-10-15 11:52:41 +02:00

39 lines
7.5 KiB
JSON

{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "realtime-cursor-react",
"type": "registry:component",
"title": "Realtime Cursor",
"description": "Component which renders realtime cursors from other users in a room.",
"dependencies": [
"lucide-react",
"@supabase/supabase-js@latest"
],
"registryDependencies": [],
"files": [
{
"path": "registry/default/blocks/realtime-cursor/components/cursor.tsx",
"content": "import { cn } from '@/lib/utils'\nimport { MousePointer2 } from 'lucide-react'\n\nexport const Cursor = ({\n className,\n style,\n color,\n name,\n}: {\n className?: string\n style?: React.CSSProperties\n color: string\n name: string\n}) => {\n return (\n <div className={cn('pointer-events-none', className)} style={style}>\n <MousePointer2 color={color} fill={color} size={30} />\n\n <div\n className=\"mt-1 px-2 py-1 rounded text-xs font-bold text-white text-center\"\n style={{ backgroundColor: color }}\n >\n {name}\n </div>\n </div>\n )\n}\n",
"type": "registry:component"
},
{
"path": "registry/default/blocks/realtime-cursor/components/realtime-cursors.tsx",
"content": "'use client'\n\nimport { Cursor } from '@/registry/default/blocks/realtime-cursor/components/cursor'\nimport { useRealtimeCursors } from '@/registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors'\n\nconst THROTTLE_MS = 50\n\nexport const RealtimeCursors = ({ roomName, username }: { roomName: string; username: string }) => {\n const { cursors } = useRealtimeCursors({ roomName, username, throttleMs: THROTTLE_MS })\n\n return (\n <div>\n {Object.keys(cursors).map((id) => (\n <Cursor\n key={id}\n className=\"fixed transition-transform ease-in-out z-50\"\n style={{\n transitionDuration: '20ms',\n top: 0,\n left: 0,\n transform: `translate(${cursors[id].position.x}px, ${cursors[id].position.y}px)`,\n }}\n color={cursors[id].color}\n name={cursors[id].user.name}\n />\n ))}\n </div>\n )\n}\n",
"type": "registry:component"
},
{
"path": "registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts",
"content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { RealtimeChannel, REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * Throttle a callback to a certain delay, It will only call the callback if the delay has passed, with the arguments\n * from the last call\n */\nconst useThrottleCallback = <Params extends unknown[], Return>(\n callback: (...args: Params) => Return,\n delay: number\n) => {\n const lastCall = useRef(0)\n const timeout = useRef<NodeJS.Timeout | null>(null)\n\n return useCallback(\n (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall.current)\n\n if (remainingTime <= 0) {\n if (timeout.current) {\n clearTimeout(timeout.current)\n timeout.current = null\n }\n lastCall.current = now\n callback(...args)\n } else if (!timeout.current) {\n timeout.current = setTimeout(() => {\n lastCall.current = Date.now()\n timeout.current = null\n callback(...args)\n }, remainingTime)\n }\n },\n [callback, delay]\n )\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () => Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\ntype CursorEventPayload = {\n position: {\n x: number\n y: number\n }\n user: {\n id: number\n name: string\n }\n color: string\n timestamp: number\n}\n\nexport const useRealtimeCursors = ({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) => {\n const [color] = useState(generateRandomColor())\n const [userId] = useState(generateRandomNumber())\n const [cursors, setCursors] = useState<Record<string, CursorEventPayload>>({})\n const cursorPayload = useRef<CursorEventPayload | null>(null)\n\n const channelRef = useRef<RealtimeChannel | null>(null)\n\n const callback = useCallback(\n (event: MouseEvent) => {\n const { clientX, clientY } = event\n\n const payload: CursorEventPayload = {\n position: {\n x: clientX,\n y: clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color: color,\n timestamp: new Date().getTime(),\n }\n\n cursorPayload.current = payload\n\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: payload,\n })\n },\n [color, userId, username]\n )\n\n const handleMouseMove = useThrottleCallback(callback, throttleMs)\n\n useEffect(() => {\n const channel = supabase.channel(roomName)\n\n channel\n .on('presence', { event: 'leave' }, ({ leftPresences }) => {\n leftPresences.forEach(function (element) {\n // Remove cursor when user leaves\n setCursors((prev) => {\n if (prev[element.key]) {\n delete prev[element.key]\n }\n\n return { ...prev }\n })\n })\n })\n .on('presence', { event: 'join' }, () => {\n if (!cursorPayload.current) return\n\n // All cursors broadcast their position when a new cursor joins\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: cursorPayload.current,\n })\n })\n .on('broadcast', { event: EVENT_NAME }, (data: { payload: CursorEventPayload }) => {\n const { user } = data.payload\n // Don't render your own cursor\n if (user.id === userId) return\n\n setCursors((prev) => {\n if (prev[userId]) {\n delete prev[userId]\n }\n\n return {\n ...prev,\n [user.id]: data.payload,\n }\n })\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n await channel.track({ key: userId })\n channelRef.current = channel\n } else {\n setCursors({})\n channelRef.current = null\n }\n })\n\n return () => {\n channel.unsubscribe()\n channelRef.current = null\n }\n }, [])\n\n useEffect(() => {\n // Add event listener for mousemove\n window.addEventListener('mousemove', handleMouseMove)\n\n // Cleanup on unmount\n return () => {\n window.removeEventListener('mousemove', handleMouseMove)\n }\n }, [handleMouseMove])\n\n return { cursors }\n}\n",
"type": "registry:hook"
},
{
"path": "registry/default/clients/react/lib/supabase/client.ts",
"content": "import { createClient as createSupabaseClient } from '@supabase/supabase-js'\n\nexport function createClient() {\n return createSupabaseClient(\n import.meta.env.VITE_SUPABASE_URL!,\n import.meta.env.VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY!\n )\n}\n",
"type": "registry:lib"
}
],
"envVars": {
"VITE_SUPABASE_URL": "",
"VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
},
"docs": "You'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY`."
}