{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "realtime-avatar-stack-react", "title": "Realtime Avatar Stack (React)", "description": "Display a stack of avatars showing users currently present in a room. Updates in realtime as users join and leave. Includes full Convex backend with presence tracking.", "dependencies": ["convex@latest", "@auth/core", "@convex-dev/auth@latest"], "registryDependencies": ["avatar", "tooltip"], "files": [ { "path": "src/registry/convex/blocks/realtime-avatar-stack-react/components/avatar-stack.tsx", "content": "import { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface AvatarStackUser {\n id: string;\n name: string;\n image?: string;\n color?: string;\n}\n\ninterface AvatarStackProps {\n users: AvatarStackUser[];\n maxVisible?: number;\n size?: \"sm\" | \"md\" | \"lg\";\n className?: string;\n}\n\nconst sizeClasses = {\n sm: \"h-6 w-6 text-xs\",\n md: \"h-8 w-8 text-sm\",\n lg: \"h-10 w-10 text-base\",\n};\n\nconst overlapClasses = {\n sm: \"-ml-2\",\n md: \"-ml-3\",\n lg: \"-ml-4\",\n};\n\nfunction getInitials(name: string): string {\n return name\n .split(\" \")\n .map((n: any) => n[0])\n .join(\"\")\n .toUpperCase()\n .slice(0, 2);\n}\n\nfunction getColorFromName(name: string): string {\n const colors = [\n \"#ef4444\",\n \"#f97316\",\n \"#f59e0b\",\n \"#84cc16\",\n \"#22c55e\",\n \"#14b8a6\",\n \"#06b6d4\",\n \"#3b82f6\",\n \"#6366f1\",\n \"#8b5cf6\",\n \"#a855f7\",\n \"#d946ef\",\n \"#ec4899\",\n \"#f43f5e\",\n ];\n let hash = 0;\n for (let i = 0; i < name.length; i++) {\n hash = name.charCodeAt(i) + ((hash << 5) - hash);\n }\n return colors[Math.abs(hash) % colors.length];\n}\n\nexport function AvatarStack({\n users,\n maxVisible = 5,\n size = \"md\",\n className,\n}: AvatarStackProps) {\n const visibleUsers = users.slice(0, maxVisible);\n const remainingCount = users.length - maxVisible;\n\n return (\n \n
\n {visibleUsers.map((user: any, index: any) => (\n \n \n 0 && overlapClasses[size],\n )}\n style={{\n zIndex: users.length - index,\n }}\n >\n \n \n {getInitials(user.name)}\n \n \n \n \n

{user.name}

\n
\n
\n ))}\n {remainingCount > 0 && (\n \n \n \n \n +{remainingCount}\n \n \n \n \n

{remainingCount} more users

\n
\n
\n )}\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/avatar-stack.tsx" }, { "path": "src/registry/convex/blocks/realtime-avatar-stack-react/components/realtime-avatar-stack.tsx", "content": "\"use client\";\n\nimport { AvatarStack } from \"./avatar-stack\";\nimport {\n useRealtimePresenceRoom,\n type PresenceUser,\n} from \"../hooks/use-realtime-presence-room\";\n\ninterface RealtimeAvatarStackProps {\n roomName: string;\n user: {\n name: string;\n image?: string;\n color?: string;\n };\n maxVisible?: number;\n size?: \"sm\" | \"md\" | \"lg\";\n className?: string;\n}\n\nexport function RealtimeAvatarStack({\n roomName,\n user,\n maxVisible = 5,\n size = \"md\",\n className,\n}: RealtimeAvatarStackProps) {\n const { users, isConnected, userCount } = useRealtimePresenceRoom({\n roomName,\n user,\n });\n\n const avatarUsers = users.map((u: PresenceUser) => ({\n id: u.id,\n name: u.name,\n image: u.image,\n color: u.color,\n }));\n\n return (\n
\n \n {userCount > 0 && (\n \n {userCount} {userCount === 1 ? \"user\" : \"users\"} online\n \n )}\n {!isConnected && (\n (connecting...)\n )}\n
\n );\n}\n", "type": "registry:component", "target": "components/realtime-avatar-stack.tsx" }, { "path": "src/registry/convex/blocks/realtime-avatar-stack-react/hooks/use-realtime-presence-room.ts", "content": "\"use client\";\n\nimport { useQuery, useMutation } from \"convex/react\";\nimport { api } from \"@/convex/_generated/api\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\ninterface UserPresence {\n name: string;\n image?: string;\n color?: string;\n}\n\ninterface UseRealtimePresenceRoomProps {\n roomName: string;\n user: UserPresence;\n heartbeatMs?: number;\n}\n\nexport interface PresenceUser {\n id: string;\n name: string;\n image?: string;\n color?: string;\n}\n\n// Generate a unique session ID per component instance\n// Important: Do NOT use localStorage as it's shared between iframes/tabs on the same domain,\n// which would cause all instances to share the same sessionId and filter out each other\nfunction generateSessionId(): string {\n return `session-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;\n}\n\nexport function useRealtimePresenceRoom({\n roomName,\n user,\n heartbeatMs = 10000,\n}: UseRealtimePresenceRoomProps) {\n const [sessionId, setSessionId] = useState(\"\");\n const presenceData = useQuery(api.presence.list, { roomId: roomName });\n const updatePresence = useMutation(api.presence.update);\n const leaveRoom = useMutation(api.presence.leave);\n const heartbeatRef = useRef(null);\n\n useEffect(() => {\n setSessionId(generateSessionId());\n }, []);\n\n const users: PresenceUser[] = (presenceData ?? []).map((p: any) => ({\n id: p._id,\n name: p.data?.name || \"Anonymous\",\n image: p.data?.userImage,\n color: p.data?.color,\n }));\n\n const join = useCallback(() => {\n if (!sessionId) return;\n updatePresence({\n roomId: roomName,\n data: {\n name: user.name,\n userImage: user.image,\n color: user.color,\n },\n sessionId,\n });\n }, [updatePresence, roomName, user, sessionId]);\n\n const leave = useCallback(() => {\n if (!sessionId) return;\n leaveRoom({ roomId: roomName, sessionId });\n }, [leaveRoom, roomName, sessionId]);\n\n // Join room on mount and set up heartbeat\n useEffect(() => {\n if (!sessionId) return;\n\n join();\n\n heartbeatRef.current = setInterval(() => {\n join();\n }, heartbeatMs);\n\n return () => {\n if (heartbeatRef.current) {\n clearInterval(heartbeatRef.current);\n }\n leave();\n };\n }, [join, leave, heartbeatMs, sessionId]);\n\n return {\n users,\n isConnected: presenceData !== undefined,\n userCount: users.length,\n };\n}\n", "type": "registry:hook", "target": "hooks/use-realtime-presence-room.ts" }, { "path": "src/registry/convex/blocks/realtime-avatar-stack-react/convex/schema.ts", "content": "import { defineSchema, defineTable } from \"convex/server\";\nimport { v } from \"convex/values\";\n\n/**\n * Schema for realtime avatar stack/presence tracking.\n *\n * This schema supports both authenticated and demo modes:\n * - Authenticated: userId links presence to the user\n * - Demo mode: sessionId tracks presence without authentication\n *\n * Security considerations:\n * - Presence is scoped to rooms for isolation\n * - lastSeen enables automatic cleanup of stale entries\n * - Multiple indexes for efficient lookups\n */\nexport default defineSchema({\n // Presence tracking table for avatar stacks and user state\n presence: defineTable({\n // Room identifier for presence isolation\n roomId: v.string(),\n\n // User reference (optional for demo mode)\n userId: v.optional(v.id(\"users\")),\n\n // Session identifier for demo mode tracking\n sessionId: v.optional(v.string()),\n\n // Presence data (user info, status)\n data: v.object({\n // Cursor/pointer position (optional, used by cursor component)\n cursor: v.optional(v.object({ x: v.number(), y: v.number() })),\n position: v.optional(v.object({ x: v.number(), y: v.number() })),\n\n // User status (online, away, etc.)\n status: v.optional(v.string()),\n\n // Display information for avatar stack\n userName: v.optional(v.string()),\n userImage: v.optional(v.string()),\n name: v.optional(v.string()),\n color: v.optional(v.string()),\n }),\n\n // Timestamp for staleness detection\n lastSeen: v.number(),\n })\n .index(\"by_room\", [\"roomId\"])\n .index(\"by_user_and_room\", [\"userId\", \"roomId\"])\n .index(\"by_session_and_room\", [\"sessionId\", \"roomId\"]),\n\n // Optional: Users table if you want to track users\n // Uncomment if using with authentication\n // users: defineTable({\n // name: v.optional(v.string()),\n // email: v.optional(v.string()),\n // image: v.optional(v.string()),\n // }),\n});\n", "type": "registry:file", "target": "convex/schema.ts" }, { "path": "src/registry/convex/blocks/realtime-avatar-stack-react/convex/presence.ts", "content": "import { query, mutation, internalMutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\n/**\n * Presence data validator - defines allowed fields for presence data.\n */\nconst presenceDataValidator = v.object({\n cursor: v.optional(v.object({ x: v.number(), y: v.number() })),\n position: v.optional(v.object({ x: v.number(), y: v.number() })),\n status: v.optional(v.string()),\n userName: v.optional(v.string()),\n userImage: v.optional(v.string()),\n name: v.optional(v.string()),\n color: v.optional(v.string()),\n});\n\n/**\n * Full presence record validator.\n */\nconst presenceValidator = v.object({\n _id: v.id(\"presence\"),\n _creationTime: v.number(),\n roomId: v.string(),\n userId: v.optional(v.id(\"users\")),\n sessionId: v.optional(v.string()),\n data: presenceDataValidator,\n lastSeen: v.number(),\n});\n\n/**\n * Presence timeout in milliseconds.\n * Entries older than this are considered stale and filtered out.\n */\nconst PRESENCE_TIMEOUT = 30000; // 30 seconds\n\n/**\n * Maximum room ID length.\n */\nconst MAX_ROOM_ID_LENGTH = 100;\n\n/**\n * List all active presence entries in a room.\n *\n * This query is reactive - it automatically updates when presence changes.\n * Filters out stale entries (older than PRESENCE_TIMEOUT).\n */\nexport const list = query({\n args: { roomId: v.string() },\n returns: v.array(presenceValidator),\n handler: async (ctx: any, args: any) => {\n // Validate room ID\n if (!args.roomId || args.roomId.length > MAX_ROOM_ID_LENGTH) {\n return [];\n }\n\n const now = Date.now();\n const presenceList = await ctx.db\n .query(\"presence\")\n .withIndex(\"by_room\", (q: any) => q.eq(\"roomId\", args.roomId))\n .collect();\n\n // Filter out stale presence entries\n return presenceList.filter((p: any) => now - p.lastSeen < PRESENCE_TIMEOUT);\n },\n});\n\n/**\n * Update presence (user info, status, etc.) in a room.\n *\n * Creates a new presence entry if one doesn't exist, otherwise updates existing.\n * Supports both authenticated and demo modes.\n *\n * Security:\n * - Requires either userId (from auth) or sessionId (demo mode)\n * - Data fields are validated\n */\nexport const update = mutation({\n args: {\n roomId: v.string(),\n data: presenceDataValidator,\n sessionId: v.optional(v.string()),\n },\n returns: v.null(),\n handler: async (ctx: any, args: any) => {\n // Validate room ID\n if (!args.roomId || args.roomId.length > MAX_ROOM_ID_LENGTH) {\n return null;\n }\n\n // Try to get authenticated user ID\n let userId: any = null;\n try {\n const { getAuthUserId } = await import(\"@convex-dev/auth/server\");\n userId = await getAuthUserId(ctx);\n } catch {\n // Auth not configured - demo mode only\n }\n\n // Require at least a sessionId if no auth\n if (!userId && !args.sessionId) {\n return null;\n }\n\n // IMPORTANT: Prioritize sessionId for lookups to support multiple presence\n // entries from the same authenticated user (e.g., two browser tabs/iframes).\n // Only fall back to userId when sessionId is not provided.\n let existing = null;\n if (args.sessionId) {\n existing = await ctx.db\n .query(\"presence\")\n .withIndex(\"by_session_and_room\", (q: any) =>\n q.eq(\"sessionId\", args.sessionId).eq(\"roomId\", args.roomId),\n )\n .first();\n } else if (userId) {\n existing = await ctx.db\n .query(\"presence\")\n .withIndex(\"by_user_and_room\", (q: any) =>\n q.eq(\"userId\", userId).eq(\"roomId\", args.roomId),\n )\n .first();\n }\n\n if (existing) {\n // Update existing presence\n await ctx.db.patch(existing._id, {\n data: args.data,\n lastSeen: Date.now(),\n });\n } else {\n // Create new presence entry\n await ctx.db.insert(\"presence\", {\n roomId: args.roomId,\n userId: userId,\n sessionId: args.sessionId,\n data: args.data,\n lastSeen: Date.now(),\n });\n }\n\n return null;\n },\n});\n\n/**\n * Leave a room (mark presence as stale).\n *\n * Marks the presence entry with lastSeen: 0 so it's filtered out immediately.\n * The actual deletion is handled by the cleanup function.\n */\nexport const leave = mutation({\n args: {\n roomId: v.string(),\n sessionId: v.optional(v.string()),\n },\n returns: v.null(),\n handler: async (ctx: any, args: any) => {\n // Try to get authenticated user ID\n let userId: any = null;\n try {\n const { getAuthUserId } = await import(\"@convex-dev/auth/server\");\n userId = await getAuthUserId(ctx);\n } catch {\n // Auth not configured\n }\n\n // Prioritize sessionId for lookups (same as update)\n let existing = null;\n if (args.sessionId) {\n existing = await ctx.db\n .query(\"presence\")\n .withIndex(\"by_session_and_room\", (q: any) =>\n q.eq(\"sessionId\", args.sessionId).eq(\"roomId\", args.roomId),\n )\n .first();\n } else if (userId) {\n existing = await ctx.db\n .query(\"presence\")\n .withIndex(\"by_user_and_room\", (q: any) =>\n q.eq(\"userId\", userId).eq(\"roomId\", args.roomId),\n )\n .first();\n }\n\n if (existing) {\n // Mark as stale instead of deleting to avoid race conditions\n await ctx.db.patch(existing._id, { lastSeen: 0 });\n }\n\n return null;\n },\n});\n\n/**\n * Cleanup stale presence entries.\n *\n * This is an internal mutation meant to be called from a cron job.\n * Deletes all presence entries older than PRESENCE_TIMEOUT.\n */\nexport const cleanup = internalMutation({\n args: {},\n returns: v.number(),\n handler: async (ctx: any) => {\n const cutoff = Date.now() - PRESENCE_TIMEOUT;\n const stalePresence = await ctx.db\n .query(\"presence\")\n .filter((q: any) => q.lt(q.field(\"lastSeen\"), cutoff))\n .collect();\n\n // Delete sequentially to avoid write conflicts\n for (const p of stalePresence) {\n await ctx.db.delete(p._id);\n }\n\n return stalePresence.length;\n },\n});\n", "type": "registry:file", "target": "convex/presence.ts" }, { "path": "src/registry/convex/clients/react/lib/convex/client.ts", "content": "import { ConvexReactClient } from \"convex/react\";\n\nconst convexUrl = (import.meta as any).env.VITE_CONVEX_URL as string;\n\nexport const convex = new ConvexReactClient(convexUrl);\n", "type": "registry:lib", "target": "lib/convex/client.ts" }, { "path": "src/registry/convex/clients/react/lib/convex/provider.tsx", "content": "import { ConvexAuthProvider } from \"@convex-dev/auth/react\";\nimport { ConvexReactClient } from \"convex/react\";\nimport { ReactNode } from \"react\";\n\nconst convex = new ConvexReactClient(\n (import.meta as any).env.VITE_CONVEX_URL as string,\n);\n\nexport function ConvexClientProvider({ children }: { children: ReactNode }) {\n return {children};\n}\n", "type": "registry:lib", "target": "lib/convex/provider.tsx" } ], "envVars": { "CONVEX_DEPLOYMENT": "", "VITE_CONVEX_URL": "" }, "docs": "## Post-Install Setup\n\n1. Run `npx convex dev` to start the Convex dev server.\n\n2. Copy the deployment URL to your `.env` file as `VITE_CONVEX_URL`.\n\n3. The Convex schema and functions will be automatically deployed when you run `npx convex dev`.\n\n## Post-Install Setup\n\n1. Run `npx convex dev` to start the Convex dev server and get your deployment URL.\n\n2. Copy the deployment URL to your `.env` file as `VITE_CONVEX_URL`.\n\n3. Wrap your app with the `ConvexAuthProvider` from `lib/convex/provider.tsx`.", "type": "registry:component" }