<!DOCTYPE html>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"> <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script> :root {
--bg-void: #070910; --bg-servers: #0c0e18; --bg-sidebar: #10131f; --bg-main: #151a28;
--bg-input: #1c2133; --bg-hover: #192035; --bg-active: #212a42; --accent: #818cf8;
--accent-dim: #4f56c5; --accent-glow: rgba(129,140,248,0.18); --green: #4ade80;
--amber: #fbbf24; --red: #f87171; --text-primary: #e8ecf5; --text-secondary: #8b96b3;
--text-muted: #4a5470; --border: rgba(255,255,255,0.07); --radius: 8px;
--font: 'Syne', sans-serif; --mono: 'JetBrains Mono', monospace;
}
[data-theme="light"] {
--bg-void: #f5f7fa; --bg-servers: #eef0f5; --bg-sidebar: #ffffff; --bg-main: #fafbfc;
--bg-input: #edf2f7; --bg-hover: #e2e8f0; --bg-active: #cbd5e1; --accent: #4f46e5;
--accent-dim: #4338ca; --accent-glow: rgba(79,70,229,0.15); --green: #10b981;
--amber: #f59e0b; --red: #ef4444; --text-primary: #0f172a; --text-secondary: #475569;
--text-muted: #94a3b8; --border: rgba(0,0,0,0.06);
}
[data-theme="high-contrast"] {
--bg-void: #000; --bg-servers: #111; --bg-sidebar: #1a1a1a; --bg-main: #222;
--bg-input: #2d2d2d; --bg-hover: #333; --bg-active: #444; --accent: #ffcc00;
--accent-dim: #e6b800; --accent-glow: rgba(255,204,0,0.2); --green: #00ff88; --amber: #ffaa00;
--red: #ff4444; --text-primary: #fff; --text-secondary: #ccc; --text-muted: #888;
--border: rgba(255,255,255,0.15);
}
[data-theme="forest"] {
--bg-void: #0a1c14; --bg-servers: #0e241c; --bg-sidebar: #132a20; --bg-main: #18382a;
--bg-input: #1e4534; --bg-hover: #265a44; --bg-active: #2f6e54; --accent: #6ee7b7;
--accent-dim: #34d399; --accent-glow: rgba(110,231,183,0.2); --green: #10b981; --amber: #fbbf24;
--red: #f87171; --text-primary: #e2f3eb; --text-secondary: #a7d7c5; --text-muted: #5f9c83;
--border: rgba(255,255,255,0.08);
}
[data-theme="slate"] {
--bg-void: #0d0d0d; --bg-servers: #161616; --bg-sidebar: #1e1e1e; --bg-main: #242424;
--bg-input: #2c2c2c; --bg-hover: #333333; --bg-active: #3d3d3d; --accent: #a8a8a8;
--accent-dim: #707070; --accent-glow: rgba(168,168,168,0.12); --green: #6fcf97;
--amber: #f2c94c; --red: #eb5757; --text-primary: #dcdcdc; --text-secondary: #999999;
--text-muted: #555555; --border: rgba(255,255,255,0.07);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; font-family: var(--font); background: var(--bg-void); color: var(--text-primary); }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 2px; }
#auth-page { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 100; background: radial-gradient(ellipse at 25% 35%, rgba(129,140,248,.14) 0%, transparent 55%), radial-gradient(ellipse at 75% 75%, rgba(251,191,36,.06) 0%, transparent 50%), var(--bg-void); }
.auth-card { width: 380px; background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 16px; padding: 40px; box-shadow: 0 40px 90px rgba(0,0,0,.7); }
.auth-logo { font-size: 28px; font-weight: 800; color: var(--accent); letter-spacing: -1px; margin-bottom: 6px; }
.auth-logo span { color: var(--text-muted); font-weight: 400; }
.auth-subtitle { color: var(--text-secondary); font-size: 13px; margin-bottom: 28px; }
.auth-tabs { display: flex; gap: 4px; margin-bottom: 24px; background: var(--bg-void); border-radius: 8px; padding: 4px; }
.auth-tab { flex: 1; padding: 8px; text-align: center; font-size: 13px; font-weight: 600; font-family: var(--font); border: none; border-radius: 6px; cursor: pointer; color: var(--text-muted); background: transparent; transition: all .2s; }
.auth-tab.active { background: var(--accent); color: #fff; }
.form-field { margin-bottom: 14px; }
.form-field label { display: block; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 7px; }
.form-field input { width: 100%; padding: 11px 13px; background: var(--bg-void); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); font-family: var(--mono); font-size: 13px; outline: none; transition: border-color .2s; }
.form-field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.btn-primary { width: 100%; padding: 13px; background: var(--accent); color: #fff; border: none; border-radius: var(--radius); font-family: var(--font); font-size: 14px; font-weight: 700; cursor: pointer; margin-top: 8px; transition: all .2s; letter-spacing: .02em; }
.btn-primary:hover { background: var(--accent-dim); transform: translateY(-1px); }
.auth-error { color: var(--red); font-size: 12px; margin-top: 10px; text-align: center; min-height: 16px; }
#app-page { display: flex; width: 100%; height: 100vh; }
#servers-bar { width: 66px; background: var(--bg-servers); display: flex; flex-direction: column; align-items: center; padding: 10px 0; gap: 5px; border-right: 1px solid var(--border); overflow-y: auto; overflow-x: hidden; flex-shrink: 0; }
.server-icon { width: 44px; height: 44px; border-radius: 14px; background: var(--bg-sidebar); display: flex; align-items: center; justify-content: center; cursor: pointer; font-weight: 700; font-size: 18px; color: var(--text-secondary); transition: all .2s; border: 2px solid transparent; flex-shrink: 0; user-select: none; position: relative; overflow: hidden; }
.server-icon i { font-size: 20px; }
.server-icon:hover { border-radius: 10px; background: var(--accent); color: #fff; }
.server-icon.active { border-color: var(--accent); color: var(--accent); border-radius: 10px; background: var(--bg-active); }
.server-icon.dm-icon { font-size: 18px; color: var(--accent); background: var(--bg-active); }
.server-icon.dm-icon.active { background: var(--accent); color: #fff; }
.server-divider { width: 28px; height: 1px; background: var(--border); margin: 3px 0; }
.add-server-btn { width: 44px; height: 44px; border-radius: 50%; background: var(--bg-sidebar); border: 2px dashed rgba(74,222,128,.3); display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--green); font-size: 20px; transition: all .2s; flex-shrink: 0; }
.add-server-btn:hover { background: rgba(74,222,128,.1); border-color: var(--green); border-radius: 14px; }
#channel-sidebar { width: 228px; background: var(--bg-sidebar); display: flex; flex-direction: column; border-right: 1px solid var(--border); flex-shrink: 0; }
.sidebar-header { padding: 0 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; height: 50px; flex-shrink: 0; gap: 8px; }
.sidebar-title { font-size: 14px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.sidebar-btn { width: 22px; height: 22px; background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: all .15s; flex-shrink: 0; }
.sidebar-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
.channels-list { flex: 1; overflow-y: auto; padding: 6px 6px; }
.ch-section { font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted); padding: 10px 8px 4px; display: flex; align-items: center; justify-content: space-between; }
.channel-item { display: flex; align-items: center; gap: 7px; padding: 7px 8px; border-radius: 6px; cursor: pointer; color: var(--text-muted); font-size: 13px; font-weight: 500; transition: all .15s; user-select: none; }
.channel-item i { width: 16px; font-size: 12px; }
.channel-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.channel-item.active { background: var(--bg-active); color: var(--text-primary); }
.dm-item { display: flex; align-items: center; gap: 9px; padding: 7px 8px; border-radius: 6px; cursor: pointer; color: var(--text-muted); font-size: 13px; transition: all .15s; user-select: none; }
.dm-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.dm-item.active { background: var(--bg-active); color: var(--text-primary); }
.dm-avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--accent-dim); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; overflow: hidden; }
.dm-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.dm-name { font-weight: 600; font-family: var(--mono); font-size: 12px; }
.pin-btn { font-size: 12px; margin-left: auto; color: var(--amber); cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
.pin-btn:hover { opacity: 1; }
.user-bar { padding: 9px 10px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 9px; background: var(--bg-servers); flex-shrink: 0; cursor: pointer; }
.user-av { width: 30px; height: 30px; border-radius: 50%; background: var(--accent-dim); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; overflow: hidden; }
.user-av img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.user-name-display { font-size: 12px; font-weight: 600; font-family: var(--mono); color: var(--text-primary); }
.user-status-dot { font-size: 10px; color: var(--green); }
.logout-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 15px; padding: 3px; border-radius: 4px; margin-left: auto; transition: all .15s; }
.logout-btn:hover { color: var(--red); }
#main-area { flex: 1; display: flex; flex-direction: column; background: var(--bg-main); min-width: 0; overflow: hidden; position: relative; }
.chat-header { height: 50px; border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 18px; gap: 10px; flex-shrink: 0; }
.chat-header i { color: var(--text-muted); font-size: 16px; width: 20px; }
.chat-header-name { font-size: 14px; font-weight: 700; }
.video-call-btn { margin-left: auto; background: none; border: none; color: var(--accent); cursor: pointer; font-size: 18px; padding: 5px; border-radius: 4px; transition: all 0.2s; }
.video-call-btn:hover { background: var(--bg-hover); }
#messages-wrap { flex: 1; overflow-y: auto; padding: 16px 18px 0; display: flex; flex-direction: column; gap: 1px; }
.msg-group { display: flex; gap: 12px; padding: 5px 6px; border-radius: 6px; transition: background .1s; position: relative; }
.msg-group:hover { background: rgba(255,255,255,.025); }
.msg-av { width: 34px; height: 34px; border-radius: 50%; background: var(--accent-dim); display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; color: #fff; flex-shrink: 0; margin-top: 1px; overflow: hidden; }
.msg-av img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.msg-body { flex: 1; min-width: 0; }
.msg-meta { display: flex; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.msg-author { font-size: 13px; font-weight: 700; color: var(--accent); font-family: var(--mono); }
.msg-time { font-size: 10px; color: var(--text-muted); }
.msg-text { font-size: 13px; color: var(--text-primary); line-height: 1.55; word-break: break-word; white-space: pre-wrap; font-family: var(--mono); }
.msg-img-wrapper { position: relative; display: inline-block; }
.msg-img { max-width: 380px; max-height: 280px; border-radius: 8px; object-fit: cover; cursor: pointer; margin-top: 4px; display: block; border: 1px solid var(--border); }
.gif-badge { position: absolute; bottom: 6px; right: 6px; background: rgba(0,0,0,0.6); color: white; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 12px; backdrop-filter: blur(2px); pointer-events: none; }
.msg-file { display: inline-flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 8px; margin-top: 4px; max-width: 340px; }
.msg-file-icon { font-size: 22px; flex-shrink: 0; }
.msg-file-info { flex: 1; min-width: 0; }
.msg-file-name { font-size: 12px; font-weight: 600; font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-primary); }
.msg-file-type { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
.fa-btn { padding: 6px 11px; border-radius: 6px; border: none; font-family: var(--font); font-size: 11px; font-weight: 700; cursor: pointer; transition: all .15s; white-space: nowrap; text-decoration: none; }
.fa-demo { background: var(--accent); color: #fff; }
.fa-demo:hover { background: var(--accent-dim); }
.fa-preview { background: rgba(251,191,36,.15); color: var(--amber); }
.fa-preview:hover { background: rgba(251,191,36,.28); }
.fa-dl { background: var(--bg-active); color: var(--text-primary); display: inline-flex; align-items: center; gap: 4px; }
.fa-dl:hover { background: var(--bg-hover); }
.msg-delete { position: absolute; top: 5px; right: 8px; width: 22px; height: 22px; border: none; border-radius: 6px; background: rgba(248,113,113,.12); color: var(--red); cursor: pointer; font-size: 12px; display: none; }
.msg-group:hover .msg-delete { display: flex; align-items: center; justify-content: center; }
.msg-delete:hover { background: rgba(248,113,113,.24); }
.input-area { padding: 14px 18px; flex-shrink: 0; }
.upload-preview { display: none; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg-input); border-radius: 7px; border: 1px dashed var(--accent-dim); font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; }
.upload-cancel { background: none; border: none; color: var(--red); cursor: pointer; font-size: 15px; margin-left: auto; }
.input-bar { display: flex; align-items: flex-end; gap: 9px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px; padding: 9px 13px; transition: border-color .2s; }
.input-bar:focus-within { border-color: var(--accent-dim); }
.attach-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 18px; padding: 1px; transition: color .15s; flex-shrink: 0; }
.attach-btn:hover { color: var(--accent); }
#msg-input { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-family: var(--font); font-size: 13px; resize: none; max-height: 110px; line-height: 1.5; }
#msg-input::placeholder { color: var(--text-muted); }
.send-btn { width: 32px; height: 32px; border-radius: 7px; background: var(--accent); border: none; color: #fff; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; transition: all .15s; flex-shrink: 0; }
.send-btn:hover { background: var(--accent-dim); transform: scale(1.06); }
.send-btn:disabled { opacity: .35; cursor: default; transform: none; }
#file-input { display: none; }
#friends-view { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.ftabs { display: flex; gap: 3px; padding: 0 14px; border-bottom: 1px solid var(--border); height: 50px; align-items: center; }
.ftab { padding: 5px 13px; border-radius: 6px; border: none; background: none; color: var(--text-muted); font-family: var(--font); font-size: 13px; font-weight: 600; cursor: pointer; transition: all .15s; }
.ftab i { margin-right: 4px; }
.ftab:hover { background: var(--bg-hover); color: var(--text-primary); }
.ftab.active { background: var(--bg-active); color: var(--text-primary); }
.badge { background: var(--red); color: #fff; font-size: 10px; font-weight: 700; padding: 1px 5px; border-radius: 10px; margin-left: 4px; }
.friends-content { flex: 1; overflow-y: auto; padding: 20px; }
.f-section { font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted); padding-bottom: 10px; border-bottom: 1px solid var(--border); margin-bottom: 10px; }
.friend-card { display: flex; align-items: center; gap: 11px; padding: 10px 12px; border-radius: 8px; transition: background .15s; }
.friend-card:hover { background: var(--bg-hover); }
.friend-av { width: 38px; height: 38px; border-radius: 50%; background: var(--accent-dim); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px; color: #fff; flex-shrink: 0; overflow: hidden; }
.friend-av img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.friend-username { font-size: 13px; font-weight: 600; font-family: var(--mono); }
.friend-label { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.friend-actions { display: flex; gap: 5px; margin-left: auto; }
.ibt { width: 32px; height: 32px; border-radius: 50%; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; transition: all .15s; }
.ibt.acc { background: rgba(74,222,128,.15); color: var(--green); }
.ibt.acc:hover { background: rgba(74,222,128,.3); }
.ibt.dec { background: rgba(248,113,113,.15); color: var(--red); }
.ibt.dec:hover { background: rgba(248,113,113,.3); }
.ibt.msg { background: var(--bg-active); color: var(--accent); }
.ibt.msg:hover { background: var(--accent); color: #fff; }
.add-friend-bar { display: flex; gap: 9px; margin-bottom: 18px; }
.add-friend-bar input { flex: 1; padding: 10px 13px; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); font-family: var(--mono); font-size: 13px; outline: none; transition: border-color .2s; }
.add-friend-bar input:focus { border-color: var(--accent); }
.add-friend-bar .send-fr-btn { padding: 10px 16px; background: var(--accent); border: none; border-radius: var(--radius); color: #fff; font-family: var(--font); font-size: 13px; font-weight: 700; cursor: pointer; transition: background .15s; white-space: nowrap; }
.add-friend-bar .send-fr-btn:hover { background: var(--accent-dim); }
#add-friend-msg { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
#welcome-screen { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 14px; color: var(--text-muted); }
.wlc-icon { font-size: 56px; }
.wlc-title { font-size: 20px; font-weight: 700; color: var(--text-secondary); }
.wlc-sub { font-size: 13px; }
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.75); display: flex; align-items: center; justify-content: center; z-index: 200; backdrop-filter: blur(5px); }
.modal { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 14px; padding: 28px; width: 400px; box-shadow: 0 40px 90px rgba(0,0,0,.7); }
.modal h3 { font-size: 17px; font-weight: 700; margin-bottom: 18px; }
.modal label { display: block; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 7px; }
.modal input, .modal select { width: 100%; padding: 10px 13px; background: var(--bg-void); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); font-family: var(--mono); font-size: 13px; outline: none; margin-bottom: 14px; transition: border-color .2s; }
.modal input:focus, .modal select:focus { border-color: var(--accent); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 6px; flex-wrap: wrap; }
.btn-cancel { padding: 9px 16px; background: var(--bg-active); border: none; border-radius: var(--radius); color: var(--text-secondary); font-family: var(--font); font-size: 13px; font-weight: 600; cursor: pointer; }
.btn-confirm { padding: 9px 18px; background: var(--accent); border: none; border-radius: var(--radius); color: #fff; font-family: var(--font); font-size: 13px; font-weight: 700; cursor: pointer; }
.btn-confirm:hover { background: var(--accent-dim); }
.btn-ghost { padding: 9px 16px; background: transparent; border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-secondary); font-family: var(--font); font-size: 13px; font-weight: 600; cursor: pointer; }
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
.modal-tabs { display: flex; gap: 4px; margin-bottom: 18px; background: var(--bg-void); border-radius: 8px; padding: 4px; }
.modal-tab { flex: 1; padding: 8px; text-align: center; font-size: 12px; font-weight: 600; font-family: var(--font); border: none; border-radius: 6px; cursor: pointer; color: var(--text-muted); background: transparent; transition: all .2s; }
.modal-tab.active { background: var(--accent); color: #fff; }
.share-id { padding: 8px 12px; background: var(--bg-void); border-radius: 6px; font-family: var(--mono); font-size: 11px; color: var(--text-muted); word-break: break-all; margin-top: -6px; margin-bottom: 14px; cursor: pointer; border: 1px solid var(--border); transition: all .15s; }
.share-id:hover { border-color: var(--accent); color: var(--accent); }
.preview-box { display: flex; align-items: center; gap: 12px; padding: 10px 12px; background: var(--bg-void); border: 1px solid var(--border); border-radius: 10px; margin-bottom: 14px; }
.preview-box img { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); }
.preview-box .txt { font-size: 12px; color: var(--text-muted); font-family: var(--mono); word-break: break-word; }
.small-note { font-size: 11px; color: var(--text-muted); margin-top: -8px; margin-bottom: 14px; }
#ctx-menu { position: fixed; background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); z-index: 1000; display: none; min-width: 150px; }
.ctx-item { padding: 8px 12px; cursor: pointer; font-size: 13px; border-radius: 4px; color: var(--text-primary); transition: background 0.1s; }
.ctx-item:hover { background: var(--bg-hover); }
#timeout-overlay { position: absolute; inset: 0; background: #000000dd; backdrop-filter: blur(4px); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 50; }
.timeout-timer { font-size: 48px; font-family: var(--mono); color: white; margin-bottom: 20px; }
#toast { position: fixed; bottom: 22px; right: 22px; padding: 11px 18px; background: var(--bg-active); border: 1px solid var(--border); border-radius: 10px; font-size: 12px; color: var(--text-primary); z-index: 500; transform: translateY(70px); opacity: 0; transition: all .3s; box-shadow: 0 8px 24px rgba(0,0,0,.5); max-width: 320px; }
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { border-color: var(--green); }
#toast.error { border-color: var(--red); }
.empty-state { text-align: center; padding: 36px 16px; color: var(--text-muted); font-size: 12px; }
.empty-state .ico { font-size: 36px; margin-bottom: 10px; }
.csv-wrap { flex: 1; overflow: auto; }
.csv-tbl { border-collapse: collapse; font-size: 12px; font-family: var(--mono); }
.csv-tbl th, .csv-tbl td { padding: 6px 10px; border: 1px solid var(--border); }
.csv-tbl th { background: var(--bg-input); color: var(--accent); }
#video-call-modal { z-index: 400; }
#video-call-modal .modal { width: min(95vw, 1000px); padding: 20px; max-height: 95vh; overflow-y: auto; }
.vc-videos { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
@media (max-width: 580px) { .vc-videos { grid-template-columns: 1fr; } }
.vc-video-wrap { position: relative; background: #000; border-radius: 10px; overflow: hidden; aspect-ratio: 16/9; border: 2px solid var(--border); }
.vc-video-wrap video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
#vc-local-video { transform: scaleX(-1); }
.vc-label { position: absolute; bottom: 8px; left: 10px; background: rgba(0,0,0,0.65); padding: 3px 10px; border-radius: 20px; font-size: 0.78rem; pointer-events: none; color: white; }
.vc-controls { display: flex; gap: 10px; justify-content: center; margin-top: 16px; }
.vc-btn { padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-weight: 700; transition: all 0.2s; font-family: var(--font); font-size: 14px; }
.vc-btn-mute { background: #555; color: white; }
.vc-btn-mute.active { background: #c0392b; }
.vc-btn-video { background: #555; color: white; }
.vc-btn-video.active { background: #c0392b; }
.vc-btn-hangup { background: #c0392b; color: white; width: 100%; margin-top: 16px; }
.vc-status { text-align: center; margin-bottom: 12px; padding: 8px; border-radius: 6px; background: var(--bg-input); font-size: 13px; color: var(--text-secondary); }
.vc-connecting-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.75); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: white; font-size: 13px; }
.spinner { width: 32px; height: 32px; border: 3px solid #333; border-top-color: var(--accent); border-radius: 50%; animation: spin 0.75s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
#incoming-call-popup { position: fixed; top: 20px; right: 20px; background: var(--bg-sidebar); border: 1px solid var(--accent); border-radius: 12px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); z-index: 600; display: none; align-items: center; gap: 16px; max-width: 350px; }
.popup-avatar { width: 48px; height: 48px; border-radius: 50%; background: var(--accent-dim); display: flex; align-items: center; justify-content: center; font-weight: 700; color: white; flex-shrink: 0; overflow: hidden; }
.popup-info { flex: 1; }
.popup-title { font-weight: 700; margin-bottom: 4px; }
.popup-sub { font-size: 12px; color: var(--text-muted); }
.popup-actions { display: flex; gap: 8px; }
.popup-btn { width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center; }
.popup-accept { background: var(--green); color: white; }
.popup-decline { background: var(--red); color: white; }
#lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: flex; align-items: center; justify-content: center; z-index: 800; cursor: zoom-out; }
#lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 8px; object-fit: contain; }
<div class="auth-subtitle">Open a conversation. Build a community.
</div> <button class="auth-tab active" onclick="switchAuthTab('login')">Sign In
</button> <button class="auth-tab" onclick="switchAuthTab('signup')">Create Account
</button> <input type="text" id="au" placeholder="your_handle" onkeydown="if(event.key==='Enter')handleAuth()"> <input type="password" id="ap" placeholder="••••••••" onkeydown="if(event.key==='Enter')handleAuth()"> <button class="btn-primary" id="auth-btn" onclick="handleAuth()">Sign In
</button> <div class="auth-error" id="auth-error"></div> <div id="app-page" style="display:none"> <div class="server-icon dm-icon active" id="dm-icon" title="Direct Messages" onclick="showDMHome()"><i class="fas fa-comment-dots"></i></div> <div class="server-icon" title="Friends & Requests" onclick="openFriendsView()"><i class="fas fa-user-friends"></i></div> <div class="server-divider"></div> <div id="server-icons-list"></div> <div class="server-divider"></div> <div class="add-server-btn" title="Create or Join Server" onclick="openServerModal()"><i class="fas fa-plus"></i></div> <div id="channel-sidebar"> <div class="sidebar-header"> <span class="sidebar-title" id="sidebar-title">Direct Messages
</span> <div class="sidebar-actions"> <button class="sidebar-btn" title="Friends" onclick="openFriendsView()"><i class="fas fa-user-friends"></i></button> <button class="sidebar-btn" id="copy-server-id-btn" style="display:none" title="Copy Server ID" onclick="copyCurrentServerId()"><i class="fas fa-copy"></i></button> <button class="sidebar-btn" id="server-settings-btn" style="display:none" title="Server Settings" onclick="openServerSettingsModal()"><i class="fas fa-cog"></i></button> <button class="sidebar-btn" id="ch-add-btn" style="display:none" title="Create Channel" onclick="openChannelModal()"><i class="fas fa-plus"></i></button> <div class="channels-list" id="sidebar-list"></div> <div class="user-bar" onclick="openSettingsModal('profile')"> <div class="user-av" id="uav">?
</div> <div class="user-name-display" id="uname">...
</div> <div class="user-status-dot"><i class="fas fa-circle"></i> online
</div> <button class="logout-btn" onclick="event.stopPropagation();doLogout()" title="Log out"><i class="fas fa-sign-out-alt"></i></button> <div id="welcome-screen"> <div class="wlc-icon"><i class="fas fa-comments"></i></div> <div class="wlc-title">Welcome to NexChat
</div> <div class="wlc-sub">Select a channel or conversation to get started
</div> <div id="chat-view" style="display:none;flex-direction:column;flex:1;overflow:hidden"> <div class="chat-header"> <i id="chat-icon" class="fas fa-hashtag"></i> <span class="chat-header-name" id="chat-name">-
</span> <button class="video-call-btn" id="start-video-call-btn" style="display:none" title="Start Video Call" onclick="initiateVideoCall()"><i class="fas fa-video"></i></button> <div style="margin-left:auto;display:flex;gap:8px"> <button class="attach-btn" id="admin-members-btn" style="display:none" title="Manage Members" onclick="openAdminMembersModal()"><i class="fas fa-users-cog"></i></button> <div id="messages-wrap"></div> <div id="timeout-overlay" style="display:none"> <div class="timeout-timer" id="timeout-timer">--:--
</div> <div style="color:white;font-size:14px">You are timed out from chatting
</div> <div class="upload-preview" id="upload-prev"> <button class="upload-cancel" onclick="cancelUpload()"><i class="fas fa-times"></i></button> <button class="attach-btn" onclick="document.getElementById('file-input').click()" title="Attach file"><i class="fas fa-paperclip"></i></button> <button class="attach-btn" onclick="openGifModal()" title="Send GIF"><i class="fas fa-images"></i></button> <textarea id="msg-input" placeholder="Send a message..." rows="1" onkeydown="handleMsgKey(event)" oninput="autoGrow(this)"></textarea> <button class="send-btn" id="send-btn" onclick="sendMessage()"><i class="fas fa-paper-plane"></i></button> <input type="file" id="file-input" onchange="onFileChosen(event)"> <div id="friends-view" style="display:none;flex-direction:column;flex:1;overflow:hidden"> <button class="ftab active" onclick="ftab('friends')"><i class="fas fa-user-friends"></i> Friends
</button> <button class="ftab" onclick="ftab('pending')"><i class="fas fa-clock"></i> Pending
<span class="badge" id="pend-badge" style="display:none">0
</span></button> <button class="ftab" onclick="ftab('add')"><i class="fas fa-user-plus"></i> Add Friend
</button> <div class="friends-content" id="friends-content"></div> <!-- Server Modal -->
<div class="overlay" id="server-modal" style="display:none" onclick="if(event.target===this)closeModal('server-modal')"> <h3><i class="fas fa-server"></i> Server
</h3> <button class="modal-tab active" id="mt-create" onclick="srvTab('create')">Create
</button> <button class="modal-tab" id="mt-join" onclick="srvTab('join')">Join
</button> <input type="text" id="srv-name" placeholder="My Awesome Server"> <input type="file" id="srv-icon-file" accept="image/*"> <div class="small-note">You can also change the icon later from server settings.
</div> <div id="srv-join" style="display:none"> <input type="text" id="srv-id" placeholder="Paste server ID or invite code here..."> <div class="modal-actions"> <button class="btn-cancel" onclick="closeModal('server-modal')">Cancel
</button> <button class="btn-confirm" id="srv-btn" onclick="handleServerModal()">Create
</button> <!-- Server Settings Modal -->
<div class="overlay" id="server-settings-modal" style="display:none" onclick="if(event.target===this)closeModal('server-settings-modal')"> <h3><i class="fas fa-cog"></i> Server Settings
</h3> <div class="preview-box" id="server-settings-preview"> <img id="server-settings-preview-img" src="" alt="server icon preview" style="display:none"> <div class="txt" id="server-settings-preview-txt">No icon selected
</div> <input type="text" id="ss-name" placeholder="Server name"> <input type="file" id="ss-icon-file" accept="image/*"> <input type="text" id="ss-icon-url" placeholder="https://..."> <div style="display:flex;gap:8px;margin-bottom:14px"> <input type="text" id="invite-code-display" readonly style="margin-bottom:0;background:var(--bg-input)"> <button class="btn-ghost" onclick="generateTempInvite()" style="white-space:nowrap"><i class="fas fa-sync-alt"></i> Generate
</button> <div class="modal-actions" style="justify-content:space-between"> <button class="btn-ghost" style="color:var(--red)" onclick="deleteServer()"><i class="fas fa-trash-alt"></i> Delete Server
</button> <div style="display:flex;gap:8px"> <button class="btn-cancel" onclick="closeModal('server-settings-modal')">Cancel
</button> <button class="btn-confirm" onclick="saveServerSettings()">Save
</button> <!-- Settings Modal -->
<div class="overlay" id="settings-modal" style="display:none" onclick="if(event.target===this)closeModal('settings-modal')"> <div class="modal" style="width:500px;max-width:90vw;"> <h3><i class="fas fa-sliders-h"></i> Settings
</h3> <button class="modal-tab active" data-stab="profile"><i class="fas fa-user-circle"></i> Profile
</button> <button class="modal-tab" data-stab="account"><i class="fas fa-key"></i> Account
</button> <button class="modal-tab" data-stab="blocked"><i class="fas fa-ban"></i> Blocked
</button> <button class="modal-tab" data-stab="danger"><i class="fas fa-exclamation-triangle"></i> Danger
</button> <div id="stab-profile" class="stab-content"> <div class="preview-box"> <img id="settings-profile-preview-img" src="" alt="profile preview" style="display:none"> <div class="txt" id="settings-profile-preview-txt">No avatar selected
</div> <input type="text" id="settings-username" disabled> <input type="file" id="settings-avatar-file" accept="image/*"> <input type="text" id="settings-avatar-url" placeholder="https://..."> <div id="stab-account" class="stab-content" style="display:none"> <input type="password" id="old-password" placeholder="Enter current password"> <input type="password" id="new-password" placeholder="New password"> <input type="password" id="confirm-password" placeholder="Confirm new password"> <button class="btn-primary" onclick="changePassword()" style="margin-top:8px">Change Password
</button> <div id="password-change-msg" style="font-size:12px;margin-top:8px;color:var(--amber)"></div> <div id="stab-blocked" class="stab-content" style="display:none"> <div id="stab-danger" class="stab-content" style="display:none"> <p style="color:var(--red);margin-bottom:16px">Permanently delete your account and all data.
</p> <input type="password" id="delete-password" placeholder="Your password"> <button class="btn-ghost" style="color:var(--red);border-color:var(--red);width:100%" onclick="deleteAccount()">Delete Account
</button> <div class="modal-actions"> <button class="btn-cancel" onclick="closeModal('settings-modal')">Close
</button> <button class="btn-confirm" id="save-settings-btn" onclick="saveProfileSettings()">Save
</button> <!-- Channel Modal -->
<div class="overlay" id="channel-modal" style="display:none" onclick="if(event.target===this)closeModal('channel-modal')"> <h3><i class="fas fa-hashtag"></i> New Channel
</h3> <input type="text" id="ch-name" placeholder="general"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:14px"> <input type="checkbox" id="ch-restricted" style="width:auto;margin-bottom:0"> <label style="margin:0;text-transform:none;letter-spacing:normal;font-size:13px">Restricted (only selected members)
</label> <div class="modal-actions"> <button class="btn-cancel" onclick="closeModal('channel-modal')">Cancel
</button> <button class="btn-confirm" onclick="createChannel()">Create
</button> <!-- GIF Modal -->
<div class="overlay" id="gif-modal" style="display:none" onclick="if(event.target===this)closeModal('gif-modal')"> <h3><i class="fas fa-images"></i> GIF
</h3> <input type="text" id="gif-url" placeholder="Paste GIF link here..."> <input type="text" id="gif-caption" placeholder="Add text with the GIF..."> <div class="small-note">Direct .gif links always work. Tenor/Giphy page links are auto-resolved when possible.
</div> <div class="modal-actions"> <button class="btn-cancel" onclick="closeModal('gif-modal')">Cancel
</button> <button class="btn-confirm" onclick="sendGifFromModal()">Send GIF
</button> <!-- Admin Members Modal -->
<div class="overlay" id="admin-members-modal" style="display:none" onclick="if(event.target===this)closeModal('admin-members-modal')"> <div class="modal" style="width:500px;max-width:90vw"> <h3><i class="fas fa-users-cog"></i> Manage Members
</h3> <div id="admin-members-list"></div> <!-- Invite Server Modal -->
<div class="overlay" id="invite-server-modal" style="display:none" onclick="if(event.target===this)closeModal('invite-server-modal')"> <h3><i class="fas fa-user-plus"></i> Invite to Server
</h3> <p style="font-size:13px;margin-bottom:16px">Select a server to invite
<span id="invite-friend-name"></span></p> <div id="invite-server-list" style="max-height:300px;overflow-y:auto"></div> <div class="modal-actions"> <button class="btn-cancel" onclick="closeModal('invite-server-modal')">Cancel
</button> <!-- Demo Modal -->
<div class="overlay" id="demo-modal" style="display:none" onclick="if(event.target===this)closeModal('demo-modal')"> <div class="modal" style="width:min(92vw,960px);padding:18px;display:flex;flex-direction:column;max-height:90vh"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px"> <span style="font-weight:700;font-size:14px" id="demo-title">HTML Preview
</span> <button class="btn-cancel" onclick="closeModal('demo-modal')">✕ Close
</button> <iframe id="demo-iframe" sandbox="allow-scripts allow-same-origin allow-forms" style="flex:1;border:1px solid var(--border);border-radius:8px;background:#fff;min-height:60vh"></iframe> <!-- CSV Modal -->
<div class="overlay" id="csv-modal" style="display:none" onclick="if(event.target===this)closeModal('csv-modal')"> <div class="modal" style="width:min(88vw,820px);max-height:82vh;overflow:hidden;display:flex;flex-direction:column;padding:20px"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px"> <span style="font-weight:700;font-size:14px" id="csv-title">CSV Preview
</span> <button class="btn-cancel" onclick="closeModal('csv-modal')">✕ Close
</button> <div class="csv-wrap" id="csv-wrap"></div> <!-- Video Call Modal -->
<div class="overlay" id="video-call-modal" style="display:none" onclick="if(event.target===this)vcHangUp()"> <h3><i class="fas fa-video"></i> Video Call with
<span id="vc-peer-name"></span></h3> <div class="vc-status" id="vc-status">Connecting...
</div> <div class="vc-video-wrap"> <video id="vc-local-video" autoplay playsinline muted></video>
<div class="vc-label">You
</div> <div class="vc-video-wrap" id="vc-remote-wrap"> <video id="vc-remote-video" autoplay playsinline></video>
<div class="vc-label" id="vc-remote-label">Remote
</div> <div class="vc-connecting-overlay" id="vc-remote-overlay"> <div class="vc-controls"> <button class="vc-btn vc-btn-mute" id="vc-mute-btn" onclick="vcToggleMute()"><i class="fas fa-microphone"></i> Mute
</button> <button class="vc-btn vc-btn-video" id="vc-video-btn" onclick="vcToggleVideo()"><i class="fas fa-video"></i> Stop Video
</button> <button class="vc-btn vc-btn-hangup" onclick="vcHangUp()"><i class="fas fa-phone-slash"></i> End Call
</button> <!-- Incoming Call Popup -->
<div id="incoming-call-popup"> <div class="popup-avatar" id="incoming-avatar">?
</div> <div class="popup-title" id="incoming-title">Incoming Video Call
</div> <div class="popup-sub" id="incoming-sub">from ...
</div> <div class="popup-actions"> <button class="popup-btn popup-accept" onclick="acceptIncomingCall()"><i class="fas fa-check"></i></button> <button class="popup-btn popup-decline" onclick="declineIncomingCall()"><i class="fas fa-times"></i></button> <!-- Lightbox -->
<div id="lightbox" style="display:none" onclick="closeLightbox()"> <img id="lb-img" src="" alt=""> <!-- Context Menu -->
<div class="ctx-item" data-action="download"><i class="fas fa-download"></i> Download
</div> <div class="ctx-item" data-action="copy-link"><i class="fas fa-link"></i> Copy Link
</div> <!-- Toast -->
// ==================== CONFIGURATION ====================
const SUPABASE_URL = 'https://nmcawpwleesngoqobvzp.supabase.co';
const SUPABASE_ANON = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5tY2F3cHdsZWVzbmdvcW9idnpwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU3NTg0MzcsImV4cCI6MjA5MTMzNDQzN30.iTpKNR7d5MztIfxj01ycd7de8vCvLBHSqG7LCCcgVAY';
const BUCKET = 'chat-files';
const sb = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON);
const GAS_URL = 'https://script.google.com/macros/s/AKfycbxZai-422M6DQZlXGgzqdteSSez5qad_IXBCZKPuk2oBsv7iCg3wcFXH8dAK2WZS7eg/exec';
const ICE_CONFIG = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' },
{ urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' },
{ urls: 'turn:openrelay.metered.ca:443?transport=tcp',username: 'openrelayproject', credential: 'openrelayproject' }
],
iceCandidatePoolSize: 10
};
const VC_POLL_INTERVAL = 1500;
const VC_MAX_POLL = 80;
const VC_ICE_BATCH_MS = 600;
// ==================== GLOBAL STATE ====================
let CU = null, CS = null, CC = null, CDM = null, VIEW = null;
let srvModalTab = 'create', activeFTab = 'friends', pendingFile = null, msgSub = null;
let pBadgeTimer = null, msgPollTimer = null, authMode = 'login', _tt = null;
let renderedMsgIds = new Set(), pendingGifUrl = null;
let currentContextTarget = null, timeoutInterval = null;
let pendingInviteFriendId = null, pendingInviteFriendName = '';
let activityInterval = null;
// Video call state
let vcLocalStream = null;
let vcPeerConnection = null;
let vcCurrentCallId = null;
let vcCurrentGasCode = null;
let vcCurrentPeerId = null;
let vcCurrentPeerName = '';
let vcIsCaller = false;
let vcAudioMuted = false;
let vcVideoStopped = false;
let vcCallSubscription = null;
let vcIceBatchQueue = [];
let vcIceBatchTimer = null;
let vcPollTimer = null;
let vcHangupPollTimer = null;
let vcKnownCallerIce = 0;
let vcKnownCalleeIce = 0;
let vcCallActive = false;
let vcHangupSignalled = false;
let vcIsHangingUp = false; // guard against double-execution
let incomingCallData = null;
let incomingPollInterval= null;
let vcStatusChannel = null; // Supabase realtime for call status changes
// ==================== UTILITY ====================
function toast(msg, type = '') {
const el = document.getElementById('toast');
el.textContent = msg; el.className = 'show ' + type;
if (_tt) clearTimeout(_tt);
_tt = setTimeout(() => el.className = '', 3500);
}
async function gasApi(action, payload = {}) {
const res = await fetch(GAS_URL, {
method: 'POST', mode: 'cors', redirect: 'follow',
headers: { 'Content-Type': 'text/plain' },
body: JSON.stringify({ action, ...payload })
});
return res.json();
}
function getCurrentAuthorId(m) { return m.user_id ?? m.sender_id ?? m.author_id ?? null; }
function hasCurrentOwnership(m) { const aid = getCurrentAuthorId(m); return CU && aid === CU.id; }
function fmtBytes(b) {
if (b < 1024) return b + 'B';
if (b < 1048576) return (b / 1024).toFixed(1) + 'KB';
return (b / 1048576).toFixed(1) + 'MB';
}
async function clearMsgSubscription() {
if (msgSub) { try { await sb.removeChannel(msgSub); } catch (_) {} msgSub = null; }
}
function stopMsgPoll() { if (msgPollTimer) { clearInterval(msgPollTimer); msgPollTimer = null; } }
function startMsgPoll() {
stopMsgPoll();
msgPollTimer = setInterval(() => { if ((VIEW === 'channel' && CC) || (VIEW === 'dm' && CDM)) loadMessages(); }, 2500);
}
// ==================== ACTIVITY TRACKING ====================
function startActivityTracking() {
if (activityInterval) clearInterval(activityInterval);
const updateLastSeen = async () => {
if (!CU) return;
await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
};
updateLastSeen();
activityInterval = setInterval(updateLastSeen, 30000);
}
// ==================== AUTH ====================
async function init() {
document.getElementById('auth-page').style.display = 'flex';
document.getElementById('app-page').style.display = 'none';
showWelcome();
initContextMenu();
applyTheme(localStorage.getItem('nexchat_theme') || 'dark');
applyMessageFont(localStorage.getItem('nexchat_msg_font') || 'JetBrains Mono');
}
async function finishLoginFlow(userData) {
CU = {
id: userData.id, username: userData.username,
avatar_url: userData.avatar_url || null,
pinned_friends: userData.pinned_friends || [],
blocked_users: userData.blocked_users || [],
last_seen: userData.last_seen || null
};
document.getElementById('uname').textContent = userData.username;
document.getElementById('uav').innerHTML = userData.avatar_url
? `
<img src="${userData.avatar_url}" alt="avatar">`
: userData.username[0].toUpperCase();
document.getElementById('auth-page').style.display = 'none';
document.getElementById('app-page').style.display = 'flex';
try {
startActivityTracking();
await showDMHome();
await loadServers();
startPendingPoll();
setupVideoCallSubscriptions();
setupServerMembersSubscription();
try { await cleanupStaleCalls(); } catch (_) {}
startIncomingCallPolling();
} catch (e) {
console.error('[LOGIN] Setup error:', e);
toast('Login error: ' + (e.message || e) + ' — try refreshing.', 'error');
}
}
async function doLogout() {
if (activityInterval) clearInterval(activityInterval);
if (incomingPollInterval) clearInterval(incomingPollInterval);
if (vcCallSubscription) { sb.removeChannel(vcCallSubscription).catch(() => {}); vcCallSubscription = null; }
if (vcStatusChannel) { sb.removeChannel(vcStatusChannel).catch(() => {}); vcStatusChannel = null; }
CU = CS = CC = CDM = null; VIEW = null; pendingFile = null; renderedMsgIds.clear();
stopMsgPoll(); await clearMsgSubscription();
if (pBadgeTimer) { clearInterval(pBadgeTimer); pBadgeTimer = null; }
document.getElementById('au').value = ''; document.getElementById('ap').value = '';
document.getElementById('auth-error').textContent = '';
document.getElementById('auth-page').style.display = 'flex';
document.getElementById('app-page').style.display = 'none';
showWelcome();
}
function switchAuthTab(m) {
authMode = m;
document.querySelectorAll('.auth-tab').forEach((t, i) => t.classList.toggle('active', m === 'login' ? i === 0 : i === 1));
document.getElementById('auth-btn').textContent = m === 'login' ? 'Sign In' : 'Create Account';
document.getElementById('auth-error').textContent = '';
}
async function handleAuth() {
const username = document.getElementById('au').value.trim().toLowerCase();
const password = document.getElementById('ap').value;
const errEl = document.getElementById('auth-error');
const btn = document.getElementById('auth-btn');
errEl.textContent = '';
if (!username || !password) { errEl.textContent = 'Fill in all fields.'; return; }
if (username.length < 3) { errEl.textContent = 'Username must be ≥3 chars.'; return; }
if (authMode === 'signup' && !/^[a-z0-9_]+$/.test(username)) { errEl.textContent = 'Username may only contain letters, numbers, and underscores.'; return; }
btn.disabled = true; btn.textContent = 'Please wait…';
try {
if (authMode === 'signup') {
if (password.length < 6) { errEl.textContent = 'Password must be ≥6 chars.'; return; }
const { data: existingUser } = await sb.from('profiles').select('id').eq('username', username).maybeSingle();
if (existingUser) { errEl.textContent = 'Username already taken.'; return; }
const { data: newUser, error } = await sb.from('profiles').insert({ username, password, last_seen: new Date().toISOString() }).select('*').single();
if (error) { errEl.textContent = 'Error creating account: ' + error.message; return; }
await finishLoginFlow(newUser);
} else {
const { data: user, error } = await sb.from('profiles').select('*').eq('username', username).eq('password', password).maybeSingle();
if (error) { errEl.textContent = 'Database error: ' + error.message; return; }
if (!user) { errEl.textContent = 'Invalid username or password.'; return; }
await finishLoginFlow(user);
}
} catch (e) {
console.error('[AUTH] Unexpected error:', e);
errEl.textContent = 'Something went wrong: ' + (e.message || e);
} finally {
btn.disabled = false;
btn.textContent = authMode === 'login' ? 'Sign In' : 'Create Account';
}
}
// ==================== SERVERS ====================
async function loadServers() {
if (!CU) return;
const list = document.getElementById('server-icons-list');
list.innerHTML = '';
const { data: memberships, error: memErr } = await sb.from('server_members').select('server_id').eq('user_id', CU.id);
if (memErr) { console.error('[Servers] membership fetch error:', memErr); return; }
const serverIds = [...new Set((memberships || []).map(r => r.server_id).filter(Boolean))];
if (!serverIds.length) return;
const { data: servers, error: srvErr } = await sb.from('servers').select('id,name,icon_url,owner_id').in('id', serverIds);
if (srvErr) { console.error('[Servers] servers fetch error:', srvErr); return; }
const serverMap = new Map((servers || []).map(s => [s.id, s]));
serverIds.forEach(id => {
const s = serverMap.get(id);
if (!s) return;
const el = document.createElement('div');
el.className = 'server-icon' + (CS?.id === s.id ? ' active' : '');
el.title = s.name;
el.dataset.id = s.id;
el.innerHTML = s.icon_url
? `
<img src="${s.icon_url}" alt="server icon" style="width:100%;height:100%;object-fit:cover;border-radius:inherit">`
: `
<span style="font-size:18px;font-weight:800;letter-spacing:-0.5px">${s.name.trim()[0].toUpperCase()}
</span>`;
el.onclick = () => selectServer(s, true);
list.appendChild(el);
});
}
function setupServerMembersSubscription() {
if (!CU) return;
sb.channel('server-members-' + CU.id)
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'server_members', filter: `user_id=eq.${CU.id}` },
async () => { await loadServers(); toast('You were added to a server!', 'success'); }
).subscribe();
}
async function selectServer(server, skipCheck = false) {
if (!skipCheck) {
const { data: membership } = await sb.from('server_members').select('server_id').eq('server_id', server.id).eq('user_id', CU.id).maybeSingle();
if (!membership) { toast('You are not a member of this server.', 'error'); return; }
}
CS = server; CC = null; CDM = null; VIEW = 'channel';
stopMsgPoll(); await clearMsgSubscription();
document.querySelectorAll('.server-icon[data-id]').forEach(el => el.classList.toggle('active', el.dataset.id === server.id));
document.getElementById('dm-icon').classList.remove('active');
document.getElementById('sidebar-title').textContent = server.name;
document.getElementById('ch-add-btn').style.display = 'flex';
document.getElementById('copy-server-id-btn').style.display = 'flex';
document.getElementById('server-settings-btn').style.display = CS.owner_id === CU.id ? 'flex' : 'none';
document.getElementById('admin-members-btn').style.display = CS.owner_id === CU.id ? 'flex' : 'none';
document.getElementById('start-video-call-btn').style.display = 'none';
showWelcome();
await loadChannels(server.id);
checkTimeoutOrBan();
await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
}
async function loadChannels(serverId) {
const { data, error } = await sb.from('channels').select('*').eq('server_id', serverId).order('name', { ascending: true });
if (error) { toast('Failed to load channels', 'error'); return; }
const list = document.getElementById('sidebar-list');
list.innerHTML = '
<div class="ch-section"><i class="fas fa-hashtag"></i> Text Channels
</div>';
if (!data?.length) { list.innerHTML += '
<div class="empty-state"><div>No channels yet
</div></div>'; return; }
data.forEach(ch => {
const el = document.createElement('div');
el.className = 'channel-item' + (CC?.id === ch.id ? ' active' : '');
el.dataset.id = ch.id;
el.innerHTML = `
<i class="fas fa-hashtag"></i><span>${ch.name}
</span>`;
el.onclick = () => selectChannel(ch);
list.appendChild(el);
});
}
function openServerModal() {
srvTab('create');
document.getElementById('srv-name').value = '';
document.getElementById('srv-id').value = '';
document.getElementById('srv-icon-file').value = '';
document.getElementById('server-modal').style.display = 'flex';
}
function srvTab(t) {
srvModalTab = t;
document.getElementById('srv-create').style.display = t === 'create' ? 'block' : 'none';
document.getElementById('srv-join').style.display = t === 'join' ? 'block' : 'none';
document.getElementById('mt-create').classList.toggle('active', t === 'create');
document.getElementById('mt-join').classList.toggle('active', t === 'join');
document.getElementById('srv-btn').textContent = t === 'create' ? 'Create' : 'Join';
}
async function uploadImageFile(file, folder) {
const path = `${folder}/${CU.id}/${Date.now()}-${file.name}`;
const { error } = await sb.storage.from(BUCKET).upload(path, file, { upsert: false });
if (error) throw error;
return sb.storage.from(BUCKET).getPublicUrl(path).data.publicUrl;
}
async function handleServerModal() {
if (!CU) return;
if (srvModalTab === 'create') {
const name = document.getElementById('srv-name').value.trim();
if (!name) return;
let icon_url = null;
const iconFile = document.getElementById('srv-icon-file').files[0];
if (iconFile) { try { icon_url = await uploadImageFile(iconFile, 'server-icons'); } catch (e) { toast('Icon upload failed: ' + e.message, 'error'); return; } }
const { data: srv, error } = await sb.from('servers').insert({ name, owner_id: CU.id, icon_url }).select('id,name,icon_url,owner_id').single();
if (error || !srv) { toast('Error creating server', 'error'); return; }
await sb.from('server_members').insert({ server_id: srv.id, user_id: CU.id, role: 'owner' });
await sb.from('channels').insert({ server_id: srv.id, name: 'general' });
closeModal('server-modal'); await loadServers(); await selectServer(srv, true);
toast(`"${name}" created!`, 'success');
setTimeout(() => { navigator.clipboard?.writeText(srv.id); toast('Server ID copied!', 'success'); }, 1200);
} else {
const input = document.getElementById('srv-id').value.trim();
if (!input) return;
if (input.length <= 10) {
const { data: invite } = await sb.from('server_invites').select('server_id').eq('code', input.toUpperCase()).gt('expires_at', new Date().toISOString()).maybeSingle();
if (invite) {
const { data: srv } = await sb.from('servers').select('id,name,icon_url,owner_id').eq('id', invite.server_id).single();
if (srv) {
const { error: memErr } = await sb.from('server_members').insert({ server_id: srv.id, user_id: CU.id });
if (memErr) { toast('Failed to join', 'error'); return; }
closeModal('server-modal'); await loadServers(); await selectServer(srv, true);
toast(`Joined "${srv.name}"!`, 'success'); return;
}
}
}
const cleanSid = input.replace(/\s+/g, '');
const { data: srv } = await sb.from('servers').select('id,name,icon_url,owner_id').eq('id', cleanSid).maybeSingle();
if (!srv) { toast('Server not found', 'error'); return; }
const { data: ex } = await sb.from('server_members').select('server_id').eq('server_id', cleanSid).eq('user_id', CU.id).maybeSingle();
if (ex) { toast('Already a member', 'error'); return; }
const { error: memErr } = await sb.from('server_members').insert({ server_id: cleanSid, user_id: CU.id });
if (memErr) { toast('Failed to join', 'error'); return; }
closeModal('server-modal'); await loadServers(); await selectServer(srv, true);
toast(`Joined "${srv.name}"!`, 'success');
}
}
// ==================== SERVER SETTINGS ====================
function openServerSettingsModal() {
if (!CS) return;
document.getElementById('ss-name').value = CS.name || '';
document.getElementById('ss-icon-file').value = '';
document.getElementById('ss-icon-url').value = CS.icon_url || '';
setPreview('server-settings-preview', CS.icon_url);
document.getElementById('invite-code-display').value = '';
document.getElementById('server-settings-modal').style.display = 'flex';
}
async function generateTempInvite() {
if (!CS || CS.owner_id !== CU.id) return;
const code = Math.random().toString(36).substring(2, 7).toUpperCase();
const expires = new Date(Date.now() + 3600000).toISOString();
await sb.from('server_invites').insert({ server_id: CS.id, code, expires_at: expires });
document.getElementById('invite-code-display').value = code;
toast('Invite code generated! Valid 1 hour.', 'success');
navigator.clipboard?.writeText(code);
}
async function deleteServer() {
if (!CS || CS.owner_id !== CU.id) return;
if (!confirm('Delete this server permanently? All data will be lost.')) return;
await sb.from('servers').delete().eq('id', CS.id);
CS = null; CC = null;
closeModal('server-settings-modal');
await showDMHome(); await loadServers();
toast('Server deleted', 'success');
}
async function saveServerSettings() {
if (!CS) return;
const name = document.getElementById('ss-name').value.trim();
const file = document.getElementById('ss-icon-file').files[0];
const url = document.getElementById('ss-icon-url').value.trim();
let icon_url = url || CS.icon_url || null;
if (file) { try { icon_url = await uploadImageFile(file, 'server-icons'); } catch (e) { toast('Upload failed', 'error'); return; } }
const { data, error } = await sb.from('servers').update({ name: name || CS.name, icon_url }).eq('id', CS.id).select('id,name,icon_url,owner_id').single();
if (error) { toast('Failed to save', 'error'); return; }
CS = data;
document.getElementById('sidebar-title').textContent = CS.name;
closeModal('server-settings-modal'); await loadServers();
toast('Server updated!', 'success');
}
function copyCurrentServerId() { if (CS?.id) navigator.clipboard?.writeText(CS.id).then(() => toast('Server ID copied!', 'success')); }
// ==================== CHANNELS ====================
function openChannelModal() {
if (!CS) return;
document.getElementById('ch-name').value = '';
document.getElementById('ch-restricted').checked = false;
document.getElementById('channel-modal').style.display = 'flex';
}
async function createChannel() {
const name = document.getElementById('ch-name').value.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
if (!name || !CS) return;
const restricted = document.getElementById('ch-restricted').checked;
const { error } = await sb.from('channels').insert({ server_id: CS.id, name, is_restricted: restricted });
if (error) { toast('Failed to create channel', 'error'); return; }
closeModal('channel-modal'); await loadChannels(CS.id);
toast(`#${name} created`, 'success');
}
async function selectChannel(ch) {
if (ch.is_restricted) {
const { data: allowed } = await sb.from('channel_allowed_users').select('user_id').eq('channel_id', ch.id).eq('user_id', CU.id).maybeSingle();
if (!allowed && CS.owner_id !== CU.id) { toast('You do not have access to this restricted channel', 'error'); return; }
}
CC = ch; CDM = null; VIEW = 'channel';
await clearMsgSubscription();
document.querySelectorAll('.channel-item').forEach(el => el.classList.toggle('active', el.dataset.id == ch.id));
document.getElementById('chat-icon').className = 'fas fa-hashtag';
document.getElementById('chat-name').textContent = ch.name;
document.getElementById('msg-input').placeholder = `Message #${ch.name}`;
document.getElementById('start-video-call-btn').style.display = 'none';
showChatView(); await loadMessages(); await subscribeMsgs(); startMsgPoll();
checkTimeoutOrBan();
await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
}
// ==================== DM / FRIENDS ====================
async function showDMHome() {
CS = null; CC = null; CDM = null; VIEW = 'home';
stopMsgPoll(); await clearMsgSubscription();
document.querySelectorAll('.server-icon[data-id]').forEach(el => el.classList.remove('active'));
document.getElementById('dm-icon').classList.add('active');
document.getElementById('sidebar-title').textContent = 'Direct Messages';
document.getElementById('ch-add-btn').style.display = 'none';
document.getElementById('copy-server-id-btn').style.display = 'none';
document.getElementById('server-settings-btn').style.display = 'none';
document.getElementById('admin-members-btn').style.display = 'none';
document.getElementById('start-video-call-btn').style.display = 'none';
showWelcome(); await loadDMSidebar();
}
async function loadDMSidebar() {
if (!CU) return;
const { data: fships } = await sb.from('friendships').select('id,requester_id,addressee_id,status').or(`requester_id.eq.${CU.id},addressee_id.eq.${CU.id}`).eq('status', 'accepted');
const list = document.getElementById('sidebar-list');
list.innerHTML = '
<div class="ch-section"><i class="fas fa-comment-dots"></i> Direct Messages
<button class="sidebar-btn" onclick="openFriendsView()" style="display:inline-flex;width:18px;height:18px;font-size:13px"><i class="fas fa-user-friends"></i></button></div>';
const friendIds = [...new Set((fships || []).map(f => f.requester_id === CU.id ? f.addressee_id : f.requester_id).filter(Boolean))];
if (!friendIds.length) { list.innerHTML += '
<div class="empty-state"><div class="ico"><i class="fas fa-user-plus"></i></div><div>Add friends to start DMing
</div></div>'; return; }
const { data: profiles } = await sb.from('profiles').select('id,username,avatar_url,last_seen').in('id', friendIds);
const profileMap = new Map((profiles || []).map(p => [String(p.id), p]));
const pinned = CU.pinned_friends || [];
const sortedIds = [...friendIds].sort((a, b) => (pinned.includes(b) ? 1 : 0) - (pinned.includes(a) ? 1 : 0));
sortedIds.forEach(fid => {
const prof = profileMap.get(String(fid)); if (!prof) return;
const isPinned = pinned.includes(fid);
const el = document.createElement('div');
el.className = 'dm-item' + (CDM?.id === fid ? ' active' : '');
el.onclick = () => openDM({ id: fid, username: prof.username, avatar_url: prof.avatar_url || null });
const av = document.createElement('div'); av.className = 'dm-avatar';
av.innerHTML = prof.avatar_url ? `
<img src="${prof.avatar_url}" alt="avatar">` : (prof.username || '?')[0].toUpperCase();
const name = document.createElement('span'); name.className = 'dm-name'; name.textContent = prof.username;
const pinBtn = document.createElement('span'); pinBtn.className = 'pin-btn';
pinBtn.innerHTML = isPinned ? '
<i class="fas fa-thumbtack"></i>' : '
<i class="far fa-thumbtack"></i>';
pinBtn.title = isPinned ? 'Unpin' : 'Pin';
pinBtn.onclick = (e) => { e.stopPropagation(); togglePinFriend(fid); };
el.appendChild(av); el.appendChild(name); el.appendChild(pinBtn);
list.appendChild(el);
});
}
async function togglePinFriend(friendId) {
let pinned = CU.pinned_friends || [];
if (pinned.includes(friendId)) pinned = pinned.filter(id => id !== friendId);
else pinned.push(friendId);
await sb.from('profiles').update({ pinned_friends: pinned }).eq('id', CU.id);
CU.pinned_friends = pinned;
await loadDMSidebar();
}
function openFriendsView() {
VIEW = 'friends'; CDM = null;
stopMsgPoll(); clearMsgSubscription();
showFriendsPanel(); ftab('friends');
}
async function openDM(user) {
CDM = user; CS = null; CC = null; VIEW = 'dm';
await clearMsgSubscription();
document.querySelectorAll('.dm-item').forEach(el => el.classList.remove('active'));
document.getElementById('chat-icon').className = 'fas fa-user';
document.getElementById('chat-name').textContent = user.username;
document.getElementById('msg-input').placeholder = `Message @${user.username}`;
document.getElementById('start-video-call-btn').style.display = 'flex';
showChatView(); await loadMessages(); await subscribeMsgs(); startMsgPoll();
await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
if (!vcCallSubscription) setupVideoCallSubscriptions();
}
// ==================== MESSAGES ====================
async function loadMessages() {
if (!CU) return;
let data = [];
if (VIEW === 'channel' && CC) {
const { data: rows } = await sb.from('messages').select('*').eq('channel_id', CC.id).order('created_at', { ascending: true }).limit(100);
data = rows || [];
} else if (VIEW === 'dm' && CDM) {
const { data: rows } = await sb.from('direct_messages').select('*').in('sender_id', [CU.id, CDM.id]).in('receiver_id', [CU.id, CDM.id]).order('created_at', { ascending: true }).limit(100);
data = (rows || []).filter(m => (m.sender_id === CU.id && m.receiver_id === CDM.id) || (m.sender_id === CDM.id && m.receiver_id === CU.id));
}
const authorIds = [...new Set(data.map(getCurrentAuthorId).filter(Boolean))];
let profileMap = new Map();
if (authorIds.length) {
const { data: profiles } = await sb.from('profiles').select('id,username,avatar_url').in('id', authorIds);
profileMap = new Map((profiles || []).map(p => [String(p.id), p]));
}
data = data.map(m => {
const aid = getCurrentAuthorId(m); const pr = profileMap.get(String(aid));
return { ...m, _username: pr?.username || 'Unknown', _avatar_url: pr?.avatar_url || null };
});
const wrap = document.getElementById('messages-wrap');
wrap.innerHTML = ''; renderedMsgIds.clear();
if (!data.length) wrap.innerHTML = `
<div class="empty-state"><div class="ico"><i class="fas fa-comments"></i></div><div>No messages yet — say something!
</div></div>`;
else data.forEach(m => { renderedMsgIds.add(m.id); wrap.appendChild(buildMsg(m)); });
wrap.scrollTop = wrap.scrollHeight;
}
function buildMsg(m) {
const uname = m._username || 'Unknown';
const time = m.created_at ? new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
const g = document.createElement('div'); g.className = 'msg-group'; g.dataset.id = m.id || '';
const av = document.createElement('div'); av.className = 'msg-av';
av.innerHTML = m._avatar_url ? `
<img src="${m._avatar_url}" alt="avatar">` : (uname || '?')[0].toUpperCase();
const body = document.createElement('div'); body.className = 'msg-body';
const meta = document.createElement('div'); meta.className = 'msg-meta';
const author = document.createElement('span'); author.className = 'msg-author'; author.textContent = uname;
const tm = document.createElement('span'); tm.className = 'msg-time'; tm.textContent = time;
meta.appendChild(author); meta.appendChild(tm); body.appendChild(meta);
const t = m.msg_type || 'text';
if (t === 'text') {
const d = document.createElement('div'); d.className = 'msg-text'; d.textContent = m.content || ''; body.appendChild(d);
} else if (t === 'image' || t === 'gif') {
const wrapper = document.createElement('div'); wrapper.className = 'msg-img-wrapper';
const img = document.createElement('img'); img.className = 'msg-img'; img.src = m.file_url; img.alt = m.file_name || 'image';
img.onclick = () => openLightbox(m.file_url);
wrapper.appendChild(img);
if (t === 'gif') { const badge = document.createElement('span'); badge.className = 'gif-badge'; badge.textContent = 'GIF'; wrapper.appendChild(badge); }
body.appendChild(wrapper);
} else if (t === 'file') {
body.appendChild(buildFileEl(m));
}
if (m.content && t !== 'text') { const d = document.createElement('div'); d.className = 'msg-text'; d.textContent = m.content; d.style.marginTop = '4px'; body.appendChild(d); }
if (hasCurrentOwnership(m)) {
const del = document.createElement('button'); del.className = 'msg-delete'; del.title = 'Delete message'; del.innerHTML = '
<i class="fas fa-trash-alt"></i>';
del.onclick = () => deleteMyMessage(m); g.appendChild(del);
}
g.appendChild(av); g.appendChild(body);
return g;
}
const FILE_ICONS = { zip:'fa-file-archive',gz:'fa-file-archive',rar:'fa-file-archive',tar:'fa-file-archive',csv:'fa-file-csv',json:'fa-file-code',html:'fa-file-code',htm:'fa-file-code',pdf:'fa-file-pdf',mp4:'fa-file-video',webm:'fa-file-video',mp3:'fa-file-audio',wav:'fa-file-audio',txt:'fa-file-alt',js:'fa-file-code',ts:'fa-file-code',py:'fa-file-code',rb:'fa-file-code',go:'fa-file-code',rs:'fa-file-code',png:'fa-file-image',jpg:'fa-file-image',gif:'fa-file-image',webp:'fa-file-image',svg:'fa-file-image',docx:'fa-file-word',xlsx:'fa-file-excel',pptx:'fa-file-powerpoint',exe:'fa-file',dmg:'fa-file' };
function buildFileEl(m) {
const ext = (m.file_name || '').split('.').pop().toLowerCase();
const mime = m.file_mime || '';
const wrap = document.createElement('div'); wrap.className = 'msg-file';
const iconEl = document.createElement('div'); iconEl.className = 'msg-file-icon'; iconEl.innerHTML = `
<i class="fas ${FILE_ICONS[ext] || 'fa-file'}"></i>`;
const info = document.createElement('div'); info.className = 'msg-file-info';
const name = document.createElement('div'); name.className = 'msg-file-name'; name.title = m.file_name || ''; name.textContent = m.file_name || 'file';
const type = document.createElement('div'); type.className = 'msg-file-type'; type.textContent = mime || ext.toUpperCase();
info.appendChild(name); info.appendChild(type);
let btn;
if (ext === 'html' || ext === 'htm' || mime === 'text/html') {
btn = document.createElement('button'); btn.className = 'fa-btn fa-demo'; btn.innerHTML = '
<i class="fas fa-play"></i> Demo';
btn.onclick = () => openHTMLDemo(m.file_url, m.file_name);
} else if (ext === 'csv' || mime === 'text/csv') {
btn = document.createElement('button'); btn.className = 'fa-btn fa-preview'; btn.innerHTML = '
<i class="fas fa-table"></i> Preview';
btn.onclick = () => openCSVPreview(m.file_url, m.file_name);
} else {
btn = document.createElement('a'); btn.className = 'fa-btn fa-dl'; btn.href = m.file_url; btn.target = '_blank'; btn.download = m.file_name; btn.innerHTML = '
<i class="fas fa-download"></i> Download';
}
wrap.appendChild(iconEl); wrap.appendChild(info); wrap.appendChild(btn);
return wrap;
}
async function subscribeMsgs() {
await clearMsgSubscription();
if (!CU) return;
if (VIEW === 'channel' && CC) {
msgSub = sb.channel('ch-' + CC.id)
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `channel_id=eq.${CC.id}` }, async p => {
const m = p.new; if (m.id && renderedMsgIds.has(m.id)) return;
const authorId = getCurrentAuthorId(m);
const { data: pr } = await sb.from('profiles').select('id,username,avatar_url').eq('id', authorId).maybeSingle();
m._username = pr?.username || 'Unknown'; m._avatar_url = pr?.avatar_url || null;
appendMsg(m);
}).subscribe();
} else if (VIEW === 'dm' && CDM) {
msgSub = sb.channel('dm-' + CU.id + '-' + CDM.id)
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'direct_messages' }, async p => {
const m = p.new;
if (!((m.sender_id === CU.id && m.receiver_id === CDM.id) || (m.sender_id === CDM.id && m.receiver_id === CU.id))) return;
if (m.id && renderedMsgIds.has(m.id)) return;
const { data: pr } = await sb.from('profiles').select('id,username,avatar_url').eq('id', m.sender_id).maybeSingle();
m._username = pr?.username || 'Unknown'; m._avatar_url = pr?.avatar_url || null;
appendMsg(m);
}).subscribe();
}
}
function appendMsg(m) {
const wrap = document.getElementById('messages-wrap');
const empty = wrap.querySelector('.empty-state'); if (empty) empty.remove();
if (m.id && renderedMsgIds.has(m.id)) return;
if (m.id) renderedMsgIds.add(m.id);
wrap.appendChild(buildMsg(m));
wrap.scrollTop = wrap.scrollHeight;
}
async function sendMessage() {
if (!CU) return;
const sendBtn = document.getElementById('send-btn'); if (sendBtn.disabled) return;
if (pendingGifUrl) { await sendGifResolved(pendingGifUrl, document.getElementById('gif-caption').value.trim()); return; }
if (pendingFile) { await uploadAndSend(); return; }
const input = document.getElementById('msg-input'); const content = input.value.trim(); if (!content) return;
sendBtn.disabled = true; input.value = ''; input.style.height = '';
try {
if (VIEW === 'channel' && CC) {
const { error } = await sb.from('messages').insert({ channel_id: CC.id, user_id: CU.id, content, msg_type: 'text' });
if (error) { toast('Failed to send', 'error'); input.value = content; return; }
await loadMessages();
} else if (VIEW === 'dm' && CDM) {
const { error } = await sb.from('direct_messages').insert({ sender_id: CU.id, receiver_id: CDM.id, content, msg_type: 'text' });
if (error) { toast('Failed to send', 'error'); input.value = content; return; }
await loadMessages();
}
await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
} finally { sendBtn.disabled = false; }
}
function handleMsgKey(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }
function autoGrow(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 110) + 'px'; }
function onFileChosen(e) {
const f = e.target.files[0]; if (!f) return;
pendingFile = f; pendingGifUrl = null;
document.getElementById('up-name').textContent = `📎 ${f.name} (${fmtBytes(f.size)})`;
document.getElementById('upload-prev').style.display = 'flex';
document.getElementById('msg-input').placeholder = 'Add a caption (optional)...';
e.target.value = '';
}
function cancelUpload() {
pendingFile = null; pendingGifUrl = null;
document.getElementById('upload-prev').style.display = 'none';
document.getElementById('msg-input').placeholder = VIEW === 'dm' ? `Message @${CDM?.username || ''}` : `Message #${CC?.name || ''}`;
}
async function uploadAndSend() {
if (!pendingFile || !CU) return;
const f = pendingFile; const caption = document.getElementById('msg-input').value.trim();
const sendBtn = document.getElementById('send-btn'); sendBtn.disabled = true;
const path = `chat/${CU.id}/${Date.now()}-${f.name}`;
const { error } = await sb.storage.from(BUCKET).upload(path, f, { upsert: false });
if (error) { toast('Upload failed', 'error'); sendBtn.disabled = false; return; }
const { data: publicData } = sb.storage.from(BUCKET).getPublicUrl(path);
const publicUrl = publicData.publicUrl;
const isImage = f.type.startsWith('image/');
const row = { user_id: CU.id, content: caption || null, msg_type: isImage ? 'image' : 'file', file_url: publicUrl, file_name: f.name, file_mime: f.type };
let err = null;
if (VIEW === 'channel' && CC) ({ error: err } = await sb.from('messages').insert({ ...row, channel_id: CC.id }));
else if (VIEW === 'dm' && CDM) ({ error: err } = await sb.from('direct_messages').insert({ ...row, sender_id: CU.id, receiver_id: CDM.id }));
if (err) { toast('Failed to send file', 'error'); sendBtn.disabled = false; return; }
cancelUpload(); document.getElementById('msg-input').value = ''; sendBtn.disabled = false;
await loadMessages(); toast('File sent!', 'success');
await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
}
async function deleteMyMessage(m) {
if (!hasCurrentOwnership(m)) return;
if (!confirm('Delete this message?')) return;
const table = VIEW === 'dm' ? 'direct_messages' : 'messages';
const { error } = await sb.from(table).delete().eq('id', m.id);
if (error) { toast('Failed to delete', 'error'); return; }
renderedMsgIds.delete(m.id); await loadMessages();
}
// ==================== GIF ====================
function openGifModal() {
if (!VIEW || (!CC && !CDM)) { toast('Open a channel or DM first', 'error'); return; }
document.getElementById('gif-url').value = '';
document.getElementById('gif-caption').value = '';
pendingGifUrl = null;
document.getElementById('gif-modal').style.display = 'flex';
}
async function resolveGifUrl(input) {
const raw = input.trim(); if (!raw) return null;
if (/\.(gif)(\?.*)?$/i.test(raw)) return raw;
if (/^https?:\/\//i.test(raw) && /tenor\.com|giphy\.com/i.test(raw)) {
const endpoints = [
`https://giphy.com/services/oembed?url=${encodeURIComponent(raw)}`,
`https://tenor.com/oembed?url=${encodeURIComponent(raw)}`
];
for (const ep of endpoints) { try { const r = await fetch(ep); if (!r.ok) continue; const j = await r.json(); return j.url || j.thumbnail_url || j.image_url || null; } catch (_) {} }
}
if (/^https?:\/\//i.test(raw)) return raw;
return null;
}
async function sendGifFromModal() {
const raw = document.getElementById('gif-url').value.trim(); const caption = document.getElementById('gif-caption').value.trim();
const resolved = await resolveGifUrl(raw); if (!resolved) { toast('Could not resolve GIF link', 'error'); return; }
closeModal('gif-modal'); await sendGifResolved(resolved, caption);
}
async function sendGifResolved(url, caption) {
if (!CU) return;
const row = { user_id: CU.id, content: caption || null, msg_type: 'gif', file_url: url, file_name: 'gif', file_mime: 'image/gif' };
let err = null;
if (VIEW === 'channel' && CC) ({ error: err } = await sb.from('messages').insert({ ...row, channel_id: CC.id }));
else if (VIEW === 'dm' && CDM) ({ error: err } = await sb.from('direct_messages').insert({ ...row, sender_id: CU.id, receiver_id: CDM.id }));
if (err) { toast('Failed to send GIF', 'error'); return; }
document.getElementById('gif-url').value = ''; document.getElementById('gif-caption').value = ''; pendingGifUrl = null;
await loadMessages(); toast('GIF sent!', 'success');
await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
}
// ==================== FRIENDS PANEL ====================
async function ftab(t) {
activeFTab = t;
document.querySelectorAll('.ftab').forEach((el, i) => el.classList.toggle('active', ['friends', 'pending', 'add'][i] === t));
await renderFriends();
}
function isOnline(lastSeen) { if (!lastSeen) return false; return new Date(lastSeen) > new Date(Date.now() - 5 * 60 * 1000); }
async function renderFriends() {
const content = document.getElementById('friends-content'); content.innerHTML = '';
if (activeFTab === 'add') {
content.innerHTML = `
<div class="f-section"><i class="fas fa-user-plus"></i> Add Friend
</div><div class="add-friend-bar"><input type="text" id="af-input" placeholder="Enter username..." onkeydown="if(event.key==='Enter')sendFriendReq()"><button class="send-fr-btn" onclick="sendFriendReq()"><i class="fas fa-paper-plane"></i> Send
</button></div><div id="add-friend-msg"></div>`;
return;
}
const { data: fships } = await sb.from('friendships').select('id,requester_id,addressee_id,status').or(`requester_id.eq.${CU.id},addressee_id.eq.${CU.id}`).eq('status', activeFTab === 'pending' ? 'pending' : 'accepted');
if (!fships) return;
const allIds = [...new Set(fships.flatMap(f => [f.requester_id, f.addressee_id]).filter(Boolean))];
let profiles = []; if (allIds.length) { const { data: fetched } = await sb.from('profiles').select('id,username,avatar_url,last_seen').in('id', allIds); profiles = fetched || []; }
const findProfile = id => profiles.find(p => p.id == id);
function makeAvatar(pr) { const av = document.createElement('div'); av.className = 'friend-av'; av.innerHTML = pr.avatar_url ? `
<img src="${pr.avatar_url}" alt="avatar">` : (pr.username || '?')[0].toUpperCase(); return av; }
if (activeFTab === 'friends') {
const accepted = fships.filter(f => f.status === 'accepted');
if (!accepted.length) { content.innerHTML = `
<div class="empty-state"><div class="ico"><i class="fas fa-user-friends"></i></div><div>No friends yet
</div></div>`; return; }
content.innerHTML = `
<div class="f-section"><i class="fas fa-user-friends"></i> Friends — ${accepted.length}
</div>`;
for (const f of accepted) {
const fid = f.requester_id == CU.id ? f.addressee_id : f.requester_id;
const pr = findProfile(fid); if (!pr) continue;
const online = isOnline(pr.last_seen);
const card = document.createElement('div'); card.className = 'friend-card';
const info = document.createElement('div');
const uname = document.createElement('div'); uname.className = 'friend-username'; uname.textContent = pr.username;
const label = document.createElement('div'); label.className = 'friend-label';
label.innerHTML = online ? '
<i class="fas fa-circle" style="color:var(--green);font-size:8px"></i> online' : '
<i class="fas fa-circle" style="color:var(--text-muted);font-size:8px"></i> offline';
info.appendChild(uname); info.appendChild(label);
const actions = document.createElement('div'); actions.className = 'friend-actions';
actions.innerHTML = `
<button class="ibt msg" title="Message"><i class="fas fa-comment"></i></button><button class="ibt" title="Invite to Server" style="background:var(--bg-active);color:var(--accent)"><i class="fas fa-user-plus"></i></button><button class="ibt dec" title="Block"><i class="fas fa-ban"></i></button>`;
actions.children[0].onclick = () => openDM({ id: fid, username: pr.username, avatar_url: pr.avatar_url || null });
actions.children[1].onclick = () => inviteToServer(fid);
actions.children[2].onclick = () => blockUser(fid);
card.appendChild(makeAvatar(pr)); card.appendChild(info); card.appendChild(actions); content.appendChild(card);
}
}
if (activeFTab === 'pending') {
const pending = fships.filter(f => f.status === 'pending');
if (!pending.length) { content.innerHTML = `
<div class="empty-state"><div class="ico"><i class="fas fa-clock"></i></div><div>No pending requests
</div></div>`; return; }
const incoming = pending.filter(f => f.addressee_id == CU.id);
const outgoing = pending.filter(f => f.requester_id == CU.id);
if (incoming.length) {
const sec = document.createElement('div'); sec.className = 'f-section'; sec.innerHTML = `
<i class="fas fa-arrow-down"></i> Incoming — ${incoming.length}`; content.appendChild(sec);
for (const f of incoming) {
const pr = findProfile(f.requester_id); if (!pr) continue;
const card = document.createElement('div'); card.className = 'friend-card';
const info = document.createElement('div'); info.innerHTML = `
<div class="friend-username">${pr.username}
</div><div class="friend-label">Wants to be friends
</div>`;
const actions = document.createElement('div'); actions.className = 'friend-actions';
actions.innerHTML = `
<button class="ibt acc" title="Accept"><i class="fas fa-check"></i></button><button class="ibt dec" title="Decline"><i class="fas fa-times"></i></button>`;
actions.children[0].onclick = () => respondFR(f.id, 'accepted');
actions.children[1].onclick = () => respondFR(f.id, 'declined');
card.appendChild(makeAvatar(pr)); card.appendChild(info); card.appendChild(actions); content.appendChild(card);
}
}
if (outgoing.length) {
const sec = document.createElement('div'); sec.className = 'f-section'; sec.style.marginTop = '20px'; sec.innerHTML = `
<i class="fas fa-arrow-up"></i> Outgoing — ${outgoing.length}`; content.appendChild(sec);
for (const f of outgoing) {
const pr = findProfile(f.addressee_id); if (!pr) continue;
const card = document.createElement('div'); card.className = 'friend-card';
const info = document.createElement('div'); info.innerHTML = `
<div class="friend-username">${pr.username}
</div><div class="friend-label">Request pending…
</div>`;
const actions = document.createElement('div'); actions.className = 'friend-actions';
actions.innerHTML = `
<button class="ibt dec" title="Cancel"><i class="fas fa-times"></i></button>`;
actions.children[0].onclick = () => cancelFR(f.id);
card.appendChild(makeAvatar(pr)); card.appendChild(info); card.appendChild(actions); content.appendChild(card);
}
}
}
}
async function sendFriendReq() {
const inp = document.getElementById('af-input'); const msgEl = document.getElementById('add-friend-msg');
const username = inp?.value.trim().toLowerCase(); if (!username) return;
if (username === CU.username) { if (msgEl) { msgEl.style.color = 'var(--red)'; msgEl.textContent = "That's you!"; } return; }
const { data: target } = await sb.from('profiles').select('id').eq('username', username).maybeSingle();
if (!target) { if (msgEl) { msgEl.style.color = 'var(--red)'; msgEl.textContent = 'User not found.'; } return; }
const { data: ex } = await sb.from('friendships').select('id,status').or(`and(requester_id.eq.${CU.id},addressee_id.eq.${target.id}),and(requester_id.eq.${target.id},addressee_id.eq.${CU.id})`).maybeSingle();
if (ex) { if (msgEl) { msgEl.style.color = 'var(--amber)'; msgEl.textContent = ex.status === 'accepted' ? 'Already friends!' : 'Request already sent.'; } return; }
const { error } = await sb.from('friendships').insert({ requester_id: CU.id, addressee_id: target.id });
if (error) { if (msgEl) { msgEl.style.color = 'var(--red)'; msgEl.textContent = 'Failed to send request.'; } return; }
if (msgEl) { msgEl.style.color = 'var(--green)'; msgEl.textContent = `Request sent to ${username}!`; }
if (inp) inp.value = ''; await updatePendingBadge();
}
async function respondFR(id, status) {
if (status === 'accepted') {
const { error } = await sb.from('friendships').update({ status }).eq('id', id);
if (error) { toast('Failed to accept', 'error'); return; }
toast('Friend added!', 'success'); await loadDMSidebar();
} else {
const { error } = await sb.from('friendships').delete().eq('id', id);
if (error) { toast('Failed to decline', 'error'); return; }
toast('Request declined.', '');
}
await renderFriends(); await updatePendingBadge();
}
async function cancelFR(id) { await sb.from('friendships').delete().eq('id', id); await renderFriends(); await updatePendingBadge(); }
async function updatePendingBadge() {
if (!CU) return;
const { data } = await sb.from('friendships').select('id').eq('addressee_id', CU.id).eq('status', 'pending');
const badge = document.getElementById('pend-badge'); if (!badge) return;
if (data?.length) { badge.style.display = 'inline'; badge.textContent = data.length; } else badge.style.display = 'none';
}
function startPendingPoll() { updatePendingBadge(); pBadgeTimer = setInterval(updatePendingBadge, 12000); }
async function blockUser(userId) {
let blocked = CU.blocked_users || []; if (blocked.includes(parseInt(userId))) return;
blocked.push(parseInt(userId));
await sb.from('profiles').update({ blocked_users: blocked }).eq('id', CU.id);
CU.blocked_users = blocked; toast('User blocked', 'success');
await renderFriends();
}
async function inviteToServer(friendId) {
if (!CU) return;
const { data: profile } = await sb.from('profiles').select('username').eq('id', friendId).single();
pendingInviteFriendId = friendId;
pendingInviteFriendName = profile?.username || 'this user';
document.getElementById('invite-friend-name').textContent = pendingInviteFriendName;
const { data: memberships } = await sb.from('server_members').select('server_id,role').eq('user_id', CU.id).in('role', ['owner', 'admin']);
if (!memberships?.length) { toast('You are not an owner/admin in any server', 'error'); return; }
const serverIds = memberships.map(m => m.server_id);
const { data: servers } = await sb.from('servers').select('id,name').in('id', serverIds);
const list = document.getElementById('invite-server-list'); list.innerHTML = '';
servers.forEach(srv => {
const div = document.createElement('div'); div.className = 'friend-card'; div.style.cursor = 'pointer';
div.innerHTML = `
<span class="friend-username"><i class="fas fa-server"></i> ${srv.name}
</span><button class="ibt msg"><i class="fas fa-plus"></i></button>`;
div.onclick = () => { closeModal('invite-server-modal'); confirmInviteToServer(srv.id, pendingInviteFriendId); };
list.appendChild(div);
});
document.getElementById('invite-server-modal').style.display = 'flex';
}
async function confirmInviteToServer(serverId, friendId) {
const { data: existing } = await sb.from('server_members').select('user_id').eq('server_id', serverId).eq('user_id', friendId).maybeSingle();
if (existing) { toast('User already in this server', 'error'); return; }
await sb.from('server_members').insert({ server_id: serverId, user_id: friendId });
toast(`Invited ${pendingInviteFriendName} to server!`, 'success');
pendingInviteFriendId = null;
}
// ==================== SETTINGS ====================
function openSettingsModal(initialTab = 'profile') {
if (!CU) return;
document.getElementById('settings-username').value = CU.username;
document.getElementById('settings-avatar-url').value = CU.avatar_url || '';
document.getElementById('settings-avatar-file').value = '';
setPreview('settings-profile-preview', CU.avatar_url);
document.getElementById('settings-theme').value = localStorage.getItem('nexchat_theme') || 'dark';
document.getElementById('settings-font').value = localStorage.getItem('nexchat_msg_font') || 'JetBrains Mono';
document.querySelectorAll('#settings-modal .modal-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.stab === initialTab);
document.getElementById(`stab-${tab.dataset.stab}`).style.display = tab.dataset.stab === initialTab ? 'block' : 'none';
});
if (initialTab === 'blocked') renderBlockedList();
document.getElementById('settings-modal').style.display = 'flex';
}
function setPreview(prefix, url) {
const img = document.getElementById(prefix + '-img'); const txt = document.getElementById(prefix + '-txt');
if (url) { img.style.display = 'block'; img.src = url; txt.textContent = url; }
else { img.style.display = 'none'; img.src = ''; txt.textContent = 'No avatar selected'; }
}
async function saveProfileSettings() {
if (!CU) return;
const file = document.getElementById('settings-avatar-file').files[0];
const url = document.getElementById('settings-avatar-url').value.trim();
let avatar_url = url || CU.avatar_url;
if (file) { try { avatar_url = await uploadImageFile(file, 'avatars'); } catch (e) { toast('Upload failed', 'error'); return; } }
const theme = document.getElementById('settings-theme').value;
const font = document.getElementById('settings-font').value;
applyTheme(theme); applyMessageFont(font);
const { data, error } = await sb.from('profiles').update({ avatar_url }).eq('id', CU.id).select().single();
if (error) { toast('Failed to save profile', 'error'); return; }
CU.avatar_url = data.avatar_url;
document.getElementById('uav').innerHTML = CU.avatar_url ? `
<img src="${CU.avatar_url}" alt="avatar">` : CU.username[0].toUpperCase();
closeModal('settings-modal'); toast('Profile updated!', 'success');
await refreshAllVisibleAvatars();
}
async function changePassword() {
const oldPass = document.getElementById('old-password').value;
const newPass = document.getElementById('new-password').value;
const confirm = document.getElementById('confirm-password').value;
const msgEl = document.getElementById('password-change-msg');
if (!oldPass || !newPass || !confirm) { msgEl.textContent = 'All fields required'; return; }
if (newPass !== confirm) { msgEl.textContent = 'New passwords do not match'; return; }
const { data: user } = await sb.from('profiles').select('password').eq('id', CU.id).single();
if (user.password !== oldPass) { msgEl.textContent = 'Current password is incorrect'; return; }
const { error } = await sb.from('profiles').update({ password: newPass }).eq('id', CU.id);
if (error) { msgEl.textContent = 'Failed to update password'; return; }
msgEl.style.color = 'var(--green)'; msgEl.textContent = 'Password changed successfully!';
document.getElementById('old-password').value = document.getElementById('new-password').value = document.getElementById('confirm-password').value = '';
}
async function renderBlockedList() {
const container = document.getElementById('blocked-list');
const blocked = CU.blocked_users || [];
if (!blocked.length) { container.innerHTML = '
<div class="empty-state"><i class="fas fa-ban"></i> No blocked users
</div>'; return; }
const { data: profiles } = await sb.from('profiles').select('id,username').in('id', blocked);
container.innerHTML = '';
(profiles || []).forEach(p => {
const div = document.createElement('div'); div.className = 'friend-card';
div.innerHTML = `
<span class="friend-username">${p.username}
</span>`;
const btn = document.createElement('button'); btn.className = 'ibt dec'; btn.innerHTML = '
<i class="fas fa-undo-alt"></i> Unblock';
btn.onclick = async () => {
let newBlocked = blocked.filter(id => id !== p.id);
await sb.from('profiles').update({ blocked_users: newBlocked }).eq('id', CU.id);
CU.blocked_users = newBlocked; renderBlockedList(); toast(`${p.username} unblocked`, 'success');
};
div.appendChild(btn); container.appendChild(div);
});
}
async function deleteAccount() {
const password = document.getElementById('delete-password').value;
const { data: user } = await sb.from('profiles').select('password').eq('id', CU.id).single();
if (user.password !== password) { toast('Incorrect password', 'error'); return; }
await sb.from('profiles').delete().eq('id', CU.id);
doLogout(); toast('Account deleted', 'success');
}
function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('nexchat_theme', theme); }
function applyMessageFont(font) { document.documentElement.style.setProperty('--mono', `'${font}', monospace`); localStorage.setItem('nexchat_msg_font', font); }
// ==================== ADMIN TIMEOUT / BAN ====================
async function openAdminMembersModal() {
if (!CS || CS.owner_id !== CU.id) return;
const { data: members } = await sb.from('server_members').select('user_id').eq('server_id', CS.id);
const userIds = members.map(m => m.user_id);
const { data: profiles } = await sb.from('profiles').select('id,username').in('id', userIds);
const list = document.getElementById('admin-members-list'); list.innerHTML = '';
profiles.forEach(p => {
if (p.id === CU.id) return;
const div = document.createElement('div'); div.className = 'friend-card';
div.innerHTML = `
<span class="friend-username">${p.username}
</span>`;
const actions = document.createElement('div'); actions.className = 'friend-actions';
actions.innerHTML = `
<button class="ibt dec" title="Kick"><i class="fas fa-user-slash"></i></button><button class="ibt" title="Timeout 5min" style="background:var(--bg-active);color:var(--amber)"><i class="fas fa-clock"></i></button><button class="ibt dec" title="Ban"><i class="fas fa-gavel"></i></button>`;
actions.children[0].onclick = () => kickUser(p.id);
actions.children[1].onclick = () => timeoutUser(p.id, 5);
actions.children[2].onclick = () => banUser(p.id);
div.appendChild(actions); list.appendChild(div);
});
document.getElementById('admin-members-modal').style.display = 'flex';
}
async function kickUser(userId) { await sb.from('server_members').delete().match({ server_id: CS.id, user_id: userId }); toast('User kicked', 'success'); closeModal('admin-members-modal'); }
async function timeoutUser(userId, minutes) {
const ip = await fetch('https://api.ipify.org?format=json').then(r => r.json()).then(d => d.ip).catch(() => null);
const until = new Date(Date.now() + minutes * 60000).toISOString();
await sb.from('timeouts').insert({ server_id: CS.id, user_id: userId, until, ip });
toast(`User timed out for ${minutes} min`, 'success');
if (userId === CU.id) checkTimeoutOrBan();
closeModal('admin-members-modal');
}
async function banUser(userId) {
const ip = await fetch('https://api.ipify.org?format=json').then(r => r.json()).then(d => d.ip).catch(() => null);
await sb.from('bans').insert({ server_id: CS.id, user_id: userId, ip });
await sb.from('server_members').delete().match({ server_id: CS.id, user_id: userId });
toast('User banned', 'success');
if (userId === CU.id) showDMHome();
closeModal('admin-members-modal');
}
async function checkTimeoutOrBan() {
if (!CS || !CU) return;
const { data: ban } = await sb.from('bans').select('id').eq('server_id', CS.id).eq('user_id', CU.id).maybeSingle();
if (ban) { toast('You are banned from this server', 'error'); showDMHome(); return; }
const { data: timeout } = await sb.from('timeouts').select('until').eq('server_id', CS.id).eq('user_id', CU.id).gt('until', new Date().toISOString()).maybeSingle();
const overlay = document.getElementById('timeout-overlay');
const timerEl = document.getElementById('timeout-timer');
if (timeoutInterval) clearInterval(timeoutInterval);
if (timeout) {
overlay.style.display = 'flex';
const updateTimer = () => {
const remaining = new Date(timeout.until) - new Date();
if (remaining <= 0) { overlay.style.display = 'none'; clearInterval(timeoutInterval); timeoutInterval = null; return; }
const mins = Math.floor(remaining / 60000); const secs = Math.floor((remaining % 60000) / 1000);
timerEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
};
updateTimer(); timeoutInterval = setInterval(updateTimer, 1000);
} else {
overlay.style.display = 'none';
}
}
// ==================== VIDEO CALL ====================
// DUAL HANGUP APPROACH:
// 1. GAS signalHangup — polled every 1.5s by BOTH sides from the moment a call starts
// 2. Supabase video_calls.status = 'ended' — watched via realtime on BOTH sides
// Either signal triggers vcHandleRemoteHangup() on the non-initiating side.
function setupVideoCallSubscriptions() {
if (!CU) return;
if (vcCallSubscription) sb.removeChannel(vcCallSubscription).catch(() => {});
vcCallSubscription = sb.channel('vc-incoming-' + CU.id)
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'video_calls', filter: `callee_id=eq.${CU.id}` },
payload => {
const call = payload.new;
if (new Date() - new Date(call.created_at) > 60000) return;
if (call.status === 'pending' && (!incomingCallData || incomingCallData.id !== call.id)) {
showIncomingCallPopup(call);
}
}
)
.on('postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'video_calls', filter: `callee_id=eq.${CU.id}` },
payload => {
const call = payload.new;
if (call.status === 'cancelled' && incomingCallData?.id === call.id) {
hideIncomingPopup(); toast('Call cancelled', '');
}
// *** FIX: callee receives 'ended' via Supabase realtime ***
if (call.status === 'ended' && vcCurrentCallId === call.id && vcCallActive) {
vcHandleRemoteHangup();
}
}
)
.subscribe();
}
// Watch for the caller's call row being updated to 'ended' — covers callee hanging up
function vcSubscribeCallStatus(callId) {
if (vcStatusChannel) { sb.removeChannel(vcStatusChannel).catch(() => {}); vcStatusChannel = null; }
vcStatusChannel = sb.channel('vc-status-' + callId)
.on('postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'video_calls', filter: `id=eq.${callId}` },
payload => {
const s = payload.new.status;
// Declined / missed — call never connected
if ((s === 'declined' || s === 'missed') && !vcCallActive) {
toast(s === 'declined' ? 'Call was declined' : 'No answer', '');
vcHangUp(); return;
}
// *** FIX: caller receives 'ended' via Supabase realtime when callee hangs up ***
if (s === 'ended' && vcCurrentCallId === callId && vcCallActive) {
vcHandleRemoteHangup();
}
}
).subscribe();
}
async function cleanupStaleCalls() {
if (!CU) return;
const cutoff = new Date(Date.now() - 60000).toISOString();
await sb.from('video_calls')
.update({ status: 'missed' })
.eq('callee_id', CU.id)
.eq('status', 'pending')
.lt('created_at', cutoff);
}
function startIncomingCallPolling() {
if (incomingPollInterval) clearInterval(incomingPollInterval);
incomingPollInterval = setInterval(async () => {
if (!CU) return;
const { data: calls } = await sb.from('video_calls')
.select('*').eq('callee_id', CU.id).eq('status', 'pending')
.gt('created_at', new Date(Date.now() - 60000).toISOString())
.order('created_at', { ascending: false }).limit(1);
if (calls?.length > 0) {
const call = calls[0];
if (!incomingCallData || incomingCallData.id !== call.id) showIncomingCallPopup(call);
}
}, 3000);
}
function showIncomingCallPopup(call) {
incomingCallData = call;
document.getElementById('incoming-title').textContent = 'Incoming Video Call';
document.getElementById('incoming-sub').textContent = `from ${call.caller_name || 'User'}`;
const av = document.getElementById('incoming-avatar');
av.innerHTML = call.caller_avatar
? `
<img src="${call.caller_avatar}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`
: (call.caller_name?.[0] || '?').toUpperCase();
document.getElementById('incoming-call-popup').style.display = 'flex';
setTimeout(() => {
if (incomingCallData?.id === call.id) {
hideIncomingPopup();
sb.from('video_calls').update({ status: 'missed' }).eq('id', call.id).then(() => {});
}
}, 30000);
}
function hideIncomingPopup() {
document.getElementById('incoming-call-popup').style.display = 'none';
incomingCallData = null;
}
async function acceptIncomingCall() {
if (!incomingCallData) return;
const call = incomingCallData;
hideIncomingPopup();
await sb.from('video_calls').update({ status: 'accepted' }).eq('id', call.id);
vcCurrentCallId = call.id;
vcCurrentGasCode = call.gas_room_code;
vcCurrentPeerId = call.caller_id;
vcCurrentPeerName = call.caller_name;
vcIsCaller = false;
vcHangupSignalled = false;
vcIsHangingUp = false;
document.getElementById('vc-peer-name').textContent = vcCurrentPeerName;
if (!vcCurrentGasCode) { toast('No room code found in call', 'error'); return; }
const granted = await requestVideoCallPermissions();
if (!granted) { vcHangUp(); return; }
openVideoCallModal();
await setupVcPeerConnection();
// Start hangup polling immediately — don't wait for ontrack
vcStartHangupPoll();
vcPollForOffer();
// *** FIX: callee subscribes to Supabase status updates too ***
vcSubscribeCallStatus(call.id);
}
function declineIncomingCall() {
if (incomingCallData) {
sb.from('video_calls').update({ status: 'declined' }).eq('id', incomingCallData.id).then(() => {});
hideIncomingPopup();
}
}
async function initiateVideoCall() {
if (!CU) return;
if (!(VIEW === 'dm' && CDM)) { toast('Video calls are only available in DMs', 'error'); return; }
const callBtn = document.getElementById('start-video-call-btn');
const origHTML = callBtn.innerHTML;
callBtn.disabled = true;
callBtn.innerHTML = '
<i class="fas fa-spinner fa-spin"></i>';
const showStartingPopup = () => {
const existing = document.getElementById('vc-starting-popup');
if (existing) existing.remove();
const popup = document.createElement('div');
popup.id = 'vc-starting-popup';
popup.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:var(--bg-sidebar);border:1px solid var(--accent);border-radius:10px;padding:14px 22px;z-index:700;font-size:13px;font-weight:600;color:var(--text-primary);box-shadow:0 8px 24px rgba(0,0,0,.5);display:flex;align-items:center;gap:10px;';
popup.innerHTML = '
<div class="spinner" style="width:18px;height:18px;border-width:2px"></div> Starting call with ' + CDM.username + '…';
document.body.appendChild(popup);
};
const hideStartingPopup = () => { const p = document.getElementById('vc-starting-popup'); if (p) p.remove(); };
const resetBtn = () => { callBtn.disabled = false; callBtn.innerHTML = origHTML; };
showStartingPopup();
const granted = await requestVideoCallPermissions();
if (!granted) { hideStartingPopup(); resetBtn(); return; }
let gasData;
try { gasData = await gasApi('generateRoomCode'); }
catch (e) { toast('Failed to get room code', 'error'); hideStartingPopup(); resetBtn(); return; }
const code = gasData?.code;
if (!code) { toast('Failed to get room code', 'error'); hideStartingPopup(); resetBtn(); return; }
const { data: call, error } = await sb.from('video_calls').insert({
caller_id: CU.id,
callee_id: CDM.id,
caller_name: CU.username,
caller_avatar: CU.avatar_url,
gas_room_code: code,
status: 'pending'
}).select().single();
if (error) { toast('Failed to start call', 'error'); hideStartingPopup(); resetBtn(); return; }
hideStartingPopup();
resetBtn();
vcCurrentCallId = call.id;
vcCurrentGasCode = code;
vcCurrentPeerId = CDM.id;
vcCurrentPeerName = CDM.username;
vcIsCaller = true;
vcHangupSignalled = false;
vcIsHangingUp = false;
document.getElementById('vc-peer-name').textContent = CDM.username;
openVideoCallModal();
document.getElementById('vc-status').textContent = 'Waiting for peer to join...';
await setupVcPeerConnection();
await vcCreateAndSendOffer();
// Start hangup polling immediately — don't wait for ontrack
vcStartHangupPoll();
vcPollForAnswer();
// *** FIX: caller subscribes to Supabase status updates ***
vcSubscribeCallStatus(call.id);
}
async function requestVideoCallPermissions() {
if (vcLocalStream) return true;
try {
vcLocalStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' },
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
});
const lv = document.getElementById('vc-local-video');
lv.srcObject = vcLocalStream; lv.muted = true;
await lv.play().catch(e => console.warn('local play:', e));
return true;
} catch (err) {
toast('Camera/mic access denied', 'error');
return false;
}
}
function openVideoCallModal() {
document.getElementById('video-call-modal').style.display = 'flex';
document.getElementById('vc-status').textContent = 'Connecting...';
document.getElementById('vc-remote-overlay').style.display = 'flex';
document.getElementById('vc-remote-label').textContent = vcCurrentPeerName || 'Remote';
vcAudioMuted = false; vcVideoStopped = false;
document.getElementById('vc-mute-btn').innerHTML = '
<i class="fas fa-microphone"></i> Mute';
document.getElementById('vc-video-btn').innerHTML = '
<i class="fas fa-video"></i> Stop Video';
document.getElementById('vc-mute-btn').classList.remove('active');
document.getElementById('vc-video-btn').classList.remove('active');
}
async function setupVcPeerConnection() {
if (vcPeerConnection) { try { vcPeerConnection.close(); } catch (e) {} vcPeerConnection = null; }
vcPeerConnection = new RTCPeerConnection(ICE_CONFIG);
vcLocalStream.getTracks().forEach(t => vcPeerConnection.addTrack(t, vcLocalStream));
vcPeerConnection.ontrack = event => {
const rv = document.getElementById('vc-remote-video');
if (rv.srcObject !== event.streams[0]) {
rv.srcObject = event.streams[0];
rv.play().catch(e => console.warn('remote play:', e));
document.getElementById('vc-remote-overlay').style.display = 'none';
document.getElementById('vc-status').textContent = 'Connected';
vcCallActive = true;
}
};
vcPeerConnection.onicecandidate = event => {
if (event.candidate) {
vcIceBatchQueue.push(JSON.stringify(event.candidate));
if (vcIceBatchTimer) clearTimeout(vcIceBatchTimer);
vcIceBatchTimer = setTimeout(vcFlushIceBatch, VC_ICE_BATCH_MS);
} else {
if (vcIceBatchQueue.length) vcFlushIceBatch();
}
};
vcPeerConnection.oniceconnectionstatechange = () => {
const s = vcPeerConnection?.iceConnectionState;
if (!s) return;
if (s === 'connected' || s === 'completed') { document.getElementById('vc-status').textContent = 'Connected'; vcCallActive = true; }
if (s === 'failed') document.getElementById('vc-status').textContent = 'Connection failed';
if (s === 'disconnected') document.getElementById('vc-status').textContent = 'Connection dropped…';
};
}
// *** FIXED: Start hangup polling immediately when call starts, on BOTH sides ***
// This polls GAS every 1.5s looking for the hangup flag set by whichever side ends the call.
function vcStartHangupPoll() {
if (vcHangupPollTimer) { clearInterval(vcHangupPollTimer); vcHangupPollTimer = null; }
vcHangupPollTimer = setInterval(async () => {
if (!vcCurrentGasCode) { clearInterval(vcHangupPollTimer); vcHangupPollTimer = null; return; }
try {
const data = await gasApi('getRoomData', { code: vcCurrentGasCode });
if (data && data.hangup === true) {
clearInterval(vcHangupPollTimer); vcHangupPollTimer = null;
if (!vcIsHangingUp) vcHandleRemoteHangup();
}
} catch (e) { /* network blip — keep polling */ }
}, VC_POLL_INTERVAL);
}
function vcFlushIceBatch() {
if (!vcIceBatchQueue.length || !vcCurrentGasCode) return;
const batch = [...vcIceBatchQueue]; vcIceBatchQueue = [];
gasApi(vcIsCaller ? 'addCallerCandidates' : 'addCalleeCandidate', {
code: vcCurrentGasCode, candidates: batch
}).catch(e => console.warn('[VC] ICE flush error:', e));
}
async function vcCreateAndSendOffer() {
const offer = await vcPeerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true });
await vcPeerConnection.setLocalDescription(offer);
await gasApi('storeOffer', { code: vcCurrentGasCode, offer: JSON.stringify(offer) });
document.getElementById('vc-status').textContent = 'Waiting for peer...';
}
function vcPollForAnswer() {
let count = 0;
vcPollTimer = setInterval(async () => {
if (++count > VC_MAX_POLL) { clearInterval(vcPollTimer); vcPollTimer = null; toast('Call timed out', ''); vcHangUp(); return; }
try {
const data = await gasApi('getRoomData', { code: vcCurrentGasCode });
if (!data || !vcPeerConnection) return;
if (data.answer && !vcPeerConnection.remoteDescription) {
await vcPeerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(data.answer)));
}
if (vcPeerConnection.remoteDescription && data.calleeCandidates?.length > vcKnownCalleeIce) {
const fresh = data.calleeCandidates.slice(vcKnownCalleeIce);
for (const c of fresh) { try { await vcPeerConnection.addIceCandidate(new RTCIceCandidate(JSON.parse(c))); } catch (e) {} }
vcKnownCalleeIce = data.calleeCandidates.length;
}
if (vcCallActive) { clearInterval(vcPollTimer); vcPollTimer = null; }
} catch (e) { console.warn('[VC] answer poll error:', e); }
}, VC_POLL_INTERVAL);
}
function vcPollForOffer() {
let count = 0;
document.getElementById('vc-status').textContent = 'Waiting for host...';
vcPollTimer = setInterval(async () => {
if (++count > VC_MAX_POLL) { clearInterval(vcPollTimer); vcPollTimer = null; toast('Call timed out', ''); vcHangUp(); return; }
try {
const data = await gasApi('getRoomData', { code: vcCurrentGasCode });
if (!data || !vcPeerConnection) return;
if (data.offer && !vcPeerConnection.remoteDescription) {
await vcPeerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(data.offer)));
const answer = await vcPeerConnection.createAnswer();
await vcPeerConnection.setLocalDescription(answer);
await gasApi('storeAnswer', { code: vcCurrentGasCode, answer: JSON.stringify(answer) });
document.getElementById('vc-status').textContent = 'Connecting...';
}
if (vcPeerConnection.remoteDescription && data.callerCandidates?.length > vcKnownCallerIce) {
const fresh = data.callerCandidates.slice(vcKnownCallerIce);
for (const c of fresh) { try { await vcPeerConnection.addIceCandidate(new RTCIceCandidate(JSON.parse(c))); } catch (e) {} }
vcKnownCallerIce = data.callerCandidates.length;
}
if (vcCallActive) { clearInterval(vcPollTimer); vcPollTimer = null; }
} catch (e) { console.warn('[VC] offer poll error:', e); }
}, VC_POLL_INTERVAL);
}
// Called on the REMOTE side when the other peer ends the call
function vcHandleRemoteHangup() {
if (vcIsHangingUp) return; // prevent double execution
vcIsHangingUp = true;
vcCallActive = false;
toast('Call ended by ' + (vcCurrentPeerName || 'peer'), '');
vcCleanupTimers();
vcCleanupMedia();
document.getElementById('video-call-modal').style.display = 'none';
vcResetState();
}
// *** FIXED vcHangUp: signals BEFORE cleanup so state is still intact ***
async function vcHangUp() {
if (vcIsHangingUp) return; // prevent double execution
vcIsHangingUp = true;
// Capture values BEFORE any cleanup nulls them
const callId = vcCurrentCallId;
const gasCode = vcCurrentGasCode;
// Close modal immediately for responsiveness
document.getElementById('video-call-modal').style.display = 'none';
// Stop all timers so we don't react to our own hangup signal
vcCleanupTimers();
// *** SIGNAL the remote peer via BOTH channels BEFORE media cleanup ***
// 1. GAS hangup flag — the other peer's vcHangupPollTimer will pick this up
if (gasCode && !vcHangupSignalled) {
vcHangupSignalled = true;
try { await gasApi('signalHangup', { code: gasCode }); } catch (e) { console.warn('[VC] GAS hangup signal failed:', e); }
// Delete the room after a short delay so the other peer has time to read the flag
setTimeout(() => gasApi('deleteRoom', { code: gasCode }).catch(() => {}), 5000);
}
// 2. Supabase status = 'ended' — the other peer's vcSubscribeCallStatus listener picks this up
if (callId) {
try { await sb.from('video_calls').update({ status: 'ended' }).eq('id', callId); } catch (e) { console.warn('[VC] Supabase hangup signal failed:', e); }
}
// Now clean up local media and state
vcCleanupMedia();
vcResetState();
toast('Call ended', '');
}
// Split cleanup into timers + media so we can call them independently
function vcCleanupTimers() {
if (vcPollTimer) { clearInterval(vcPollTimer); vcPollTimer = null; }
if (vcHangupPollTimer) { clearInterval(vcHangupPollTimer); vcHangupPollTimer = null; }
if (vcIceBatchTimer) { clearTimeout(vcIceBatchTimer); vcIceBatchTimer = null; }
if (vcStatusChannel) { sb.removeChannel(vcStatusChannel).catch(() => {}); vcStatusChannel = null; }
}
function vcCleanupMedia() {
if (vcLocalStream) { vcLocalStream.getTracks().forEach(t => t.stop()); vcLocalStream = null; }
if (vcPeerConnection) { try { vcPeerConnection.close(); } catch (e) {} vcPeerConnection = null; }
const lv = document.getElementById('vc-local-video'); if (lv) lv.srcObject = null;
const rv = document.getElementById('vc-remote-video'); if (rv) rv.srcObject = null;
}
function vcResetState() {
vcCurrentCallId = null;
vcCurrentGasCode = null;
vcCurrentPeerId = null;
vcCurrentPeerName = '';
vcCallActive = false;
vcHangupSignalled = false;
vcIsHangingUp = false;
vcIceBatchQueue = [];
vcKnownCallerIce = 0;
vcKnownCalleeIce = 0;
}
function vcToggleMute() {
if (!vcLocalStream) return;
vcAudioMuted = !vcAudioMuted;
vcLocalStream.getAudioTracks().forEach(t => t.enabled = !vcAudioMuted);
const btn = document.getElementById('vc-mute-btn');
btn.innerHTML = vcAudioMuted ? '
<i class="fas fa-microphone-slash"></i> Unmute' : '
<i class="fas fa-microphone"></i> Mute';
btn.classList.toggle('active', vcAudioMuted);
}
function vcToggleVideo() {
if (!vcLocalStream) return;
vcVideoStopped = !vcVideoStopped;
vcLocalStream.getVideoTracks().forEach(t => t.enabled = !vcVideoStopped);
const btn = document.getElementById('vc-video-btn');
btn.innerHTML = vcVideoStopped ? '
<i class="fas fa-video-slash"></i> Start Video' : '
<i class="fas fa-video"></i> Stop Video';
btn.classList.toggle('active', vcVideoStopped);
}
// ==================== UI HELPERS ====================
function showWelcome() { stopMsgPoll(); document.getElementById('welcome-screen').style.display = 'flex'; document.getElementById('chat-view').style.display = 'none'; document.getElementById('friends-view').style.display = 'none'; }
function showChatView() { document.getElementById('welcome-screen').style.display = 'none'; document.getElementById('chat-view').style.display = 'flex'; document.getElementById('friends-view').style.display = 'none'; }
function showFriendsPanel() { document.getElementById('welcome-screen').style.display = 'none'; document.getElementById('chat-view').style.display = 'none'; document.getElementById('friends-view').style.display = 'flex'; }
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
function openLightbox(url) { document.getElementById('lb-img').src = url; document.getElementById('lightbox').style.display = 'flex'; }
function closeLightbox() { document.getElementById('lightbox').style.display = 'none'; }
function openHTMLDemo(url, name) { document.getElementById('demo-title').textContent = '▶ ' + (name || 'HTML File'); document.getElementById('demo-iframe').src = url; document.getElementById('demo-modal').style.display = 'flex'; }
async function openCSVPreview(url, name) {
document.getElementById('csv-title').textContent = '📊 ' + (name || 'CSV File');
const wrap = document.getElementById('csv-wrap');
wrap.innerHTML = '
<div style="padding:20px;color:var(--text-muted)">Loading...
</div>';
document.getElementById('csv-modal').style.display = 'flex';
try {
const text = await (await fetch(url)).text();
const rows = text.trim().split('\n').map(r => {
const cells = []; let cur = '', inQ = false;
for (let c of r) { if (c === '"') inQ = !inQ; else if (c === ',' && !inQ) { cells.push(cur.trim()); cur = ''; } else cur += c; }
cells.push(cur.trim()); return cells;
});
const heads = rows[0] || []; const body = rows.slice(1, 51);
wrap.innerHTML = `
<table class="csv-tbl"><thead><tr>${heads.map(h => `
<th>${h}
</th>`).join('')}
</tr></thead><tbody>${body.map(r => `
<tr>${r.map(c => `
<td>${c}
</td>`).join('')}
</tr>`).join('')}
</tbody></table>${rows.length > 51 ? `
<div style="padding:7px;font-size:10px;color:var(--text-muted)">Showing first 50 of ${rows.length - 1} rows
</div>` : ''}`;
} catch { wrap.innerHTML = '
<div style="padding:20px;color:var(--red)">Failed to load CSV.
</div>'; }
}
async function refreshAllVisibleAvatars() {
if (VIEW === 'dm' && CDM) await openDM(CDM);
else if (VIEW === 'channel' && CC) await selectChannel(CC);
else if (VIEW === 'home') await loadDMSidebar();
}
function initContextMenu() {
const menu = document.getElementById('ctx-menu');
document.addEventListener('contextmenu', e => {
const img = e.target.closest('.msg-img');
if (!img) return;
e.preventDefault(); currentContextTarget = img.src;
menu.style.display = 'block'; menu.style.left = e.pageX + 'px'; menu.style.top = e.pageY + 'px';
});
document.addEventListener('click', () => menu.style.display = 'none');
menu.addEventListener('click', e => {
const action = e.target.dataset.action;
if (action === 'download' && currentContextTarget) { const a = document.createElement('a'); a.href = currentContextTarget; a.download = ''; a.click(); }
else if (action === 'copy-link' && currentContextTarget) { navigator.clipboard?.writeText(currentContextTarget); toast('Link copied!', 'success'); }
menu.style.display = 'none';
});
}
document.querySelectorAll('#settings-modal .modal-tab').forEach(tab => {
tab.addEventListener('click', () => {
const stab = tab.dataset.stab;
document.querySelectorAll('#settings-modal .modal-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('#settings-modal .stab-content').forEach(c => c.style.display = 'none');
document.getElementById(`stab-${stab}`).style.display = 'block';
if (stab === 'blocked') renderBlockedList();
});
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeLightbox();
['demo-modal','csv-modal','server-modal','channel-modal','server-settings-modal','settings-modal','gif-modal','admin-members-modal','invite-server-modal'].forEach(id => {
const el = document.getElementById(id);
if (el && el.style.display !== 'none') closeModal(id);
});
// ESC on video call modal = hang up
const vcm = document.getElementById('video-call-modal');
if (vcm && vcm.style.display !== 'none') vcHangUp();
}
});
window.addEventListener('beforeunload', () => {
// Fire hangup signals synchronously on page close
if (vcCurrentCallId || vcCurrentGasCode) {
const callId = vcCurrentCallId;
const gasCode = vcCurrentGasCode;
if (gasCode && !vcHangupSignalled) {
vcHangupSignalled = true;
navigator.sendBeacon && navigator.sendBeacon(GAS_URL, JSON.stringify({ action: 'signalHangup', code: gasCode }));
}
if (callId) {
// Best-effort — may not complete on unload
sb.from('video_calls').update({ status: 'ended' }).eq('id', callId).catch(() => {});
}
}
if (msgSub) sb.removeChannel(msgSub).catch(() => {});
if (pBadgeTimer) clearInterval(pBadgeTimer);
if (msgPollTimer) clearInterval(msgPollTimer);
if (timeoutInterval) clearInterval(timeoutInterval);
if (vcCallSubscription) sb.removeChannel(vcCallSubscription).catch(() => {});
if (vcStatusChannel) sb.removeChannel(vcStatusChannel).catch(() => {});
if (activityInterval) clearInterval(activityInterval);
if (incomingPollInterval) clearInterval(incomingPollInterval);
});
init();