{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "messenger-shadcnui", "type": "registry:component", "title": "Messenger", "description": "Glassmorphism messenger workspace with accessible motion and quick replies", "registryDependencies": [ "button" ], "dependencies": [ "framer-motion", "react" ], "files": [ { "path": "@uitripled/react-shadcn/src/components/components/chat/messenger.tsx", "content": "\"use client\";\n\nimport { Avatar, AvatarFallback } from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\";\nimport {\n CheckCheck,\n MoreVertical,\n Paperclip,\n Phone,\n Search,\n Send,\n Video,\n} from \"lucide-react\";\nimport { FormEvent, useEffect, useMemo, useRef, useState } from \"react\";\n\ntype Message = {\n id: string;\n sender: \"user\" | \"contact\";\n author: string;\n text: string;\n timestamp: string;\n};\n\ntype Conversation = {\n id: string;\n name: string;\n title: string;\n status: \"online\" | \"offline\";\n unread: number;\n initials: string;\n messages: Message[];\n quickReplies: string[];\n autoReplies: string[];\n};\n\nconst initialConversations: Conversation[] = [\n {\n id: \"product-updates\",\n name: \"Morgan James\",\n title: \"Product Strategy\",\n status: \"online\",\n unread: 2,\n initials: \"MJ\",\n messages: [\n {\n id: \"product-1\",\n sender: \"contact\",\n author: \"Morgan\",\n text: \"Thanks for the recap on the CashFlow release. The beta cohort loves the motion work.\",\n timestamp: \"09:18\",\n },\n {\n id: \"product-2\",\n sender: \"user\",\n author: \"You\",\n text: \"Amazing to hear. Do we have a decision on the new Messenger surface?\",\n timestamp: \"09:21\",\n },\n {\n id: \"product-3\",\n sender: \"contact\",\n author: \"Morgan\",\n text: \"Almost there. Design asked for one more pass on the animation timings - soft ease on expand.\",\n timestamp: \"09:24\",\n },\n ],\n quickReplies: [\n \"I can share a timing proposal.\",\n \"Let me know if you need a demo recording.\",\n \"We can add an onboarding tooltip.\",\n ],\n autoReplies: [\n \"That would help so much - a short clip would be perfect.\",\n \"Can we confirm the handoff checklist by tomorrow?\",\n \"Love the attention to accessibility details here.\",\n ],\n },\n {\n id: \"customer-success\",\n name: \"Leah Patel\",\n title: \"Customer Success\",\n status: \"offline\",\n unread: 0,\n initials: \"LP\",\n messages: [\n {\n id: \"success-1\",\n sender: \"contact\",\n author: \"Leah\",\n text: \"Morning! Enterprise users keep mentioning how clear the summary cards feel.\",\n timestamp: \"08:05\",\n },\n {\n id: \"success-2\",\n sender: \"user\",\n author: \"You\",\n text: \"Great sign. Do we need any follow-up education for folks migrating from the legacy dashboard?\",\n timestamp: \"08:08\",\n },\n {\n id: \"success-3\",\n sender: \"contact\",\n author: \"Leah\",\n text: \"Maybe a guided walkthrough. I can draft the outline if you can provide the animation cues.\",\n timestamp: \"08:11\",\n },\n ],\n quickReplies: [\n \"Happy to add cues for the walkthrough.\",\n \"Let us sync after the support standup.\",\n \"We can capture a Loom covering the flows.\",\n ],\n autoReplies: [\n \"Perfect. Support will love a beat-by-beat cue list.\",\n \"Thanks - I will drop a doc in the shared folder shortly.\",\n \"Appreciate you keeping motion accessible throughout.\",\n ],\n },\n {\n id: \"engineering-handoff\",\n name: \"Build Squad\",\n title: \"Engineering Handoff\",\n status: \"offline\",\n unread: 1,\n initials: \"BS\",\n messages: [\n {\n id: \"build-1\",\n sender: \"contact\",\n author: \"Carson\",\n text: \"Do you have the reduced-motion variants for Messenger handy?\",\n timestamp: \"Yesterday\",\n },\n {\n id: \"build-2\",\n sender: \"user\",\n author: \"You\",\n text: \"Yep - exporting now with notes on keyboard focus states.\",\n timestamp: \"Yesterday\",\n },\n ],\n quickReplies: [\n \"Uploading the reduced-motion recording now.\",\n \"Focus ring spec is ready if you need it.\",\n \"We can pair on the final QA pass.\",\n ],\n autoReplies: [\n \"Legend - the team will plug that into the Storybook build tonight.\",\n \"Appreciate the extra detail on focus management.\",\n \"We will ping if anything else blocks us.\",\n ],\n },\n];\n\ntype ReplyCursorState = Record;\n\nexport function Messenger() {\n const [selectedConversationId, setSelectedConversationId] = useState(\n initialConversations[0]?.id ?? \"\"\n );\n const [conversations, setConversations] =\n useState(initialConversations);\n const [draft, setDraft] = useState(\"\");\n const [replyCursor, setReplyCursor] = useState(() => {\n const cursor: ReplyCursorState = {};\n initialConversations.forEach((conversation) => {\n cursor[conversation.id] = 0;\n });\n return cursor;\n });\n const shouldReduceMotion = useReducedMotion();\n const messagesContainerRef = useRef(null);\n const liveRegionRef = useRef(null);\n const selectedConversationRef = useRef(selectedConversationId);\n const replyTimeoutRef = useRef(null);\n\n const activeConversation = useMemo(() => {\n return conversations.find(\n (conversation) => conversation.id === selectedConversationId\n );\n }, [conversations, selectedConversationId]);\n\n useEffect(() => {\n selectedConversationRef.current = selectedConversationId;\n setConversations((prev) =>\n prev.map((conversation) =>\n conversation.id === selectedConversationId\n ? { ...conversation, unread: 0 }\n : conversation\n )\n );\n }, [selectedConversationId]);\n\n useEffect(() => {\n if (!messagesContainerRef.current) {\n return;\n }\n const container = messagesContainerRef.current;\n const behavior = shouldReduceMotion ? \"auto\" : \"smooth\";\n\n const scrollToBottom = () => {\n container.scrollTo({ top: container.scrollHeight, behavior });\n };\n\n if (behavior === \"smooth\") {\n requestAnimationFrame(scrollToBottom);\n } else {\n scrollToBottom();\n }\n }, [\n activeConversation?.messages,\n activeConversation?.id,\n shouldReduceMotion,\n ]);\n\n useEffect(() => {\n return () => {\n if (replyTimeoutRef.current) {\n window.clearTimeout(replyTimeoutRef.current);\n }\n };\n }, []);\n\n useEffect(() => {\n if (!liveRegionRef.current || !activeConversation) {\n return;\n }\n const lastMessage =\n activeConversation.messages[activeConversation.messages.length - 1];\n if (!lastMessage) {\n return;\n }\n liveRegionRef.current.textContent =\n lastMessage.author +\n \" at \" +\n lastMessage.timestamp +\n \": \" +\n lastMessage.text;\n }, [activeConversation?.messages, activeConversation]);\n\n const handleSelectConversation = (conversationId: string) => {\n setSelectedConversationId(conversationId);\n };\n\n const handleSubmit = (event: FormEvent) => {\n event.preventDefault();\n if (!draft.trim() || !activeConversation) {\n return;\n }\n\n const conversationId = activeConversation.id;\n const timestamp = new Date().toLocaleTimeString(\"en-US\", {\n hour: \"2-digit\",\n minute: \"2-digit\",\n });\n const outgoing: Message = {\n id: \"outgoing-\" + Date.now().toString(),\n sender: \"user\",\n author: \"You\",\n text: draft.trim(),\n timestamp,\n };\n\n setConversations((prev) =>\n prev.map((conversation) =>\n conversation.id === conversationId\n ? {\n ...conversation,\n messages: [...conversation.messages, outgoing],\n unread: 0,\n }\n : conversation\n )\n );\n setDraft(\"\");\n\n const autoReplies = activeConversation.autoReplies;\n if (!autoReplies.length) {\n return;\n }\n\n const cursor = replyCursor[conversationId] ?? 0;\n const nextReply = autoReplies[cursor % autoReplies.length];\n const delay = shouldReduceMotion ? 0 : 900;\n\n replyTimeoutRef.current = window.setTimeout(() => {\n const safeTimestamp = new Date().toLocaleTimeString(\"en-US\", {\n hour: \"2-digit\",\n minute: \"2-digit\",\n });\n const incoming: Message = {\n id: \"incoming-\" + Date.now().toString(),\n sender: \"contact\",\n author: activeConversation.name,\n text: nextReply,\n timestamp: safeTimestamp,\n };\n\n const currentSelected = selectedConversationRef.current;\n setConversations((prev) =>\n prev.map((conversation) => {\n if (conversation.id !== conversationId) {\n return conversation;\n }\n const isActive = currentSelected === conversationId;\n return {\n ...conversation,\n messages: [...conversation.messages, incoming],\n unread: isActive ? 0 : conversation.unread + 1,\n };\n })\n );\n\n setReplyCursor((prev) => ({\n ...prev,\n [conversationId]: cursor + 1,\n }));\n }, delay);\n };\n\n if (!activeConversation) {\n return null;\n }\n\n const statusDotColor: Record = {\n online: \"bg-green-500\",\n offline: \"bg-red-500\",\n };\n\n return (\n
\n
\n
\n
\n
\n

\n Messenger\n

\n

\n {conversations.length} active conversation\n {conversations.length === 1 ? \"\" : \"s\"}\n

\n
\n \n Live\n \n
\n
\n \n Conversation\n \n handleSelectConversation(event.target.value)}\n className=\"w-full rounded-xl border border-border/40 bg-background/70 px-2.5 py-1.5 text-xs text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/30 sm:rounded-2xl sm:px-3 sm:py-2 sm:text-sm\"\n >\n {conversations.map((conversation) => (\n \n ))}\n \n
\n
\n\n
\n
\n
\n

Messenger

\n

\n {conversations.length} active conversation\n {conversations.length === 1 ? \"\" : \"s\"}\n

\n
\n \n Live\n \n
\n\n \n
\n \n \n
\n\n \n {conversations.map((conversation) => {\n const isActive = conversation.id === selectedConversationId;\n const lastMessage =\n conversation.messages[conversation.messages.length - 1];\n return (\n handleSelectConversation(conversation.id)}\n aria-pressed={isActive}\n className={cn(\n \"group relative flex w-full items-start gap-3 rounded-2xl border border-transparent p-3 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n isActive\n ? \"border-primary/40 bg-primary/10\"\n : \"bg-background/70 hover:border-border/40 hover:bg-muted/40\"\n )}\n role=\"listitem\"\n >\n
\n \n \n {conversation.initials}\n \n \n \n
\n
\n
\n
\n

\n {conversation.name}\n

\n

\n {conversation.title}\n

\n
\n
\n {lastMessage ? (\n

\n {lastMessage.author}: {lastMessage.text}\n

\n ) : (\n

\n No messages yet\n

\n )}\n
\n {conversation.unread > 0 && (\n \n {conversation.unread}\n \n )}\n \n );\n })}\n
\n
\n\n \n \n
\n
\n
\n \n \n {activeConversation.initials}\n \n \n \n
\n
\n

\n {activeConversation.name}\n

\n

\n {activeConversation.title}\n

\n
\n
\n\n
\n \n \n \n \n
\n
\n\n \n \n {activeConversation.messages.map((message) => (\n \n \n

\n {message.author}\n

\n \n {message.text}\n

\n
\n \n {message.timestamp}\n \n {message.sender === \"user\" && (\n \n )}\n
\n \n \n ))}\n
\n \n\n \n \n
\n
\n setDraft(event.target.value)}\n placeholder={\"Message \" + activeConversation.name}\n rows={2}\n className=\"min-h-[3rem] w-full resize-none border-none bg-transparent text-xs text-foreground placeholder:text-muted-foreground/70 focus-visible:ring-0 focus-visible:outline-none sm:min-h-[4rem] sm:text-sm\"\n aria-label={\"Message \" + activeConversation.name}\n />\n
\n {activeConversation.quickReplies.map((reply) => (\n setDraft(reply)}\n className=\"rounded-full border border-border/50 bg-background/70 px-2.5 py-0.5 text-[0.65rem] text-muted-foreground transition hover:border-primary/40 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:px-3 sm:py-1 sm:text-xs\"\n >\n {reply}\n \n ))}\n
\n
\n
\n \n \n \n \n \n \n
\n
\n \n \n
\n \n \n
\n );\n}\n", "type": "registry:component", "target": "components/uitripled/messenger-shadcnui.tsx" } ] }