614 lines
19 KiB
TypeScript
614 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useRef } from 'react'
|
|
import { io, Socket } from 'socket.io-client'
|
|
import { useSession, signIn } from 'next-auth/react'
|
|
import useSWR from 'swr'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
import { MessageCircle, Send, X, Minimize2, Maximize2, Trash2, CloudRain } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
|
import { ExternalLink } from "lucide-react"
|
|
import Link from "next/link"
|
|
import { toast } from 'sonner';
|
|
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
|
import { UserBadges } from '@/components/ui/user-badges'
|
|
import {
|
|
NET_EVENTS,
|
|
verifyMessage
|
|
} from "@/lib/vendor-metrics";
|
|
|
|
interface Notification {
|
|
_id: string;
|
|
coinId: string;
|
|
userId: string;
|
|
userName: string;
|
|
userImage: string;
|
|
text: string;
|
|
createdAt: string;
|
|
userVerified?: boolean;
|
|
userIsAdmin?: boolean;
|
|
userIsBetaTester?: boolean;
|
|
userIsBugHunter?: boolean;
|
|
}
|
|
|
|
interface RainEvent {
|
|
_id: string;
|
|
amount: number;
|
|
hostId: string;
|
|
hostName: string;
|
|
endsAt: string;
|
|
participants: { id: string, name: string, image: string }[];
|
|
active: boolean;
|
|
}
|
|
|
|
interface SessionConfig {
|
|
market: string;
|
|
nonce: string;
|
|
}
|
|
|
|
export function GlobalChat() {
|
|
const { data: session } = useSession()
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [socket, setSocket] = useState<Socket | null>(null)
|
|
const [messages, setMessages] = useState<Notification[]>([])
|
|
const [newMessage, setNewMessage] = useState('')
|
|
const [isConnected, setIsConnected] = useState(false)
|
|
const [activeRain, setActiveRain] = useState<RainEvent | null>(null)
|
|
|
|
const [sessionConfig, setSessionConfig] = useState<SessionConfig | null>(null)
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
|
|
const renderTextWithLinks = (text: string) => {
|
|
const parts = text.split(/(@\w+|\$\w+)/);
|
|
return parts.map((part, i) => {
|
|
if (part.startsWith('@')) {
|
|
const username = part.slice(1);
|
|
return <Link key={i} href={`/u/${encodeURIComponent(username)}`} className="text-blue-500 hover:underline">{part}</Link>;
|
|
} else if (part.startsWith('$')) {
|
|
const ticker = part.slice(1);
|
|
return <Link key={i} href={`/?search=${encodeURIComponent(ticker)}`} className="text-green-500 hover:underline">{part}</Link>;
|
|
} else {
|
|
return <span key={i}>{part}</span>;
|
|
}
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetch('/api/rain').then(res => res.json()).then(data => {
|
|
if(data.rain && data.rain.active) setActiveRain(data.rain);
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const saved = localStorage.getItem('pummmp_global_chat')
|
|
if (saved) {
|
|
try {
|
|
setMessages(JSON.parse(saved))
|
|
} catch (e) {
|
|
console.error('Failed to parse chat history', e)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (messages.length > 0) {
|
|
const toSave = messages.slice(-100);
|
|
localStorage.setItem('pummmp_global_chat', JSON.stringify(toSave))
|
|
}
|
|
}, [messages])
|
|
|
|
useEffect(() => {
|
|
const SOCKET_URL = window.location.hostname === 'localhost'
|
|
? "http://localhost:6767"
|
|
: "https://pummmp.fun";
|
|
|
|
const url = process.env.NEXT_PUBLIC_SOCKET_URL || SOCKET_URL;
|
|
|
|
const newSocket = io(url, {
|
|
path: "/socket.io",
|
|
transports: ["websocket"],
|
|
withCredentials: true,
|
|
reconnection: true,
|
|
reconnectionAttempts: 10,
|
|
reconnectionDelay: 1000,
|
|
reconnectionDelayMax: 5000,
|
|
timeout: 20000,
|
|
forceNew: false,
|
|
});
|
|
|
|
newSocket.on('connect', () => {
|
|
console.log('Chat connected');
|
|
setIsConnected(true);
|
|
});
|
|
|
|
newSocket.on(NET_EVENTS.CONNECT, (data: any) => {
|
|
setSessionConfig({
|
|
market: data._h_mid,
|
|
nonce: data._h_nonce
|
|
});
|
|
});
|
|
|
|
newSocket.on('nonce_update', (data: any) => {
|
|
setSessionConfig(prev => prev ? { ...prev, nonce: data.nonce } : null);
|
|
});
|
|
|
|
newSocket.on('rain_update', (rain: any) => {
|
|
if(rain) setActiveRain(rain);
|
|
});
|
|
|
|
newSocket.on('rain_ended', (data: any) => {
|
|
setActiveRain(null);
|
|
if (data && data.amount) {
|
|
toast.success(`Rain Ended! ${data.totalParticipants} users got ${(data.payoutPerUser || 0).toFixed(4)} SOL!`);
|
|
}
|
|
});
|
|
|
|
newSocket.on(NET_EVENTS.INCOMING, (signedData: string) => {
|
|
if (!signedData || typeof signedData !== 'string') return;
|
|
});
|
|
|
|
newSocket.on('disconnect', (reason) => {
|
|
console.log('Chat disconnected:', reason);
|
|
setIsConnected(false);
|
|
setSessionConfig(null);
|
|
});
|
|
|
|
newSocket.on('connect_error', (error) => {
|
|
console.error('Chat connection error:', error);
|
|
setIsConnected(false);
|
|
});
|
|
|
|
newSocket.on('reconnect', (attemptNumber) => {
|
|
console.log('Chat reconnected after', attemptNumber, 'attempts');
|
|
setIsConnected(true);
|
|
});
|
|
|
|
newSocket.on('reconnect_error', (error) => {
|
|
console.error('Chat reconnection failed:', error);
|
|
});
|
|
|
|
newSocket.on('reconnect_failed', () => {
|
|
console.error('Chat reconnection failed completely');
|
|
setIsConnected(false);
|
|
});
|
|
|
|
newSocket.on('error_message', (msg) => toast.error(msg));
|
|
|
|
setSocket(newSocket)
|
|
|
|
return () => {
|
|
newSocket.disconnect()
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!socket || !sessionConfig) return;
|
|
|
|
const handler = async (signedData: string) => {
|
|
const raw = await verifyMessage(signedData, sessionConfig.market);
|
|
if (!raw) {
|
|
console.log('[Chat] Failed to verify message');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const clean = raw.replace(/\0/g, '').trim();
|
|
if (!clean.startsWith('{') && !clean.startsWith('[')) {
|
|
console.log('[Chat] Message not JSON:', clean.substring(0, 50));
|
|
return;
|
|
}
|
|
|
|
const msg: Notification = JSON.parse(clean);
|
|
console.log('[Chat] Received message:', msg.userName, msg.text.substring(0, 50));
|
|
setMessages((prev) => {
|
|
if (prev.find(m => m._id === msg._id)) return prev;
|
|
return [...prev, msg].slice(-100);
|
|
});
|
|
} catch(e) {
|
|
console.error('[Chat] Error parsing message:', e);
|
|
}
|
|
};
|
|
|
|
socket.on(NET_EVENTS.INCOMING, handler);
|
|
|
|
return () => {
|
|
socket.off(NET_EVENTS.INCOMING, handler);
|
|
}
|
|
}, [socket, sessionConfig]);
|
|
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollIntoView({ behavior: 'smooth' })
|
|
}
|
|
}, [messages, isOpen])
|
|
|
|
useEffect(() => {
|
|
if (activeRain && !isOpen) {
|
|
setIsOpen(true);
|
|
toast.success(`🌧️ Rain started by ${activeRain.hostName}! You could win ${activeRain.amount} SOL!`, {
|
|
duration: 5000,
|
|
});
|
|
}
|
|
}, [activeRain, isOpen])
|
|
|
|
const handleJoinRain = async () => {
|
|
if (!activeRain || !session) return;
|
|
try {
|
|
const res = await fetch('/api/rain', { method: 'PUT' });
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
toast.success('You entered the rain!');
|
|
setActiveRain(prev => {
|
|
if(!prev) return null;
|
|
const uid = (session.user as any).id || (session as any).id;
|
|
if (!uid) return prev;
|
|
return {
|
|
...prev,
|
|
participants: prev.participants.some(p => p.id === uid) ? prev.participants : [...prev.participants, {
|
|
id: uid,
|
|
name: session.user.name || 'Unknown',
|
|
image: session.user.image || '/logo.ico'
|
|
}]
|
|
}
|
|
})
|
|
} else {
|
|
toast.error(data.error);
|
|
}
|
|
} catch (e) {
|
|
toast.error('Failed to join rain');
|
|
}
|
|
}
|
|
|
|
const handleSendMessage = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
if (!newMessage.trim() || !session || !socket || !sessionConfig || !sessionConfig.nonce) return;
|
|
|
|
if (newMessage.startsWith('.help')) {
|
|
const isAdmin = session?.user?.isAdmin;
|
|
const helpMsg = {
|
|
_id: Math.random().toString(),
|
|
coinId: 'system',
|
|
userId: 'system',
|
|
userName: 'System',
|
|
userImage: '/logo.ico',
|
|
text: isAdmin
|
|
? `Commands: .tip <username> <amount> - Tip someone SOL ~ .gamble <amount> <chance%> - Roll the dice (1-95%) ~ .ban <username> <duration> - Ban user (30m/2h/1d/perm) ~ .unban <username> - Unban user`
|
|
: `Commands: .tip <username> <amount> - Tip someone SOL ~ .gamble <amount> <chance%> - Roll the dice (1-95%)`,
|
|
createdAt: new Date().toISOString(),
|
|
userVerified: true
|
|
}
|
|
setMessages(prev => [...prev, helpMsg].slice(-100))
|
|
setNewMessage('')
|
|
return
|
|
}
|
|
|
|
if (newMessage.startsWith('.gamble ')) {
|
|
const parts = newMessage.split(' ');
|
|
if (parts.length >= 3) {
|
|
const amount = parts[1];
|
|
const chance = parts[2];
|
|
|
|
try {
|
|
const res = await fetch('/api/gamble', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ amount, chance })
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
setNewMessage('');
|
|
|
|
const payloadObj = {
|
|
type: 'gamble',
|
|
amount: parseFloat(amount),
|
|
won: data.won,
|
|
payout: data.payout,
|
|
chance: parseFloat(chance),
|
|
_nonce: sessionConfig.nonce
|
|
};
|
|
|
|
socket.emit(NET_EVENTS.OUTGOING, payloadObj);
|
|
|
|
const resultMsg = {
|
|
_id: Math.random().toString(),
|
|
coinId: 'system',
|
|
userId: 'system',
|
|
userName: 'pummmp.casino',
|
|
userImage: '/logo.ico',
|
|
text: data.won
|
|
? `You WON ${data.payout.toFixed(4)} SOL! (Rolled ${data.roll.toFixed(2)})`
|
|
: `You lost ${amount} SOL (Rolled ${data.roll.toFixed(2)})`,
|
|
createdAt: new Date().toISOString(),
|
|
userVerified: true
|
|
}
|
|
setMessages(prev => [...prev, resultMsg].slice(-100))
|
|
|
|
} else {
|
|
toast.error(data.error);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
toast.error('Gamble failed');
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (newMessage.startsWith('.rain ')) {
|
|
const parts = newMessage.split(' ');
|
|
if (parts.length >= 2) {
|
|
const amount = parts[1];
|
|
try {
|
|
const res = await fetch('/api/rain', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ amount })
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
setNewMessage('');
|
|
if (data.rain) setActiveRain(data.rain); // Immediate update
|
|
toast.success('Rain started! 🌧️');
|
|
} else {
|
|
toast.error(data.error);
|
|
}
|
|
} catch (err) {
|
|
toast.error('Failed to start rain');
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (newMessage.startsWith('.tip ')) {
|
|
const parts = newMessage.split(' ');
|
|
if (parts.length >= 3) {
|
|
const recipient = parts[1];
|
|
const amount = parts[2];
|
|
|
|
try {
|
|
const res = await fetch('/api/tip', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
recipientUsername: recipient,
|
|
amount: amount
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
setNewMessage('');
|
|
|
|
const payloadObj = {
|
|
type: 'tip',
|
|
recipientName: recipient,
|
|
amount: amount,
|
|
_nonce: sessionConfig.nonce
|
|
};
|
|
|
|
socket.emit(NET_EVENTS.OUTGOING, payloadObj);
|
|
|
|
const confirmMsg = {
|
|
_id: Math.random().toString(),
|
|
coinId: 'system',
|
|
userId: 'system',
|
|
userName: 'pummmp.bot',
|
|
userImage: '/logo.ico',
|
|
text: `✅ Tipped ${recipient} ${amount} SOL!`,
|
|
createdAt: new Date().toISOString(),
|
|
userVerified: true
|
|
}
|
|
setMessages(prev => [...prev, confirmMsg].slice(-100))
|
|
} else {
|
|
toast.error(data.error);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
toast.error('Failed to send tip');
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
socket.emit(NET_EVENTS.OUTGOING, {
|
|
text: newMessage,
|
|
_nonce: sessionConfig.nonce
|
|
});
|
|
|
|
setNewMessage('')
|
|
}
|
|
|
|
const handleClearChat = () => {
|
|
localStorage.removeItem('pummmp_global_chat')
|
|
setMessages([])
|
|
toast.success('Chat history cleared', {position: 'bottom-center'})
|
|
}
|
|
|
|
if (!isOpen) {
|
|
return (
|
|
<div
|
|
onClick={() => setIsOpen(true)}
|
|
className="fixed right-0 top-20 z-40 flex h-12 w-10 cursor-pointer items-center justify-center rounded-l-xl border-y border-l border-border bg-background shadow-md transition-all hover:w-12 hover:bg-accent/50 group"
|
|
>
|
|
<div className="relative">
|
|
<MessageCircle className="h-5 w-5 text-primary group-hover:text-primary/80" />
|
|
{isConnected && (
|
|
<span className="absolute -right-1 -top-1 flex h-2.5 w-2.5">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-green-500"></span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="fixed right-0 top-16 bottom-0 z-40 flex w-80 flex-col border-l border-border bg-background/95 backdrop-blur-sm shadow-2xl transition-all animate-in slide-in-from-right duration-300">
|
|
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border px-4 bg-muted/20">
|
|
<div className="flex items-center gap-2">
|
|
<MessageCircle className="h-4 w-4 text-primary" />
|
|
<h3 className="text-sm font-semibold">pummmp.chat</h3>
|
|
<div className={`h-1.5 w-1.5 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
|
<span className="text-[10px] text-muted-foreground">{isConnected ? 'Connected' : 'Connecting...'}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleClearChat}
|
|
className="h-7 w-7 rounded-full hover:bg-background hover:text-destructive"
|
|
title="Clear Local History"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)} className="h-7 w-7 rounded-full hover:bg-background">
|
|
<Minimize2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{activeRain && (
|
|
<div className="relative overflow-hidden border-b border-primary/20 bg-gradient-to-r from-primary/5 via-primary/10 to-primary/5 p-4 animate-in slide-in-from-top-2 shadow-inner">
|
|
|
|
<div className="relative z-10 flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded text-primary shadow-sm">
|
|
<CloudRain className="h-5 w-5 text-white" />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-black drop-shadow-sm">RAIN EVENT ACTIVE!</span>
|
|
<span className="text-[10px] font-medium text-muted-foreground">Hosted by {activeRain.hostName}</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-lg font-bold font-mono leading-none text-foreground">{activeRain.amount} SOL</div>
|
|
<div className="text-[10px] text-muted-foreground font-medium">Total distributed</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-2 mt-1">
|
|
<div className="flex -space-x-2 overflow-hidden">
|
|
{activeRain.participants.slice(0, 3).map((participant, i) => (
|
|
<Avatar key={participant.id} className="h-6 w-6 shrink-0 border border-border">
|
|
<AvatarImage src={participant.image} />
|
|
<AvatarFallback>{participant.name?.[0]}</AvatarFallback>
|
|
</Avatar>
|
|
))}
|
|
{(activeRain.participants?.length || 0) > 3 && (
|
|
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-background bg-muted flex items-center justify-center text-[12px] font-bold text-muted-foreground">
|
|
+{(activeRain.participants?.length || 0) - 3}
|
|
</div>
|
|
)}
|
|
{(activeRain.participants?.length || 0) === 0 && <span className="text-xs text-muted-foreground pl-2">0 participants | Be the first!</span>}
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
className={`h-8 px-4 font-bold transition-all shadow-md ${
|
|
activeRain.participants?.some(p => p.id === (session?.user?.id || ''))
|
|
? "bg-green-500/20 text-green-600 hover:bg-green-500/30 border border-green-500/50"
|
|
: "hover:scale-105 active:scale-95"
|
|
}`}
|
|
onClick={() => handleJoinRain()}
|
|
disabled={!session || activeRain.participants?.some(p => p.id === session.user?.id) || activeRain.hostId === session?.user?.id}
|
|
variant={activeRain.participants?.some(p => p.id === (session?.user?.id || '')) || activeRain.hostId === session?.user?.id ? "secondary" : "default"}
|
|
>
|
|
{activeRain.hostId === session?.user?.id ? 'Hosting' : (activeRain.participants?.some(p => p.id === session?.user?.id) ? 'Entered ✓' : 'Join Rain')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ScrollArea className="flex-1 p-3 min-h-0 overflow-hidden">
|
|
<div className="flex flex-col gap-3 max-w-[80%]">
|
|
{messages.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground text-sm">
|
|
<p>No messages yet.</p>
|
|
<p className="text-xs opacity-50">Say hello! 👋</p>
|
|
</div>
|
|
)}
|
|
{messages.map((msg, i) => (
|
|
<div key={`${msg._id || i}-${i}`} className={cn(
|
|
"flex gap-2 duration-300",
|
|
i === messages.length - 1 ? "animate-in fade-in slide-in-from-bottom-2" : ""
|
|
)}>
|
|
<Avatar className="h-6 w-6 shrink-0 mt-0.5 border border-border">
|
|
<AvatarImage src={msg.userImage} />
|
|
<AvatarFallback>{msg.userName?.[0]}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex flex-col min-w-0">
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className={cn("text-xs font-semibold truncate hover:underline cursor-pointer", msg.coinId === 'system' && "text-primary")}>
|
|
<a href={msg.coinId !== 'system' ? `/u/${encodeURIComponent(msg.userName)}` : undefined}>
|
|
{msg.userName}
|
|
</a>
|
|
</span>
|
|
<UserBadges
|
|
isAdmin={msg.userIsAdmin}
|
|
isVerified={msg.userVerified}
|
|
isBugHunter={msg.userIsBugHunter}
|
|
isBetaTester={msg.userIsBetaTester}
|
|
className="h-3 w-3"
|
|
onlyShowHighest
|
|
/>
|
|
<span className="text-[10px] text-muted-foreground">{new Date(msg.createdAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
|
|
</div>
|
|
<p className="text-xs leading-relaxed break-words text-foreground/90 font-medium">
|
|
{renderTextWithLinks(msg.text)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div ref={scrollRef} />
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<form onSubmit={handleSendMessage} className="p-3 border-t border-border bg-background">
|
|
<div className="flex gap-2">
|
|
{!session ? (
|
|
<Button type="button" variant="outline" className="w-full h-8 text-xs" onClick={() => signIn()}>
|
|
Sign in to chat
|
|
</Button>
|
|
) : (session?.user?.chatBannedUntil && new Date(session.user.chatBannedUntil) > new Date()) ? (
|
|
<Button type="button" variant="outline" className="w-full h-8 text-xs" disabled>
|
|
You are chat banned until {new Date(session.user.chatBannedUntil).toLocaleString()}
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Input
|
|
value={newMessage}
|
|
onChange={(e) => setNewMessage(e.target.value)}
|
|
placeholder="Type a message... | .help for commands"
|
|
className="h-8 text-xs"
|
|
disabled={!isConnected || !sessionConfig || !sessionConfig.nonce}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0"
|
|
disabled={!isConnected || !sessionConfig || !sessionConfig.nonce || !newMessage.trim()}
|
|
>
|
|
<Send className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|