1247 lines · 37.2 KB · xml
Raw
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>NulledRoom</title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=VT323&display=swap" rel="stylesheet">
<style>
:root {
--green: #00ff41;
--green-dim: #00aa2a;
--green-dark: #003a0e;
--green-glow: rgba(0, 255, 65, 0.15);
--bg: #0a0a0a;
--bg2: #0d0d0d;
--border: #1a3a1a;
--red: #ff3131;
--grey: #444;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
background: var(--bg);
color: var(--green);
font-family: 'Share Tech Mono', monospace;
font-size: 14px;
height: 100%;
width: 100%;
overflow: hidden;
position: fixed;
}
body::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: repeating-linear-gradient(
0deg, transparent, transparent 2px,
rgba(0,0,0,0.07) 2px, rgba(0,0,0,0.07) 4px
);
pointer-events: none;
z-index: 9999;
}
@keyframes flicker {
0%, 100% { opacity: 1; }
92% { opacity: 1; } 93% { opacity: 0.97; }
94% { opacity: 1; } 96% { opacity: 0.95; } 97% { opacity: 1; }
}
body { animation: flicker 8s infinite; }
.screen { display: none; height: 100%; flex-direction: column; }
.screen.active { display: flex; }
/* ===== BOOT ===== */
#screen-boot {
justify-content: center;
align-items: center;
padding: 24px 20px;
overflow-y: auto;
}
.boot-logo {
font-family: 'VT323', monospace;
font-size: clamp(42px, 10vw, 96px);
color: var(--green);
text-shadow: 0 0 20px var(--green), 0 0 40px var(--green-dim);
letter-spacing: 6px;
text-align: center;
margin-bottom: 6px;
line-height: 1;
}
.boot-sub {
color: var(--green-dim);
font-size: clamp(9px, 2vw, 11px);
letter-spacing: 3px;
text-align: center;
margin-bottom: 36px;
}
.boot-menu {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
max-width: 400px;
}
.boot-btn {
background: transparent;
border: 1px solid var(--green-dim);
color: var(--green);
font-family: 'Share Tech Mono', monospace;
font-size: clamp(13px, 3.5vw, 15px);
padding: 16px 20px;
cursor: pointer;
text-align: left;
letter-spacing: 2px;
transition: all 0.1s;
width: 100%;
}
.boot-btn::before { content: '> '; color: var(--green-dim); }
.boot-btn:hover, .boot-btn:active {
background: var(--green-glow);
border-color: var(--green);
box-shadow: 0 0 12px var(--green-glow);
}
.boot-divider { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
/* Room options shown before create */
.room-options {
display: none;
flex-direction: column;
gap: 8px;
margin-top: 4px;
padding: 12px;
border: 1px solid var(--border);
background: rgba(0,255,65,0.03);
}
.room-options.visible { display: flex; }
.option-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.option-label {
font-size: 11px;
color: var(--green-dim);
letter-spacing: 1px;
flex: 1;
}
.option-label span {
display: block;
font-size: 10px;
color: var(--grey);
margin-top: 2px;
}
.term-select {
background: transparent;
border: 1px solid var(--green-dim);
color: var(--green);
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
padding: 6px 8px;
outline: none;
cursor: pointer;
min-width: 110px;
}
.term-select:focus { border-color: var(--green); }
.term-select option { background: #0d0d0d; color: var(--green); }
.toggle-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.toggle {
appearance: none;
width: 36px;
height: 20px;
background: var(--border);
border: 1px solid var(--green-dim);
border-radius: 0;
cursor: pointer;
position: relative;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: var(--green-dim);
transition: all 0.2s;
}
.toggle:checked {
background: var(--green-glow);
border-color: var(--green);
}
.toggle:checked::after {
left: 18px;
background: var(--green);
}
.toggle-label {
font-size: 11px;
color: var(--green-dim);
letter-spacing: 1px;
}
input[type=checkbox]:checked + .toggle-label { color: var(--green); }
.create-confirm-btn {
background: var(--green-glow);
border: 1px solid var(--green);
color: var(--green);
font-family: 'Share Tech Mono', monospace;
font-size: 13px;
padding: 12px;
cursor: pointer;
letter-spacing: 2px;
width: 100%;
margin-top: 4px;
}
.create-confirm-btn:active { background: rgba(0,255,65,0.25); }
.join-form { display: none; flex-direction: column; gap: 8px; margin-top: 4px; }
.join-form.visible { display: flex; }
.term-input {
background: transparent;
border: 1px solid var(--green-dim);
color: var(--green);
font-family: 'Share Tech Mono', monospace;
font-size: clamp(15px, 4vw, 17px);
padding: 14px;
letter-spacing: 4px;
outline: none;
text-transform: uppercase;
width: 100%;
}
.term-input::placeholder { color: var(--green-dark); text-transform: none; letter-spacing: 1px; font-size: 13px; }
.term-input:focus { border-color: var(--green); box-shadow: 0 0 8px var(--green-glow); }
.term-btn {
background: transparent;
border: 1px solid var(--green);
color: var(--green);
font-family: 'Share Tech Mono', monospace;
font-size: 14px;
padding: 14px;
cursor: pointer;
letter-spacing: 2px;
width: 100%;
}
.term-btn:active { background: var(--green-glow); }
.boot-status {
margin-top: 16px;
font-size: 11px;
color: var(--green-dim);
letter-spacing: 1px;
min-height: 16px;
text-align: center;
}
.boot-status.error { color: var(--red); }
/* ===== ROOM ID BANNER ===== */
#room-id-banner {
display: none;
margin-top: 20px;
width: 100%;
max-width: 400px;
border: 1px solid var(--green);
background: var(--green-glow);
padding: 20px 16px 14px;
text-align: center;
box-shadow: 0 0 24px var(--green-glow);
}
#room-id-banner.visible { display: block; }
.banner-label { font-size: 10px; color: var(--green-dim); letter-spacing: 4px; margin-bottom: 10px; }
.banner-id {
font-family: 'VT323', monospace;
font-size: clamp(52px, 14vw, 72px);
color: var(--green);
text-shadow: 0 0 24px var(--green), 0 0 48px var(--green-dim);
letter-spacing: 10px;
line-height: 1;
}
.banner-hint { font-size: 10px; color: var(--green-dim); letter-spacing: 2px; margin-top: 10px; line-height: 1.6; }
.banner-settings {
margin-top: 10px;
font-size: 10px;
color: var(--grey);
letter-spacing: 1px;
border-top: 1px solid var(--border);
padding-top: 8px;
}
.banner-settings span { color: var(--green-dim); }
.banner-copy-btn {
margin-top: 12px;
background: transparent;
border: 1px solid var(--green-dim);
color: var(--green-dim);
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
padding: 10px 16px;
cursor: pointer;
letter-spacing: 2px;
width: 100%;
}
.banner-copy-btn:active { background: var(--green-glow); color: var(--green); border-color: var(--green); }
.banner-enter-btn {
margin-top: 8px;
background: rgba(0,255,65,0.12);
border: 1px solid var(--green);
color: var(--green);
font-family: 'Share Tech Mono', monospace;
font-size: 14px;
padding: 14px 16px;
cursor: pointer;
letter-spacing: 2px;
width: 100%;
}
.banner-enter-btn:active { background: rgba(0,255,65,0.25); }
/* ===== CHAT SCREEN ===== */
#screen-chat { height: 100%; overflow: hidden; }
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg2);
flex-shrink: 0;
min-height: 52px;
gap: 8px;
}
.chat-header-left { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
.header-logo {
font-family: 'VT323', monospace;
font-size: 26px;
color: var(--green);
text-shadow: 0 0 10px var(--green);
letter-spacing: 2px;
flex-shrink: 0;
}
.room-info { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.room-id-display {
font-size: clamp(11px, 3vw, 13px);
color: var(--green-dim);
letter-spacing: 1px;
white-space: nowrap;
}
.room-id-display span { color: var(--green); font-size: clamp(13px, 4vw, 16px); letter-spacing: 3px; }
.status-line { font-size: 10px; color: var(--green-dim); letter-spacing: 1px; }
.status-dot {
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 5px var(--green);
margin-right: 4px;
animation: pulse 2s infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.chat-header-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.online-count { font-size: 10px; color: var(--green-dim); border: 1px solid var(--border); padding: 3px 7px; white-space: nowrap; }
.online-count span { color: var(--green); }
.my-name-display { font-size: 10px; color: var(--green-dim); display: none; }
.my-name-display span { color: var(--green); }
@media (min-width: 480px) { .my-name-display { display: block; } }
.nuke-btn {
background: transparent;
border: 1px solid var(--red);
color: var(--red);
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
padding: 7px 10px;
cursor: pointer;
letter-spacing: 1px;
display: none;
white-space: nowrap;
}
.nuke-btn:active { background: rgba(255,49,49,0.12); }
.nuke-btn.visible { display: block; }
.chat-body { display: flex; flex: 1; overflow: hidden; min-height: 0; }
.messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--green-dim) transparent;
}
.messages::-webkit-scrollbar { width: 3px; }
.messages::-webkit-scrollbar-thumb { background: var(--green-dim); }
.msg { display: flex; gap: 8px; line-height: 1.5; word-break: break-word; align-items: baseline; }
.msg-time { color: var(--grey); font-size: 10px; flex-shrink: 0; }
.msg-from { color: var(--green-dim); flex-shrink: 0; font-size: 12px; }
.msg-from.self { color: var(--green); }
.msg-text { color: #ccc; font-size: 13px; }
.msg.system .msg-text { color: var(--green-dim); font-style: italic; font-size: 11px; }
.msg.system .msg-from { display: none; }
/* Image messages */
.msg-image-wrap { display: flex; flex-direction: column; gap: 4px; }
.msg-image {
max-width: min(280px, 70vw);
max-height: 200px;
border: 1px solid var(--border);
cursor: pointer;
display: block;
object-fit: cover;
transition: border-color 0.1s;
}
.msg-image:hover { border-color: var(--green); }
.msg-image-size { font-size: 10px; color: var(--grey); }
.users-panel {
width: 150px;
border-left: 1px solid var(--border);
padding: 10px;
background: var(--bg2);
overflow-y: auto;
flex-shrink: 0;
display: none;
}
@media (min-width: 580px) { .users-panel { display: block; } }
.users-title { font-size: 9px; color: var(--green-dim); letter-spacing: 3px; margin-bottom: 8px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.user-entry { font-size: 11px; color: var(--green-dim); padding: 3px 0; word-break: break-all; }
.user-entry::before { content: '· '; }
.user-entry.self { color: var(--green); }
.user-entry.self::before { content: '» '; }
/* Input area */
.chat-input-area {
border-top: 1px solid var(--border);
padding: 10px 12px;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg2);
flex-shrink: 0;
}
.input-prompt { color: var(--green); font-size: 14px; flex-shrink: 0; }
#msg-input {
flex: 1;
background: transparent;
border: none;
color: #ddd;
font-family: 'Share Tech Mono', monospace;
font-size: 16px;
outline: none;
caret-color: var(--green);
min-width: 0;
}
#msg-input::placeholder { color: var(--grey); font-size: 13px; }
.img-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--grey);
font-size: 16px;
padding: 7px 10px;
cursor: pointer;
flex-shrink: 0;
line-height: 1;
transition: all 0.1s;
}
.img-btn:hover, .img-btn:active { border-color: var(--green-dim); color: var(--green-dim); }
.send-btn {
background: transparent;
border: 1px solid var(--green-dim);
color: var(--green-dim);
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
padding: 9px 14px;
cursor: pointer;
letter-spacing: 1px;
flex-shrink: 0;
white-space: nowrap;
}
.send-btn:active { border-color: var(--green); color: var(--green); background: var(--green-glow); }
/* Hidden file input */
#img-file-input { display: none; }
/* Image lightbox */
#lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.95);
z-index: 500;
justify-content: center;
align-items: center;
padding: 20px;
}
#lightbox.visible { display: flex; }
#lightbox img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border: 1px solid var(--border);
}
#lightbox-close {
position: absolute;
top: 16px; right: 16px;
background: transparent;
border: 1px solid var(--grey);
color: var(--grey);
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
padding: 8px 14px;
cursor: pointer;
letter-spacing: 2px;
}
#lightbox-close:active { color: var(--green); border-color: var(--green); }
/* Nuked overlay */
#nuked-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.96);
z-index: 1000;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 16px;
padding: 24px;
text-align: center;
}
#nuked-overlay.visible { display: flex; }
.nuked-title {
font-family: 'VT323', monospace;
font-size: clamp(56px, 15vw, 96px);
color: var(--red);
text-shadow: 0 0 30px var(--red);
letter-spacing: 8px;
animation: glitch 0.3s infinite;
}
@keyframes glitch {
0%, 100% { transform: translate(0); }
25% { transform: translate(-2px, 1px); }
75% { transform: translate(2px, -1px); }
}
.nuked-sub { color: var(--grey); font-size: 11px; letter-spacing: 3px; }
.nuked-btn {
margin-top: 16px;
background: transparent;
border: 1px solid var(--grey);
color: var(--grey);
font-family: 'Share Tech Mono', monospace;
font-size: 13px;
padding: 14px 24px;
cursor: pointer;
letter-spacing: 2px;
width: 100%;
max-width: 300px;
}
.nuked-btn:active { border-color: var(--green); color: var(--green); }
@keyframes blink { 50% { opacity: 0; } }
.blink { animation: blink 1s step-end infinite; }
</style>
</head>
<body>
<!-- BOOT SCREEN -->
<div id="screen-boot" class="screen active">
<div class="boot-logo">NULLEDROOM</div>
<div class="boot-sub">// FULLY ENCRYPTED · ZERO LOGS · NO MESSAGE HISTORY · NO ACCOUNTS · NOTHING IS STORED //</div>
<div class="boot-menu">
<!-- CREATE -->
<button class="boot-btn" onclick="toggleCreateOptions()">CREATE ROOM</button>
<div class="room-options" id="room-options">
<div class="option-row">
<div class="option-label">
INACTIVITY TIMEOUT
<span>kick idle users after</span>
</div>
<select class="term-select" id="inactivity-select">
<option value="5">5 minutes</option>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">1 hour</option>
<option value="180">3 hours</option>
<option value="360">6 hours</option>
<option value="720">12 hours</option>
<option value="1440">24 hours</option>
</select>
</div>
<div class="option-row">
<div class="option-label">
NUKE ON HOST LEAVE
<span>destroy room if you disconnect</span>
</div>
<div class="toggle-wrap">
<input type="checkbox" class="toggle" id="nuke-on-leave-toggle">
<label class="toggle-label" for="nuke-on-leave-toggle">OFF</label>
</div>
</div>
<button class="create-confirm-btn" onclick="createRoom()">INITIALIZE ROOM →</button>
</div>
<hr class="boot-divider">
<!-- JOIN -->
<button class="boot-btn" onclick="toggleJoinForm()">JOIN ROOM</button>
<div class="join-form" id="join-form">
<input class="term-input" id="join-id-input" type="text" maxlength="10"
placeholder="enter room ID..." autocomplete="off" spellcheck="false"
autocorrect="off" autocapitalize="characters">
<button class="term-btn" onclick="joinRoom()">CONNECT →</button>
</div>
</div>
<!-- Room ID banner -->
<div id="room-id-banner">
<div class="banner-label">// YOUR ROOM ID //</div>
<div class="banner-id" id="banner-id-text">——</div>
<div class="banner-hint">SHARE THIS ID WITH WHOEVER YOU WANT IN THE ROOM</div>
<div class="banner-settings" id="banner-settings"></div>
<button class="banner-copy-btn" onclick="copyRoomId()">[ COPY ID ]</button>
<button class="banner-enter-btn" onclick="enterRoom()">ENTER ROOM →</button>
</div>
<div class="boot-status" id="boot-status"><span class="blink">_</span></div>
</div>
<!-- CHAT SCREEN -->
<div id="screen-chat" class="screen">
<div class="chat-header">
<div class="chat-header-left">
<div class="header-logo">NR</div>
<div class="room-info">
<div class="room-id-display">ROOM: <span id="display-room-id">—</span></div>
<div class="status-line"><span class="status-dot"></span>E2EE · LIVE</div>
</div>
</div>
<div class="chat-header-right">
<div class="online-count">ONLINE: <span id="online-count">0</span></div>
<div class="my-name-display">YOU: <span id="display-my-name">—</span></div>
<button class="nuke-btn" id="nuke-btn" onclick="confirmNuke()">⚠ NUKE</button>
</div>
</div>
<div class="chat-body">
<div class="messages" id="messages"></div>
<div class="users-panel">
<div class="users-title">ONLINE</div>
<div id="user-list"></div>
</div>
</div>
<div class="chat-input-area">
<span class="input-prompt">>></span>
<input type="text" id="msg-input" placeholder="type message..."
autocomplete="off" spellcheck="false" maxlength="2000"
autocorrect="off" autocapitalize="none">
<button class="img-btn" onclick="triggerImagePick()" title="Send image">🖼</button>
<input type="file" id="img-file-input" accept="image/*" onchange="handleImagePicked(event)">
<button class="send-btn" onclick="sendMessage()">SEND</button>
</div>
</div>
<!-- LIGHTBOX -->
<div id="lightbox" onclick="closeLightbox()">
<button id="lightbox-close" onclick="closeLightbox()">[ CLOSE ]</button>
<img id="lightbox-img" src="" alt="">
</div>
<!-- NUKED OVERLAY -->
<div id="nuked-overlay">
<div class="nuked-title">NUKED</div>
<div class="nuked-sub">// ROOM DESTROYED · ALL DATA PURGED //</div>
<button class="nuked-btn" onclick="goHome()">RETURN TO TERMINAL</button>
</div>
<script>
const WS_URL = (() => {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/ws`;
})();
const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB raw limit
const COMPRESSED_TARGET = 1400 * 1024; // target ~1.4MB after compression
let ws = null;
let myName = null;
let myClientId = null;
let isCreator = false;
let currentRoomId = null;
let cryptoKey = null;
let pingInterval = null;
let pendingRoomId = null;
let pendingSettings = {};
// Toggle nuke-on-leave label
document.getElementById('nuke-on-leave-toggle').addEventListener('change', function() {
document.querySelector('.toggle-label').textContent = this.checked ? 'ON' : 'OFF';
});
// ===== CRYPTO =====
async function deriveKey(roomId) {
const enc = new TextEncoder();
const km = await crypto.subtle.importKey(
'raw', enc.encode(roomId.toUpperCase()),
{ name: 'PBKDF2' }, false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: enc.encode('nulledroom-salt-v1'), iterations: 100000, hash: 'SHA-256' },
km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
);
}
// Safe base64 — chunked to avoid call stack overflow on large images
function uint8ToBase64(bytes) {
let binary = '';
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
async function encryptData(data) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = typeof data === 'string' ? new TextEncoder().encode(data) : data;
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, enc);
const combined = new Uint8Array(iv.length + ct.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ct), iv.length);
return uint8ToBase64(combined);
}
async function decryptData(b64, asText = true) {
const combined = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const ct = combined.slice(12);
try {
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, ct);
return asText ? new TextDecoder().decode(pt) : new Uint8Array(pt);
} catch { return asText ? '[decryption failed]' : null; }
}
function generateRoomId() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let id = '';
for (let i = 0; i < 6; i++) id += chars[Math.floor(Math.random() * chars.length)];
return id;
}
// ===== IMAGE COMPRESSION =====
function compressImage(file) {
return new Promise((resolve, reject) => {
if (file.size > MAX_IMAGE_SIZE) {
reject(new Error(`Image too large (max 20MB)`));
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let { width, height } = img;
// Scale down if needed
const maxDim = 1920;
if (width > maxDim || height > maxDim) {
const ratio = Math.min(maxDim / width, maxDim / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Try quality levels until under target size
let quality = 0.85;
const tryCompress = () => {
canvas.toBlob((blob) => {
if (blob.size <= COMPRESSED_TARGET || quality <= 0.3) {
const reader2 = new FileReader();
reader2.onload = (e2) => resolve({
dataUrl: e2.target.result,
size: blob.size,
width, height
});
reader2.readAsDataURL(blob);
} else {
quality -= 0.1;
tryCompress();
}
}, 'image/jpeg', quality);
};
tryCompress();
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = e.target.result;
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
// ===== UI =====
function setStatus(text, isError = false) {
const el = document.getElementById('boot-status');
el.textContent = text;
el.className = 'boot-status' + (isError ? ' error' : '');
}
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
function addMessage(type, from, text, isSelf = false) {
const messages = document.getElementById('messages');
const div = document.createElement('div');
div.className = 'msg' + (type === 'system' ? ' system' : '');
const now = new Date();
const time = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0');
div.innerHTML = `
<span class="msg-time">${time}</span>
<span class="msg-from ${isSelf ? 'self' : ''}">${from ? from + ':' : ''}</span>
<span class="msg-text">${escapeHtml(text)}</span>
`;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function addImageMessage(from, dataUrl, isSelf = false) {
const messages = document.getElementById('messages');
const div = document.createElement('div');
div.className = 'msg';
const now = new Date();
const time = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0');
const img = document.createElement('img');
img.className = 'msg-image';
img.src = dataUrl;
img.onclick = () => openLightbox(dataUrl);
const wrap = document.createElement('div');
wrap.className = 'msg-image-wrap';
wrap.appendChild(img);
div.innerHTML = `<span class="msg-time">${time}</span><span class="msg-from ${isSelf ? 'self' : ''}">${escapeHtml(from)}:</span>`;
div.appendChild(wrap);
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function openLightbox(src) {
document.getElementById('lightbox-img').src = src;
document.getElementById('lightbox').classList.add('visible');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('visible');
document.getElementById('lightbox-img').src = '';
}
function escapeHtml(t) {
return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function updateUserList(users) {
const ul = document.getElementById('user-list');
ul.innerHTML = '';
users.forEach(name => {
const div = document.createElement('div');
div.className = 'user-entry' + (name === myName ? ' self' : '');
div.textContent = name;
ul.appendChild(div);
});
document.getElementById('online-count').textContent = users.length;
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + 'B';
return (bytes / 1024).toFixed(0) + 'KB';
}
// ===== WS =====
const RECONNECT_MAX = 8; // max attempts
const RECONNECT_DELAY = 2000; // ms between attempts
let reconnectAttempts = 0;
let reconnectTimer = null;
let intentionalDisconnect = false; // set true when user explicitly leaves
function connectWS(onOpen) {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
onOpen();
reconnectAttempts = 0; // reset on successful connect
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
}, 30000);
};
ws.onmessage = async (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
switch (msg.type) {
case 'joined':
myName = msg.name; myClientId = msg.clientId;
isCreator = msg.isCreator; currentRoomId = msg.roomId;
document.getElementById('display-room-id').textContent = msg.roomId;
document.getElementById('display-my-name').textContent = msg.name;
if (isCreator) document.getElementById('nuke-btn').classList.add('visible');
showScreen('screen-chat');
addMessage('system', null, `// connected · room ${msg.roomId} · e2ee active · you are ${msg.name}`);
if (isCreator) addMessage('system', null, `// share ID to invite: ${msg.roomId}`);
document.getElementById('msg-input').focus();
break;
case 'rejoined':
// Silent reconnect — just update state, no message shown to self
myName = msg.name; myClientId = msg.clientId;
break;
case 'message':
if (msg.isImage) {
const dataUrl = await decryptData(msg.payload, true);
if (dataUrl) addImageMessage(msg.from, dataUrl, msg.from === myName);
} else {
const text = await decryptData(msg.payload, true);
addMessage('chat', msg.from, text, msg.from === myName);
}
break;
case 'system':
addMessage('system', null, msg.text);
break;
case 'userlist':
updateUserList(msg.users);
break;
case 'nuked':
showNuked();
break;
case 'kicked':
addMessage('system', null, '// disconnected: inactivity');
intentionalDisconnect = true;
setTimeout(goHome, 2000);
break;
case 'error':
setStatus('ERROR: ' + msg.text, true);
break;
}
};
ws.onclose = () => {
clearInterval(pingInterval);
// Guests auto-reconnect. Hosts don't — their disconnect is intentional/meaningful.
if (!intentionalDisconnect && currentRoomId && !isCreator) {
scheduleReconnect();
} else if (currentRoomId && isCreator) {
addMessage('system', null, '// connection lost');
}
};
ws.onerror = () => {
// onerror always fires before onclose, just let onclose handle it
};
}
function scheduleReconnect() {
if (reconnectAttempts >= RECONNECT_MAX) {
addMessage('system', null, '// reconnect failed · connection lost');
return;
}
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
if (!currentRoomId || intentionalDisconnect) return;
tryReconnect();
}, RECONNECT_DELAY);
}
function tryReconnect() {
if (!currentRoomId || !cryptoKey) return;
const roomToRejoin = currentRoomId;
const nameToRejoin = myName;
ws = new WebSocket(WS_URL);
ws.onopen = () => {
reconnectAttempts = 0;
// Silently rejoin with same name
ws.send(JSON.stringify({ type: 'rejoin', roomId: roomToRejoin, name: nameToRejoin }));
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
}, 30000);
};
ws.onmessage = async (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
switch (msg.type) {
case 'rejoined':
myName = msg.name; myClientId = msg.clientId;
// no message shown, reconnect was silent
break;
case 'message':
if (msg.isImage) {
const dataUrl = await decryptData(msg.payload, true);
if (dataUrl) addImageMessage(msg.from, dataUrl, msg.from === myName);
} else {
const text = await decryptData(msg.payload, true);
addMessage('chat', msg.from, text, msg.from === myName);
}
break;
case 'system':
addMessage('system', null, msg.text);
break;
case 'userlist':
updateUserList(msg.users);
break;
case 'nuked':
showNuked();
break;
case 'kicked':
addMessage('system', null, '// disconnected: inactivity');
intentionalDisconnect = true;
setTimeout(goHome, 2000);
break;
case 'error':
// Rejoin failed (room gone, etc) — give up silently
addMessage('system', null, '// could not reconnect · room may be gone');
intentionalDisconnect = true;
break;
}
};
ws.onclose = () => {
clearInterval(pingInterval);
if (!intentionalDisconnect && currentRoomId && !isCreator) {
scheduleReconnect();
}
};
ws.onerror = () => {};
}
// ===== ACTIONS =====
function toggleCreateOptions() {
const opts = document.getElementById('room-options');
opts.classList.toggle('visible');
document.getElementById('join-form').classList.remove('visible');
document.getElementById('room-id-banner').classList.remove('visible');
}
function toggleJoinForm() {
const form = document.getElementById('join-form');
form.classList.toggle('visible');
document.getElementById('room-options').classList.remove('visible');
document.getElementById('room-id-banner').classList.remove('visible');
if (form.classList.contains('visible')) document.getElementById('join-id-input').focus();
}
async function createRoom() {
const roomId = generateRoomId();
const inactivityMinutes = parseInt(document.getElementById('inactivity-select').value);
const nukeOnLeave = document.getElementById('nuke-on-leave-toggle').checked;
setStatus('GENERATING KEY...');
cryptoKey = await deriveKey(roomId);
pendingRoomId = roomId;
pendingSettings = { inactivityMinutes, nukeOnLeave };
// Show banner
document.getElementById('banner-id-text').textContent = roomId;
// Show settings summary
const settingsEl = document.getElementById('banner-settings');
const timeLabel = document.getElementById('inactivity-select').options[document.getElementById('inactivity-select').selectedIndex].text;
settingsEl.innerHTML = `TIMEOUT: <span>${timeLabel}</span> · NUKE ON LEAVE: <span>${nukeOnLeave ? 'YES' : 'NO'}</span>`;
document.getElementById('room-id-banner').classList.add('visible');
document.getElementById('room-options').classList.remove('visible');
setStatus('');
}
function copyRoomId() {
if (!pendingRoomId) return;
navigator.clipboard.writeText(pendingRoomId).then(() => {
const btn = document.querySelector('.banner-copy-btn');
btn.textContent = '[ COPIED! ]';
setTimeout(() => btn.textContent = '[ COPY ID ]', 2000);
}).catch(() => {
const el = document.getElementById('banner-id-text');
const range = document.createRange();
range.selectNode(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
});
}
function enterRoom() {
if (!pendingRoomId || !cryptoKey) return;
setStatus('CONNECTING...');
connectWS(() => {
ws.send(JSON.stringify({
type: 'create',
roomId: pendingRoomId,
inactivityMinutes: pendingSettings.inactivityMinutes,
nukeOnLeave: pendingSettings.nukeOnLeave
}));
});
}
async function joinRoom() {
const input = document.getElementById('join-id-input');
const roomId = input.value.trim().toUpperCase();
if (!roomId || roomId.length < 4) { setStatus('INVALID ROOM ID', true); return; }
setStatus('DERIVING KEY...');
cryptoKey = await deriveKey(roomId);
setStatus('CONNECTING...');
connectWS(() => ws.send(JSON.stringify({ type: 'join', roomId })));
}
async function sendMessage() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
input.value = '';
const payload = await encryptData(text);
ws.send(JSON.stringify({ type: 'message', payload, isImage: false }));
addMessage('chat', myName, text, true);
}
function triggerImagePick() {
document.getElementById('img-file-input').click();
}
async function handleImagePicked(event) {
const file = event.target.files[0];
event.target.value = ''; // reset so same file can be picked again
if (!file || !ws || ws.readyState !== WebSocket.OPEN) return;
addMessage('system', null, `// compressing image...`);
try {
const { dataUrl, size, width, height } = await compressImage(file);
addMessage('system', null, `// sending image (${width}x${height} · ${formatBytes(size)})`);
const payload = await encryptData(dataUrl);
ws.send(JSON.stringify({ type: 'message', payload, isImage: true }));
// Show own image locally
addImageMessage(myName, dataUrl, true);
} catch (err) {
addMessage('system', null, `// image error: ${err.message}`);
}
}
function confirmNuke() {
if (confirm('NUKE this room? All users will be disconnected.')) {
ws.send(JSON.stringify({ type: 'nuke' }));
}
}
function showNuked() {
clearInterval(pingInterval);
currentRoomId = null;
document.getElementById('nuked-overlay').classList.add('visible');
}
function goHome() {
intentionalDisconnect = true;
clearTimeout(reconnectTimer);
document.getElementById('nuked-overlay').classList.remove('visible');
closeLightbox();
if (ws) { ws.close(); ws = null; }
clearInterval(pingInterval);
myName = myClientId = currentRoomId = cryptoKey = pendingRoomId = null;
isCreator = false;
intentionalDisconnect = false;
reconnectAttempts = 0;
pendingSettings = {};
document.getElementById('messages').innerHTML = '';
document.getElementById('user-list').innerHTML = '';
document.getElementById('join-id-input').value = '';
document.getElementById('join-form').classList.remove('visible');
document.getElementById('room-options').classList.remove('visible');
document.getElementById('room-id-banner').classList.remove('visible');
document.getElementById('nuke-btn').classList.remove('visible');
document.getElementById('online-count').textContent = '0';
document.querySelector('.toggle-label').textContent = 'OFF';
document.getElementById('nuke-on-leave-toggle').checked = false;
setStatus('');
showScreen('screen-boot');
}
document.getElementById('msg-input').addEventListener('keydown', e => {
if (e.key === 'Enter') sendMessage();
});
document.getElementById('join-id-input').addEventListener('keydown', e => {
if (e.key === 'Enter') joinRoom();
});
document.addEventListener('touchmove', e => {
if (!e.target.closest('.messages') && !e.target.closest('#screen-boot')) {
e.preventDefault();
}
}, { passive: false });
</script>
</body>
</html>