pummmp.fun/components/global-chat.tsx
2026-07-04 12:49:09 -07:00

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>
)
}