mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 04:45:08 +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
47 lines
9.9 KiB
JSON
47 lines
9.9 KiB
JSON
{
|
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
"name": "realtime-chat-react",
|
|
"type": "registry:component",
|
|
"title": "Realtime Chat",
|
|
"description": "Component which renders realtime chat messages from other users in a room.",
|
|
"dependencies": [
|
|
"lucide-react",
|
|
"@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/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`."
|
|
} |