Files
rondevu-demo/src/App.jsx
Bas van den Aakster b3dde85cd2 Force TURN relay mode to bypass NAT hairpinning
Added iceTransportPolicy: 'relay' to RTC_CONFIG to force TURN relay usage.
This bypasses NAT hairpinning issues when testing on the same network
(e.g., two browser tabs on the same machine).

This setting ensures maximum compatibility and reliability for WebRTC
connections by always using the TURN relay instead of attempting direct
or STUN-based connections.

Note: Should be commented out in production to allow direct connections
when possible for better performance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 13:49:52 +01:00

1106 lines
32 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react';
import { Rondevu } from '@xtr-dev/rondevu-client';
import toast, { Toaster } from 'react-hot-toast';
const API_URL = 'https://api.ronde.vu';
const RTC_CONFIG = {
iceServers: [
{ urls: ["stun:stun.ronde.vu:3478"] },
{
urls: [
"turn:turn.ronde.vu:3478?transport=tcp",
"turn:turn.ronde.vu:3478?transport=udp",
],
username: "webrtcuser",
credential: "supersecretpassword"
}
],
// Force TURN relay to bypass NAT hairpinning (when testing on same network)
// Comment out for production to allow direct connections when possible
iceTransportPolicy: 'relay'
};
export default function App() {
const [client, setClient] = useState(null);
const [credentials, setCredentials] = useState(null);
const [myUsername, setMyUsername] = useState(null);
// Setup
const [setupStep, setSetupStep] = useState('register'); // register, claim, ready
const [usernameInput, setUsernameInput] = useState('');
// Contacts
const [contacts, setContacts] = useState([]);
const [contactInput, setContactInput] = useState('');
const [onlineUsers, setOnlineUsers] = useState(new Set());
// Chat
const [activeChats, setActiveChats] = useState({});
const [selectedChat, setSelectedChat] = useState(null);
const [messageInputs, setMessageInputs] = useState({});
// Service
const [serviceHandle, setServiceHandle] = useState(null);
const chatEndRef = useRef(null);
// Load saved data and auto-register
useEffect(() => {
const savedCreds = localStorage.getItem('rondevu-chat-credentials');
const savedUsername = localStorage.getItem('rondevu-chat-username');
const savedContacts = localStorage.getItem('rondevu-chat-contacts');
const initialize = async () => {
let clientInstance;
// Load contacts first
if (savedContacts) {
try {
setContacts(JSON.parse(savedContacts));
} catch (err) {
console.error('Failed to load contacts:', err);
}
}
// Handle credentials
if (savedCreds) {
try {
const creds = JSON.parse(savedCreds);
setCredentials(creds);
clientInstance = new Rondevu({ baseUrl: API_URL, credentials: creds });
setClient(clientInstance);
// If we have username too, go straight to ready
if (savedUsername) {
setMyUsername(savedUsername);
setSetupStep('ready');
} else {
// Have creds but no username - go to claim step
setSetupStep('claim');
}
} catch (err) {
console.error('Failed to load credentials:', err);
// Invalid saved creds - auto-register
clientInstance = new Rondevu({ baseUrl: API_URL });
setClient(clientInstance);
await autoRegister(clientInstance);
}
} else {
// No saved credentials - auto-register
console.log('No saved credentials, auto-registering...');
clientInstance = new Rondevu({ baseUrl: API_URL });
setClient(clientInstance);
await autoRegister(clientInstance);
}
};
const autoRegister = async (clientInstance) => {
try {
console.log('Starting auto-registration...');
const creds = await clientInstance.register();
console.log('Registration successful:', creds);
setCredentials(creds);
localStorage.setItem('rondevu-chat-credentials', JSON.stringify(creds));
const newClient = new Rondevu({ baseUrl: API_URL, credentials: creds });
setClient(newClient);
setSetupStep('claim');
} catch (err) {
console.error('Auto-registration failed:', err);
toast.error(`Registration failed: ${err.message}`);
setSetupStep('claim'); // Still allow username claim, might work anyway
}
};
initialize();
}, []);
// Auto-scroll chat
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [activeChats, selectedChat]);
// Start chat service when ready
useEffect(() => {
if (setupStep === 'ready' && myUsername && client && !serviceHandle) {
startChatService();
}
}, [setupStep, myUsername, client]);
// Check online status periodically
// Note: Online detection by attempting to query services
// In v0.9.0 there's no direct listServices API, so we check by attempting connection
useEffect(() => {
if (setupStep !== 'ready' || !client) return;
const checkOnlineStatus = async () => {
const online = new Set();
for (const contact of contacts) {
try {
// Try to query the service via discovery endpoint
const response = await fetch(`${API_URL}/index/${contact}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceFqn: 'chat.rondevu@1.0.0' })
});
if (response.ok) {
online.add(contact);
}
} catch (err) {
// User offline or doesn't exist
}
}
setOnlineUsers(online);
};
checkOnlineStatus();
const interval = setInterval(checkOnlineStatus, 10000); // Check every 10s
return () => clearInterval(interval);
}, [contacts, setupStep, client]);
// Claim username
const handleClaimUsername = async () => {
if (!client || !usernameInput) return;
try {
const claim = await client.usernames.claimUsername(usernameInput);
client.usernames.saveKeypairToStorage(usernameInput, claim.publicKey, claim.privateKey);
setMyUsername(usernameInput);
localStorage.setItem('rondevu-chat-username', usernameInput);
setSetupStep('ready');
toast.success(`Welcome, ${usernameInput}!`);
} catch (err) {
toast.error(`Error: ${err.message}`);
}
};
// Start pooled chat service with durable connections
const startChatService = async () => {
if (!client || !myUsername || serviceHandle) return;
try {
const keypair = client.usernames.loadKeypairFromStorage(myUsername);
if (!keypair) {
toast.error('Username keypair not found');
return;
}
const service = await client.exposeService({
username: myUsername,
privateKey: keypair.privateKey,
serviceFqn: 'chat.rondevu@1.0.0',
isPublic: true,
ttl: 300000, // 5 minutes - service auto-refreshes
ttlRefreshMargin: 0.2, // Refresh at 80% of TTL
poolSize: 10, // Support up to 10 simultaneous connections
rtcConfig: RTC_CONFIG,
handler: (channel, connectionId) => {
console.log(`📡 New chat connection: ${connectionId}`);
// Wait for peer to identify themselves
channel.on('message', (data) => {
try {
const msg = JSON.parse(data);
if (msg.type === 'identify') {
// Peer identified themselves
setActiveChats(prev => ({
...prev,
[msg.from]: {
username: msg.from,
channel,
connectionId,
messages: prev[msg.from]?.messages || [],
status: 'connected'
}
}));
// Remove old handler and add new one for chat messages
channel.off('message');
channel.on('message', (chatData) => {
try {
const chatMsg = JSON.parse(chatData);
if (chatMsg.type === 'message') {
setActiveChats(prev => ({
...prev,
[msg.from]: {
...prev[msg.from],
messages: [...(prev[msg.from]?.messages || []), {
from: msg.from,
text: chatMsg.text,
timestamp: Date.now()
}]
}
}));
}
} catch (err) {
console.error('Failed to parse chat message:', err);
}
});
// Send acknowledgment
channel.send(JSON.stringify({
type: 'identify_ack',
from: myUsername
}));
}
} catch (err) {
console.error('Failed to parse identify message:', err);
}
});
channel.on('close', () => {
console.log(`👋 Chat closed: ${connectionId}`);
setActiveChats(prev => {
const updated = { ...prev };
Object.keys(updated).forEach(user => {
if (updated[user].connectionId === connectionId) {
updated[user] = { ...updated[user], status: 'disconnected' };
}
});
return updated;
});
});
}
});
// Start the service
await service.start();
// Listen for service events
service.on('connection', (connId) => {
console.log(`🔗 New connection: ${connId}`);
});
service.on('disconnection', (connId) => {
console.log(`🔌 Disconnected: ${connId}`);
});
service.on('ttl-refreshed', (expiresAt) => {
console.log(`🔄 Service TTL refreshed, expires at: ${new Date(expiresAt)}`);
});
service.on('error', (error, context) => {
console.error(`❌ Service error (${context}):`, error);
});
setServiceHandle(service);
console.log('✅ Chat service started');
} catch (err) {
console.error('Error starting chat service:', err);
toast.error(`Failed to start chat: ${err.message}`);
}
};
// Add contact
const handleAddContact = () => {
if (!contactInput || contacts.includes(contactInput)) {
toast.error('Invalid or duplicate contact');
return;
}
if (contactInput === myUsername) {
toast.error("You can't add yourself!");
return;
}
const newContacts = [...contacts, contactInput];
setContacts(newContacts);
localStorage.setItem('rondevu-chat-contacts', JSON.stringify(newContacts));
setContactInput('');
toast.success(`Added ${contactInput}`);
};
// Remove contact
const handleRemoveContact = (contact) => {
const newContacts = contacts.filter(c => c !== contact);
setContacts(newContacts);
localStorage.setItem('rondevu-chat-contacts', JSON.stringify(newContacts));
if (selectedChat === contact) {
setSelectedChat(null);
}
toast.success(`Removed ${contact}`);
};
// Start chat with contact using durable connection
const handleStartChat = async (contact) => {
if (!client || activeChats[contact]?.status === 'connected') {
setSelectedChat(contact);
return;
}
try {
toast.loading(`Connecting to ${contact}...`, { id: 'connecting' });
// Create durable connection
const connection = await client.connect(contact, 'chat.rondevu@1.0.0', {
rtcConfig: RTC_CONFIG,
maxReconnectAttempts: 5
});
// Create data channel
const channel = connection.createChannel('chat');
// Listen for connection events
connection.on('connected', () => {
console.log(`✅ Connected to ${contact}`);
});
connection.on('reconnecting', (attempt, max, delay) => {
console.log(`🔄 Reconnecting to ${contact} (${attempt}/${max}) in ${delay}ms`);
toast.loading(`Reconnecting to ${contact}...`, { id: 'reconnecting' });
});
connection.on('disconnected', () => {
console.log(`🔌 Disconnected from ${contact}`);
setActiveChats(prev => ({
...prev,
[contact]: { ...prev[contact], status: 'reconnecting' }
}));
});
connection.on('failed', (error) => {
console.error(`❌ Connection to ${contact} failed:`, error);
toast.error(`Connection to ${contact} failed`, { id: 'connecting' });
setActiveChats(prev => ({
...prev,
[contact]: { ...prev[contact], status: 'disconnected' }
}));
});
// Wait for acknowledgment
channel.on('message', (data) => {
try {
const msg = JSON.parse(data);
if (msg.type === 'identify_ack') {
// Connection established
toast.success(`Connected to ${contact}`, { id: 'connecting' });
setActiveChats(prev => ({
...prev,
[contact]: {
username: contact,
channel,
connection,
messages: prev[contact]?.messages || [],
status: 'connected'
}
}));
setSelectedChat(contact);
// Update handler for chat messages
channel.off('message');
channel.on('message', (chatData) => {
try {
const chatMsg = JSON.parse(chatData);
if (chatMsg.type === 'message') {
setActiveChats(prev => ({
...prev,
[contact]: {
...prev[contact],
messages: [...(prev[contact]?.messages || []), {
from: contact,
text: chatMsg.text,
timestamp: Date.now()
}]
}
}));
}
} catch (err) {
console.error('Failed to parse message:', err);
}
});
}
} catch (err) {
console.error('Failed to parse ack:', err);
}
});
channel.on('close', () => {
setActiveChats(prev => ({
...prev,
[contact]: { ...prev[contact], status: 'disconnected' }
}));
toast.error(`Disconnected from ${contact}`);
});
// Connect and send identification
await connection.connect();
channel.send(JSON.stringify({
type: 'identify',
from: myUsername
}));
} catch (err) {
console.error('Failed to connect:', err);
toast.error(`Failed to connect to ${contact}`, { id: 'connecting' });
}
};
// Send message
const handleSendMessage = (contact) => {
const text = messageInputs[contact];
if (!text || !activeChats[contact]?.channel) return;
const chat = activeChats[contact];
if (chat.status !== 'connected') {
toast.error('Not connected');
return;
}
try {
chat.channel.send(JSON.stringify({
type: 'message',
text
}));
setActiveChats(prev => ({
...prev,
[contact]: {
...prev[contact],
messages: [...prev[contact].messages, {
from: myUsername,
text,
timestamp: Date.now()
}]
}
}));
setMessageInputs(prev => ({ ...prev, [contact]: '' }));
} catch (err) {
console.error('Failed to send message:', err);
toast.error('Failed to send message');
}
};
// Clear all data
const handleLogout = () => {
if (window.confirm('Are you sure you want to logout? This will clear all data.')) {
localStorage.clear();
window.location.reload();
}
};
if (!client) {
return <div style={styles.loading}>Loading...</div>;
}
return (
<div style={styles.container}>
<Toaster position="top-right" />
{/* Setup Screen */}
{setupStep !== 'ready' && (
<div style={styles.setupScreen}>
<div style={styles.setupBox}>
<h1 style={styles.setupTitle}>Rondevu Chat</h1>
<p style={styles.setupSubtitle}>Decentralized P2P Chat</p>
{setupStep === 'register' && (
<div>
<p style={styles.setupDesc}>Registering...</p>
</div>
)}
{setupStep === 'claim' && (
<div>
<p style={styles.setupDesc}>Choose your unique username</p>
<input
type="text"
placeholder="Enter username"
value={usernameInput}
onChange={(e) => setUsernameInput(e.target.value.toLowerCase())}
onKeyPress={(e) => e.key === 'Enter' && handleClaimUsername()}
style={styles.setupInput}
autoFocus
/>
<button
onClick={handleClaimUsername}
disabled={!usernameInput}
style={styles.setupButton}
>
Claim Username
</button>
<p style={styles.setupHint}>
3-32 characters, lowercase letters, numbers, and dashes only
</p>
</div>
)}
</div>
</div>
)}
{/* Main Chat Screen */}
{setupStep === 'ready' && (
<div style={styles.mainScreen}>
{/* Sidebar */}
<div style={styles.sidebar}>
{/* User Header */}
<div style={styles.userHeader}>
<div>
<div style={styles.userHeaderName}>@{myUsername}</div>
<div style={styles.userHeaderStatus}>
<span style={styles.onlineDot}></span> Online
</div>
</div>
<button onClick={handleLogout} style={styles.logoutBtn} title="Logout">
Logout
</button>
</div>
{/* Add Contact */}
<div style={styles.addContactBox}>
<input
type="text"
placeholder="Add friend by username..."
value={contactInput}
onChange={(e) => setContactInput(e.target.value.toLowerCase())}
onKeyPress={(e) => e.key === 'Enter' && handleAddContact()}
style={styles.contactInput}
/>
<button onClick={handleAddContact} style={styles.addBtn} title="Add friend">
Add
</button>
</div>
{/* Contacts List */}
<div style={styles.contactsList}>
<div style={styles.contactsHeader}>
Friends ({contacts.length})
</div>
{contacts.length === 0 ? (
<div style={styles.emptyState}>
<p>No friends yet</p>
<p style={{ fontSize: '12px', color: '#999' }}>
Add friends by their username above
</p>
</div>
) : (
contacts.map(contact => {
const isOnline = onlineUsers.has(contact);
const hasActiveChat = activeChats[contact]?.status === 'connected';
return (
<div
key={contact}
className="contact-item"
style={{
...styles.contactItem,
...(selectedChat === contact ? styles.contactItemActive : {})
}}
onClick={() => hasActiveChat ? setSelectedChat(contact) : handleStartChat(contact)}
>
<div style={styles.contactAvatar}>
{contact[0].toUpperCase()}
<span style={{
...styles.contactDot,
background: isOnline ? '#4caf50' : '#999'
}}></span>
</div>
<div style={{ flex: 1 }}>
<div style={styles.contactName}>{contact}</div>
<div style={styles.contactStatus}>
{hasActiveChat ? 'Chatting' : isOnline ? 'Online' : 'Offline'}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleRemoveContact(contact);
}}
style={styles.removeBtn}
title="Remove friend"
>
</button>
</div>
);
})
)}
</div>
</div>
{/* Chat Area */}
<div style={styles.chatArea}>
{!selectedChat ? (
<div style={styles.emptyChat}>
<h2 style={{ margin: 0 }}>Select a friend to chat</h2>
<p style={{ marginTop: '10px' }}>
Click on a friend from the sidebar to start chatting
</p>
</div>
) : (
<>
{/* Chat Header */}
<div style={styles.chatHeader}>
<div style={styles.chatHeaderAvatar}>
{selectedChat[0].toUpperCase()}
</div>
<div style={{ flex: 1 }}>
<div style={styles.chatHeaderName}>@{selectedChat}</div>
<div style={styles.chatHeaderStatus}>
{activeChats[selectedChat]?.status === 'connected' ? (
<><span style={styles.onlineDot}></span> Connected</>
) : (
'Connecting...'
)}
</div>
</div>
<button
onClick={() => setSelectedChat(null)}
style={styles.closeChatBtn}
title="Close chat"
>
</button>
</div>
{/* Messages */}
<div style={styles.messagesArea}>
{(!activeChats[selectedChat] || activeChats[selectedChat].messages.length === 0) ? (
<div style={styles.emptyMessages}>
<p>No messages yet</p>
<p style={{ fontSize: '12px', color: '#999' }}>
Send a message to start the conversation
</p>
</div>
) : (
activeChats[selectedChat].messages.map((msg, idx) => (
<div
key={idx}
style={{
...styles.message,
...(msg.from === myUsername ? styles.messageMe : styles.messageThem)
}}
>
<div style={{
...styles.messageText,
background: msg.from === myUsername ? '#4a9eff' : '#2a2a2a',
color: 'white'
}}>
{msg.text}
</div>
<div style={styles.messageTime}>
{new Date(msg.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
))
)}
<div ref={chatEndRef} />
</div>
{/* Input */}
<div style={styles.inputArea}>
<input
type="text"
placeholder="Type a message..."
value={messageInputs[selectedChat] || ''}
onChange={(e) => setMessageInputs(prev => ({
...prev,
[selectedChat]: e.target.value
}))}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage(selectedChat);
}
}}
disabled={activeChats[selectedChat]?.status !== 'connected'}
style={styles.messageInput}
autoFocus
/>
<button
onClick={() => handleSendMessage(selectedChat)}
disabled={!messageInputs[selectedChat] || activeChats[selectedChat]?.status !== 'connected'}
style={styles.sendBtn}
title="Send message"
>
Send
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}
const styles = {
container: {
height: '100vh',
background: '#1a1a1a',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
loading: {
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#e0e0e0',
fontSize: '24px'
},
setupScreen: {
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
},
setupBox: {
background: '#2a2a2a',
borderRadius: '16px',
padding: '40px',
maxWidth: '400px',
width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
textAlign: 'center',
border: '1px solid #3a3a3a'
},
setupTitle: {
fontSize: '2.5em',
margin: '0 0 10px 0',
color: '#e0e0e0'
},
setupSubtitle: {
fontSize: '1.1em',
color: '#a0a0a0',
margin: '0 0 30px 0'
},
setupDesc: {
color: '#a0a0a0',
marginBottom: '20px'
},
setupInput: {
width: '100%',
padding: '15px',
fontSize: '16px',
border: '1px solid #3a3a3a',
background: '#1a1a1a',
color: '#e0e0e0',
borderRadius: '8px',
marginBottom: '15px',
boxSizing: 'border-box',
outline: 'none',
},
setupButton: {
width: '100%',
padding: '15px',
fontSize: '16px',
fontWeight: '600',
background: '#4a9eff',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
},
setupHint: {
fontSize: '12px',
color: '#808080',
marginTop: '10px'
},
mainScreen: {
height: '100vh',
display: 'flex'
},
sidebar: {
width: '320px',
background: '#2a2a2a',
borderRight: '1px solid #3a3a3a',
display: 'flex',
flexDirection: 'column'
},
userHeader: {
padding: '20px',
borderBottom: '1px solid #3a3a3a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
},
userHeaderName: {
fontSize: '18px',
fontWeight: '600',
color: '#e0e0e0'
},
userHeaderStatus: {
fontSize: '12px',
color: '#a0a0a0',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '5px'
},
onlineDot: {
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#4caf50',
display: 'inline-block'
},
logoutBtn: {
padding: '8px 12px',
background: '#3a3a3a',
color: '#e0e0e0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
},
addContactBox: {
padding: '15px',
borderBottom: '1px solid #3a3a3a',
display: 'flex',
gap: '8px'
},
contactInput: {
flex: 1,
padding: '10px',
border: '1px solid #3a3a3a',
background: '#1a1a1a',
color: '#e0e0e0',
borderRadius: '6px',
fontSize: '14px',
outline: 'none'
},
addBtn: {
padding: '10px 15px',
background: '#4a9eff',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
},
contactsList: {
flex: 1,
overflowY: 'auto'
},
contactsHeader: {
padding: '15px 20px',
fontSize: '12px',
fontWeight: '600',
color: '#808080',
textTransform: 'uppercase',
letterSpacing: '0.5px'
},
emptyState: {
padding: '40px 20px',
textAlign: 'center',
color: '#808080'
},
contactItem: {
padding: '15px 20px',
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
transition: 'background 0.2s',
borderBottom: '1px solid #3a3a3a'
},
contactItemActive: {
background: '#3a3a3a'
},
contactAvatar: {
width: '40px',
height: '40px',
borderRadius: '50%',
background: '#4a9eff',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
fontWeight: '600',
position: 'relative'
},
contactDot: {
position: 'absolute',
bottom: '0',
right: '0',
width: '12px',
height: '12px',
borderRadius: '50%',
border: '2px solid #2a2a2a'
},
contactName: {
fontSize: '15px',
fontWeight: '600',
color: '#e0e0e0'
},
contactStatus: {
fontSize: '12px',
color: '#a0a0a0',
marginTop: '2px'
},
removeBtn: {
padding: '4px 8px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: '16px',
color: '#808080',
opacity: 0.6,
},
chatArea: {
flex: 1,
background: '#1a1a1a',
display: 'flex',
flexDirection: 'column'
},
emptyChat: {
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#808080'
},
chatHeader: {
padding: '20px',
borderBottom: '1px solid #3a3a3a',
display: 'flex',
alignItems: 'center',
gap: '15px',
background: '#2a2a2a'
},
chatHeaderAvatar: {
width: '48px',
height: '48px',
borderRadius: '50%',
background: '#4a9eff',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
fontWeight: '600'
},
chatHeaderName: {
fontSize: '18px',
fontWeight: '600',
color: '#e0e0e0'
},
chatHeaderStatus: {
fontSize: '13px',
color: '#a0a0a0',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '5px'
},
closeChatBtn: {
padding: '8px 12px',
background: '#3a3a3a',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
color: '#e0e0e0'
},
messagesArea: {
flex: 1,
overflowY: 'auto',
padding: '20px',
background: '#1a1a1a'
},
emptyMessages: {
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#808080'
},
message: {
marginBottom: '12px',
display: 'flex',
flexDirection: 'column',
maxWidth: '70%'
},
messageMe: {
alignSelf: 'flex-end',
alignItems: 'flex-end'
},
messageThem: {
alignSelf: 'flex-start',
alignItems: 'flex-start'
},
messageText: {
padding: '12px 16px',
borderRadius: '16px',
fontSize: '15px',
lineHeight: '1.4',
wordWrap: 'break-word',
boxShadow: '0 1px 2px rgba(0,0,0,0.2)'
},
messageTime: {
fontSize: '11px',
color: '#808080',
marginTop: '4px',
padding: '0 8px'
},
inputArea: {
padding: '20px',
borderTop: '1px solid #3a3a3a',
display: 'flex',
gap: '12px',
background: '#2a2a2a'
},
messageInput: {
flex: 1,
padding: '12px 16px',
border: '1px solid #3a3a3a',
background: '#1a1a1a',
color: '#e0e0e0',
borderRadius: '24px',
fontSize: '15px',
outline: 'none',
},
sendBtn: {
padding: '12px 24px',
borderRadius: '24px',
background: '#4a9eff',
color: 'white',
border: 'none',
cursor: 'pointer',
fontSize: '15px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
};
// Add hover effects via CSS
if (typeof document !== 'undefined') {
const style = document.createElement('style');
style.textContent = `
button:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.02);
}
button:active:not(:disabled) {
transform: scale(0.98);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.contact-item:hover {
background: #333333 !important;
}
.contact-item:active {
background: #2a2a2a !important;
}
input:focus {
border-color: #4a9eff !important;
}
`;
document.head.appendChild(style);
}