mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 21:05:06 +03:00
* 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
50 lines
11 KiB
JSON
50 lines
11 KiB
JSON
{
|
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
"name": "realtime-cursor-nextjs",
|
|
"type": "registry:component",
|
|
"title": "Realtime Cursor",
|
|
"description": "Component which renders realtime cursors from other users in a room.",
|
|
"dependencies": [
|
|
"lucide-react",
|
|
"@supabase/ssr@latest",
|
|
"@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/nextjs/lib/supabase/client.ts",
|
|
"content": "import { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!\n )\n}\n",
|
|
"type": "registry:lib"
|
|
},
|
|
{
|
|
"path": "registry/default/clients/nextjs/lib/supabase/middleware.ts",
|
|
"content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n",
|
|
"type": "registry:lib"
|
|
},
|
|
{
|
|
"path": "registry/default/clients/nextjs/lib/supabase/server.ts",
|
|
"content": "import { createServerClient } from '@supabase/ssr'\nimport { cookies } from 'next/headers'\n\n/**\n * If using Fluid compute: Don't put this client in a global variable. Always create a new client within each\n * function when using it.\n */\nexport async function createClient() {\n const cookieStore = await cookies()\n\n return createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return cookieStore.getAll()\n },\n setAll(cookiesToSet) {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options)\n )\n } catch {\n // The `setAll` method was called from a Server Component.\n // This can be ignored if you have middleware refreshing\n // user sessions.\n }\n },\n },\n }\n )\n}\n",
|
|
"type": "registry:lib"
|
|
}
|
|
],
|
|
"envVars": {
|
|
"NEXT_PUBLIC_SUPABASE_URL": "",
|
|
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
|
},
|
|
"docs": "You'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY`."
|
|
} |