Files
supabase/apps/ui-library/public/r/realtime-chat-react-router.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

53 lines
11 KiB
JSON

{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "realtime-chat-react-router",
"type": "registry:component",
"title": "Realtime Chat",
"description": "Component which renders realtime chat messages from other users in a room.",
"dependencies": [
"lucide-react",
"@supabase/ssr@latest",
"@supabase/supabase-js@latest"
],
"registryDependencies": [
"input",
"button"
],
"files": [
{
"path": "registry/default/blocks/realtime-chat/components/chat-message.tsx",
"content": "import { cn } from '@/lib/utils'\nimport type { ChatMessage } from '@/registry/default/blocks/realtime-chat/hooks/use-realtime-chat'\n\ninterface ChatMessageItemProps {\n message: ChatMessage\n isOwnMessage: boolean\n showHeader: boolean\n}\n\nexport const ChatMessageItem = ({ message, isOwnMessage, showHeader }: ChatMessageItemProps) => {\n return (\n <div className={`flex mt-2 ${isOwnMessage ? 'justify-end' : 'justify-start'}`}>\n <div\n className={cn('max-w-[75%] w-fit flex flex-col gap-1', {\n 'items-end': isOwnMessage,\n })}\n >\n {showHeader && (\n <div\n className={cn('flex items-center gap-2 text-xs px-3', {\n 'justify-end flex-row-reverse': isOwnMessage,\n })}\n >\n <span className={'font-medium'}>{message.user.name}</span>\n <span className=\"text-foreground/50 text-xs\">\n {new Date(message.createdAt).toLocaleTimeString('en-US', {\n hour: '2-digit',\n minute: '2-digit',\n hour12: true,\n })}\n </span>\n </div>\n )}\n <div\n className={cn(\n 'py-2 px-3 rounded-xl text-sm w-fit',\n isOwnMessage ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground'\n )}\n >\n {message.content}\n </div>\n </div>\n </div>\n )\n}\n",
"type": "registry:component"
},
{
"path": "registry/default/blocks/realtime-chat/components/realtime-chat.tsx",
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { ChatMessageItem } from '@/registry/default/blocks/realtime-chat/components/chat-message'\nimport { useChatScroll } from '@/registry/default/blocks/realtime-chat/hooks/use-chat-scroll'\nimport {\n type ChatMessage,\n useRealtimeChat,\n} from '@/registry/default/blocks/realtime-chat/hooks/use-realtime-chat'\nimport { Button } from '@/registry/default/components/ui/button'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Send } from 'lucide-react'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\n\ninterface RealtimeChatProps {\n roomName: string\n username: string\n onMessage?: (messages: ChatMessage[]) => void\n messages?: ChatMessage[]\n}\n\n/**\n * Realtime chat component\n * @param roomName - The name of the room to join. Each room is a unique chat.\n * @param username - The username of the user\n * @param onMessage - The callback function to handle the messages. Useful if you want to store the messages in a database.\n * @param messages - The messages to display in the chat. Useful if you want to display messages from a database.\n * @returns The chat component\n */\nexport const RealtimeChat = ({\n roomName,\n username,\n onMessage,\n messages: initialMessages = [],\n}: RealtimeChatProps) => {\n const { containerRef, scrollToBottom } = useChatScroll()\n\n const {\n messages: realtimeMessages,\n sendMessage,\n isConnected,\n } = useRealtimeChat({\n roomName,\n username,\n })\n const [newMessage, setNewMessage] = useState('')\n\n // Merge realtime messages with initial messages\n const allMessages = useMemo(() => {\n const mergedMessages = [...initialMessages, ...realtimeMessages]\n // Remove duplicates based on message id\n const uniqueMessages = mergedMessages.filter(\n (message, index, self) => index === self.findIndex((m) => m.id === message.id)\n )\n // Sort by creation date\n const sortedMessages = uniqueMessages.sort((a, b) => a.createdAt.localeCompare(b.createdAt))\n\n return sortedMessages\n }, [initialMessages, realtimeMessages])\n\n useEffect(() => {\n if (onMessage) {\n onMessage(allMessages)\n }\n }, [allMessages, onMessage])\n\n useEffect(() => {\n // Scroll to bottom whenever messages change\n scrollToBottom()\n }, [allMessages, scrollToBottom])\n\n const handleSendMessage = useCallback(\n (e: React.FormEvent) => {\n e.preventDefault()\n if (!newMessage.trim() || !isConnected) return\n\n sendMessage(newMessage)\n setNewMessage('')\n },\n [newMessage, isConnected, sendMessage]\n )\n\n return (\n <div className=\"flex flex-col h-full w-full bg-background text-foreground antialiased\">\n {/* Messages */}\n <div ref={containerRef} className=\"flex-1 overflow-y-auto p-4 space-y-4\">\n {allMessages.length === 0 ? (\n <div className=\"text-center text-sm text-muted-foreground\">\n No messages yet. Start the conversation!\n </div>\n ) : null}\n <div className=\"space-y-1\">\n {allMessages.map((message, index) => {\n const prevMessage = index > 0 ? allMessages[index - 1] : null\n const showHeader = !prevMessage || prevMessage.user.name !== message.user.name\n\n return (\n <div\n key={message.id}\n className=\"animate-in fade-in slide-in-from-bottom-4 duration-300\"\n >\n <ChatMessageItem\n message={message}\n isOwnMessage={message.user.name === username}\n showHeader={showHeader}\n />\n </div>\n )\n })}\n </div>\n </div>\n\n <form onSubmit={handleSendMessage} className=\"flex w-full gap-2 border-t border-border p-4\">\n <Input\n className={cn(\n 'rounded-full bg-background text-sm transition-all duration-300',\n isConnected && newMessage.trim() ? 'w-[calc(100%-36px)]' : 'w-full'\n )}\n type=\"text\"\n value={newMessage}\n onChange={(e) => setNewMessage(e.target.value)}\n placeholder=\"Type a message...\"\n disabled={!isConnected}\n />\n {isConnected && newMessage.trim() && (\n <Button\n className=\"aspect-square rounded-full animate-in fade-in slide-in-from-right-4 duration-300\"\n type=\"submit\"\n disabled={!isConnected}\n >\n <Send className=\"size-4\" />\n </Button>\n )}\n </form>\n </div>\n )\n}\n",
"type": "registry:component"
},
{
"path": "registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx",
"content": "'use client'\n\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useCallback, useEffect, useState } from 'react'\n\ninterface UseRealtimeChatProps {\n roomName: string\n username: string\n}\n\nexport interface ChatMessage {\n id: string\n content: string\n user: {\n name: string\n }\n createdAt: string\n}\n\nconst EVENT_MESSAGE_TYPE = 'message'\n\nexport function useRealtimeChat({ roomName, username }: UseRealtimeChatProps) {\n const supabase = createClient()\n const [messages, setMessages] = useState<ChatMessage[]>([])\n const [channel, setChannel] = useState<ReturnType<typeof supabase.channel> | null>(null)\n const [isConnected, setIsConnected] = useState(false)\n\n useEffect(() => {\n const newChannel = supabase.channel(roomName)\n\n newChannel\n .on('broadcast', { event: EVENT_MESSAGE_TYPE }, (payload) => {\n setMessages((current) => [...current, payload.payload as ChatMessage])\n })\n .subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n setIsConnected(true)\n } else {\n setIsConnected(false)\n }\n })\n\n setChannel(newChannel)\n\n return () => {\n supabase.removeChannel(newChannel)\n }\n }, [roomName, username, supabase])\n\n const sendMessage = useCallback(\n async (content: string) => {\n if (!channel || !isConnected) return\n\n const message: ChatMessage = {\n id: crypto.randomUUID(),\n content,\n user: {\n name: username,\n },\n createdAt: new Date().toISOString(),\n }\n\n // Update local state immediately for the sender\n setMessages((current) => [...current, message])\n\n await channel.send({\n type: 'broadcast',\n event: EVENT_MESSAGE_TYPE,\n payload: message,\n })\n },\n [channel, isConnected, username]\n )\n\n return { messages, sendMessage, isConnected }\n}\n",
"type": "registry:hook"
},
{
"path": "registry/default/blocks/realtime-chat/hooks/use-chat-scroll.tsx",
"content": "import { useCallback, useRef } from 'react'\n\nexport function useChatScroll() {\n const containerRef = useRef<HTMLDivElement>(null)\n\n const scrollToBottom = useCallback(() => {\n if (!containerRef.current) return\n\n const container = containerRef.current\n container.scrollTo({\n top: container.scrollHeight,\n behavior: 'smooth',\n })\n }, [])\n\n return { containerRef, scrollToBottom }\n}\n",
"type": "registry:hook"
},
{
"path": "registry/default/clients/react-router/lib/supabase/client.ts",
"content": "/// <reference types=\"vite/types/importMeta.d.ts\" />\nimport { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n import.meta.env.VITE_SUPABASE_URL!,\n import.meta.env.VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY!\n )\n}\n",
"type": "registry:lib"
},
{
"path": "registry/default/clients/react-router/lib/supabase/server.ts",
"content": "import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'\n\nexport function createClient(request: Request) {\n const headers = new Headers()\n\n const supabase = createServerClient(\n process.env.VITE_SUPABASE_URL!,\n process.env.VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return parseCookieHeader(request.headers.get('Cookie') ?? '') as {\n name: string\n value: string\n }[]\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value, options }) =>\n headers.append('Set-Cookie', serializeCookieHeader(name, value, options))\n )\n },\n },\n }\n )\n\n return { supabase, headers }\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`."
}