initial
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
7
src-tauri/.gitignore
vendored
Normal file
7
src-tauri/.gitignore
vendored
Normal 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
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
25
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Normal file
10
src-tauri/capabilities/default.json
Normal 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
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
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
7
src-tauri/src/lib.rs
Normal 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
6
src-tauri/src/main.rs
Normal 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
30
src-tauri/tauri.conf.json
Normal 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
6
src/assets/tauri.svg
Normal 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
494
src/index.html
Normal 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
931
src/main.js
Normal 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
548
src/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;
|
||||
}
|
||||
Reference in New Issue
Block a user