fixed some stuff mainly chat interface and made some stuff work
This commit is contained in:
28
dockerfile
Normal file
28
dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Use an official lightweight Python image
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Create a working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies (if needed)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy the application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3248
|
||||||
|
|
||||||
|
# Run with Gunicorn WSGI server
|
||||||
|
# Assumes your Flask app is named "app" inside "app.py"
|
||||||
|
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:3248", "app:app"]
|
||||||
BIN
src/static/favicon.png
Normal file
BIN
src/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,925 @@
|
|||||||
|
// Content Security Policy enforcement
|
||||||
|
if (!window.crypto || !window.crypto.subtle) {
|
||||||
|
alert('This browser does not support required cryptographic features. Please use a modern browser.');
|
||||||
|
throw new Error('WebCrypto API not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global variables
|
||||||
|
let socket = null;
|
||||||
|
let currentRoom = null;
|
||||||
|
let currentUserId = null;
|
||||||
|
let currentDisplayName = null;
|
||||||
|
let keyPair = null;
|
||||||
|
let sessionKey = null;
|
||||||
|
let roomUsers = {};
|
||||||
|
let keysReady = false;
|
||||||
|
let lastMessageTime = 0;
|
||||||
|
let messageQueue = [];
|
||||||
|
let isConnected = false;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||||
|
const MESSAGE_RATE_LIMIT = 1000;
|
||||||
|
const MAX_MESSAGE_LENGTH = 4000;
|
||||||
|
const MAX_ROOM_ID_LENGTH = 32;
|
||||||
|
const MAX_MESSAGES = 256;
|
||||||
|
|
||||||
|
// Security utilities
|
||||||
|
const SecurityUtils = {
|
||||||
|
sanitizeInput: function(input) {
|
||||||
|
if (typeof input !== 'string') return '';
|
||||||
|
return input.trim().substring(0, MAX_MESSAGE_LENGTH);
|
||||||
|
},
|
||||||
|
isValidRoomId: function(roomId) {
|
||||||
|
if (!roomId || typeof roomId !== 'string') return false;
|
||||||
|
if (roomId.length > MAX_ROOM_ID_LENGTH) return false;
|
||||||
|
return /^[a-zA-Z0-9\-_]+$/.test(roomId);
|
||||||
|
},
|
||||||
|
canSendMessage: function() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastMessageTime < MESSAGE_RATE_LIMIT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
lastMessageTime = now;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
generateSecureId: function(length = 8) {
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('').toUpperCase().substring(0, length);
|
||||||
|
},
|
||||||
|
isValidBase64: function(str) {
|
||||||
|
try {
|
||||||
|
return btoa(atob(str)) === str;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class SecureChatError extends Error {
|
||||||
|
constructor(message, code = 'UNKNOWN') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SecureChatError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the app
|
||||||
|
async function initializeApp() {
|
||||||
|
try {
|
||||||
|
console.log("Initializing bytechat");
|
||||||
|
|
||||||
|
// Initialize socket connection
|
||||||
|
socket = io({
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: MAX_RECONNECT_ATTEMPTS,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateKeyPair();
|
||||||
|
setupSocketListeners();
|
||||||
|
setupSecurityFeatures();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize app:', error);
|
||||||
|
updateStatus('Failed to initialize application', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSecurityFeatures() {
|
||||||
|
document.addEventListener('visibilitychange', function() {
|
||||||
|
if (document.hidden) {
|
||||||
|
const messageInput = document.getElementById('messageInput');
|
||||||
|
if (messageInput) {
|
||||||
|
messageInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
clearSensitiveData();
|
||||||
|
});
|
||||||
|
|
||||||
|
const securityIndicator = document.getElementById('securityIndicator');
|
||||||
|
securityIndicator.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSensitiveData() {
|
||||||
|
sessionKey = null;
|
||||||
|
keyPair = null;
|
||||||
|
roomUsers = {};
|
||||||
|
|
||||||
|
const passwordInput = document.getElementById('roomPasswordInput');
|
||||||
|
if (passwordInput) {
|
||||||
|
passwordInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateKeyPair() {
|
||||||
|
try {
|
||||||
|
updateStatus('Generating encryption keys...', false);
|
||||||
|
|
||||||
|
keyPair = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: "RSA-OAEP",
|
||||||
|
modulusLength: 2048, // Reduced from 4096 for better performance
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: "SHA-256"
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
keysReady = true;
|
||||||
|
updateStatus("Ready to join a room", false);
|
||||||
|
console.log("Key pair generated successfully");
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate key pair:", error);
|
||||||
|
updateStatus("Error: Failed to generate encryption keys", true);
|
||||||
|
throw new SecureChatError("Key generation failed", "KEY_GEN_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSessionKey() {
|
||||||
|
try {
|
||||||
|
sessionKey = await window.crypto.subtle.generateKey(
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
true,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
console.log("Session key generated");
|
||||||
|
return sessionKey;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate session key:", error);
|
||||||
|
throw new SecureChatError("Session key generation failed", "SESSION_KEY_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPublicKey(key) {
|
||||||
|
try {
|
||||||
|
const exported = await window.crypto.subtle.exportKey("spki", key);
|
||||||
|
const base64Key = btoa(String.fromCharCode(...new Uint8Array(exported)));
|
||||||
|
|
||||||
|
if (!SecurityUtils.isValidBase64(base64Key)) {
|
||||||
|
throw new Error('Invalid key export');
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64Key;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export public key:", error);
|
||||||
|
throw new SecureChatError("Public key export failed", "KEY_EXPORT_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPublicKey(keyString) {
|
||||||
|
try {
|
||||||
|
if (!SecurityUtils.isValidBase64(keyString)) {
|
||||||
|
throw new Error('Invalid base64 key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyData = Uint8Array.from(atob(keyString), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
return await window.crypto.subtle.importKey(
|
||||||
|
"spki",
|
||||||
|
keyData,
|
||||||
|
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["encrypt"]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to import public key:", error);
|
||||||
|
throw new SecureChatError("Public key import failed", "KEY_IMPORT_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptSessionKey(sessionKey, publicKey) {
|
||||||
|
try {
|
||||||
|
const keyData = await window.crypto.subtle.exportKey("raw", sessionKey);
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
|
{ name: "RSA-OAEP" },
|
||||||
|
publicKey,
|
||||||
|
keyData
|
||||||
|
);
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(encrypted)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to encrypt session key:", error);
|
||||||
|
throw new SecureChatError("Session key encryption failed", "SESSION_KEY_ENCRYPT_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptSessionKey(encryptedKey) {
|
||||||
|
try {
|
||||||
|
if (!SecurityUtils.isValidBase64(encryptedKey)) {
|
||||||
|
throw new Error('Invalid encrypted key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyData = Uint8Array.from(atob(encryptedKey), c => c.charCodeAt(0));
|
||||||
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
{ name: "RSA-OAEP" },
|
||||||
|
keyPair.privateKey,
|
||||||
|
keyData
|
||||||
|
);
|
||||||
|
|
||||||
|
return await window.crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
decrypted,
|
||||||
|
{ name: "AES-GCM" },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to decrypt session key:", error);
|
||||||
|
throw new SecureChatError("Session key decryption failed", "SESSION_KEY_DECRYPT_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptMessage(message) {
|
||||||
|
if (!sessionKey) {
|
||||||
|
throw new SecureChatError("No session key available", "NO_SESSION_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sanitizedMessage = SecurityUtils.sanitizeInput(message);
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(sanitizedMessage);
|
||||||
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv: iv },
|
||||||
|
sessionKey,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted: btoa(String.fromCharCode(...new Uint8Array(encrypted))),
|
||||||
|
iv: btoa(String.fromCharCode(...iv))
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to encrypt message:", error);
|
||||||
|
throw new SecureChatError("Message encryption failed", "MESSAGE_ENCRYPT_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptMessage(encryptedData, ivString) {
|
||||||
|
if (!sessionKey) {
|
||||||
|
return "[Encrypted message - no session key]";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!SecurityUtils.isValidBase64(encryptedData) || !SecurityUtils.isValidBase64(ivString)) {
|
||||||
|
return "[Invalid encrypted message format]";
|
||||||
|
}
|
||||||
|
|
||||||
|
const encrypted = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0));
|
||||||
|
const iv = Uint8Array.from(atob(ivString), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: iv },
|
||||||
|
sessionKey,
|
||||||
|
encrypted
|
||||||
|
);
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const decryptedMessage = decoder.decode(decrypted);
|
||||||
|
|
||||||
|
return SecurityUtils.sanitizeInput(decryptedMessage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to decrypt message:", error);
|
||||||
|
return "[Failed to decrypt message]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced socket listeners with fixed key exchange
|
||||||
|
function setupSocketListeners() {
|
||||||
|
socket.on('connect', () => {
|
||||||
|
isConnected = true;
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
console.log("Socket connected securely");
|
||||||
|
updateConnectionStatus(true);
|
||||||
|
updateInputState();
|
||||||
|
|
||||||
|
// If user is already in a room, re-emit join_room after reconnect
|
||||||
|
if (currentRoom && keyPair && keysReady) {
|
||||||
|
exportPublicKey(keyPair.publicKey).then((publicKeyString) => {
|
||||||
|
socket.emit('join_room', {
|
||||||
|
room_id: currentRoom,
|
||||||
|
public_key: publicKeyString,
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason) => {
|
||||||
|
isConnected = false;
|
||||||
|
updateStatus("Connection lost. Attempting to reconnect...", true);
|
||||||
|
updateConnectionStatus(false);
|
||||||
|
updateInputState();
|
||||||
|
console.log('Socket disconnected:', reason);
|
||||||
|
|
||||||
|
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
reconnectAttempts++;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isConnected && reconnectAttempts <= MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
}, 2000 * Math.pow(2, reconnectAttempts));
|
||||||
|
} else {
|
||||||
|
updateStatus("Connection failed. Please refresh the page.", true);
|
||||||
|
updateInputState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.error('Connection error:', error);
|
||||||
|
updateStatus("Connection error. Please check your internet connection.", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('user_connected', (data) => {
|
||||||
|
if (data && data.user_id) {
|
||||||
|
currentUserId = data.user_id;
|
||||||
|
console.log("Connected as user:", currentUserId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('room_joined', async (data) => {
|
||||||
|
try {
|
||||||
|
if (data.error) {
|
||||||
|
updateStatus(data.error, true);
|
||||||
|
updateInputState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.room_id || !data.user_id || !SecurityUtils.isValidRoomId(data.room_id)) {
|
||||||
|
throw new SecureChatError("Invalid room data received", "INVALID_ROOM_DATA");
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRoom = data.room_id;
|
||||||
|
currentUserId = data.user_id;
|
||||||
|
currentDisplayName = data.display_name;
|
||||||
|
roomUsers = data.user_keys || {};
|
||||||
|
|
||||||
|
// Switch to chat screen
|
||||||
|
document.getElementById('welcomeScreen').style.display = 'none';
|
||||||
|
document.getElementById('chatScreen').style.display = 'flex';
|
||||||
|
document.getElementById('roomInfo').style.display = 'flex';
|
||||||
|
document.getElementById('inputSection').style.display = 'block';
|
||||||
|
|
||||||
|
// Update room info
|
||||||
|
document.getElementById('currentRoomId').textContent = currentRoom;
|
||||||
|
document.getElementById('userCounter').textContent = `Users: ${data.users?.length || 0}`;
|
||||||
|
document.getElementById('messageCounter').textContent = `Messages: ${data.message_count || 0}/256`;
|
||||||
|
|
||||||
|
// Handle session key based on user status
|
||||||
|
if (data.is_first_user) {
|
||||||
|
console.log("First user - generating session key");
|
||||||
|
await generateSessionKey();
|
||||||
|
updateStatus("You're the first user! Session key generated. Others will receive it when they join.", false);
|
||||||
|
updateInputState();
|
||||||
|
} else if (sessionKey) {
|
||||||
|
console.log("Rejoining with existing session key");
|
||||||
|
updateStatus("Session key present. You can chat.", false);
|
||||||
|
updateInputState();
|
||||||
|
} else {
|
||||||
|
console.log("Waiting for session key from existing users");
|
||||||
|
updateStatus("Waiting for session key from other users...", false);
|
||||||
|
updateInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing messages
|
||||||
|
if (data.messages && Array.isArray(data.messages)) {
|
||||||
|
for (const msg of data.messages) {
|
||||||
|
if (msg && typeof msg === 'object') {
|
||||||
|
await displayMessage(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[room_joined] sessionKey:", !!sessionKey, "isFirstUser:", data.is_first_user);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing room_joined:", error);
|
||||||
|
updateStatus("Error joining room", true);
|
||||||
|
updateInputState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('user_joined', async (data) => {
|
||||||
|
try {
|
||||||
|
if (!data || !data.user_id || !data.public_key) return;
|
||||||
|
|
||||||
|
roomUsers[data.user_id] = data.public_key;
|
||||||
|
updateUsersCount();
|
||||||
|
addSystemMessage(`${data.display_name || 'User'} joined the room`);
|
||||||
|
|
||||||
|
// If we have a session key, share it with the new user
|
||||||
|
if (sessionKey) {
|
||||||
|
console.log("Sharing session key with new user:", data.user_id);
|
||||||
|
try {
|
||||||
|
const publicKey = await importPublicKey(data.public_key);
|
||||||
|
const encryptedKey = await encryptSessionKey(sessionKey, publicKey);
|
||||||
|
|
||||||
|
socket.emit('share_session_key', {
|
||||||
|
room_id: currentRoom,
|
||||||
|
target_user_id: data.user_id,
|
||||||
|
encrypted_key: encryptedKey
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Session key shared with new user successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to share session key with new user:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing user_joined:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('request_session_key', async (data) => {
|
||||||
|
try {
|
||||||
|
console.log("Session key requested for new user:", data.new_user_id);
|
||||||
|
|
||||||
|
if (!sessionKey || !data.new_user_id || !data.public_key) {
|
||||||
|
console.log("Cannot fulfill session key request - missing data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const publicKey = await importPublicKey(data.public_key);
|
||||||
|
const encryptedKey = await encryptSessionKey(sessionKey, publicKey);
|
||||||
|
|
||||||
|
socket.emit('share_session_key', {
|
||||||
|
room_id: currentRoom,
|
||||||
|
target_user_id: data.new_user_id,
|
||||||
|
encrypted_key: encryptedKey
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Session key shared in response to request");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to share session key on request:", error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing request_session_key:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('session_key_received', async (data) => {
|
||||||
|
try {
|
||||||
|
console.log("Session key received from:", data.from_user_id);
|
||||||
|
|
||||||
|
if (!data || !data.encrypted_key) {
|
||||||
|
console.log("Invalid session key data received");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionKey) {
|
||||||
|
try {
|
||||||
|
sessionKey = await decryptSessionKey(data.encrypted_key);
|
||||||
|
console.log("Session key decrypted successfully");
|
||||||
|
updateStatus("Session key received! You can now chat securely.", false);
|
||||||
|
updateInputState();
|
||||||
|
addSystemMessage("🔑 Session key received - you can now chat!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to decrypt received session key:", error);
|
||||||
|
updateStatus("Failed to decrypt session key", true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("Session key already exists, ignoring duplicate");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to process session key:", error);
|
||||||
|
updateStatus("Error: Failed to decrypt session key", true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('new_message', async (data) => {
|
||||||
|
try {
|
||||||
|
if (!data || typeof data !== 'object') return;
|
||||||
|
|
||||||
|
await displayMessage(data);
|
||||||
|
|
||||||
|
if (data.message_count) {
|
||||||
|
document.getElementById('messageCounter').textContent = `Messages: ${data.message_count}/256`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing new message:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('user_left', (data) => {
|
||||||
|
try {
|
||||||
|
if (data && data.user_id) {
|
||||||
|
delete roomUsers[data.user_id];
|
||||||
|
updateUsersCount();
|
||||||
|
addSystemMessage(`User left the room`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing user_left:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
console.error('Socket error:', error);
|
||||||
|
updateStatus("Connection error occurred", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('ping', () => {
|
||||||
|
socket.emit('pong');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoom() {
|
||||||
|
try {
|
||||||
|
const roomId = SecurityUtils.generateSecureId(6);
|
||||||
|
const password = document.getElementById('roomPasswordInput').value;
|
||||||
|
await joinSpecificRoom(roomId, password);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create room:", error);
|
||||||
|
updateStatus("Error creating room", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinRoom() {
|
||||||
|
try {
|
||||||
|
const roomInput = document.getElementById('roomInput');
|
||||||
|
const roomId = SecurityUtils.sanitizeInput(roomInput.value);
|
||||||
|
const password = document.getElementById('roomPasswordInput').value;
|
||||||
|
|
||||||
|
if (!roomId) {
|
||||||
|
await createRoom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SecurityUtils.isValidRoomId(roomId)) {
|
||||||
|
updateStatus("Invalid room ID format. Use only letters, numbers, hyphens and underscores.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await joinSpecificRoom(roomId, password);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to join room:", error);
|
||||||
|
updateStatus("Error joining room", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinSpecificRoom(roomId, password = "") {
|
||||||
|
if (!keysReady) {
|
||||||
|
updateStatus("Error: Encryption keys not ready", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SecurityUtils.isValidRoomId(roomId)) {
|
||||||
|
updateStatus("Invalid room ID format", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateStatus("Joining room...", false);
|
||||||
|
|
||||||
|
const publicKeyString = await exportPublicKey(keyPair.publicKey);
|
||||||
|
|
||||||
|
socket.emit('join_room', {
|
||||||
|
room_id: roomId,
|
||||||
|
public_key: publicKeyString,
|
||||||
|
password: password
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to join room:", error);
|
||||||
|
updateStatus("Error: Failed to join room", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
try {
|
||||||
|
const input = document.getElementById('messageInput');
|
||||||
|
let message = SecurityUtils.sanitizeInput(input.value);
|
||||||
|
|
||||||
|
if (!message || !sessionKey) return;
|
||||||
|
|
||||||
|
if (!SecurityUtils.canSendMessage()) {
|
||||||
|
updateStatus("Please wait before sending another message", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.length > MAX_MESSAGE_LENGTH) {
|
||||||
|
updateStatus("Message too long", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedData = await encryptMessage(message);
|
||||||
|
|
||||||
|
socket.emit('send_message', {
|
||||||
|
encrypted_content: encryptedData.encrypted,
|
||||||
|
iv: encryptedData.iv
|
||||||
|
});
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
autoResize(input);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send message:", error);
|
||||||
|
updateStatus("Error: Failed to send message", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function displayMessage(messageData) {
|
||||||
|
try {
|
||||||
|
if (!messageData || typeof messageData !== 'object') return;
|
||||||
|
|
||||||
|
const container = document.getElementById('messagesContainer');
|
||||||
|
const isOwnMessage = messageData.sender_id === currentUserId;
|
||||||
|
|
||||||
|
let displayText;
|
||||||
|
if (messageData.iv && sessionKey && messageData.encrypted_content) {
|
||||||
|
displayText = await decryptMessage(messageData.encrypted_content, messageData.iv);
|
||||||
|
} else {
|
||||||
|
displayText = "[Encrypted message]";
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (text) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timestamp = messageData.timestamp ?
|
||||||
|
new Date(messageData.timestamp * 1000).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}) :
|
||||||
|
new Date().toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayName = messageData.display_name || `User-${messageData.sender_id?.substring(0, 8) || 'Unknown'}`;
|
||||||
|
const avatarText = displayName.substring(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
const messageGroup = document.createElement('div');
|
||||||
|
messageGroup.className = 'message-group';
|
||||||
|
messageGroup.classList.add(isOwnMessage ? 'sent' : 'received');
|
||||||
|
|
||||||
|
messageGroup.innerHTML = `
|
||||||
|
<div class="message-header">
|
||||||
|
<div class="user-avatar ${isOwnMessage ? 'own' : ''}">${escapeHtml(avatarText)}</div>
|
||||||
|
<span class="user-name">${escapeHtml(displayName)}</span>
|
||||||
|
<span class="message-time">${escapeHtml(timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-content ${isOwnMessage ? 'own' : ''}" data-message-id="${messageData.id || ''}"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const messageContentEl = messageGroup.querySelector('.message-content');
|
||||||
|
messageContentEl.textContent = displayText;
|
||||||
|
|
||||||
|
container.appendChild(messageGroup);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
|
||||||
|
const messages = container.children;
|
||||||
|
if (messages.length > MAX_MESSAGES + 50) {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
if (messages[0]) {
|
||||||
|
container.removeChild(messages[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error displaying message:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSystemMessage(text) {
|
||||||
|
try {
|
||||||
|
const container = document.getElementById('messagesContainer');
|
||||||
|
const systemMessage = document.createElement('div');
|
||||||
|
systemMessage.className = 'message-group system'; // ✅ add system class
|
||||||
|
|
||||||
|
const timestamp = new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
|
||||||
|
|
||||||
|
systemMessage.innerHTML = `
|
||||||
|
<div class="message-header">
|
||||||
|
<div class="user-avatar">ℹ️</div>
|
||||||
|
<span class="user-name">System</span>
|
||||||
|
<span class="message-time">${timestamp}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-content"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const messageContentEl = systemMessage.querySelector('.message-content');
|
||||||
|
messageContentEl.textContent = text;
|
||||||
|
|
||||||
|
container.appendChild(systemMessage);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding system message:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function updateStatus(message, isError = false) {
|
||||||
|
try {
|
||||||
|
const statusEl = document.getElementById('statusText');
|
||||||
|
const statusDiv = document.createElement('div');
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
statusDiv.className = 'error-message';
|
||||||
|
} else {
|
||||||
|
statusDiv.className = 'success-message';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusDiv.textContent = message;
|
||||||
|
statusEl.innerHTML = '';
|
||||||
|
statusEl.appendChild(statusDiv);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating status:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUsersCount() {
|
||||||
|
try {
|
||||||
|
const count = Object.keys(roomUsers).length;
|
||||||
|
const counterEl = document.getElementById('userCounter');
|
||||||
|
if (counterEl) {
|
||||||
|
counterEl.textContent = `Users: ${count}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating users count:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInputState() {
|
||||||
|
try {
|
||||||
|
const input = document.getElementById('messageInput');
|
||||||
|
const btn = document.getElementById('sendButton');
|
||||||
|
|
||||||
|
if (!input || !btn) return;
|
||||||
|
|
||||||
|
if (sessionKey && isConnected) {
|
||||||
|
input.disabled = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
input.placeholder = "Message ByteChat…";
|
||||||
|
} else {
|
||||||
|
input.disabled = true;
|
||||||
|
btn.disabled = true;
|
||||||
|
input.placeholder = isConnected ? "Waiting for encryption key..." : "Disconnected...";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating input state:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(textarea) {
|
||||||
|
try {
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
const newHeight = Math.min(Math.max(textarea.scrollHeight, 44), 200);
|
||||||
|
textarea.style.height = newHeight + 'px';
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in autoResize:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConnectionStatus(connected) {
|
||||||
|
const encryptionBadge = document.getElementById('encryptionBadge');
|
||||||
|
const securityIndicator = document.getElementById('securityIndicator');
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
if (encryptionBadge) encryptionBadge.textContent = 'E2E Encrypted';
|
||||||
|
if (securityIndicator) securityIndicator.textContent = '🔒 End-to-End Encrypted';
|
||||||
|
} else {
|
||||||
|
if (encryptionBadge) encryptionBadge.textContent = 'Disconnected';
|
||||||
|
if (securityIndicator) securityIndicator.textContent = '⚠️ Disconnected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners setup
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
try {
|
||||||
|
const messageInput = document.getElementById('messageInput');
|
||||||
|
const roomInput = document.getElementById('roomInput');
|
||||||
|
const roomPasswordInput = document.getElementById('roomPasswordInput');
|
||||||
|
|
||||||
|
if (messageInput) {
|
||||||
|
messageInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
messageInput.addEventListener('input', (e) => {
|
||||||
|
autoResize(e.target);
|
||||||
|
|
||||||
|
if (e.target.value.length > MAX_MESSAGE_LENGTH) {
|
||||||
|
e.target.value = e.target.value.substring(0, MAX_MESSAGE_LENGTH);
|
||||||
|
updateStatus(`Message truncated to ${MAX_MESSAGE_LENGTH} characters`, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
messageInput.addEventListener('paste', (e) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (messageInput.value.length > MAX_MESSAGE_LENGTH) {
|
||||||
|
messageInput.value = messageInput.value.substring(0, MAX_MESSAGE_LENGTH);
|
||||||
|
updateStatus(`Pasted content truncated to ${MAX_MESSAGE_LENGTH} characters`, true);
|
||||||
|
}
|
||||||
|
autoResize(messageInput);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomInput) {
|
||||||
|
roomInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
joinRoom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roomInput.addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value && !SecurityUtils.isValidRoomId(value)) {
|
||||||
|
e.target.setCustomValidity('Only letters, numbers, hyphens and underscores allowed');
|
||||||
|
} else {
|
||||||
|
e.target.setCustomValidity('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomPasswordInput) {
|
||||||
|
roomPasswordInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
joinRoom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinRoomBtn = document.getElementById('joinRoomBtn');
|
||||||
|
if (joinRoomBtn) joinRoomBtn.addEventListener('click', joinRoom);
|
||||||
|
|
||||||
|
const createRoomBtn = document.getElementById('createRoomBtn');
|
||||||
|
if (createRoomBtn) createRoomBtn.addEventListener('click', createRoom);
|
||||||
|
|
||||||
|
const sendButton = document.getElementById('sendButton');
|
||||||
|
if (sendButton) sendButton.addEventListener('click', sendMessage);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && messageInput && !messageInput.disabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape' && messageInput) {
|
||||||
|
messageInput.value = '';
|
||||||
|
autoResize(messageInput);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
initializeApp();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error setting up event listeners:", error);
|
||||||
|
updateStatus("Error initializing interface", true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic connection health check
|
||||||
|
setInterval(() => {
|
||||||
|
if (socket && isConnected) {
|
||||||
|
socket.volatile.emit('ping');
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Prevent clickjacking
|
||||||
|
if (top !== self) {
|
||||||
|
top.location = self.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSP violation reporting
|
||||||
|
document.addEventListener('securitypolicyviolation', (e) => {
|
||||||
|
console.error('CSP Violation:', {
|
||||||
|
violatedDirective: e.violatedDirective,
|
||||||
|
blockedURI: e.blockedURI,
|
||||||
|
lineNumber: e.lineNumber,
|
||||||
|
columnNumber: e.columnNumber
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose necessary functions
|
||||||
|
window.SecureChat = {
|
||||||
|
joinRoom,
|
||||||
|
createRoom,
|
||||||
|
sendMessage
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent console access in production
|
||||||
|
if (location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
|
||||||
|
console.log = console.warn = console.error = () => {};
|
||||||
|
}
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
|
||||||
background: #0f0f0f;
|
|
||||||
color: #ececec;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-container {
|
|
||||||
background: #212121;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header {
|
|
||||||
background: #0f0f0f;
|
|
||||||
border-bottom: 1px solid #333;
|
|
||||||
padding: 16px 20px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.encryption-badge {
|
|
||||||
background: #10a37f;
|
|
||||||
color: white;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-section {
|
|
||||||
background: #2d2d2d;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #404040;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-input-container {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-input {
|
|
||||||
flex: 1;
|
|
||||||
background: #0f0f0f;
|
|
||||||
border: 1px solid #404040;
|
|
||||||
color: #ececec;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-input:focus {
|
|
||||||
border-color: #10a37f;
|
|
||||||
box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
background: #10a37f;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: #0d8c6a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #2d2d2d;
|
|
||||||
border: 1px solid #404040;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #3d3d3d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
color: #b4b4b4;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-info {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #2d2d2d;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #b4b4b4;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-details {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px;
|
|
||||||
background: #212121;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-container::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-container::-webkit-scrollbar-track {
|
|
||||||
background: #2d2d2d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-container::-webkit-scrollbar-thumb {
|
|
||||||
background: #404040;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.message-group {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-group.sent {
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-group.received {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(45deg, #10a37f, #0d8c6a);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar.own {
|
|
||||||
background: linear-gradient(45deg, #6366f1, #8b5cf6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #ececec;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-time {
|
|
||||||
color: #888;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
background: #2d2d2d;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-left: 36px;
|
|
||||||
border: 1px solid #404040;
|
|
||||||
line-height: 1.5;
|
|
||||||
word-wrap: break-word;
|
|
||||||
max-width: 70%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-group.sent .message-content {
|
|
||||||
background: #0f0f0f;
|
|
||||||
border-color: #333;
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 36px;
|
|
||||||
text-align: right;
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-group.received .message-content {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-section {
|
|
||||||
background: #0f0f0f;
|
|
||||||
padding: 20px;
|
|
||||||
border-top: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-container {
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-input {
|
|
||||||
width: 100%;
|
|
||||||
background: #2d2d2d;
|
|
||||||
border: 1px solid #404040;
|
|
||||||
color: #ececec;
|
|
||||||
padding: 16px 60px 16px 20px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 16px;
|
|
||||||
outline: none;
|
|
||||||
resize: none;
|
|
||||||
min-height: 24px;
|
|
||||||
max-height: 200px;
|
|
||||||
font-family: inherit;
|
|
||||||
line-height: 1.5;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-input:focus {
|
|
||||||
border-color: #10a37f;
|
|
||||||
box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-input:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-button {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: #10a37f;
|
|
||||||
border: none;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-button:hover:not(:disabled) {
|
|
||||||
background: #0d8c6a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-button:disabled {
|
|
||||||
background: #404040;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-screen {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-title {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
background: linear-gradient(45deg, #10a37f, #6366f1);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-subtitle {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #b4b4b4;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-top: 32px;
|
|
||||||
max-width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-item {
|
|
||||||
background: #2d2d2d;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #404040;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-title {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #ececec;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-desc {
|
|
||||||
color: #b4b4b4;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-message {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: #b4b4b4;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid #404040;
|
|
||||||
border-top: 2px solid #10a37f;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
background: #dc2626;
|
|
||||||
color: white;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 16px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing-indicator {
|
|
||||||
color: #888;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 8px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.chat-container {
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-content {
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-input-container {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-input, .btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-details {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-list {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
548
src/static/stylesheet.css
Normal file
548
src/static/stylesheet.css
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
/* Reset & base */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden; /* prevents double scrollbars */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main container */
|
||||||
|
.chat-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.chat-header {
|
||||||
|
background: rgba(20, 20, 20, 0.95);
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encryption-badge {
|
||||||
|
background: linear-gradient(45deg, #00ff88, #00cc6a);
|
||||||
|
color: #000;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welcome screen */
|
||||||
|
.welcome-screen {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-title {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: linear-gradient(45deg, #00ff88, #ffffff);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
max-width: 600px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-input-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-input {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00ff88;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(45deg, #00ff88, #00cc6a);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 255, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: linear-gradient(45deg, #333, #555);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status text */
|
||||||
|
.status-text {
|
||||||
|
color: #aaa;
|
||||||
|
margin-top: 1rem;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-top: 2px solid #00ff88;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ff4444;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
color: #00ff88;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features */
|
||||||
|
.feature-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-desc {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Room info */
|
||||||
|
.room-info {
|
||||||
|
background: rgba(20, 20, 20, 0.95);
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-details span {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-details strong {
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat screen & messages */
|
||||||
|
.chat-screen {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
padding-bottom: 80px; /* leave space for input */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Align left vs right */
|
||||||
|
.message-group.sent {
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-group.received {
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-group.sent .message-header {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System messages */
|
||||||
|
.message-group.system {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0.5rem auto;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-group.system .message-content {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
width: fit-content; /* 👈 shrink to text */
|
||||||
|
max-width: 90%; /* still wrap if very long */
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(45deg, #00ff88, #00cc6a);
|
||||||
|
color: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar.own {
|
||||||
|
background: linear-gradient(45deg, #0088ff, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The bubble */
|
||||||
|
.message-content {
|
||||||
|
display: inline-block;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-width: 75%; /* prevents overly long messages */
|
||||||
|
width: auto; /* shrink to fit content */
|
||||||
|
position: relative;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-group.sent .message-content {
|
||||||
|
background: #1a4d3a;
|
||||||
|
border-color: #00ff88;
|
||||||
|
text-align: left; /* keep text natural */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input section */
|
||||||
|
.input-section {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(20, 20, 20, 0.95);
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
max-height: 120px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00ff88;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(45deg, #00ff88, #00cc6a);
|
||||||
|
color: #000;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:hover:not(:disabled) {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 255, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Security indicator */
|
||||||
|
.security-indicator {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(1rem + 70px);
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #00ff88;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid #00ff88;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.welcome-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-input-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-input {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-details {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-section {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.room-details span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus,
|
||||||
|
.room-input:focus,
|
||||||
|
.message-input:focus,
|
||||||
|
.send-button:focus {
|
||||||
|
outline: 2px solid #00ff88;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user