Files
supabase/apps/ui-library/public/r/realtime-avatar-stack-nextjs.json
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

62 lines
11 KiB
JSON

{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "realtime-avatar-stack-nextjs",
"type": "registry:component",
"title": "Avatar Stack with Realtime Presence",
"description": "Component which stack of avatars, tracked by realtime presence.",
"dependencies": [
"@supabase/ssr@latest",
"@supabase/supabase-js@latest"
],
"registryDependencies": [
"avatar",
"tooltip"
],
"files": [
{
"path": "registry/default/blocks/realtime-avatar-stack/components/avatar-stack.tsx",
"content": "import { cn } from '@/lib/utils'\nimport { Avatar, AvatarFallback, AvatarImage } from '@/registry/default/components/ui/avatar'\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/registry/default/components/ui/tooltip'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport * as React from 'react'\n\nconst avatarStackVariants = cva('flex -space-x-4 -space-y-4', {\n variants: {\n orientation: {\n vertical: 'flex-row',\n horizontal: 'flex-col',\n },\n },\n defaultVariants: {\n orientation: 'vertical',\n },\n})\n\nexport interface AvatarStackProps\n extends React.HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof avatarStackVariants> {\n avatars: { name: string; image: string }[]\n maxAvatarsAmount?: number\n}\n\nconst AvatarStack = ({\n className,\n orientation,\n avatars,\n maxAvatarsAmount = 3,\n ...props\n}: AvatarStackProps) => {\n const shownAvatars = avatars.slice(0, maxAvatarsAmount)\n const hiddenAvatars = avatars.slice(maxAvatarsAmount)\n\n return (\n <div\n className={cn(\n avatarStackVariants({ orientation }),\n className,\n orientation === 'horizontal' ? '-space-x-0' : '-space-y-0'\n )}\n {...props}\n >\n {shownAvatars.map(({ name, image }, index) => (\n <Tooltip key={`${name}-${image}-${index}`}>\n <TooltipTrigger asChild>\n <Avatar className=\"hover:z-10\">\n <AvatarImage src={image} />\n <AvatarFallback>\n {name\n ?.split(' ')\n ?.map((word) => word[0])\n ?.join('')\n ?.toUpperCase()}\n </AvatarFallback>\n </Avatar>\n </TooltipTrigger>\n <TooltipContent>\n <p>{name}</p>\n </TooltipContent>\n </Tooltip>\n ))}\n\n {hiddenAvatars.length ? (\n <Tooltip key=\"hidden-avatars\">\n <TooltipTrigger asChild>\n <Avatar>\n <AvatarFallback>+{avatars.length - shownAvatars.length}</AvatarFallback>\n </Avatar>\n </TooltipTrigger>\n <TooltipContent>\n {hiddenAvatars.map(({ name }, index) => (\n <p key={`${name}-${index}`}>{name}</p>\n ))}\n </TooltipContent>\n </Tooltip>\n ) : null}\n </div>\n )\n}\n\nexport { AvatarStack, avatarStackVariants }\n",
"type": "registry:component"
},
{
"path": "registry/default/blocks/realtime-avatar-stack/components/realtime-avatar-stack.tsx",
"content": "'use client'\n\nimport { AvatarStack } from '@/registry/default/blocks/realtime-avatar-stack/components/avatar-stack'\nimport { useRealtimePresenceRoom } from '@/registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room'\nimport { useMemo } from 'react'\n\nexport const RealtimeAvatarStack = ({ roomName }: { roomName: string }) => {\n const { users: usersMap } = useRealtimePresenceRoom(roomName)\n const avatars = useMemo(() => {\n return Object.values(usersMap).map((user) => ({\n name: user.name,\n image: user.image,\n }))\n }, [usersMap])\n\n return <AvatarStack avatars={avatars} />\n}\n",
"type": "registry:component"
},
{
"path": "registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts",
"content": "'use client'\n\nimport { useCurrentUserImage } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-image'\nimport { useCurrentUserName } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-name'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'\nimport { useEffect, useState } from 'react'\n\nconst supabase = createClient()\n\nexport type RealtimeUser = {\n id: string\n name: string\n image: string\n}\n\nexport const useRealtimePresenceRoom = (roomName: string) => {\n const currentUserImage = useCurrentUserImage()\n const currentUserName = useCurrentUserName()\n\n const [users, setUsers] = useState<Record<string, RealtimeUser>>({})\n\n useEffect(() => {\n const room = supabase.channel(roomName)\n\n room\n .on('presence', { event: 'sync' }, () => {\n const newState = room.presenceState<{ image: string; name: string }>()\n\n const newUsers = Object.fromEntries(\n Object.entries(newState).map(([key, values]) => [\n key,\n { name: values[0].name, image: values[0].image },\n ])\n ) as Record<string, RealtimeUser>\n setUsers(newUsers)\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n await room.track({\n name: currentUserName,\n image: currentUserImage,\n })\n } else {\n setUsers({})\n }\n })\n\n return () => {\n room.unsubscribe()\n }\n }, [roomName, currentUserName, currentUserImage])\n\n return { users }\n}\n",
"type": "registry:hook"
},
{
"path": "registry/default/blocks/current-user-avatar/hooks/use-current-user-name.ts",
"content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useEffect, useState } from 'react'\n\nexport const useCurrentUserName = () => {\n const [name, setName] = useState<string | null>(null)\n\n useEffect(() => {\n const fetchProfileName = async () => {\n const { data, error } = await createClient().auth.getSession()\n if (error) {\n console.error(error)\n }\n\n setName(data.session?.user.user_metadata.full_name ?? '?')\n }\n\n fetchProfileName()\n }, [])\n\n return name || '?'\n}\n",
"type": "registry:hook"
},
{
"path": "registry/default/blocks/current-user-avatar/hooks/use-current-user-image.ts",
"content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useEffect, useState } from 'react'\n\nexport const useCurrentUserImage = () => {\n const [image, setImage] = useState<string | null>(null)\n\n useEffect(() => {\n const fetchUserImage = async () => {\n const { data, error } = await createClient().auth.getSession()\n if (error) {\n console.error(error)\n }\n\n setImage(data.session?.user.user_metadata.avatar_url ?? null)\n }\n fetchUserImage()\n }, [])\n\n return image\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`."
}