This commit is contained in:
2025-08-26 17:56:24 +02:00
commit 913d75efac
17 changed files with 7311 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}

1
README.md Normal file
View File

@@ -0,0 +1 @@
# Tauri Desktop Application

7
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5216
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,25 @@
[package]
name = "bytechat-desktop"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "bytechat_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

7
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,7 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
bytechat_desktop_lib::run()
}

30
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "bytechat-desktop",
"version": "0.1.0",
"identifier": "com.rattatwinko.bytechat-desktop",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "ByteChat",
"width": 800,
"height": 600,
"devtools": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/icon.png"
]
}
}

6
src/assets/tauri.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

494
src/index.html Normal file
View File

@@ -0,0 +1,494 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ByteChat</title>
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-Frame-Options" content="DENY">
<link rel="stylesheet" href="./stylesheet.css">
<script src="https://cdn.jsdelivr.net/npm/socket.io-client@4.7.2/dist/socket.io.min.js"></script>
<script src="./main.js"></script>
</head>
<body style="overflow-y: auto;">
<div class="chat-container">
<div class="chat-header" style="padding: 0.75rem 1rem;">
<div class="header-content" style="flex-wrap: wrap; gap: 0.5rem;">
<div class="logo" style="font-size: clamp(1.25rem, 4vw, 1.5rem);">
<a href="/" style="text-decoration: none; font-weight: inherit; font-style: inherit; color: inherit;">
ByteChat
</a>
</div>
<div class="encryption-badge" id="encryptionBadge" style="font-size: clamp(0.75rem, 3vw, 0.85rem); padding: 0.4rem 0.8rem;">
E2E Encrypted
</div>
</div>
</div>
<div id="welcomeScreen" class="welcome-screen" style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 1rem; flex: 1; min-height: 0;">
<h1 class="welcome-title" style="font-size: clamp(1.75rem, 8vw, 3rem); margin-bottom: 0.75rem;">ByteChat</h1>
<div class="room-section" style="width: 100%; max-width: 500px;">
<div class="room-input-container" style="flex-direction: column; gap: 0.75rem; width: 100%;">
<input type="text"
class="room-input"
id="roomInput"
placeholder="Enter room ID"
maxlength="32"
pattern="[a-zA-Z0-9\-_]+"
title="Only letters, numbers, hyphens and underscores allowed"
style="width: 100%; min-width: unset; font-size: clamp(0.9rem, 4vw, 1rem);">
<input type="password"
class="room-input"
id="roomPasswordInput"
placeholder="Optional: Channel Password"
maxlength="128"
autocomplete="new-password"
style="width: 100%; min-width: unset; max-width: none; font-size: clamp(0.9rem, 4vw, 1rem); overflow: hidden; text-overflow: ellipsis;">
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<button class="btn" id="joinRoomBtn" aria-label="Join existing room" style="flex: 1; min-width: 120px; font-size: clamp(0.85rem, 3.5vw, 1rem); padding: 0.6rem 1rem;">Join Room</button>
<button class="btn btn-secondary" id="createRoomBtn" aria-label="Create new room" style="flex: 1; min-width: 120px; font-size: clamp(0.85rem, 3.5vw, 1rem); padding: 0.6rem 1rem;">Create New Room</button>
</div>
</div>
<div class="status-text" id="statusText" style="font-size: clamp(0.8rem, 3vw, 0.9rem);">
<div class="loading-message">
<div class="loading-spinner"></div>
<span aria-live="polite">Initializing encryption keys...</span>
</div>
</div>
</div>
</div>
<!-- Chat Screen -->
<div id="chatScreen" style="display: none;" class="chat-screen">
<div class="room-info" id="roomInfo" style="display: none; padding: 0.6rem 0.75rem; flex-wrap: wrap; gap: 0.5rem;">
<div class="room-details" style="min-width: auto; flex: 1;">
<span style="font-size: clamp(0.75rem, 3vw, 0.85rem);">Room: <strong id="currentRoomId"></strong></span>
<span id="messageCounter" style="font-size: clamp(0.75rem, 3vw, 0.85rem);">Messages: 0/256</span>
</div>
<div class="room-details" style="min-width: auto; flex: 1;">
<span id="userCounter" style="font-size: clamp(0.75rem, 3vw, 0.85rem);">Users: 0</span>
<span id="encryptionStatus" style="font-size: clamp(0.75rem, 3vw, 0.85rem);">🔐 Encrypted</span>
</div>
</div>
<!-- wrapper for scroll -->
<div class="messages-wrapper" id="messagesWrapper" style="padding: 0.75rem; padding-bottom: 80px; flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; scroll-behavior: smooth;">
<div class="messages-container" id="messagesContainer" role="log" aria-live="polite" aria-label="Chat messages">
<!-- Messages will be inserted here -->
</div>
</div>
</div>
<div class="input-section" id="inputSection" style="display: none; padding: 0.6rem 0.75rem; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; background: rgba(20, 20, 20, 0.98); backdrop-filter: blur(10px); border-top: 1px solid #333; box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.3);">
<div class="input-container" style="gap: 0.4rem; max-width: 100%;">
<label for="messageInput" class="visually-hidden">Type your message</label>
<textarea
class="message-input"
id="messageInput"
placeholder="Message ByteChat…"
rows="1"
disabled
maxlength="4000"
aria-label="Type your message"
style="font-size: clamp(0.9rem, 4vw, 1rem); padding: 0.6rem 0.8rem; border-radius: 10px; resize: none; max-height: 120px; min-height: 44px; font-size: 16px; -webkit-appearance: none;"
autocomplete="off"
autocapitalize="sentences"
spellcheck="true"
></textarea>
<button class="send-button" id="sendButton" disabled aria-label="Send message" style="width: 40px; height: 40px; flex-shrink: 0; -webkit-tap-highlight-color: transparent;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m22 2-7 20-4-9-9-4 20-7z"/>
</svg>
</button>
</div>
</div>
</div>
<!--<div class="security-indicator" id="securityIndicator" style="display: none; bottom: calc(0.75rem + 80px); right: 15px; padding: 0.4rem 0.8rem; font-size: clamp(0.7rem, 3vw, 0.8rem); z-index: 999;">
🔒 End-to-End Encrypted
</div>-->
<script>
// Mobile keyboard handling
let initialViewportHeight = window.innerHeight;
function adjustForKeyboard() {
const currentHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
const diff = initialViewportHeight - currentHeight;
const messagesWrapper = document.getElementById('messagesWrapper');
if (diff > 150) { // Keyboard is likely open
keyboardOpen = true;
if (messagesWrapper) {
messagesWrapper.style.paddingBottom = '140px'; // Even more padding for mobile keyboard
// Reset scroll state and aggressively scroll to bottom
userScrolled = false;
// Multiple scroll attempts with increasing delays
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 150;
}, 100);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 150;
}, 250);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 150;
}, 400);
// iOS often needs even more attempts
if (isIOS) {
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 200;
}, 600);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 200;
}, 800);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 200;
}, 1000);
}
}
} else {
keyboardOpen = false;
if (messagesWrapper) {
messagesWrapper.style.paddingBottom = '80px';
// Scroll to bottom when keyboard closes too
setTimeout(() => {
if (!userScrolled) {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 100;
}
}, 200);
setTimeout(() => {
if (!userScrolled) {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 100;
}
}, 400);
}
}
}
// Listen for viewport changes (keyboard open/close)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', adjustForKeyboard);
} else {
window.addEventListener('resize', adjustForKeyboard);
}
// Mobile-optimized focus handling for textarea
document.addEventListener('DOMContentLoaded', function() {
const messageInput = document.getElementById('messageInput');
if (messageInput) {
messageInput.addEventListener('focus', function() {
// Reset scroll state when focusing input
userScrolled = false;
// Multiple timeout attempts for different mobile browsers
setTimeout(adjustForKeyboard, 200);
setTimeout(adjustForKeyboard, 400);
if (isMobile) {
setTimeout(adjustForKeyboard, 600);
setTimeout(() => {
const messagesWrapper = document.getElementById('messagesWrapper');
if (messagesWrapper) {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 200;
}
}, 800);
// Additional scroll attempt for stubborn mobile browsers
setTimeout(() => {
const messagesWrapper = document.getElementById('messagesWrapper');
if (messagesWrapper) {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 250;
}
}, 1200);
}
});
messageInput.addEventListener('blur', function() {
setTimeout(adjustForKeyboard, 200);
setTimeout(adjustForKeyboard, 400);
});
// Handle input events for better mobile experience
messageInput.addEventListener('input', function() {
if (isMobile && !userScrolled) {
setTimeout(() => {
scrollToBottom(false);
}, 100);
}
});
}
});
// Mobile-optimized auto-scroll functionality
let isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth <= 768;
let isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
let keyboardOpen = false;
function scrollToBottom(smooth = true) {
const messagesWrapper = document.getElementById('messagesWrapper');
if (messagesWrapper) {
// On mobile, especially iOS, direct scrollTop assignment is more reliable
if (isMobile) {
// Different scroll behavior based on keyboard state
const extraPadding = keyboardOpen ? 200 : 100;
const targetScroll = messagesWrapper.scrollHeight + extraPadding;
messagesWrapper.scrollTop = targetScroll;
// Multiple aggressive scroll attempts for mobile reliability
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + extraPadding;
}, 50);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + extraPadding;
}, 150);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + extraPadding;
}, 300);
// Extra attempts when just viewing (no keyboard)
if (!keyboardOpen) {
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 150;
}, 500);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 150;
}, 750);
}
// Final iOS-specific attempt
if (isIOS) {
const finalPadding = keyboardOpen ? 250 : 200;
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + finalPadding;
}, keyboardOpen ? 500 : 1000);
}
} else {
if (smooth) {
messagesWrapper.scrollTo({
top: messagesWrapper.scrollHeight,
behavior: 'smooth'
});
} else {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight;
}
}
}
}
// Function to check if user is near bottom of messages
function isNearBottom() {
const messagesWrapper = document.getElementById('messagesWrapper');
if (!messagesWrapper) return true;
// Larger threshold for mobile to account for touch scrolling momentum
const threshold = isMobile ? 150 : 100;
return messagesWrapper.scrollHeight - messagesWrapper.scrollTop - messagesWrapper.clientHeight < threshold;
}
// Mobile scroll state management
let lastScrollTop = 0;
let userScrolled = false;
let scrollTimeout;
let touchStartY = 0;
let isScrolling = false;
// Detect user scroll with mobile optimizations
document.addEventListener('DOMContentLoaded', function() {
const messagesWrapper = document.getElementById('messagesWrapper');
if (messagesWrapper) {
// Handle touch events for better mobile detection
messagesWrapper.addEventListener('touchstart', function(e) {
touchStartY = e.touches[0].clientY;
isScrolling = false;
clearTimeout(scrollTimeout);
}, { passive: true });
messagesWrapper.addEventListener('touchmove', function(e) {
if (!isScrolling) {
isScrolling = true;
const touchY = e.touches[0].clientY;
const deltaY = touchStartY - touchY;
// If user is swiping up (scrolling up), mark as user scrolled
if (deltaY < -10 && !isNearBottom()) {
userScrolled = true;
}
}
}, { passive: true });
messagesWrapper.addEventListener('touchend', function() {
// Check scroll position after touch ends with delay for momentum scrolling
setTimeout(() => {
if (isNearBottom()) {
userScrolled = false;
}
isScrolling = false;
}, 300);
}, { passive: true });
// Regular scroll event with debouncing for mobile
messagesWrapper.addEventListener('scroll', function() {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const currentScrollTop = messagesWrapper.scrollTop;
const maxScroll = messagesWrapper.scrollHeight - messagesWrapper.clientHeight;
// More lenient detection for mobile
const upScrollThreshold = isMobile ? 30 : 50;
// If user scrolled up manually, don't auto-scroll for new messages
if (currentScrollTop < lastScrollTop && currentScrollTop < maxScroll - upScrollThreshold) {
userScrolled = true;
}
// If user scrolled to near bottom, resume auto-scrolling
if (isNearBottom()) {
userScrolled = false;
}
lastScrollTop = currentScrollTop;
}, isMobile ? 150 : 50); // Longer debounce for mobile
}, { passive: true });
}
});
// Mobile-optimized message adding function
function addMessageWithAutoScroll(messageElement) {
const messagesContainer = document.getElementById('messagesContainer');
if (messagesContainer && messageElement) {
messagesContainer.appendChild(messageElement);
// Only auto-scroll if user hasn't manually scrolled up
if (!userScrolled) {
const messagesWrapper = document.getElementById('messagesWrapper');
if (messagesWrapper) {
// Immediate first scroll
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 100;
// Progressive scroll attempts with different padding
setTimeout(() => {
const padding = keyboardOpen ? 200 : 150;
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + padding;
}, 100);
setTimeout(() => {
const padding = keyboardOpen ? 200 : 150;
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + padding;
}, 250);
// Extra attempts when just viewing (no keyboard)
if (!keyboardOpen && isMobile) {
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 200;
}, 400);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 250;
}, 600);
// Final attempt for stubborn browsers
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 300;
}, 1000);
}
// iOS-specific final attempt
if (isIOS) {
const finalPadding = keyboardOpen ? 250 : 300;
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + finalPadding;
}, keyboardOpen ? 600 : 1200);
}
}
}
}
}
// Force scroll with mobile optimizations
function forceScrollToBottom() {
userScrolled = false;
const messagesWrapper = document.getElementById('messagesWrapper');
if (messagesWrapper) {
// Immediate aggressive scroll
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 150;
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 150;
}, 100);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 200;
}, 300);
// Extra attempts for iOS
if (isIOS) {
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 250;
}, 500);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 250;
}, 700);
}
}
}
// Mobile-optimized mutation observer
document.addEventListener('DOMContentLoaded', function() {
const messagesContainer = document.getElementById('messagesContainer');
if (messagesContainer) {
// Create a MutationObserver to watch for new messages
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// Check if any added nodes are message elements
const hasNewMessage = Array.from(mutation.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.classList.contains('message-group') || node.querySelector('.message-group'))
);
if (hasNewMessage && !userScrolled) {
const messagesWrapper = document.getElementById('messagesWrapper');
if (messagesWrapper) {
// Immediate scroll
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 100;
setTimeout(() => {
const padding = keyboardOpen ? 200 : 150;
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + padding;
}, 100);
setTimeout(() => {
const padding = keyboardOpen ? 250 : 200;
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + padding;
}, 300);
// Extra scrolling when just viewing (no keyboard)
if (!keyboardOpen && isMobile) {
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 250;
}, 500);
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 300;
}, 800);
// Final aggressive attempt for viewing mode
setTimeout(() => {
messagesWrapper.scrollTop = messagesWrapper.scrollHeight + 350;
}, 1200);
}
}
}
}
});
});
// Start observing
observer.observe(messagesContainer, {
childList: true,
subtree: true
});
}
});
// Expose functions globally so your existing JavaScript can use them
window.ByteChat = window.ByteChat || {};
window.ByteChat.scrollToBottom = scrollToBottom;
window.ByteChat.forceScrollToBottom = forceScrollToBottom;
window.ByteChat.addMessageWithAutoScroll = addMessageWithAutoScroll;
</script>
</body>
</html>

931
src/main.js Normal file
View File

@@ -0,0 +1,931 @@
// 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 = 10;
const MESSAGE_RATE_LIMIT = 1000;
const MAX_MESSAGE_LENGTH = 4000;
const MAX_ROOM_ID_LENGTH = 32;
const MAX_MESSAGES = 512;
// 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
// Define your backend server (for now ngrok)
const SERVER_URL = "https://kind-mosquito-multiply.ngrok-free.app";
async function initializeApp() {
try {
console.log("Initializing bytechat");
// Initialize socket connection to ngrok server
socket = io(SERVER_URL, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: MAX_RECONNECT_ATTEMPTS,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 10000,
secure: true
});
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
// WelcomeScreen div gets removed if you join a room, this is required cause it looks very weird
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 = () => {};
}

548
src/stylesheet.css Normal file
View 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;
}