Not a member of GistPad yet?
Sign Up,
it unlocks many cool features!
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <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">
- <style>
- :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; }
- </style>
- </head>
- <body>
- <div id="auth-page">
- <div class="auth-card">
- <div class="auth-tabs">
- </div>
- <div class="form-field">
- <input type="text" id="au" placeholder="your_handle" onkeydown="if(event.key==='Enter')handleAuth()">
- </div>
- <div class="form-field">
- <input type="password" id="ap" placeholder="••••••••" onkeydown="if(event.key==='Enter')handleAuth()">
- </div>
- </div>
- </div>
- <div id="app-page" style="display:none">
- <div id="servers-bar">
- </div>
- <div id="channel-sidebar">
- <div class="sidebar-header">
- <div class="sidebar-actions">
- </div>
- </div>
- <div class="user-bar" onclick="openSettingsModal('profile')">
- <div>
- </div>
- </div>
- </div>
- <div id="main-area">
- <div id="welcome-screen">
- </div>
- <div id="chat-view" style="display:none;flex-direction:column;flex:1;overflow:hidden">
- <div class="chat-header">
- <div style="margin-left:auto;display:flex;gap:8px">
- </div>
- </div>
- <div id="timeout-overlay" style="display:none">
- </div>
- <div class="input-area">
- <div class="upload-preview" id="upload-prev">
- </div>
- <div class="input-bar">
- </div>
- <input type="file" id="file-input" onchange="onFileChosen(event)">
- </div>
- </div>
- <div id="friends-view" style="display:none;flex-direction:column;flex:1;overflow:hidden">
- <div class="ftabs">
- </div>
- </div>
- </div>
- </div>
- <!-- Server Modal -->
- <div class="overlay" id="server-modal" style="display:none" onclick="if(event.target===this)closeModal('server-modal')">
- <div class="modal">
- <div class="modal-tabs">
- </div>
- <div id="srv-create">
- <input type="text" id="srv-name" placeholder="My Awesome Server">
- <input type="file" id="srv-icon-file" accept="image/*">
- </div>
- <div id="srv-join" style="display:none">
- <input type="text" id="srv-id" placeholder="Paste server ID or invite code here...">
- </div>
- <div class="modal-actions">
- </div>
- </div>
- </div>
- <!-- Server Settings Modal -->
- <div class="overlay" id="server-settings-modal" style="display:none" onclick="if(event.target===this)closeModal('server-settings-modal')">
- <div class="modal">
- <div class="preview-box" id="server-settings-preview">
- <img id="server-settings-preview-img" src="" alt="server icon preview" style="display:none">
- </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)">
- </div>
- <div class="modal-actions" style="justify-content:space-between">
- <div style="display:flex;gap:8px">
- </div>
- </div>
- </div>
- </div>
- <!-- 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;">
- <div class="modal-tabs">
- </div>
- <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>
- <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://...">
- <select id="settings-theme">
- </select>
- <select id="settings-font">
- </select>
- </div>
- <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">
- </div>
- <div id="stab-blocked" class="stab-content" style="display:none">
- </div>
- <div id="stab-danger" class="stab-content" style="display:none">
- <input type="password" id="delete-password" placeholder="Your password">
- </div>
- <div class="modal-actions">
- </div>
- </div>
- </div>
- <!-- Channel Modal -->
- <div class="overlay" id="channel-modal" style="display:none" onclick="if(event.target===this)closeModal('channel-modal')">
- <div class="modal">
- <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">
- </div>
- <div class="modal-actions">
- </div>
- </div>
- </div>
- <!-- GIF Modal -->
- <div class="overlay" id="gif-modal" style="display:none" onclick="if(event.target===this)closeModal('gif-modal')">
- <div class="modal">
- <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="modal-actions">
- </div>
- </div>
- </div>
- <!-- 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">
- </div>
- </div>
- <!-- Invite Server Modal -->
- <div class="overlay" id="invite-server-modal" style="display:none" onclick="if(event.target===this)closeModal('invite-server-modal')">
- <div class="modal">
- <div class="modal-actions">
- </div>
- </div>
- </div>
- <!-- 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">
- </div>
- </div>
- </div>
- <!-- 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">
- </div>
- </div>
- </div>
- <!-- Video Call Modal -->
- <div class="overlay" id="video-call-modal" style="display:none" onclick="if(event.target===this)vcHangUp()">
- <div class="modal">
- <div class="vc-videos">
- <div class="vc-video-wrap">
- <video id="vc-local-video" autoplay playsinline muted></video>
- </div>
- <div class="vc-video-wrap" id="vc-remote-wrap">
- <video id="vc-remote-video" autoplay playsinline></video>
- <div class="vc-connecting-overlay" id="vc-remote-overlay">
- </div>
- </div>
- </div>
- <div class="vc-controls">
- </div>
- </div>
- </div>
- <!-- Incoming Call Popup -->
- <div id="incoming-call-popup">
- <div class="popup-info">
- </div>
- <div class="popup-actions">
- </div>
- </div>
- <!-- Lightbox -->
- <div id="lightbox" style="display:none" onclick="closeLightbox()">
- <img id="lb-img" src="" alt="">
- </div>
- <!-- Context Menu -->
- <div id="ctx-menu">
- </div>
- <!-- Toast -->
- <script>
- // ==================== 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">`
- 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');
- data.forEach(ch => {
- const el = document.createElement('div');
- el.className = 'channel-item' + (CC?.id === ch.id ? ' active' : '');
- el.dataset.id = ch.id;
- 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');
- const friendIds = [...new Set((fships || []).map(f => f.requester_id === CU.id ? f.addressee_id : f.requester_id).filter(Boolean))];
- 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.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();
- 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)) {
- 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 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.onclick = () => openHTMLDemo(m.file_url, m.file_name);
- } else if (ext === 'csv' || mime === 'text/csv') {
- btn.onclick = () => openCSVPreview(m.file_url, m.file_name);
- } else {
- }
- 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');
- 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';
- 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');
- const incoming = pending.filter(f => f.addressee_id == CU.id);
- const outgoing = pending.filter(f => f.requester_id == CU.id);
- if (incoming.length) {
- for (const f of incoming) {
- const pr = findProfile(f.requester_id); if (!pr) continue;
- const card = document.createElement('div'); card.className = 'friend-card';
- const actions = document.createElement('div'); actions.className = 'friend-actions';
- 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) {
- for (const f of outgoing) {
- const pr = findProfile(f.addressee_id); if (!pr) continue;
- const card = document.createElement('div'); card.className = 'friend-card';
- const actions = document.createElement('div'); actions.className = 'friend-actions';
- 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.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 || [];
- 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';
- 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';
- 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;
- 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;';
- 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').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.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.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');
- 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>` : ''}`;
- }
- 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();
- </script>
- </body>
- </html>
RAW Paste Data
Copied
