Guest

Nexchat

Apr 11th, 2026
13
0
Never
Not a member of GistPad yet? Sign Up, it unlocks many cool features!
HTML 384.56 KB | Source Code | 0 0
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <meta charset="UTF-8">
  4. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  5. <title>NexChat</title>
  6. <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">
  7. <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
  8. <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
  9. :root {
  10. --bg-void: #070910; --bg-servers: #0c0e18; --bg-sidebar: #10131f; --bg-main: #151a28;
  11. --bg-input: #1c2133; --bg-hover: #192035; --bg-active: #212a42; --accent: #818cf8;
  12. --accent-dim: #4f56c5; --accent-glow: rgba(129,140,248,0.18); --green: #4ade80;
  13. --amber: #fbbf24; --red: #f87171; --text-primary: #e8ecf5; --text-secondary: #8b96b3;
  14. --text-muted: #4a5470; --border: rgba(255,255,255,0.07); --radius: 8px;
  15. --font: 'Syne', sans-serif; --mono: 'JetBrains Mono', monospace;
  16. }
  17. [data-theme="light"] {
  18. --bg-void: #f5f7fa; --bg-servers: #eef0f5; --bg-sidebar: #ffffff; --bg-main: #fafbfc;
  19. --bg-input: #edf2f7; --bg-hover: #e2e8f0; --bg-active: #cbd5e1; --accent: #4f46e5;
  20. --accent-dim: #4338ca; --accent-glow: rgba(79,70,229,0.15); --green: #10b981;
  21. --amber: #f59e0b; --red: #ef4444; --text-primary: #0f172a; --text-secondary: #475569;
  22. --text-muted: #94a3b8; --border: rgba(0,0,0,0.06);
  23. }
  24. [data-theme="high-contrast"] {
  25. --bg-void: #000; --bg-servers: #111; --bg-sidebar: #1a1a1a; --bg-main: #222;
  26. --bg-input: #2d2d2d; --bg-hover: #333; --bg-active: #444; --accent: #ffcc00;
  27. --accent-dim: #e6b800; --accent-glow: rgba(255,204,0,0.2); --green: #00ff88; --amber: #ffaa00;
  28. --red: #ff4444; --text-primary: #fff; --text-secondary: #ccc; --text-muted: #888;
  29. --border: rgba(255,255,255,0.15);
  30. }
  31. [data-theme="forest"] {
  32. --bg-void: #0a1c14; --bg-servers: #0e241c; --bg-sidebar: #132a20; --bg-main: #18382a;
  33. --bg-input: #1e4534; --bg-hover: #265a44; --bg-active: #2f6e54; --accent: #6ee7b7;
  34. --accent-dim: #34d399; --accent-glow: rgba(110,231,183,0.2); --green: #10b981; --amber: #fbbf24;
  35. --red: #f87171; --text-primary: #e2f3eb; --text-secondary: #a7d7c5; --text-muted: #5f9c83;
  36. --border: rgba(255,255,255,0.08);
  37. }
  38. [data-theme="slate"] {
  39. --bg-void: #0d0d0d; --bg-servers: #161616; --bg-sidebar: #1e1e1e; --bg-main: #242424;
  40. --bg-input: #2c2c2c; --bg-hover: #333333; --bg-active: #3d3d3d; --accent: #a8a8a8;
  41. --accent-dim: #707070; --accent-glow: rgba(168,168,168,0.12); --green: #6fcf97;
  42. --amber: #f2c94c; --red: #eb5757; --text-primary: #dcdcdc; --text-secondary: #999999;
  43. --text-muted: #555555; --border: rgba(255,255,255,0.07);
  44. }
  45. * { margin: 0; padding: 0; box-sizing: border-box; }
  46. html, body { height: 100%; overflow: hidden; font-family: var(--font); background: var(--bg-void); color: var(--text-primary); }
  47. ::-webkit-scrollbar { width: 4px; height: 4px; }
  48. ::-webkit-scrollbar-track { background: transparent; }
  49. ::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 2px; }
  50. #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); }
  51. .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); }
  52. .auth-logo { font-size: 28px; font-weight: 800; color: var(--accent); letter-spacing: -1px; margin-bottom: 6px; }
  53. .auth-logo span { color: var(--text-muted); font-weight: 400; }
  54. .auth-subtitle { color: var(--text-secondary); font-size: 13px; margin-bottom: 28px; }
  55. .auth-tabs { display: flex; gap: 4px; margin-bottom: 24px; background: var(--bg-void); border-radius: 8px; padding: 4px; }
  56. .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; }
  57. .auth-tab.active { background: var(--accent); color: #fff; }
  58. .form-field { margin-bottom: 14px; }
  59. .form-field label { display: block; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 7px; }
  60. .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; }
  61. .form-field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
  62. .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; }
  63. .btn-primary:hover { background: var(--accent-dim); transform: translateY(-1px); }
  64. .auth-error { color: var(--red); font-size: 12px; margin-top: 10px; text-align: center; min-height: 16px; }
  65. #app-page { display: flex; width: 100%; height: 100vh; }
  66. #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; }
  67. .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; }
  68. .server-icon i { font-size: 20px; }
  69. .server-icon:hover { border-radius: 10px; background: var(--accent); color: #fff; }
  70. .server-icon.active { border-color: var(--accent); color: var(--accent); border-radius: 10px; background: var(--bg-active); }
  71. .server-icon.dm-icon { font-size: 18px; color: var(--accent); background: var(--bg-active); }
  72. .server-icon.dm-icon.active { background: var(--accent); color: #fff; }
  73. .server-divider { width: 28px; height: 1px; background: var(--border); margin: 3px 0; }
  74. .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; }
  75. .add-server-btn:hover { background: rgba(74,222,128,.1); border-color: var(--green); border-radius: 14px; }
  76. #channel-sidebar { width: 228px; background: var(--bg-sidebar); display: flex; flex-direction: column; border-right: 1px solid var(--border); flex-shrink: 0; }
  77. .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; }
  78. .sidebar-title { font-size: 14px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  79. .sidebar-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
  80. .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; }
  81. .sidebar-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
  82. .channels-list { flex: 1; overflow-y: auto; padding: 6px 6px; }
  83. .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; }
  84. .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; }
  85. .channel-item i { width: 16px; font-size: 12px; }
  86. .channel-item:hover { background: var(--bg-hover); color: var(--text-primary); }
  87. .channel-item.active { background: var(--bg-active); color: var(--text-primary); }
  88. .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; }
  89. .dm-item:hover { background: var(--bg-hover); color: var(--text-primary); }
  90. .dm-item.active { background: var(--bg-active); color: var(--text-primary); }
  91. .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; }
  92. .dm-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
  93. .dm-name { font-weight: 600; font-family: var(--mono); font-size: 12px; }
  94. .pin-btn { font-size: 12px; margin-left: auto; color: var(--amber); cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
  95. .pin-btn:hover { opacity: 1; }
  96. .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; }
  97. .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; }
  98. .user-av img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
  99. .user-name-display { font-size: 12px; font-weight: 600; font-family: var(--mono); color: var(--text-primary); }
  100. .user-status-dot { font-size: 10px; color: var(--green); }
  101. .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; }
  102. .logout-btn:hover { color: var(--red); }
  103. #main-area { flex: 1; display: flex; flex-direction: column; background: var(--bg-main); min-width: 0; overflow: hidden; position: relative; }
  104. .chat-header { height: 50px; border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 18px; gap: 10px; flex-shrink: 0; }
  105. .chat-header i { color: var(--text-muted); font-size: 16px; width: 20px; }
  106. .chat-header-name { font-size: 14px; font-weight: 700; }
  107. .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; }
  108. .video-call-btn:hover { background: var(--bg-hover); }
  109. #messages-wrap { flex: 1; overflow-y: auto; padding: 16px 18px 0; display: flex; flex-direction: column; gap: 1px; }
  110. .msg-group { display: flex; gap: 12px; padding: 5px 6px; border-radius: 6px; transition: background .1s; position: relative; }
  111. .msg-group:hover { background: rgba(255,255,255,.025); }
  112. .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; }
  113. .msg-av img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
  114. .msg-body { flex: 1; min-width: 0; }
  115. .msg-meta { display: flex; align-items: baseline; gap: 8px; margin-bottom: 3px; }
  116. .msg-author { font-size: 13px; font-weight: 700; color: var(--accent); font-family: var(--mono); }
  117. .msg-time { font-size: 10px; color: var(--text-muted); }
  118. .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); }
  119. .msg-img-wrapper { position: relative; display: inline-block; }
  120. .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); }
  121. .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; }
  122. .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; }
  123. .msg-file-icon { font-size: 22px; flex-shrink: 0; }
  124. .msg-file-info { flex: 1; min-width: 0; }
  125. .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); }
  126. .msg-file-type { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
  127. .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; }
  128. .fa-demo { background: var(--accent); color: #fff; }
  129. .fa-demo:hover { background: var(--accent-dim); }
  130. .fa-preview { background: rgba(251,191,36,.15); color: var(--amber); }
  131. .fa-preview:hover { background: rgba(251,191,36,.28); }
  132. .fa-dl { background: var(--bg-active); color: var(--text-primary); display: inline-flex; align-items: center; gap: 4px; }
  133. .fa-dl:hover { background: var(--bg-hover); }
  134. .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; }
  135. .msg-group:hover .msg-delete { display: flex; align-items: center; justify-content: center; }
  136. .msg-delete:hover { background: rgba(248,113,113,.24); }
  137. .input-area { padding: 14px 18px; flex-shrink: 0; }
  138. .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; }
  139. .upload-cancel { background: none; border: none; color: var(--red); cursor: pointer; font-size: 15px; margin-left: auto; }
  140. .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; }
  141. .input-bar:focus-within { border-color: var(--accent-dim); }
  142. .attach-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 18px; padding: 1px; transition: color .15s; flex-shrink: 0; }
  143. .attach-btn:hover { color: var(--accent); }
  144. #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; }
  145. #msg-input::placeholder { color: var(--text-muted); }
  146. .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; }
  147. .send-btn:hover { background: var(--accent-dim); transform: scale(1.06); }
  148. .send-btn:disabled { opacity: .35; cursor: default; transform: none; }
  149. #file-input { display: none; }
  150. #friends-view { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
  151. .ftabs { display: flex; gap: 3px; padding: 0 14px; border-bottom: 1px solid var(--border); height: 50px; align-items: center; }
  152. .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; }
  153. .ftab i { margin-right: 4px; }
  154. .ftab:hover { background: var(--bg-hover); color: var(--text-primary); }
  155. .ftab.active { background: var(--bg-active); color: var(--text-primary); }
  156. .badge { background: var(--red); color: #fff; font-size: 10px; font-weight: 700; padding: 1px 5px; border-radius: 10px; margin-left: 4px; }
  157. .friends-content { flex: 1; overflow-y: auto; padding: 20px; }
  158. .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; }
  159. .friend-card { display: flex; align-items: center; gap: 11px; padding: 10px 12px; border-radius: 8px; transition: background .15s; }
  160. .friend-card:hover { background: var(--bg-hover); }
  161. .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; }
  162. .friend-av img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
  163. .friend-username { font-size: 13px; font-weight: 600; font-family: var(--mono); }
  164. .friend-label { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
  165. .friend-actions { display: flex; gap: 5px; margin-left: auto; }
  166. .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; }
  167. .ibt.acc { background: rgba(74,222,128,.15); color: var(--green); }
  168. .ibt.acc:hover { background: rgba(74,222,128,.3); }
  169. .ibt.dec { background: rgba(248,113,113,.15); color: var(--red); }
  170. .ibt.dec:hover { background: rgba(248,113,113,.3); }
  171. .ibt.msg { background: var(--bg-active); color: var(--accent); }
  172. .ibt.msg:hover { background: var(--accent); color: #fff; }
  173. .add-friend-bar { display: flex; gap: 9px; margin-bottom: 18px; }
  174. .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; }
  175. .add-friend-bar input:focus { border-color: var(--accent); }
  176. .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; }
  177. .add-friend-bar .send-fr-btn:hover { background: var(--accent-dim); }
  178. #add-friend-msg { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
  179. #welcome-screen { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 14px; color: var(--text-muted); }
  180. .wlc-icon { font-size: 56px; }
  181. .wlc-title { font-size: 20px; font-weight: 700; color: var(--text-secondary); }
  182. .wlc-sub { font-size: 13px; }
  183. .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); }
  184. .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); }
  185. .modal h3 { font-size: 17px; font-weight: 700; margin-bottom: 18px; }
  186. .modal label { display: block; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 7px; }
  187. .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; }
  188. .modal input:focus, .modal select:focus { border-color: var(--accent); }
  189. .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 6px; flex-wrap: wrap; }
  190. .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; }
  191. .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; }
  192. .btn-confirm:hover { background: var(--accent-dim); }
  193. .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; }
  194. .btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
  195. .modal-tabs { display: flex; gap: 4px; margin-bottom: 18px; background: var(--bg-void); border-radius: 8px; padding: 4px; }
  196. .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; }
  197. .modal-tab.active { background: var(--accent); color: #fff; }
  198. .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; }
  199. .share-id:hover { border-color: var(--accent); color: var(--accent); }
  200. .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; }
  201. .preview-box img { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); }
  202. .preview-box .txt { font-size: 12px; color: var(--text-muted); font-family: var(--mono); word-break: break-word; }
  203. .small-note { font-size: 11px; color: var(--text-muted); margin-top: -8px; margin-bottom: 14px; }
  204. #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; }
  205. .ctx-item { padding: 8px 12px; cursor: pointer; font-size: 13px; border-radius: 4px; color: var(--text-primary); transition: background 0.1s; }
  206. .ctx-item:hover { background: var(--bg-hover); }
  207. #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; }
  208. .timeout-timer { font-size: 48px; font-family: var(--mono); color: white; margin-bottom: 20px; }
  209. #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; }
  210. #toast.show { transform: translateY(0); opacity: 1; }
  211. #toast.success { border-color: var(--green); }
  212. #toast.error { border-color: var(--red); }
  213. .empty-state { text-align: center; padding: 36px 16px; color: var(--text-muted); font-size: 12px; }
  214. .empty-state .ico { font-size: 36px; margin-bottom: 10px; }
  215. .csv-wrap { flex: 1; overflow: auto; }
  216. .csv-tbl { border-collapse: collapse; font-size: 12px; font-family: var(--mono); }
  217. .csv-tbl th, .csv-tbl td { padding: 6px 10px; border: 1px solid var(--border); }
  218. .csv-tbl th { background: var(--bg-input); color: var(--accent); }
  219. #video-call-modal { z-index: 400; }
  220. #video-call-modal .modal { width: min(95vw, 1000px); padding: 20px; max-height: 95vh; overflow-y: auto; }
  221. .vc-videos { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
  222. @media (max-width: 580px) { .vc-videos { grid-template-columns: 1fr; } }
  223. .vc-video-wrap { position: relative; background: #000; border-radius: 10px; overflow: hidden; aspect-ratio: 16/9; border: 2px solid var(--border); }
  224. .vc-video-wrap video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
  225. #vc-local-video { transform: scaleX(-1); }
  226. .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; }
  227. .vc-controls { display: flex; gap: 10px; justify-content: center; margin-top: 16px; }
  228. .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; }
  229. .vc-btn-mute { background: #555; color: white; }
  230. .vc-btn-mute.active { background: #c0392b; }
  231. .vc-btn-video { background: #555; color: white; }
  232. .vc-btn-video.active { background: #c0392b; }
  233. .vc-btn-hangup { background: #c0392b; color: white; width: 100%; margin-top: 16px; }
  234. .vc-status { text-align: center; margin-bottom: 12px; padding: 8px; border-radius: 6px; background: var(--bg-input); font-size: 13px; color: var(--text-secondary); }
  235. .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; }
  236. .spinner { width: 32px; height: 32px; border: 3px solid #333; border-top-color: var(--accent); border-radius: 50%; animation: spin 0.75s linear infinite; }
  237. @keyframes spin { to { transform: rotate(360deg); } }
  238. #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; }
  239. .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; }
  240. .popup-info { flex: 1; }
  241. .popup-title { font-weight: 700; margin-bottom: 4px; }
  242. .popup-sub { font-size: 12px; color: var(--text-muted); }
  243. .popup-actions { display: flex; gap: 8px; }
  244. .popup-btn { width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center; }
  245. .popup-accept { background: var(--green); color: white; }
  246. .popup-decline { background: var(--red); color: white; }
  247. #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; }
  248. #lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 8px; object-fit: contain; }
  249. </style>
  250. </head>
  251. <div id="auth-page">
  252. <div class="auth-card">
  253. <div class="auth-logo">Nex<span>Chat</span></div>
  254. <div class="auth-subtitle">Open a conversation. Build a community.</div>
  255. <div class="auth-tabs">
  256. <button class="auth-tab active" onclick="switchAuthTab('login')">Sign In</button>
  257. <button class="auth-tab" onclick="switchAuthTab('signup')">Create Account</button>
  258. </div>
  259. <div class="form-field">
  260. <label>Username</label>
  261. <input type="text" id="au" placeholder="your_handle" onkeydown="if(event.key==='Enter')handleAuth()">
  262. </div>
  263. <div class="form-field">
  264. <label>Password</label>
  265. <input type="password" id="ap" placeholder="••••••••" onkeydown="if(event.key==='Enter')handleAuth()">
  266. </div>
  267. <button class="btn-primary" id="auth-btn" onclick="handleAuth()">Sign In</button>
  268. <div class="auth-error" id="auth-error"></div>
  269. </div>
  270. </div>
  271. <div id="app-page" style="display:none">
  272. <div id="servers-bar">
  273. <div class="server-icon dm-icon active" id="dm-icon" title="Direct Messages" onclick="showDMHome()"><i class="fas fa-comment-dots"></i></div>
  274. <div class="server-icon" title="Friends & Requests" onclick="openFriendsView()"><i class="fas fa-user-friends"></i></div>
  275. <div class="server-divider"></div>
  276. <div id="server-icons-list"></div>
  277. <div class="server-divider"></div>
  278. <div class="add-server-btn" title="Create or Join Server" onclick="openServerModal()"><i class="fas fa-plus"></i></div>
  279. </div>
  280. <div id="channel-sidebar">
  281. <div class="sidebar-header">
  282. <span class="sidebar-title" id="sidebar-title">Direct Messages</span>
  283. <div class="sidebar-actions">
  284. <button class="sidebar-btn" title="Friends" onclick="openFriendsView()"><i class="fas fa-user-friends"></i></button>
  285. <button class="sidebar-btn" id="copy-server-id-btn" style="display:none" title="Copy Server ID" onclick="copyCurrentServerId()"><i class="fas fa-copy"></i></button>
  286. <button class="sidebar-btn" id="server-settings-btn" style="display:none" title="Server Settings" onclick="openServerSettingsModal()"><i class="fas fa-cog"></i></button>
  287. <button class="sidebar-btn" id="ch-add-btn" style="display:none" title="Create Channel" onclick="openChannelModal()"><i class="fas fa-plus"></i></button>
  288. </div>
  289. </div>
  290. <div class="channels-list" id="sidebar-list"></div>
  291. <div class="user-bar" onclick="openSettingsModal('profile')">
  292. <div class="user-av" id="uav">?</div>
  293. <div>
  294. <div class="user-name-display" id="uname">...</div>
  295. <div class="user-status-dot"><i class="fas fa-circle"></i> online</div>
  296. </div>
  297. <button class="logout-btn" onclick="event.stopPropagation();doLogout()" title="Log out"><i class="fas fa-sign-out-alt"></i></button>
  298. </div>
  299. </div>
  300. <div id="main-area">
  301. <div id="welcome-screen">
  302. <div class="wlc-icon"><i class="fas fa-comments"></i></div>
  303. <div class="wlc-title">Welcome to NexChat</div>
  304. <div class="wlc-sub">Select a channel or conversation to get started</div>
  305. </div>
  306. <div id="chat-view" style="display:none;flex-direction:column;flex:1;overflow:hidden">
  307. <div class="chat-header">
  308. <i id="chat-icon" class="fas fa-hashtag"></i>
  309. <span class="chat-header-name" id="chat-name">-</span>
  310. <button class="video-call-btn" id="start-video-call-btn" style="display:none" title="Start Video Call" onclick="initiateVideoCall()"><i class="fas fa-video"></i></button>
  311. <div style="margin-left:auto;display:flex;gap:8px">
  312. <button class="attach-btn" id="admin-members-btn" style="display:none" title="Manage Members" onclick="openAdminMembersModal()"><i class="fas fa-users-cog"></i></button>
  313. </div>
  314. </div>
  315. <div id="messages-wrap"></div>
  316. <div id="timeout-overlay" style="display:none">
  317. <div class="timeout-timer" id="timeout-timer">--:--</div>
  318. <div style="color:white;font-size:14px">You are timed out from chatting</div>
  319. </div>
  320. <div class="input-area">
  321. <div class="upload-preview" id="upload-prev">
  322. <span id="up-name"></span>
  323. <button class="upload-cancel" onclick="cancelUpload()"><i class="fas fa-times"></i></button>
  324. </div>
  325. <div class="input-bar">
  326. <button class="attach-btn" onclick="document.getElementById('file-input').click()" title="Attach file"><i class="fas fa-paperclip"></i></button>
  327. <button class="attach-btn" onclick="openGifModal()" title="Send GIF"><i class="fas fa-images"></i></button>
  328. <textarea id="msg-input" placeholder="Send a message..." rows="1" onkeydown="handleMsgKey(event)" oninput="autoGrow(this)"></textarea>
  329. <button class="send-btn" id="send-btn" onclick="sendMessage()"><i class="fas fa-paper-plane"></i></button>
  330. </div>
  331. <input type="file" id="file-input" onchange="onFileChosen(event)">
  332. </div>
  333. </div>
  334. <div id="friends-view" style="display:none;flex-direction:column;flex:1;overflow:hidden">
  335. <div class="ftabs">
  336. <button class="ftab active" onclick="ftab('friends')"><i class="fas fa-user-friends"></i> Friends</button>
  337. <button class="ftab" onclick="ftab('pending')"><i class="fas fa-clock"></i> Pending <span class="badge" id="pend-badge" style="display:none">0</span></button>
  338. <button class="ftab" onclick="ftab('add')"><i class="fas fa-user-plus"></i> Add Friend</button>
  339. </div>
  340. <div class="friends-content" id="friends-content"></div>
  341. </div>
  342. </div>
  343. </div>
  344. <!-- Server Modal -->
  345. <div class="overlay" id="server-modal" style="display:none" onclick="if(event.target===this)closeModal('server-modal')">
  346. <div class="modal">
  347. <h3><i class="fas fa-server"></i> Server</h3>
  348. <div class="modal-tabs">
  349. <button class="modal-tab active" id="mt-create" onclick="srvTab('create')">Create</button>
  350. <button class="modal-tab" id="mt-join" onclick="srvTab('join')">Join</button>
  351. </div>
  352. <div id="srv-create">
  353. <label>Server Name</label>
  354. <input type="text" id="srv-name" placeholder="My Awesome Server">
  355. <label>Server Icon (optional)</label>
  356. <input type="file" id="srv-icon-file" accept="image/*">
  357. <div class="small-note">You can also change the icon later from server settings.</div>
  358. </div>
  359. <div id="srv-join" style="display:none">
  360. <label>Server ID or Invite Code</label>
  361. <input type="text" id="srv-id" placeholder="Paste server ID or invite code here...">
  362. </div>
  363. <div class="modal-actions">
  364. <button class="btn-cancel" onclick="closeModal('server-modal')">Cancel</button>
  365. <button class="btn-confirm" id="srv-btn" onclick="handleServerModal()">Create</button>
  366. </div>
  367. </div>
  368. </div>
  369. <!-- Server Settings Modal -->
  370. <div class="overlay" id="server-settings-modal" style="display:none" onclick="if(event.target===this)closeModal('server-settings-modal')">
  371. <div class="modal">
  372. <h3><i class="fas fa-cog"></i> Server Settings</h3>
  373. <div class="preview-box" id="server-settings-preview">
  374. <img id="server-settings-preview-img" src="" alt="server icon preview" style="display:none">
  375. <div class="txt" id="server-settings-preview-txt">No icon selected</div>
  376. </div>
  377. <label>Server Name</label>
  378. <input type="text" id="ss-name" placeholder="Server name">
  379. <label>Server Icon (file upload)</label>
  380. <input type="file" id="ss-icon-file" accept="image/*">
  381. <label>Or Icon URL</label>
  382. <input type="text" id="ss-icon-url" placeholder="https://...">
  383. <label>Invite Code (valid 1 hour)</label>
  384. <div style="display:flex;gap:8px;margin-bottom:14px">
  385. <input type="text" id="invite-code-display" readonly style="margin-bottom:0;background:var(--bg-input)">
  386. <button class="btn-ghost" onclick="generateTempInvite()" style="white-space:nowrap"><i class="fas fa-sync-alt"></i> Generate</button>
  387. </div>
  388. <div class="modal-actions" style="justify-content:space-between">
  389. <button class="btn-ghost" style="color:var(--red)" onclick="deleteServer()"><i class="fas fa-trash-alt"></i> Delete Server</button>
  390. <div style="display:flex;gap:8px">
  391. <button class="btn-cancel" onclick="closeModal('server-settings-modal')">Cancel</button>
  392. <button class="btn-confirm" onclick="saveServerSettings()">Save</button>
  393. </div>
  394. </div>
  395. </div>
  396. </div>
  397. <!-- Settings Modal -->
  398. <div class="overlay" id="settings-modal" style="display:none" onclick="if(event.target===this)closeModal('settings-modal')">
  399. <div class="modal" style="width:500px;max-width:90vw;">
  400. <h3><i class="fas fa-sliders-h"></i> Settings</h3>
  401. <div class="modal-tabs">
  402. <button class="modal-tab active" data-stab="profile"><i class="fas fa-user-circle"></i> Profile</button>
  403. <button class="modal-tab" data-stab="account"><i class="fas fa-key"></i> Account</button>
  404. <button class="modal-tab" data-stab="blocked"><i class="fas fa-ban"></i> Blocked</button>
  405. <button class="modal-tab" data-stab="danger"><i class="fas fa-exclamation-triangle"></i> Danger</button>
  406. </div>
  407. <div id="stab-profile" class="stab-content">
  408. <div class="preview-box">
  409. <img id="settings-profile-preview-img" src="" alt="profile preview" style="display:none">
  410. <div class="txt" id="settings-profile-preview-txt">No avatar selected</div>
  411. </div>
  412. <label>Username</label>
  413. <input type="text" id="settings-username" disabled>
  414. <label>Profile Pic (file upload)</label>
  415. <input type="file" id="settings-avatar-file" accept="image/*">
  416. <label>Or Avatar URL</label>
  417. <input type="text" id="settings-avatar-url" placeholder="https://...">
  418. <label>Theme</label>
  419. <select id="settings-theme">
  420. <option value="dark">Dark</option>
  421. <option value="light">Light</option>
  422. <option value="high-contrast">High Contrast</option>
  423. <option value="forest">Forest</option>
  424. <option value="slate">Slate</option>
  425. </select>
  426. <label>Message Font</label>
  427. <select id="settings-font">
  428. <option value="JetBrains Mono">JetBrains Mono</option>
  429. <option value="Syne">Syne</option>
  430. <option value="Inter">Inter</option>
  431. <option value="monospace">Monospace</option>
  432. </select>
  433. </div>
  434. <div id="stab-account" class="stab-content" style="display:none">
  435. <label>Current Password</label>
  436. <input type="password" id="old-password" placeholder="Enter current password">
  437. <label>New Password</label>
  438. <input type="password" id="new-password" placeholder="New password">
  439. <label>Confirm New Password</label>
  440. <input type="password" id="confirm-password" placeholder="Confirm new password">
  441. <button class="btn-primary" onclick="changePassword()" style="margin-top:8px">Change Password</button>
  442. <div id="password-change-msg" style="font-size:12px;margin-top:8px;color:var(--amber)"></div>
  443. </div>
  444. <div id="stab-blocked" class="stab-content" style="display:none">
  445. <div id="blocked-list"></div>
  446. </div>
  447. <div id="stab-danger" class="stab-content" style="display:none">
  448. <p style="color:var(--red);margin-bottom:16px">Permanently delete your account and all data.</p>
  449. <label>Enter your password to confirm</label>
  450. <input type="password" id="delete-password" placeholder="Your password">
  451. <button class="btn-ghost" style="color:var(--red);border-color:var(--red);width:100%" onclick="deleteAccount()">Delete Account</button>
  452. </div>
  453. <div class="modal-actions">
  454. <button class="btn-cancel" onclick="closeModal('settings-modal')">Close</button>
  455. <button class="btn-confirm" id="save-settings-btn" onclick="saveProfileSettings()">Save</button>
  456. </div>
  457. </div>
  458. </div>
  459. <!-- Channel Modal -->
  460. <div class="overlay" id="channel-modal" style="display:none" onclick="if(event.target===this)closeModal('channel-modal')">
  461. <div class="modal">
  462. <h3><i class="fas fa-hashtag"></i> New Channel</h3>
  463. <label>Channel Name</label>
  464. <input type="text" id="ch-name" placeholder="general">
  465. <div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
  466. <input type="checkbox" id="ch-restricted" style="width:auto;margin-bottom:0">
  467. <label style="margin:0;text-transform:none;letter-spacing:normal;font-size:13px">Restricted (only selected members)</label>
  468. </div>
  469. <div class="modal-actions">
  470. <button class="btn-cancel" onclick="closeModal('channel-modal')">Cancel</button>
  471. <button class="btn-confirm" onclick="createChannel()">Create</button>
  472. </div>
  473. </div>
  474. </div>
  475. <!-- GIF Modal -->
  476. <div class="overlay" id="gif-modal" style="display:none" onclick="if(event.target===this)closeModal('gif-modal')">
  477. <div class="modal">
  478. <h3><i class="fas fa-images"></i> GIF</h3>
  479. <label>GIF URL or Tenor/Giphy link</label>
  480. <input type="text" id="gif-url" placeholder="Paste GIF link here...">
  481. <label>Caption (optional)</label>
  482. <input type="text" id="gif-caption" placeholder="Add text with the GIF...">
  483. <div class="small-note">Direct .gif links always work. Tenor/Giphy page links are auto-resolved when possible.</div>
  484. <div class="modal-actions">
  485. <button class="btn-cancel" onclick="closeModal('gif-modal')">Cancel</button>
  486. <button class="btn-confirm" onclick="sendGifFromModal()">Send GIF</button>
  487. </div>
  488. </div>
  489. </div>
  490. <!-- Admin Members Modal -->
  491. <div class="overlay" id="admin-members-modal" style="display:none" onclick="if(event.target===this)closeModal('admin-members-modal')">
  492. <div class="modal" style="width:500px;max-width:90vw">
  493. <h3><i class="fas fa-users-cog"></i> Manage Members</h3>
  494. <div id="admin-members-list"></div>
  495. </div>
  496. </div>
  497. <!-- Invite Server Modal -->
  498. <div class="overlay" id="invite-server-modal" style="display:none" onclick="if(event.target===this)closeModal('invite-server-modal')">
  499. <div class="modal">
  500. <h3><i class="fas fa-user-plus"></i> Invite to Server</h3>
  501. <p style="font-size:13px;margin-bottom:16px">Select a server to invite <span id="invite-friend-name"></span></p>
  502. <div id="invite-server-list" style="max-height:300px;overflow-y:auto"></div>
  503. <div class="modal-actions">
  504. <button class="btn-cancel" onclick="closeModal('invite-server-modal')">Cancel</button>
  505. </div>
  506. </div>
  507. </div>
  508. <!-- Demo Modal -->
  509. <div class="overlay" id="demo-modal" style="display:none" onclick="if(event.target===this)closeModal('demo-modal')">
  510. <div class="modal" style="width:min(92vw,960px);padding:18px;display:flex;flex-direction:column;max-height:90vh">
  511. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
  512. <span style="font-weight:700;font-size:14px" id="demo-title">HTML Preview</span>
  513. <button class="btn-cancel" onclick="closeModal('demo-modal')">✕ Close</button>
  514. </div>
  515. <iframe id="demo-iframe" sandbox="allow-scripts allow-same-origin allow-forms" style="flex:1;border:1px solid var(--border);border-radius:8px;background:#fff;min-height:60vh"></iframe>
  516. </div>
  517. </div>
  518. <!-- CSV Modal -->
  519. <div class="overlay" id="csv-modal" style="display:none" onclick="if(event.target===this)closeModal('csv-modal')">
  520. <div class="modal" style="width:min(88vw,820px);max-height:82vh;overflow:hidden;display:flex;flex-direction:column;padding:20px">
  521. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
  522. <span style="font-weight:700;font-size:14px" id="csv-title">CSV Preview</span>
  523. <button class="btn-cancel" onclick="closeModal('csv-modal')">✕ Close</button>
  524. </div>
  525. <div class="csv-wrap" id="csv-wrap"></div>
  526. </div>
  527. </div>
  528. <!-- Video Call Modal -->
  529. <div class="overlay" id="video-call-modal" style="display:none" onclick="if(event.target===this)vcHangUp()">
  530. <div class="modal">
  531. <h3><i class="fas fa-video"></i> Video Call with <span id="vc-peer-name"></span></h3>
  532. <div class="vc-status" id="vc-status">Connecting...</div>
  533. <div class="vc-videos">
  534. <div class="vc-video-wrap">
  535. <video id="vc-local-video" autoplay playsinline muted></video>
  536. <div class="vc-label">You</div>
  537. </div>
  538. <div class="vc-video-wrap" id="vc-remote-wrap">
  539. <video id="vc-remote-video" autoplay playsinline></video>
  540. <div class="vc-label" id="vc-remote-label">Remote</div>
  541. <div class="vc-connecting-overlay" id="vc-remote-overlay">
  542. <div class="spinner"></div>
  543. <span>Waiting for peer...</span>
  544. </div>
  545. </div>
  546. </div>
  547. <div class="vc-controls">
  548. <button class="vc-btn vc-btn-mute" id="vc-mute-btn" onclick="vcToggleMute()"><i class="fas fa-microphone"></i> Mute</button>
  549. <button class="vc-btn vc-btn-video" id="vc-video-btn" onclick="vcToggleVideo()"><i class="fas fa-video"></i> Stop Video</button>
  550. </div>
  551. <button class="vc-btn vc-btn-hangup" onclick="vcHangUp()"><i class="fas fa-phone-slash"></i> End Call</button>
  552. </div>
  553. </div>
  554. <!-- Incoming Call Popup -->
  555. <div id="incoming-call-popup">
  556. <div class="popup-avatar" id="incoming-avatar">?</div>
  557. <div class="popup-info">
  558. <div class="popup-title" id="incoming-title">Incoming Video Call</div>
  559. <div class="popup-sub" id="incoming-sub">from ...</div>
  560. </div>
  561. <div class="popup-actions">
  562. <button class="popup-btn popup-accept" onclick="acceptIncomingCall()"><i class="fas fa-check"></i></button>
  563. <button class="popup-btn popup-decline" onclick="declineIncomingCall()"><i class="fas fa-times"></i></button>
  564. </div>
  565. </div>
  566. <!-- Lightbox -->
  567. <div id="lightbox" style="display:none" onclick="closeLightbox()">
  568. <img id="lb-img" src="" alt="">
  569. </div>
  570. <!-- Context Menu -->
  571. <div id="ctx-menu">
  572. <div class="ctx-item" data-action="download"><i class="fas fa-download"></i> Download</div>
  573. <div class="ctx-item" data-action="copy-link"><i class="fas fa-link"></i> Copy Link</div>
  574. </div>
  575. <!-- Toast -->
  576. <div id="toast"></div>
  577. // ==================== CONFIGURATION ====================
  578. const SUPABASE_URL = 'https://nmcawpwleesngoqobvzp.supabase.co';
  579. const SUPABASE_ANON = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5tY2F3cHdsZWVzbmdvcW9idnpwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU3NTg0MzcsImV4cCI6MjA5MTMzNDQzN30.iTpKNR7d5MztIfxj01ycd7de8vCvLBHSqG7LCCcgVAY';
  580. const BUCKET = 'chat-files';
  581. const sb = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON);
  582. const GAS_URL = 'https://script.google.com/macros/s/AKfycbxZai-422M6DQZlXGgzqdteSSez5qad_IXBCZKPuk2oBsv7iCg3wcFXH8dAK2WZS7eg/exec';
  583. const ICE_CONFIG = {
  584. iceServers: [
  585. { urls: 'stun:stun.l.google.com:19302' },
  586. { urls: 'stun:stun1.l.google.com:19302' },
  587. { urls: 'stun:stun2.l.google.com:19302' },
  588. { urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' },
  589. { urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' },
  590. { urls: 'turn:openrelay.metered.ca:443?transport=tcp',username: 'openrelayproject', credential: 'openrelayproject' }
  591. ],
  592. iceCandidatePoolSize: 10
  593. };
  594. const VC_POLL_INTERVAL = 1500;
  595. const VC_MAX_POLL = 80;
  596. const VC_ICE_BATCH_MS = 600;
  597.  
  598. // ==================== GLOBAL STATE ====================
  599. let CU = null, CS = null, CC = null, CDM = null, VIEW = null;
  600. let srvModalTab = 'create', activeFTab = 'friends', pendingFile = null, msgSub = null;
  601. let pBadgeTimer = null, msgPollTimer = null, authMode = 'login', _tt = null;
  602. let renderedMsgIds = new Set(), pendingGifUrl = null;
  603. let currentContextTarget = null, timeoutInterval = null;
  604. let pendingInviteFriendId = null, pendingInviteFriendName = '';
  605. let activityInterval = null;
  606.  
  607. // Video call state
  608. let vcLocalStream = null;
  609. let vcPeerConnection = null;
  610. let vcCurrentCallId = null;
  611. let vcCurrentGasCode = null;
  612. let vcCurrentPeerId = null;
  613. let vcCurrentPeerName = '';
  614. let vcIsCaller = false;
  615. let vcAudioMuted = false;
  616. let vcVideoStopped = false;
  617. let vcCallSubscription = null;
  618. let vcIceBatchQueue = [];
  619. let vcIceBatchTimer = null;
  620. let vcPollTimer = null;
  621. let vcHangupPollTimer = null;
  622. let vcKnownCallerIce = 0;
  623. let vcKnownCalleeIce = 0;
  624. let vcCallActive = false;
  625. let vcHangupSignalled = false;
  626. let vcIsHangingUp = false; // guard against double-execution
  627. let incomingCallData = null;
  628. let incomingPollInterval= null;
  629. let vcStatusChannel = null; // Supabase realtime for call status changes
  630.  
  631. // ==================== UTILITY ====================
  632. function toast(msg, type = '') {
  633. const el = document.getElementById('toast');
  634. el.textContent = msg; el.className = 'show ' + type;
  635. if (_tt) clearTimeout(_tt);
  636. _tt = setTimeout(() => el.className = '', 3500);
  637. }
  638. async function gasApi(action, payload = {}) {
  639. const res = await fetch(GAS_URL, {
  640. method: 'POST', mode: 'cors', redirect: 'follow',
  641. headers: { 'Content-Type': 'text/plain' },
  642. body: JSON.stringify({ action, ...payload })
  643. });
  644. return res.json();
  645. }
  646. function getCurrentAuthorId(m) { return m.user_id ?? m.sender_id ?? m.author_id ?? null; }
  647. function hasCurrentOwnership(m) { const aid = getCurrentAuthorId(m); return CU && aid === CU.id; }
  648. function fmtBytes(b) {
  649. if (b < 1024) return b + 'B';
  650. if (b < 1048576) return (b / 1024).toFixed(1) + 'KB';
  651. return (b / 1048576).toFixed(1) + 'MB';
  652. }
  653. async function clearMsgSubscription() {
  654. if (msgSub) { try { await sb.removeChannel(msgSub); } catch (_) {} msgSub = null; }
  655. }
  656. function stopMsgPoll() { if (msgPollTimer) { clearInterval(msgPollTimer); msgPollTimer = null; } }
  657. function startMsgPoll() {
  658. stopMsgPoll();
  659. msgPollTimer = setInterval(() => { if ((VIEW === 'channel' && CC) || (VIEW === 'dm' && CDM)) loadMessages(); }, 2500);
  660. }
  661.  
  662. // ==================== ACTIVITY TRACKING ====================
  663. function startActivityTracking() {
  664. if (activityInterval) clearInterval(activityInterval);
  665. const updateLastSeen = async () => {
  666. if (!CU) return;
  667. await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
  668. };
  669. updateLastSeen();
  670. activityInterval = setInterval(updateLastSeen, 30000);
  671. }
  672.  
  673. // ==================== AUTH ====================
  674. async function init() {
  675. document.getElementById('auth-page').style.display = 'flex';
  676. document.getElementById('app-page').style.display = 'none';
  677. showWelcome();
  678. initContextMenu();
  679. applyTheme(localStorage.getItem('nexchat_theme') || 'dark');
  680. applyMessageFont(localStorage.getItem('nexchat_msg_font') || 'JetBrains Mono');
  681. }
  682. async function finishLoginFlow(userData) {
  683. CU = {
  684. id: userData.id, username: userData.username,
  685. avatar_url: userData.avatar_url || null,
  686. pinned_friends: userData.pinned_friends || [],
  687. blocked_users: userData.blocked_users || [],
  688. last_seen: userData.last_seen || null
  689. };
  690. document.getElementById('uname').textContent = userData.username;
  691. document.getElementById('uav').innerHTML = userData.avatar_url
  692. ? `<img src="${userData.avatar_url}" alt="avatar">`
  693. : userData.username[0].toUpperCase();
  694. document.getElementById('auth-page').style.display = 'none';
  695. document.getElementById('app-page').style.display = 'flex';
  696. try {
  697. startActivityTracking();
  698. await showDMHome();
  699. await loadServers();
  700. startPendingPoll();
  701. setupVideoCallSubscriptions();
  702. setupServerMembersSubscription();
  703. try { await cleanupStaleCalls(); } catch (_) {}
  704. startIncomingCallPolling();
  705. } catch (e) {
  706. console.error('[LOGIN] Setup error:', e);
  707. toast('Login error: ' + (e.message || e) + ' — try refreshing.', 'error');
  708. }
  709. }
  710. async function doLogout() {
  711. if (activityInterval) clearInterval(activityInterval);
  712. if (incomingPollInterval) clearInterval(incomingPollInterval);
  713. if (vcCallSubscription) { sb.removeChannel(vcCallSubscription).catch(() => {}); vcCallSubscription = null; }
  714. if (vcStatusChannel) { sb.removeChannel(vcStatusChannel).catch(() => {}); vcStatusChannel = null; }
  715. CU = CS = CC = CDM = null; VIEW = null; pendingFile = null; renderedMsgIds.clear();
  716. stopMsgPoll(); await clearMsgSubscription();
  717. if (pBadgeTimer) { clearInterval(pBadgeTimer); pBadgeTimer = null; }
  718. document.getElementById('au').value = ''; document.getElementById('ap').value = '';
  719. document.getElementById('auth-error').textContent = '';
  720. document.getElementById('auth-page').style.display = 'flex';
  721. document.getElementById('app-page').style.display = 'none';
  722. showWelcome();
  723. }
  724. function switchAuthTab(m) {
  725. authMode = m;
  726. document.querySelectorAll('.auth-tab').forEach((t, i) => t.classList.toggle('active', m === 'login' ? i === 0 : i === 1));
  727. document.getElementById('auth-btn').textContent = m === 'login' ? 'Sign In' : 'Create Account';
  728. document.getElementById('auth-error').textContent = '';
  729. }
  730. async function handleAuth() {
  731. const username = document.getElementById('au').value.trim().toLowerCase();
  732. const password = document.getElementById('ap').value;
  733. const errEl = document.getElementById('auth-error');
  734. const btn = document.getElementById('auth-btn');
  735. errEl.textContent = '';
  736. if (!username || !password) { errEl.textContent = 'Fill in all fields.'; return; }
  737. if (username.length < 3) { errEl.textContent = 'Username must be ≥3 chars.'; return; }
  738. if (authMode === 'signup' && !/^[a-z0-9_]+$/.test(username)) { errEl.textContent = 'Username may only contain letters, numbers, and underscores.'; return; }
  739. btn.disabled = true; btn.textContent = 'Please wait…';
  740. try {
  741. if (authMode === 'signup') {
  742. if (password.length < 6) { errEl.textContent = 'Password must be ≥6 chars.'; return; }
  743. const { data: existingUser } = await sb.from('profiles').select('id').eq('username', username).maybeSingle();
  744. if (existingUser) { errEl.textContent = 'Username already taken.'; return; }
  745. const { data: newUser, error } = await sb.from('profiles').insert({ username, password, last_seen: new Date().toISOString() }).select('*').single();
  746. if (error) { errEl.textContent = 'Error creating account: ' + error.message; return; }
  747. await finishLoginFlow(newUser);
  748. } else {
  749. const { data: user, error } = await sb.from('profiles').select('*').eq('username', username).eq('password', password).maybeSingle();
  750. if (error) { errEl.textContent = 'Database error: ' + error.message; return; }
  751. if (!user) { errEl.textContent = 'Invalid username or password.'; return; }
  752. await finishLoginFlow(user);
  753. }
  754. } catch (e) {
  755. console.error('[AUTH] Unexpected error:', e);
  756. errEl.textContent = 'Something went wrong: ' + (e.message || e);
  757. } finally {
  758. btn.disabled = false;
  759. btn.textContent = authMode === 'login' ? 'Sign In' : 'Create Account';
  760. }
  761. }
  762.  
  763. // ==================== SERVERS ====================
  764. async function loadServers() {
  765. if (!CU) return;
  766. const list = document.getElementById('server-icons-list');
  767. list.innerHTML = '';
  768. const { data: memberships, error: memErr } = await sb.from('server_members').select('server_id').eq('user_id', CU.id);
  769. if (memErr) { console.error('[Servers] membership fetch error:', memErr); return; }
  770. const serverIds = [...new Set((memberships || []).map(r => r.server_id).filter(Boolean))];
  771. if (!serverIds.length) return;
  772. const { data: servers, error: srvErr } = await sb.from('servers').select('id,name,icon_url,owner_id').in('id', serverIds);
  773. if (srvErr) { console.error('[Servers] servers fetch error:', srvErr); return; }
  774. const serverMap = new Map((servers || []).map(s => [s.id, s]));
  775. serverIds.forEach(id => {
  776. const s = serverMap.get(id);
  777. if (!s) return;
  778. const el = document.createElement('div');
  779. el.className = 'server-icon' + (CS?.id === s.id ? ' active' : '');
  780. el.title = s.name;
  781. el.dataset.id = s.id;
  782. el.innerHTML = s.icon_url
  783. ? `<img src="${s.icon_url}" alt="server icon" style="width:100%;height:100%;object-fit:cover;border-radius:inherit">`
  784. : `<span style="font-size:18px;font-weight:800;letter-spacing:-0.5px">${s.name.trim()[0].toUpperCase()}</span>`;
  785. el.onclick = () => selectServer(s, true);
  786. list.appendChild(el);
  787. });
  788. }
  789. function setupServerMembersSubscription() {
  790. if (!CU) return;
  791. sb.channel('server-members-' + CU.id)
  792. .on('postgres_changes',
  793. { event: 'INSERT', schema: 'public', table: 'server_members', filter: `user_id=eq.${CU.id}` },
  794. async () => { await loadServers(); toast('You were added to a server!', 'success'); }
  795. ).subscribe();
  796. }
  797. async function selectServer(server, skipCheck = false) {
  798. if (!skipCheck) {
  799. const { data: membership } = await sb.from('server_members').select('server_id').eq('server_id', server.id).eq('user_id', CU.id).maybeSingle();
  800. if (!membership) { toast('You are not a member of this server.', 'error'); return; }
  801. }
  802. CS = server; CC = null; CDM = null; VIEW = 'channel';
  803. stopMsgPoll(); await clearMsgSubscription();
  804. document.querySelectorAll('.server-icon[data-id]').forEach(el => el.classList.toggle('active', el.dataset.id === server.id));
  805. document.getElementById('dm-icon').classList.remove('active');
  806. document.getElementById('sidebar-title').textContent = server.name;
  807. document.getElementById('ch-add-btn').style.display = 'flex';
  808. document.getElementById('copy-server-id-btn').style.display = 'flex';
  809. document.getElementById('server-settings-btn').style.display = CS.owner_id === CU.id ? 'flex' : 'none';
  810. document.getElementById('admin-members-btn').style.display = CS.owner_id === CU.id ? 'flex' : 'none';
  811. document.getElementById('start-video-call-btn').style.display = 'none';
  812. showWelcome();
  813. await loadChannels(server.id);
  814. checkTimeoutOrBan();
  815. await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
  816. }
  817. async function loadChannels(serverId) {
  818. const { data, error } = await sb.from('channels').select('*').eq('server_id', serverId).order('name', { ascending: true });
  819. if (error) { toast('Failed to load channels', 'error'); return; }
  820. const list = document.getElementById('sidebar-list');
  821. list.innerHTML = '<div class="ch-section"><i class="fas fa-hashtag"></i> Text Channels</div>';
  822. if (!data?.length) { list.innerHTML += '<div class="empty-state"><div>No channels yet</div></div>'; return; }
  823. data.forEach(ch => {
  824. const el = document.createElement('div');
  825. el.className = 'channel-item' + (CC?.id === ch.id ? ' active' : '');
  826. el.dataset.id = ch.id;
  827. el.innerHTML = `<i class="fas fa-hashtag"></i><span>${ch.name}</span>`;
  828. el.onclick = () => selectChannel(ch);
  829. list.appendChild(el);
  830. });
  831. }
  832. function openServerModal() {
  833. srvTab('create');
  834. document.getElementById('srv-name').value = '';
  835. document.getElementById('srv-id').value = '';
  836. document.getElementById('srv-icon-file').value = '';
  837. document.getElementById('server-modal').style.display = 'flex';
  838. }
  839. function srvTab(t) {
  840. srvModalTab = t;
  841. document.getElementById('srv-create').style.display = t === 'create' ? 'block' : 'none';
  842. document.getElementById('srv-join').style.display = t === 'join' ? 'block' : 'none';
  843. document.getElementById('mt-create').classList.toggle('active', t === 'create');
  844. document.getElementById('mt-join').classList.toggle('active', t === 'join');
  845. document.getElementById('srv-btn').textContent = t === 'create' ? 'Create' : 'Join';
  846. }
  847. async function uploadImageFile(file, folder) {
  848. const path = `${folder}/${CU.id}/${Date.now()}-${file.name}`;
  849. const { error } = await sb.storage.from(BUCKET).upload(path, file, { upsert: false });
  850. if (error) throw error;
  851. return sb.storage.from(BUCKET).getPublicUrl(path).data.publicUrl;
  852. }
  853. async function handleServerModal() {
  854. if (!CU) return;
  855. if (srvModalTab === 'create') {
  856. const name = document.getElementById('srv-name').value.trim();
  857. if (!name) return;
  858. let icon_url = null;
  859. const iconFile = document.getElementById('srv-icon-file').files[0];
  860. if (iconFile) { try { icon_url = await uploadImageFile(iconFile, 'server-icons'); } catch (e) { toast('Icon upload failed: ' + e.message, 'error'); return; } }
  861. const { data: srv, error } = await sb.from('servers').insert({ name, owner_id: CU.id, icon_url }).select('id,name,icon_url,owner_id').single();
  862. if (error || !srv) { toast('Error creating server', 'error'); return; }
  863. await sb.from('server_members').insert({ server_id: srv.id, user_id: CU.id, role: 'owner' });
  864. await sb.from('channels').insert({ server_id: srv.id, name: 'general' });
  865. closeModal('server-modal'); await loadServers(); await selectServer(srv, true);
  866. toast(`"${name}" created!`, 'success');
  867. setTimeout(() => { navigator.clipboard?.writeText(srv.id); toast('Server ID copied!', 'success'); }, 1200);
  868. } else {
  869. const input = document.getElementById('srv-id').value.trim();
  870. if (!input) return;
  871. if (input.length <= 10) {
  872. const { data: invite } = await sb.from('server_invites').select('server_id').eq('code', input.toUpperCase()).gt('expires_at', new Date().toISOString()).maybeSingle();
  873. if (invite) {
  874. const { data: srv } = await sb.from('servers').select('id,name,icon_url,owner_id').eq('id', invite.server_id).single();
  875. if (srv) {
  876. const { error: memErr } = await sb.from('server_members').insert({ server_id: srv.id, user_id: CU.id });
  877. if (memErr) { toast('Failed to join', 'error'); return; }
  878. closeModal('server-modal'); await loadServers(); await selectServer(srv, true);
  879. toast(`Joined "${srv.name}"!`, 'success'); return;
  880. }
  881. }
  882. }
  883. const cleanSid = input.replace(/\s+/g, '');
  884. const { data: srv } = await sb.from('servers').select('id,name,icon_url,owner_id').eq('id', cleanSid).maybeSingle();
  885. if (!srv) { toast('Server not found', 'error'); return; }
  886. const { data: ex } = await sb.from('server_members').select('server_id').eq('server_id', cleanSid).eq('user_id', CU.id).maybeSingle();
  887. if (ex) { toast('Already a member', 'error'); return; }
  888. const { error: memErr } = await sb.from('server_members').insert({ server_id: cleanSid, user_id: CU.id });
  889. if (memErr) { toast('Failed to join', 'error'); return; }
  890. closeModal('server-modal'); await loadServers(); await selectServer(srv, true);
  891. toast(`Joined "${srv.name}"!`, 'success');
  892. }
  893. }
  894.  
  895. // ==================== SERVER SETTINGS ====================
  896. function openServerSettingsModal() {
  897. if (!CS) return;
  898. document.getElementById('ss-name').value = CS.name || '';
  899. document.getElementById('ss-icon-file').value = '';
  900. document.getElementById('ss-icon-url').value = CS.icon_url || '';
  901. setPreview('server-settings-preview', CS.icon_url);
  902. document.getElementById('invite-code-display').value = '';
  903. document.getElementById('server-settings-modal').style.display = 'flex';
  904. }
  905. async function generateTempInvite() {
  906. if (!CS || CS.owner_id !== CU.id) return;
  907. const code = Math.random().toString(36).substring(2, 7).toUpperCase();
  908. const expires = new Date(Date.now() + 3600000).toISOString();
  909. await sb.from('server_invites').insert({ server_id: CS.id, code, expires_at: expires });
  910. document.getElementById('invite-code-display').value = code;
  911. toast('Invite code generated! Valid 1 hour.', 'success');
  912. navigator.clipboard?.writeText(code);
  913. }
  914. async function deleteServer() {
  915. if (!CS || CS.owner_id !== CU.id) return;
  916. if (!confirm('Delete this server permanently? All data will be lost.')) return;
  917. await sb.from('servers').delete().eq('id', CS.id);
  918. CS = null; CC = null;
  919. closeModal('server-settings-modal');
  920. await showDMHome(); await loadServers();
  921. toast('Server deleted', 'success');
  922. }
  923. async function saveServerSettings() {
  924. if (!CS) return;
  925. const name = document.getElementById('ss-name').value.trim();
  926. const file = document.getElementById('ss-icon-file').files[0];
  927. const url = document.getElementById('ss-icon-url').value.trim();
  928. let icon_url = url || CS.icon_url || null;
  929. if (file) { try { icon_url = await uploadImageFile(file, 'server-icons'); } catch (e) { toast('Upload failed', 'error'); return; } }
  930. 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();
  931. if (error) { toast('Failed to save', 'error'); return; }
  932. CS = data;
  933. document.getElementById('sidebar-title').textContent = CS.name;
  934. closeModal('server-settings-modal'); await loadServers();
  935. toast('Server updated!', 'success');
  936. }
  937. function copyCurrentServerId() { if (CS?.id) navigator.clipboard?.writeText(CS.id).then(() => toast('Server ID copied!', 'success')); }
  938.  
  939. // ==================== CHANNELS ====================
  940. function openChannelModal() {
  941. if (!CS) return;
  942. document.getElementById('ch-name').value = '';
  943. document.getElementById('ch-restricted').checked = false;
  944. document.getElementById('channel-modal').style.display = 'flex';
  945. }
  946. async function createChannel() {
  947. const name = document.getElementById('ch-name').value.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
  948. if (!name || !CS) return;
  949. const restricted = document.getElementById('ch-restricted').checked;
  950. const { error } = await sb.from('channels').insert({ server_id: CS.id, name, is_restricted: restricted });
  951. if (error) { toast('Failed to create channel', 'error'); return; }
  952. closeModal('channel-modal'); await loadChannels(CS.id);
  953. toast(`#${name} created`, 'success');
  954. }
  955. async function selectChannel(ch) {
  956. if (ch.is_restricted) {
  957. const { data: allowed } = await sb.from('channel_allowed_users').select('user_id').eq('channel_id', ch.id).eq('user_id', CU.id).maybeSingle();
  958. if (!allowed && CS.owner_id !== CU.id) { toast('You do not have access to this restricted channel', 'error'); return; }
  959. }
  960. CC = ch; CDM = null; VIEW = 'channel';
  961. await clearMsgSubscription();
  962. document.querySelectorAll('.channel-item').forEach(el => el.classList.toggle('active', el.dataset.id == ch.id));
  963. document.getElementById('chat-icon').className = 'fas fa-hashtag';
  964. document.getElementById('chat-name').textContent = ch.name;
  965. document.getElementById('msg-input').placeholder = `Message #${ch.name}`;
  966. document.getElementById('start-video-call-btn').style.display = 'none';
  967. showChatView(); await loadMessages(); await subscribeMsgs(); startMsgPoll();
  968. checkTimeoutOrBan();
  969. await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
  970. }
  971.  
  972. // ==================== DM / FRIENDS ====================
  973. async function showDMHome() {
  974. CS = null; CC = null; CDM = null; VIEW = 'home';
  975. stopMsgPoll(); await clearMsgSubscription();
  976. document.querySelectorAll('.server-icon[data-id]').forEach(el => el.classList.remove('active'));
  977. document.getElementById('dm-icon').classList.add('active');
  978. document.getElementById('sidebar-title').textContent = 'Direct Messages';
  979. document.getElementById('ch-add-btn').style.display = 'none';
  980. document.getElementById('copy-server-id-btn').style.display = 'none';
  981. document.getElementById('server-settings-btn').style.display = 'none';
  982. document.getElementById('admin-members-btn').style.display = 'none';
  983. document.getElementById('start-video-call-btn').style.display = 'none';
  984. showWelcome(); await loadDMSidebar();
  985. }
  986. async function loadDMSidebar() {
  987. if (!CU) return;
  988. 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');
  989. const list = document.getElementById('sidebar-list');
  990. list.innerHTML = '<div class="ch-section"><i class="fas fa-comment-dots"></i> Direct Messages <button class="sidebar-btn" onclick="openFriendsView()" style="display:inline-flex;width:18px;height:18px;font-size:13px"><i class="fas fa-user-friends"></i></button></div>';
  991. const friendIds = [...new Set((fships || []).map(f => f.requester_id === CU.id ? f.addressee_id : f.requester_id).filter(Boolean))];
  992. if (!friendIds.length) { list.innerHTML += '<div class="empty-state"><div class="ico"><i class="fas fa-user-plus"></i></div><div>Add friends to start DMing</div></div>'; return; }
  993. const { data: profiles } = await sb.from('profiles').select('id,username,avatar_url,last_seen').in('id', friendIds);
  994. const profileMap = new Map((profiles || []).map(p => [String(p.id), p]));
  995. const pinned = CU.pinned_friends || [];
  996. const sortedIds = [...friendIds].sort((a, b) => (pinned.includes(b) ? 1 : 0) - (pinned.includes(a) ? 1 : 0));
  997. sortedIds.forEach(fid => {
  998. const prof = profileMap.get(String(fid)); if (!prof) return;
  999. const isPinned = pinned.includes(fid);
  1000. const el = document.createElement('div');
  1001. el.className = 'dm-item' + (CDM?.id === fid ? ' active' : '');
  1002. el.onclick = () => openDM({ id: fid, username: prof.username, avatar_url: prof.avatar_url || null });
  1003. const av = document.createElement('div'); av.className = 'dm-avatar';
  1004. av.innerHTML = prof.avatar_url ? `<img src="${prof.avatar_url}" alt="avatar">` : (prof.username || '?')[0].toUpperCase();
  1005. const name = document.createElement('span'); name.className = 'dm-name'; name.textContent = prof.username;
  1006. const pinBtn = document.createElement('span'); pinBtn.className = 'pin-btn';
  1007. pinBtn.innerHTML = isPinned ? '<i class="fas fa-thumbtack"></i>' : '<i class="far fa-thumbtack"></i>';
  1008. pinBtn.title = isPinned ? 'Unpin' : 'Pin';
  1009. pinBtn.onclick = (e) => { e.stopPropagation(); togglePinFriend(fid); };
  1010. el.appendChild(av); el.appendChild(name); el.appendChild(pinBtn);
  1011. list.appendChild(el);
  1012. });
  1013. }
  1014. async function togglePinFriend(friendId) {
  1015. let pinned = CU.pinned_friends || [];
  1016. if (pinned.includes(friendId)) pinned = pinned.filter(id => id !== friendId);
  1017. else pinned.push(friendId);
  1018. await sb.from('profiles').update({ pinned_friends: pinned }).eq('id', CU.id);
  1019. CU.pinned_friends = pinned;
  1020. await loadDMSidebar();
  1021. }
  1022. function openFriendsView() {
  1023. VIEW = 'friends'; CDM = null;
  1024. stopMsgPoll(); clearMsgSubscription();
  1025. showFriendsPanel(); ftab('friends');
  1026. }
  1027. async function openDM(user) {
  1028. CDM = user; CS = null; CC = null; VIEW = 'dm';
  1029. await clearMsgSubscription();
  1030. document.querySelectorAll('.dm-item').forEach(el => el.classList.remove('active'));
  1031. document.getElementById('chat-icon').className = 'fas fa-user';
  1032. document.getElementById('chat-name').textContent = user.username;
  1033. document.getElementById('msg-input').placeholder = `Message @${user.username}`;
  1034. document.getElementById('start-video-call-btn').style.display = 'flex';
  1035. showChatView(); await loadMessages(); await subscribeMsgs(); startMsgPoll();
  1036. await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
  1037. if (!vcCallSubscription) setupVideoCallSubscriptions();
  1038. }
  1039.  
  1040. // ==================== MESSAGES ====================
  1041. async function loadMessages() {
  1042. if (!CU) return;
  1043. let data = [];
  1044. if (VIEW === 'channel' && CC) {
  1045. const { data: rows } = await sb.from('messages').select('*').eq('channel_id', CC.id).order('created_at', { ascending: true }).limit(100);
  1046. data = rows || [];
  1047. } else if (VIEW === 'dm' && CDM) {
  1048. 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);
  1049. data = (rows || []).filter(m => (m.sender_id === CU.id && m.receiver_id === CDM.id) || (m.sender_id === CDM.id && m.receiver_id === CU.id));
  1050. }
  1051. const authorIds = [...new Set(data.map(getCurrentAuthorId).filter(Boolean))];
  1052. let profileMap = new Map();
  1053. if (authorIds.length) {
  1054. const { data: profiles } = await sb.from('profiles').select('id,username,avatar_url').in('id', authorIds);
  1055. profileMap = new Map((profiles || []).map(p => [String(p.id), p]));
  1056. }
  1057. data = data.map(m => {
  1058. const aid = getCurrentAuthorId(m); const pr = profileMap.get(String(aid));
  1059. return { ...m, _username: pr?.username || 'Unknown', _avatar_url: pr?.avatar_url || null };
  1060. });
  1061. const wrap = document.getElementById('messages-wrap');
  1062. wrap.innerHTML = ''; renderedMsgIds.clear();
  1063. if (!data.length) wrap.innerHTML = `<div class="empty-state"><div class="ico"><i class="fas fa-comments"></i></div><div>No messages yet — say something!</div></div>`;
  1064. else data.forEach(m => { renderedMsgIds.add(m.id); wrap.appendChild(buildMsg(m)); });
  1065. wrap.scrollTop = wrap.scrollHeight;
  1066. }
  1067. function buildMsg(m) {
  1068. const uname = m._username || 'Unknown';
  1069. const time = m.created_at ? new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
  1070. const g = document.createElement('div'); g.className = 'msg-group'; g.dataset.id = m.id || '';
  1071. const av = document.createElement('div'); av.className = 'msg-av';
  1072. av.innerHTML = m._avatar_url ? `<img src="${m._avatar_url}" alt="avatar">` : (uname || '?')[0].toUpperCase();
  1073. const body = document.createElement('div'); body.className = 'msg-body';
  1074. const meta = document.createElement('div'); meta.className = 'msg-meta';
  1075. const author = document.createElement('span'); author.className = 'msg-author'; author.textContent = uname;
  1076. const tm = document.createElement('span'); tm.className = 'msg-time'; tm.textContent = time;
  1077. meta.appendChild(author); meta.appendChild(tm); body.appendChild(meta);
  1078. const t = m.msg_type || 'text';
  1079. if (t === 'text') {
  1080. const d = document.createElement('div'); d.className = 'msg-text'; d.textContent = m.content || ''; body.appendChild(d);
  1081. } else if (t === 'image' || t === 'gif') {
  1082. const wrapper = document.createElement('div'); wrapper.className = 'msg-img-wrapper';
  1083. const img = document.createElement('img'); img.className = 'msg-img'; img.src = m.file_url; img.alt = m.file_name || 'image';
  1084. img.onclick = () => openLightbox(m.file_url);
  1085. wrapper.appendChild(img);
  1086. if (t === 'gif') { const badge = document.createElement('span'); badge.className = 'gif-badge'; badge.textContent = 'GIF'; wrapper.appendChild(badge); }
  1087. body.appendChild(wrapper);
  1088. } else if (t === 'file') {
  1089. body.appendChild(buildFileEl(m));
  1090. }
  1091. 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); }
  1092. if (hasCurrentOwnership(m)) {
  1093. const del = document.createElement('button'); del.className = 'msg-delete'; del.title = 'Delete message'; del.innerHTML = '<i class="fas fa-trash-alt"></i>';
  1094. del.onclick = () => deleteMyMessage(m); g.appendChild(del);
  1095. }
  1096. g.appendChild(av); g.appendChild(body);
  1097. return g;
  1098. }
  1099. 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' };
  1100. function buildFileEl(m) {
  1101. const ext = (m.file_name || '').split('.').pop().toLowerCase();
  1102. const mime = m.file_mime || '';
  1103. const wrap = document.createElement('div'); wrap.className = 'msg-file';
  1104. const iconEl = document.createElement('div'); iconEl.className = 'msg-file-icon'; iconEl.innerHTML = `<i class="fas ${FILE_ICONS[ext] || 'fa-file'}"></i>`;
  1105. const info = document.createElement('div'); info.className = 'msg-file-info';
  1106. const name = document.createElement('div'); name.className = 'msg-file-name'; name.title = m.file_name || ''; name.textContent = m.file_name || 'file';
  1107. const type = document.createElement('div'); type.className = 'msg-file-type'; type.textContent = mime || ext.toUpperCase();
  1108. info.appendChild(name); info.appendChild(type);
  1109. let btn;
  1110. if (ext === 'html' || ext === 'htm' || mime === 'text/html') {
  1111. btn = document.createElement('button'); btn.className = 'fa-btn fa-demo'; btn.innerHTML = '<i class="fas fa-play"></i> Demo';
  1112. btn.onclick = () => openHTMLDemo(m.file_url, m.file_name);
  1113. } else if (ext === 'csv' || mime === 'text/csv') {
  1114. btn = document.createElement('button'); btn.className = 'fa-btn fa-preview'; btn.innerHTML = '<i class="fas fa-table"></i> Preview';
  1115. btn.onclick = () => openCSVPreview(m.file_url, m.file_name);
  1116. } else {
  1117. btn = document.createElement('a'); btn.className = 'fa-btn fa-dl'; btn.href = m.file_url; btn.target = '_blank'; btn.download = m.file_name; btn.innerHTML = '<i class="fas fa-download"></i> Download';
  1118. }
  1119. wrap.appendChild(iconEl); wrap.appendChild(info); wrap.appendChild(btn);
  1120. return wrap;
  1121. }
  1122. async function subscribeMsgs() {
  1123. await clearMsgSubscription();
  1124. if (!CU) return;
  1125. if (VIEW === 'channel' && CC) {
  1126. msgSub = sb.channel('ch-' + CC.id)
  1127. .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `channel_id=eq.${CC.id}` }, async p => {
  1128. const m = p.new; if (m.id && renderedMsgIds.has(m.id)) return;
  1129. const authorId = getCurrentAuthorId(m);
  1130. const { data: pr } = await sb.from('profiles').select('id,username,avatar_url').eq('id', authorId).maybeSingle();
  1131. m._username = pr?.username || 'Unknown'; m._avatar_url = pr?.avatar_url || null;
  1132. appendMsg(m);
  1133. }).subscribe();
  1134. } else if (VIEW === 'dm' && CDM) {
  1135. msgSub = sb.channel('dm-' + CU.id + '-' + CDM.id)
  1136. .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'direct_messages' }, async p => {
  1137. const m = p.new;
  1138. if (!((m.sender_id === CU.id && m.receiver_id === CDM.id) || (m.sender_id === CDM.id && m.receiver_id === CU.id))) return;
  1139. if (m.id && renderedMsgIds.has(m.id)) return;
  1140. const { data: pr } = await sb.from('profiles').select('id,username,avatar_url').eq('id', m.sender_id).maybeSingle();
  1141. m._username = pr?.username || 'Unknown'; m._avatar_url = pr?.avatar_url || null;
  1142. appendMsg(m);
  1143. }).subscribe();
  1144. }
  1145. }
  1146. function appendMsg(m) {
  1147. const wrap = document.getElementById('messages-wrap');
  1148. const empty = wrap.querySelector('.empty-state'); if (empty) empty.remove();
  1149. if (m.id && renderedMsgIds.has(m.id)) return;
  1150. if (m.id) renderedMsgIds.add(m.id);
  1151. wrap.appendChild(buildMsg(m));
  1152. wrap.scrollTop = wrap.scrollHeight;
  1153. }
  1154. async function sendMessage() {
  1155. if (!CU) return;
  1156. const sendBtn = document.getElementById('send-btn'); if (sendBtn.disabled) return;
  1157. if (pendingGifUrl) { await sendGifResolved(pendingGifUrl, document.getElementById('gif-caption').value.trim()); return; }
  1158. if (pendingFile) { await uploadAndSend(); return; }
  1159. const input = document.getElementById('msg-input'); const content = input.value.trim(); if (!content) return;
  1160. sendBtn.disabled = true; input.value = ''; input.style.height = '';
  1161. try {
  1162. if (VIEW === 'channel' && CC) {
  1163. const { error } = await sb.from('messages').insert({ channel_id: CC.id, user_id: CU.id, content, msg_type: 'text' });
  1164. if (error) { toast('Failed to send', 'error'); input.value = content; return; }
  1165. await loadMessages();
  1166. } else if (VIEW === 'dm' && CDM) {
  1167. const { error } = await sb.from('direct_messages').insert({ sender_id: CU.id, receiver_id: CDM.id, content, msg_type: 'text' });
  1168. if (error) { toast('Failed to send', 'error'); input.value = content; return; }
  1169. await loadMessages();
  1170. }
  1171. await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
  1172. } finally { sendBtn.disabled = false; }
  1173. }
  1174. function handleMsgKey(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }
  1175. function autoGrow(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 110) + 'px'; }
  1176. function onFileChosen(e) {
  1177. const f = e.target.files[0]; if (!f) return;
  1178. pendingFile = f; pendingGifUrl = null;
  1179. document.getElementById('up-name').textContent = `📎 ${f.name} (${fmtBytes(f.size)})`;
  1180. document.getElementById('upload-prev').style.display = 'flex';
  1181. document.getElementById('msg-input').placeholder = 'Add a caption (optional)...';
  1182. e.target.value = '';
  1183. }
  1184. function cancelUpload() {
  1185. pendingFile = null; pendingGifUrl = null;
  1186. document.getElementById('upload-prev').style.display = 'none';
  1187. document.getElementById('msg-input').placeholder = VIEW === 'dm' ? `Message @${CDM?.username || ''}` : `Message #${CC?.name || ''}`;
  1188. }
  1189. async function uploadAndSend() {
  1190. if (!pendingFile || !CU) return;
  1191. const f = pendingFile; const caption = document.getElementById('msg-input').value.trim();
  1192. const sendBtn = document.getElementById('send-btn'); sendBtn.disabled = true;
  1193. const path = `chat/${CU.id}/${Date.now()}-${f.name}`;
  1194. const { error } = await sb.storage.from(BUCKET).upload(path, f, { upsert: false });
  1195. if (error) { toast('Upload failed', 'error'); sendBtn.disabled = false; return; }
  1196. const { data: publicData } = sb.storage.from(BUCKET).getPublicUrl(path);
  1197. const publicUrl = publicData.publicUrl;
  1198. const isImage = f.type.startsWith('image/');
  1199. 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 };
  1200. let err = null;
  1201. if (VIEW === 'channel' && CC) ({ error: err } = await sb.from('messages').insert({ ...row, channel_id: CC.id }));
  1202. else if (VIEW === 'dm' && CDM) ({ error: err } = await sb.from('direct_messages').insert({ ...row, sender_id: CU.id, receiver_id: CDM.id }));
  1203. if (err) { toast('Failed to send file', 'error'); sendBtn.disabled = false; return; }
  1204. cancelUpload(); document.getElementById('msg-input').value = ''; sendBtn.disabled = false;
  1205. await loadMessages(); toast('File sent!', 'success');
  1206. await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
  1207. }
  1208. async function deleteMyMessage(m) {
  1209. if (!hasCurrentOwnership(m)) return;
  1210. if (!confirm('Delete this message?')) return;
  1211. const table = VIEW === 'dm' ? 'direct_messages' : 'messages';
  1212. const { error } = await sb.from(table).delete().eq('id', m.id);
  1213. if (error) { toast('Failed to delete', 'error'); return; }
  1214. renderedMsgIds.delete(m.id); await loadMessages();
  1215. }
  1216.  
  1217. // ==================== GIF ====================
  1218. function openGifModal() {
  1219. if (!VIEW || (!CC && !CDM)) { toast('Open a channel or DM first', 'error'); return; }
  1220. document.getElementById('gif-url').value = '';
  1221. document.getElementById('gif-caption').value = '';
  1222. pendingGifUrl = null;
  1223. document.getElementById('gif-modal').style.display = 'flex';
  1224. }
  1225. async function resolveGifUrl(input) {
  1226. const raw = input.trim(); if (!raw) return null;
  1227. if (/\.(gif)(\?.*)?$/i.test(raw)) return raw;
  1228. if (/^https?:\/\//i.test(raw) && /tenor\.com|giphy\.com/i.test(raw)) {
  1229. const endpoints = [
  1230. `https://giphy.com/services/oembed?url=${encodeURIComponent(raw)}`,
  1231. `https://tenor.com/oembed?url=${encodeURIComponent(raw)}`
  1232. ];
  1233. 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 (_) {} }
  1234. }
  1235. if (/^https?:\/\//i.test(raw)) return raw;
  1236. return null;
  1237. }
  1238. async function sendGifFromModal() {
  1239. const raw = document.getElementById('gif-url').value.trim(); const caption = document.getElementById('gif-caption').value.trim();
  1240. const resolved = await resolveGifUrl(raw); if (!resolved) { toast('Could not resolve GIF link', 'error'); return; }
  1241. closeModal('gif-modal'); await sendGifResolved(resolved, caption);
  1242. }
  1243. async function sendGifResolved(url, caption) {
  1244. if (!CU) return;
  1245. const row = { user_id: CU.id, content: caption || null, msg_type: 'gif', file_url: url, file_name: 'gif', file_mime: 'image/gif' };
  1246. let err = null;
  1247. if (VIEW === 'channel' && CC) ({ error: err } = await sb.from('messages').insert({ ...row, channel_id: CC.id }));
  1248. else if (VIEW === 'dm' && CDM) ({ error: err } = await sb.from('direct_messages').insert({ ...row, sender_id: CU.id, receiver_id: CDM.id }));
  1249. if (err) { toast('Failed to send GIF', 'error'); return; }
  1250. document.getElementById('gif-url').value = ''; document.getElementById('gif-caption').value = ''; pendingGifUrl = null;
  1251. await loadMessages(); toast('GIF sent!', 'success');
  1252. await sb.from('profiles').update({ last_seen: new Date().toISOString() }).eq('id', CU.id);
  1253. }
  1254.  
  1255. // ==================== FRIENDS PANEL ====================
  1256. async function ftab(t) {
  1257. activeFTab = t;
  1258. document.querySelectorAll('.ftab').forEach((el, i) => el.classList.toggle('active', ['friends', 'pending', 'add'][i] === t));
  1259. await renderFriends();
  1260. }
  1261. function isOnline(lastSeen) { if (!lastSeen) return false; return new Date(lastSeen) > new Date(Date.now() - 5 * 60 * 1000); }
  1262. async function renderFriends() {
  1263. const content = document.getElementById('friends-content'); content.innerHTML = '';
  1264. if (activeFTab === 'add') {
  1265. 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>`;
  1266. return;
  1267. }
  1268. 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');
  1269. if (!fships) return;
  1270. const allIds = [...new Set(fships.flatMap(f => [f.requester_id, f.addressee_id]).filter(Boolean))];
  1271. let profiles = []; if (allIds.length) { const { data: fetched } = await sb.from('profiles').select('id,username,avatar_url,last_seen').in('id', allIds); profiles = fetched || []; }
  1272. const findProfile = id => profiles.find(p => p.id == id);
  1273. 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; }
  1274. if (activeFTab === 'friends') {
  1275. const accepted = fships.filter(f => f.status === 'accepted');
  1276. if (!accepted.length) { content.innerHTML = `<div class="empty-state"><div class="ico"><i class="fas fa-user-friends"></i></div><div>No friends yet</div></div>`; return; }
  1277. content.innerHTML = `<div class="f-section"><i class="fas fa-user-friends"></i> Friends — ${accepted.length}</div>`;
  1278. for (const f of accepted) {
  1279. const fid = f.requester_id == CU.id ? f.addressee_id : f.requester_id;
  1280. const pr = findProfile(fid); if (!pr) continue;
  1281. const online = isOnline(pr.last_seen);
  1282. const card = document.createElement('div'); card.className = 'friend-card';
  1283. const info = document.createElement('div');
  1284. const uname = document.createElement('div'); uname.className = 'friend-username'; uname.textContent = pr.username;
  1285. const label = document.createElement('div'); label.className = 'friend-label';
  1286. label.innerHTML = online ? '<i class="fas fa-circle" style="color:var(--green);font-size:8px"></i> online' : '<i class="fas fa-circle" style="color:var(--text-muted);font-size:8px"></i> offline';
  1287. info.appendChild(uname); info.appendChild(label);
  1288. const actions = document.createElement('div'); actions.className = 'friend-actions';
  1289. 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>`;
  1290. actions.children[0].onclick = () => openDM({ id: fid, username: pr.username, avatar_url: pr.avatar_url || null });
  1291. actions.children[1].onclick = () => inviteToServer(fid);
  1292. actions.children[2].onclick = () => blockUser(fid);
  1293. card.appendChild(makeAvatar(pr)); card.appendChild(info); card.appendChild(actions); content.appendChild(card);
  1294. }
  1295. }
  1296. if (activeFTab === 'pending') {
  1297. const pending = fships.filter(f => f.status === 'pending');
  1298. if (!pending.length) { content.innerHTML = `<div class="empty-state"><div class="ico"><i class="fas fa-clock"></i></div><div>No pending requests</div></div>`; return; }
  1299. const incoming = pending.filter(f => f.addressee_id == CU.id);
  1300. const outgoing = pending.filter(f => f.requester_id == CU.id);
  1301. if (incoming.length) {
  1302. const sec = document.createElement('div'); sec.className = 'f-section'; sec.innerHTML = `<i class="fas fa-arrow-down"></i> Incoming — ${incoming.length}`; content.appendChild(sec);
  1303. for (const f of incoming) {
  1304. const pr = findProfile(f.requester_id); if (!pr) continue;
  1305. const card = document.createElement('div'); card.className = 'friend-card';
  1306. const info = document.createElement('div'); info.innerHTML = `<div class="friend-username">${pr.username}</div><div class="friend-label">Wants to be friends</div>`;
  1307. const actions = document.createElement('div'); actions.className = 'friend-actions';
  1308. actions.innerHTML = `<button class="ibt acc" title="Accept"><i class="fas fa-check"></i></button><button class="ibt dec" title="Decline"><i class="fas fa-times"></i></button>`;
  1309. actions.children[0].onclick = () => respondFR(f.id, 'accepted');
  1310. actions.children[1].onclick = () => respondFR(f.id, 'declined');
  1311. card.appendChild(makeAvatar(pr)); card.appendChild(info); card.appendChild(actions); content.appendChild(card);
  1312. }
  1313. }
  1314. if (outgoing.length) {
  1315. const sec = document.createElement('div'); sec.className = 'f-section'; sec.style.marginTop = '20px'; sec.innerHTML = `<i class="fas fa-arrow-up"></i> Outgoing — ${outgoing.length}`; content.appendChild(sec);
  1316. for (const f of outgoing) {
  1317. const pr = findProfile(f.addressee_id); if (!pr) continue;
  1318. const card = document.createElement('div'); card.className = 'friend-card';
  1319. const info = document.createElement('div'); info.innerHTML = `<div class="friend-username">${pr.username}</div><div class="friend-label">Request pending…</div>`;
  1320. const actions = document.createElement('div'); actions.className = 'friend-actions';
  1321. actions.innerHTML = `<button class="ibt dec" title="Cancel"><i class="fas fa-times"></i></button>`;
  1322. actions.children[0].onclick = () => cancelFR(f.id);
  1323. card.appendChild(makeAvatar(pr)); card.appendChild(info); card.appendChild(actions); content.appendChild(card);
  1324. }
  1325. }
  1326. }
  1327. }
  1328. async function sendFriendReq() {
  1329. const inp = document.getElementById('af-input'); const msgEl = document.getElementById('add-friend-msg');
  1330. const username = inp?.value.trim().toLowerCase(); if (!username) return;
  1331. if (username === CU.username) { if (msgEl) { msgEl.style.color = 'var(--red)'; msgEl.textContent = "That's you!"; } return; }
  1332. const { data: target } = await sb.from('profiles').select('id').eq('username', username).maybeSingle();
  1333. if (!target) { if (msgEl) { msgEl.style.color = 'var(--red)'; msgEl.textContent = 'User not found.'; } return; }
  1334. 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();
  1335. if (ex) { if (msgEl) { msgEl.style.color = 'var(--amber)'; msgEl.textContent = ex.status === 'accepted' ? 'Already friends!' : 'Request already sent.'; } return; }
  1336. const { error } = await sb.from('friendships').insert({ requester_id: CU.id, addressee_id: target.id });
  1337. if (error) { if (msgEl) { msgEl.style.color = 'var(--red)'; msgEl.textContent = 'Failed to send request.'; } return; }
  1338. if (msgEl) { msgEl.style.color = 'var(--green)'; msgEl.textContent = `Request sent to ${username}!`; }
  1339. if (inp) inp.value = ''; await updatePendingBadge();
  1340. }
  1341. async function respondFR(id, status) {
  1342. if (status === 'accepted') {
  1343. const { error } = await sb.from('friendships').update({ status }).eq('id', id);
  1344. if (error) { toast('Failed to accept', 'error'); return; }
  1345. toast('Friend added!', 'success'); await loadDMSidebar();
  1346. } else {
  1347. const { error } = await sb.from('friendships').delete().eq('id', id);
  1348. if (error) { toast('Failed to decline', 'error'); return; }
  1349. toast('Request declined.', '');
  1350. }
  1351. await renderFriends(); await updatePendingBadge();
  1352. }
  1353. async function cancelFR(id) { await sb.from('friendships').delete().eq('id', id); await renderFriends(); await updatePendingBadge(); }
  1354. async function updatePendingBadge() {
  1355. if (!CU) return;
  1356. const { data } = await sb.from('friendships').select('id').eq('addressee_id', CU.id).eq('status', 'pending');
  1357. const badge = document.getElementById('pend-badge'); if (!badge) return;
  1358. if (data?.length) { badge.style.display = 'inline'; badge.textContent = data.length; } else badge.style.display = 'none';
  1359. }
  1360. function startPendingPoll() { updatePendingBadge(); pBadgeTimer = setInterval(updatePendingBadge, 12000); }
  1361. async function blockUser(userId) {
  1362. let blocked = CU.blocked_users || []; if (blocked.includes(parseInt(userId))) return;
  1363. blocked.push(parseInt(userId));
  1364. await sb.from('profiles').update({ blocked_users: blocked }).eq('id', CU.id);
  1365. CU.blocked_users = blocked; toast('User blocked', 'success');
  1366. await renderFriends();
  1367. }
  1368. async function inviteToServer(friendId) {
  1369. if (!CU) return;
  1370. const { data: profile } = await sb.from('profiles').select('username').eq('id', friendId).single();
  1371. pendingInviteFriendId = friendId;
  1372. pendingInviteFriendName = profile?.username || 'this user';
  1373. document.getElementById('invite-friend-name').textContent = pendingInviteFriendName;
  1374. const { data: memberships } = await sb.from('server_members').select('server_id,role').eq('user_id', CU.id).in('role', ['owner', 'admin']);
  1375. if (!memberships?.length) { toast('You are not an owner/admin in any server', 'error'); return; }
  1376. const serverIds = memberships.map(m => m.server_id);
  1377. const { data: servers } = await sb.from('servers').select('id,name').in('id', serverIds);
  1378. const list = document.getElementById('invite-server-list'); list.innerHTML = '';
  1379. servers.forEach(srv => {
  1380. const div = document.createElement('div'); div.className = 'friend-card'; div.style.cursor = 'pointer';
  1381. div.innerHTML = `<span class="friend-username"><i class="fas fa-server"></i> ${srv.name}</span><button class="ibt msg"><i class="fas fa-plus"></i></button>`;
  1382. div.onclick = () => { closeModal('invite-server-modal'); confirmInviteToServer(srv.id, pendingInviteFriendId); };
  1383. list.appendChild(div);
  1384. });
  1385. document.getElementById('invite-server-modal').style.display = 'flex';
  1386. }
  1387. async function confirmInviteToServer(serverId, friendId) {
  1388. const { data: existing } = await sb.from('server_members').select('user_id').eq('server_id', serverId).eq('user_id', friendId).maybeSingle();
  1389. if (existing) { toast('User already in this server', 'error'); return; }
  1390. await sb.from('server_members').insert({ server_id: serverId, user_id: friendId });
  1391. toast(`Invited ${pendingInviteFriendName} to server!`, 'success');
  1392. pendingInviteFriendId = null;
  1393. }
  1394.  
  1395. // ==================== SETTINGS ====================
  1396. function openSettingsModal(initialTab = 'profile') {
  1397. if (!CU) return;
  1398. document.getElementById('settings-username').value = CU.username;
  1399. document.getElementById('settings-avatar-url').value = CU.avatar_url || '';
  1400. document.getElementById('settings-avatar-file').value = '';
  1401. setPreview('settings-profile-preview', CU.avatar_url);
  1402. document.getElementById('settings-theme').value = localStorage.getItem('nexchat_theme') || 'dark';
  1403. document.getElementById('settings-font').value = localStorage.getItem('nexchat_msg_font') || 'JetBrains Mono';
  1404. document.querySelectorAll('#settings-modal .modal-tab').forEach(tab => {
  1405. tab.classList.toggle('active', tab.dataset.stab === initialTab);
  1406. document.getElementById(`stab-${tab.dataset.stab}`).style.display = tab.dataset.stab === initialTab ? 'block' : 'none';
  1407. });
  1408. if (initialTab === 'blocked') renderBlockedList();
  1409. document.getElementById('settings-modal').style.display = 'flex';
  1410. }
  1411. function setPreview(prefix, url) {
  1412. const img = document.getElementById(prefix + '-img'); const txt = document.getElementById(prefix + '-txt');
  1413. if (url) { img.style.display = 'block'; img.src = url; txt.textContent = url; }
  1414. else { img.style.display = 'none'; img.src = ''; txt.textContent = 'No avatar selected'; }
  1415. }
  1416. async function saveProfileSettings() {
  1417. if (!CU) return;
  1418. const file = document.getElementById('settings-avatar-file').files[0];
  1419. const url = document.getElementById('settings-avatar-url').value.trim();
  1420. let avatar_url = url || CU.avatar_url;
  1421. if (file) { try { avatar_url = await uploadImageFile(file, 'avatars'); } catch (e) { toast('Upload failed', 'error'); return; } }
  1422. const theme = document.getElementById('settings-theme').value;
  1423. const font = document.getElementById('settings-font').value;
  1424. applyTheme(theme); applyMessageFont(font);
  1425. const { data, error } = await sb.from('profiles').update({ avatar_url }).eq('id', CU.id).select().single();
  1426. if (error) { toast('Failed to save profile', 'error'); return; }
  1427. CU.avatar_url = data.avatar_url;
  1428. document.getElementById('uav').innerHTML = CU.avatar_url ? `<img src="${CU.avatar_url}" alt="avatar">` : CU.username[0].toUpperCase();
  1429. closeModal('settings-modal'); toast('Profile updated!', 'success');
  1430. await refreshAllVisibleAvatars();
  1431. }
  1432. async function changePassword() {
  1433. const oldPass = document.getElementById('old-password').value;
  1434. const newPass = document.getElementById('new-password').value;
  1435. const confirm = document.getElementById('confirm-password').value;
  1436. const msgEl = document.getElementById('password-change-msg');
  1437. if (!oldPass || !newPass || !confirm) { msgEl.textContent = 'All fields required'; return; }
  1438. if (newPass !== confirm) { msgEl.textContent = 'New passwords do not match'; return; }
  1439. const { data: user } = await sb.from('profiles').select('password').eq('id', CU.id).single();
  1440. if (user.password !== oldPass) { msgEl.textContent = 'Current password is incorrect'; return; }
  1441. const { error } = await sb.from('profiles').update({ password: newPass }).eq('id', CU.id);
  1442. if (error) { msgEl.textContent = 'Failed to update password'; return; }
  1443. msgEl.style.color = 'var(--green)'; msgEl.textContent = 'Password changed successfully!';
  1444. document.getElementById('old-password').value = document.getElementById('new-password').value = document.getElementById('confirm-password').value = '';
  1445. }
  1446. async function renderBlockedList() {
  1447. const container = document.getElementById('blocked-list');
  1448. const blocked = CU.blocked_users || [];
  1449. if (!blocked.length) { container.innerHTML = '<div class="empty-state"><i class="fas fa-ban"></i> No blocked users</div>'; return; }
  1450. const { data: profiles } = await sb.from('profiles').select('id,username').in('id', blocked);
  1451. container.innerHTML = '';
  1452. (profiles || []).forEach(p => {
  1453. const div = document.createElement('div'); div.className = 'friend-card';
  1454. div.innerHTML = `<span class="friend-username">${p.username}</span>`;
  1455. const btn = document.createElement('button'); btn.className = 'ibt dec'; btn.innerHTML = '<i class="fas fa-undo-alt"></i> Unblock';
  1456. btn.onclick = async () => {
  1457. let newBlocked = blocked.filter(id => id !== p.id);
  1458. await sb.from('profiles').update({ blocked_users: newBlocked }).eq('id', CU.id);
  1459. CU.blocked_users = newBlocked; renderBlockedList(); toast(`${p.username} unblocked`, 'success');
  1460. };
  1461. div.appendChild(btn); container.appendChild(div);
  1462. });
  1463. }
  1464. async function deleteAccount() {
  1465. const password = document.getElementById('delete-password').value;
  1466. const { data: user } = await sb.from('profiles').select('password').eq('id', CU.id).single();
  1467. if (user.password !== password) { toast('Incorrect password', 'error'); return; }
  1468. await sb.from('profiles').delete().eq('id', CU.id);
  1469. doLogout(); toast('Account deleted', 'success');
  1470. }
  1471. function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('nexchat_theme', theme); }
  1472. function applyMessageFont(font) { document.documentElement.style.setProperty('--mono', `'${font}', monospace`); localStorage.setItem('nexchat_msg_font', font); }
  1473.  
  1474. // ==================== ADMIN TIMEOUT / BAN ====================
  1475. async function openAdminMembersModal() {
  1476. if (!CS || CS.owner_id !== CU.id) return;
  1477. const { data: members } = await sb.from('server_members').select('user_id').eq('server_id', CS.id);
  1478. const userIds = members.map(m => m.user_id);
  1479. const { data: profiles } = await sb.from('profiles').select('id,username').in('id', userIds);
  1480. const list = document.getElementById('admin-members-list'); list.innerHTML = '';
  1481. profiles.forEach(p => {
  1482. if (p.id === CU.id) return;
  1483. const div = document.createElement('div'); div.className = 'friend-card';
  1484. div.innerHTML = `<span class="friend-username">${p.username}</span>`;
  1485. const actions = document.createElement('div'); actions.className = 'friend-actions';
  1486. 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>`;
  1487. actions.children[0].onclick = () => kickUser(p.id);
  1488. actions.children[1].onclick = () => timeoutUser(p.id, 5);
  1489. actions.children[2].onclick = () => banUser(p.id);
  1490. div.appendChild(actions); list.appendChild(div);
  1491. });
  1492. document.getElementById('admin-members-modal').style.display = 'flex';
  1493. }
  1494. 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'); }
  1495. async function timeoutUser(userId, minutes) {
  1496. const ip = await fetch('https://api.ipify.org?format=json').then(r => r.json()).then(d => d.ip).catch(() => null);
  1497. const until = new Date(Date.now() + minutes * 60000).toISOString();
  1498. await sb.from('timeouts').insert({ server_id: CS.id, user_id: userId, until, ip });
  1499. toast(`User timed out for ${minutes} min`, 'success');
  1500. if (userId === CU.id) checkTimeoutOrBan();
  1501. closeModal('admin-members-modal');
  1502. }
  1503. async function banUser(userId) {
  1504. const ip = await fetch('https://api.ipify.org?format=json').then(r => r.json()).then(d => d.ip).catch(() => null);
  1505. await sb.from('bans').insert({ server_id: CS.id, user_id: userId, ip });
  1506. await sb.from('server_members').delete().match({ server_id: CS.id, user_id: userId });
  1507. toast('User banned', 'success');
  1508. if (userId === CU.id) showDMHome();
  1509. closeModal('admin-members-modal');
  1510. }
  1511. async function checkTimeoutOrBan() {
  1512. if (!CS || !CU) return;
  1513. const { data: ban } = await sb.from('bans').select('id').eq('server_id', CS.id).eq('user_id', CU.id).maybeSingle();
  1514. if (ban) { toast('You are banned from this server', 'error'); showDMHome(); return; }
  1515. 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();
  1516. const overlay = document.getElementById('timeout-overlay');
  1517. const timerEl = document.getElementById('timeout-timer');
  1518. if (timeoutInterval) clearInterval(timeoutInterval);
  1519. if (timeout) {
  1520. overlay.style.display = 'flex';
  1521. const updateTimer = () => {
  1522. const remaining = new Date(timeout.until) - new Date();
  1523. if (remaining <= 0) { overlay.style.display = 'none'; clearInterval(timeoutInterval); timeoutInterval = null; return; }
  1524. const mins = Math.floor(remaining / 60000); const secs = Math.floor((remaining % 60000) / 1000);
  1525. timerEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
  1526. };
  1527. updateTimer(); timeoutInterval = setInterval(updateTimer, 1000);
  1528. } else {
  1529. overlay.style.display = 'none';
  1530. }
  1531. }
  1532.  
  1533. // ==================== VIDEO CALL ====================
  1534. // DUAL HANGUP APPROACH:
  1535. // 1. GAS signalHangup — polled every 1.5s by BOTH sides from the moment a call starts
  1536. // 2. Supabase video_calls.status = 'ended' — watched via realtime on BOTH sides
  1537. // Either signal triggers vcHandleRemoteHangup() on the non-initiating side.
  1538.  
  1539. function setupVideoCallSubscriptions() {
  1540. if (!CU) return;
  1541. if (vcCallSubscription) sb.removeChannel(vcCallSubscription).catch(() => {});
  1542. vcCallSubscription = sb.channel('vc-incoming-' + CU.id)
  1543. .on('postgres_changes',
  1544. { event: 'INSERT', schema: 'public', table: 'video_calls', filter: `callee_id=eq.${CU.id}` },
  1545. payload => {
  1546. const call = payload.new;
  1547. if (new Date() - new Date(call.created_at) > 60000) return;
  1548. if (call.status === 'pending' && (!incomingCallData || incomingCallData.id !== call.id)) {
  1549. showIncomingCallPopup(call);
  1550. }
  1551. }
  1552. )
  1553. .on('postgres_changes',
  1554. { event: 'UPDATE', schema: 'public', table: 'video_calls', filter: `callee_id=eq.${CU.id}` },
  1555. payload => {
  1556. const call = payload.new;
  1557. if (call.status === 'cancelled' && incomingCallData?.id === call.id) {
  1558. hideIncomingPopup(); toast('Call cancelled', '');
  1559. }
  1560. // *** FIX: callee receives 'ended' via Supabase realtime ***
  1561. if (call.status === 'ended' && vcCurrentCallId === call.id && vcCallActive) {
  1562. vcHandleRemoteHangup();
  1563. }
  1564. }
  1565. )
  1566. .subscribe();
  1567. }
  1568.  
  1569. // Watch for the caller's call row being updated to 'ended' — covers callee hanging up
  1570. function vcSubscribeCallStatus(callId) {
  1571. if (vcStatusChannel) { sb.removeChannel(vcStatusChannel).catch(() => {}); vcStatusChannel = null; }
  1572. vcStatusChannel = sb.channel('vc-status-' + callId)
  1573. .on('postgres_changes',
  1574. { event: 'UPDATE', schema: 'public', table: 'video_calls', filter: `id=eq.${callId}` },
  1575. payload => {
  1576. const s = payload.new.status;
  1577. // Declined / missed — call never connected
  1578. if ((s === 'declined' || s === 'missed') && !vcCallActive) {
  1579. toast(s === 'declined' ? 'Call was declined' : 'No answer', '');
  1580. vcHangUp(); return;
  1581. }
  1582. // *** FIX: caller receives 'ended' via Supabase realtime when callee hangs up ***
  1583. if (s === 'ended' && vcCurrentCallId === callId && vcCallActive) {
  1584. vcHandleRemoteHangup();
  1585. }
  1586. }
  1587. ).subscribe();
  1588. }
  1589.  
  1590. async function cleanupStaleCalls() {
  1591. if (!CU) return;
  1592. const cutoff = new Date(Date.now() - 60000).toISOString();
  1593. await sb.from('video_calls')
  1594. .update({ status: 'missed' })
  1595. .eq('callee_id', CU.id)
  1596. .eq('status', 'pending')
  1597. .lt('created_at', cutoff);
  1598. }
  1599. function startIncomingCallPolling() {
  1600. if (incomingPollInterval) clearInterval(incomingPollInterval);
  1601. incomingPollInterval = setInterval(async () => {
  1602. if (!CU) return;
  1603. const { data: calls } = await sb.from('video_calls')
  1604. .select('*').eq('callee_id', CU.id).eq('status', 'pending')
  1605. .gt('created_at', new Date(Date.now() - 60000).toISOString())
  1606. .order('created_at', { ascending: false }).limit(1);
  1607. if (calls?.length > 0) {
  1608. const call = calls[0];
  1609. if (!incomingCallData || incomingCallData.id !== call.id) showIncomingCallPopup(call);
  1610. }
  1611. }, 3000);
  1612. }
  1613. function showIncomingCallPopup(call) {
  1614. incomingCallData = call;
  1615. document.getElementById('incoming-title').textContent = 'Incoming Video Call';
  1616. document.getElementById('incoming-sub').textContent = `from ${call.caller_name || 'User'}`;
  1617. const av = document.getElementById('incoming-avatar');
  1618. av.innerHTML = call.caller_avatar
  1619. ? `<img src="${call.caller_avatar}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`
  1620. : (call.caller_name?.[0] || '?').toUpperCase();
  1621. document.getElementById('incoming-call-popup').style.display = 'flex';
  1622. setTimeout(() => {
  1623. if (incomingCallData?.id === call.id) {
  1624. hideIncomingPopup();
  1625. sb.from('video_calls').update({ status: 'missed' }).eq('id', call.id).then(() => {});
  1626. }
  1627. }, 30000);
  1628. }
  1629. function hideIncomingPopup() {
  1630. document.getElementById('incoming-call-popup').style.display = 'none';
  1631. incomingCallData = null;
  1632. }
  1633. async function acceptIncomingCall() {
  1634. if (!incomingCallData) return;
  1635. const call = incomingCallData;
  1636. hideIncomingPopup();
  1637. await sb.from('video_calls').update({ status: 'accepted' }).eq('id', call.id);
  1638. vcCurrentCallId = call.id;
  1639. vcCurrentGasCode = call.gas_room_code;
  1640. vcCurrentPeerId = call.caller_id;
  1641. vcCurrentPeerName = call.caller_name;
  1642. vcIsCaller = false;
  1643. vcHangupSignalled = false;
  1644. vcIsHangingUp = false;
  1645. document.getElementById('vc-peer-name').textContent = vcCurrentPeerName;
  1646. if (!vcCurrentGasCode) { toast('No room code found in call', 'error'); return; }
  1647. const granted = await requestVideoCallPermissions();
  1648. if (!granted) { vcHangUp(); return; }
  1649. openVideoCallModal();
  1650. await setupVcPeerConnection();
  1651. // Start hangup polling immediately — don't wait for ontrack
  1652. vcStartHangupPoll();
  1653. vcPollForOffer();
  1654. // *** FIX: callee subscribes to Supabase status updates too ***
  1655. vcSubscribeCallStatus(call.id);
  1656. }
  1657. function declineIncomingCall() {
  1658. if (incomingCallData) {
  1659. sb.from('video_calls').update({ status: 'declined' }).eq('id', incomingCallData.id).then(() => {});
  1660. hideIncomingPopup();
  1661. }
  1662. }
  1663. async function initiateVideoCall() {
  1664. if (!CU) return;
  1665. if (!(VIEW === 'dm' && CDM)) { toast('Video calls are only available in DMs', 'error'); return; }
  1666. const callBtn = document.getElementById('start-video-call-btn');
  1667. const origHTML = callBtn.innerHTML;
  1668. callBtn.disabled = true;
  1669. callBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
  1670. const showStartingPopup = () => {
  1671. const existing = document.getElementById('vc-starting-popup');
  1672. if (existing) existing.remove();
  1673. const popup = document.createElement('div');
  1674. popup.id = 'vc-starting-popup';
  1675. 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;';
  1676. popup.innerHTML = '<div class="spinner" style="width:18px;height:18px;border-width:2px"></div> Starting call with ' + CDM.username + '…';
  1677. document.body.appendChild(popup);
  1678. };
  1679. const hideStartingPopup = () => { const p = document.getElementById('vc-starting-popup'); if (p) p.remove(); };
  1680. const resetBtn = () => { callBtn.disabled = false; callBtn.innerHTML = origHTML; };
  1681. showStartingPopup();
  1682. const granted = await requestVideoCallPermissions();
  1683. if (!granted) { hideStartingPopup(); resetBtn(); return; }
  1684. let gasData;
  1685. try { gasData = await gasApi('generateRoomCode'); }
  1686. catch (e) { toast('Failed to get room code', 'error'); hideStartingPopup(); resetBtn(); return; }
  1687. const code = gasData?.code;
  1688. if (!code) { toast('Failed to get room code', 'error'); hideStartingPopup(); resetBtn(); return; }
  1689. const { data: call, error } = await sb.from('video_calls').insert({
  1690. caller_id: CU.id,
  1691. callee_id: CDM.id,
  1692. caller_name: CU.username,
  1693. caller_avatar: CU.avatar_url,
  1694. gas_room_code: code,
  1695. status: 'pending'
  1696. }).select().single();
  1697. if (error) { toast('Failed to start call', 'error'); hideStartingPopup(); resetBtn(); return; }
  1698. hideStartingPopup();
  1699. resetBtn();
  1700. vcCurrentCallId = call.id;
  1701. vcCurrentGasCode = code;
  1702. vcCurrentPeerId = CDM.id;
  1703. vcCurrentPeerName = CDM.username;
  1704. vcIsCaller = true;
  1705. vcHangupSignalled = false;
  1706. vcIsHangingUp = false;
  1707. document.getElementById('vc-peer-name').textContent = CDM.username;
  1708. openVideoCallModal();
  1709. document.getElementById('vc-status').textContent = 'Waiting for peer to join...';
  1710. await setupVcPeerConnection();
  1711. await vcCreateAndSendOffer();
  1712. // Start hangup polling immediately — don't wait for ontrack
  1713. vcStartHangupPoll();
  1714. vcPollForAnswer();
  1715. // *** FIX: caller subscribes to Supabase status updates ***
  1716. vcSubscribeCallStatus(call.id);
  1717. }
  1718.  
  1719. async function requestVideoCallPermissions() {
  1720. if (vcLocalStream) return true;
  1721. try {
  1722. vcLocalStream = await navigator.mediaDevices.getUserMedia({
  1723. video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' },
  1724. audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
  1725. });
  1726. const lv = document.getElementById('vc-local-video');
  1727. lv.srcObject = vcLocalStream; lv.muted = true;
  1728. await lv.play().catch(e => console.warn('local play:', e));
  1729. return true;
  1730. } catch (err) {
  1731. toast('Camera/mic access denied', 'error');
  1732. return false;
  1733. }
  1734. }
  1735. function openVideoCallModal() {
  1736. document.getElementById('video-call-modal').style.display = 'flex';
  1737. document.getElementById('vc-status').textContent = 'Connecting...';
  1738. document.getElementById('vc-remote-overlay').style.display = 'flex';
  1739. document.getElementById('vc-remote-label').textContent = vcCurrentPeerName || 'Remote';
  1740. vcAudioMuted = false; vcVideoStopped = false;
  1741. document.getElementById('vc-mute-btn').innerHTML = '<i class="fas fa-microphone"></i> Mute';
  1742. document.getElementById('vc-video-btn').innerHTML = '<i class="fas fa-video"></i> Stop Video';
  1743. document.getElementById('vc-mute-btn').classList.remove('active');
  1744. document.getElementById('vc-video-btn').classList.remove('active');
  1745. }
  1746. async function setupVcPeerConnection() {
  1747. if (vcPeerConnection) { try { vcPeerConnection.close(); } catch (e) {} vcPeerConnection = null; }
  1748. vcPeerConnection = new RTCPeerConnection(ICE_CONFIG);
  1749. vcLocalStream.getTracks().forEach(t => vcPeerConnection.addTrack(t, vcLocalStream));
  1750. vcPeerConnection.ontrack = event => {
  1751. const rv = document.getElementById('vc-remote-video');
  1752. if (rv.srcObject !== event.streams[0]) {
  1753. rv.srcObject = event.streams[0];
  1754. rv.play().catch(e => console.warn('remote play:', e));
  1755. document.getElementById('vc-remote-overlay').style.display = 'none';
  1756. document.getElementById('vc-status').textContent = 'Connected';
  1757. vcCallActive = true;
  1758. }
  1759. };
  1760. vcPeerConnection.onicecandidate = event => {
  1761. if (event.candidate) {
  1762. vcIceBatchQueue.push(JSON.stringify(event.candidate));
  1763. if (vcIceBatchTimer) clearTimeout(vcIceBatchTimer);
  1764. vcIceBatchTimer = setTimeout(vcFlushIceBatch, VC_ICE_BATCH_MS);
  1765. } else {
  1766. if (vcIceBatchQueue.length) vcFlushIceBatch();
  1767. }
  1768. };
  1769. vcPeerConnection.oniceconnectionstatechange = () => {
  1770. const s = vcPeerConnection?.iceConnectionState;
  1771. if (!s) return;
  1772. if (s === 'connected' || s === 'completed') { document.getElementById('vc-status').textContent = 'Connected'; vcCallActive = true; }
  1773. if (s === 'failed') document.getElementById('vc-status').textContent = 'Connection failed';
  1774. if (s === 'disconnected') document.getElementById('vc-status').textContent = 'Connection dropped…';
  1775. };
  1776. }
  1777.  
  1778. // *** FIXED: Start hangup polling immediately when call starts, on BOTH sides ***
  1779. // This polls GAS every 1.5s looking for the hangup flag set by whichever side ends the call.
  1780. function vcStartHangupPoll() {
  1781. if (vcHangupPollTimer) { clearInterval(vcHangupPollTimer); vcHangupPollTimer = null; }
  1782. vcHangupPollTimer = setInterval(async () => {
  1783. if (!vcCurrentGasCode) { clearInterval(vcHangupPollTimer); vcHangupPollTimer = null; return; }
  1784. try {
  1785. const data = await gasApi('getRoomData', { code: vcCurrentGasCode });
  1786. if (data && data.hangup === true) {
  1787. clearInterval(vcHangupPollTimer); vcHangupPollTimer = null;
  1788. if (!vcIsHangingUp) vcHandleRemoteHangup();
  1789. }
  1790. } catch (e) { /* network blip — keep polling */ }
  1791. }, VC_POLL_INTERVAL);
  1792. }
  1793.  
  1794. function vcFlushIceBatch() {
  1795. if (!vcIceBatchQueue.length || !vcCurrentGasCode) return;
  1796. const batch = [...vcIceBatchQueue]; vcIceBatchQueue = [];
  1797. gasApi(vcIsCaller ? 'addCallerCandidates' : 'addCalleeCandidate', {
  1798. code: vcCurrentGasCode, candidates: batch
  1799. }).catch(e => console.warn('[VC] ICE flush error:', e));
  1800. }
  1801. async function vcCreateAndSendOffer() {
  1802. const offer = await vcPeerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true });
  1803. await vcPeerConnection.setLocalDescription(offer);
  1804. await gasApi('storeOffer', { code: vcCurrentGasCode, offer: JSON.stringify(offer) });
  1805. document.getElementById('vc-status').textContent = 'Waiting for peer...';
  1806. }
  1807. function vcPollForAnswer() {
  1808. let count = 0;
  1809. vcPollTimer = setInterval(async () => {
  1810. if (++count > VC_MAX_POLL) { clearInterval(vcPollTimer); vcPollTimer = null; toast('Call timed out', ''); vcHangUp(); return; }
  1811. try {
  1812. const data = await gasApi('getRoomData', { code: vcCurrentGasCode });
  1813. if (!data || !vcPeerConnection) return;
  1814. if (data.answer && !vcPeerConnection.remoteDescription) {
  1815. await vcPeerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(data.answer)));
  1816. }
  1817. if (vcPeerConnection.remoteDescription && data.calleeCandidates?.length > vcKnownCalleeIce) {
  1818. const fresh = data.calleeCandidates.slice(vcKnownCalleeIce);
  1819. for (const c of fresh) { try { await vcPeerConnection.addIceCandidate(new RTCIceCandidate(JSON.parse(c))); } catch (e) {} }
  1820. vcKnownCalleeIce = data.calleeCandidates.length;
  1821. }
  1822. if (vcCallActive) { clearInterval(vcPollTimer); vcPollTimer = null; }
  1823. } catch (e) { console.warn('[VC] answer poll error:', e); }
  1824. }, VC_POLL_INTERVAL);
  1825. }
  1826. function vcPollForOffer() {
  1827. let count = 0;
  1828. document.getElementById('vc-status').textContent = 'Waiting for host...';
  1829. vcPollTimer = setInterval(async () => {
  1830. if (++count > VC_MAX_POLL) { clearInterval(vcPollTimer); vcPollTimer = null; toast('Call timed out', ''); vcHangUp(); return; }
  1831. try {
  1832. const data = await gasApi('getRoomData', { code: vcCurrentGasCode });
  1833. if (!data || !vcPeerConnection) return;
  1834. if (data.offer && !vcPeerConnection.remoteDescription) {
  1835. await vcPeerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(data.offer)));
  1836. const answer = await vcPeerConnection.createAnswer();
  1837. await vcPeerConnection.setLocalDescription(answer);
  1838. await gasApi('storeAnswer', { code: vcCurrentGasCode, answer: JSON.stringify(answer) });
  1839. document.getElementById('vc-status').textContent = 'Connecting...';
  1840. }
  1841. if (vcPeerConnection.remoteDescription && data.callerCandidates?.length > vcKnownCallerIce) {
  1842. const fresh = data.callerCandidates.slice(vcKnownCallerIce);
  1843. for (const c of fresh) { try { await vcPeerConnection.addIceCandidate(new RTCIceCandidate(JSON.parse(c))); } catch (e) {} }
  1844. vcKnownCallerIce = data.callerCandidates.length;
  1845. }
  1846. if (vcCallActive) { clearInterval(vcPollTimer); vcPollTimer = null; }
  1847. } catch (e) { console.warn('[VC] offer poll error:', e); }
  1848. }, VC_POLL_INTERVAL);
  1849. }
  1850.  
  1851. // Called on the REMOTE side when the other peer ends the call
  1852. function vcHandleRemoteHangup() {
  1853. if (vcIsHangingUp) return; // prevent double execution
  1854. vcIsHangingUp = true;
  1855. vcCallActive = false;
  1856. toast('Call ended by ' + (vcCurrentPeerName || 'peer'), '');
  1857. vcCleanupTimers();
  1858. vcCleanupMedia();
  1859. document.getElementById('video-call-modal').style.display = 'none';
  1860. vcResetState();
  1861. }
  1862.  
  1863. // *** FIXED vcHangUp: signals BEFORE cleanup so state is still intact ***
  1864. async function vcHangUp() {
  1865. if (vcIsHangingUp) return; // prevent double execution
  1866. vcIsHangingUp = true;
  1867.  
  1868. // Capture values BEFORE any cleanup nulls them
  1869. const callId = vcCurrentCallId;
  1870. const gasCode = vcCurrentGasCode;
  1871.  
  1872. // Close modal immediately for responsiveness
  1873. document.getElementById('video-call-modal').style.display = 'none';
  1874.  
  1875. // Stop all timers so we don't react to our own hangup signal
  1876. vcCleanupTimers();
  1877.  
  1878. // *** SIGNAL the remote peer via BOTH channels BEFORE media cleanup ***
  1879. // 1. GAS hangup flag — the other peer's vcHangupPollTimer will pick this up
  1880. if (gasCode && !vcHangupSignalled) {
  1881. vcHangupSignalled = true;
  1882. try { await gasApi('signalHangup', { code: gasCode }); } catch (e) { console.warn('[VC] GAS hangup signal failed:', e); }
  1883. // Delete the room after a short delay so the other peer has time to read the flag
  1884. setTimeout(() => gasApi('deleteRoom', { code: gasCode }).catch(() => {}), 5000);
  1885. }
  1886. // 2. Supabase status = 'ended' — the other peer's vcSubscribeCallStatus listener picks this up
  1887. if (callId) {
  1888. try { await sb.from('video_calls').update({ status: 'ended' }).eq('id', callId); } catch (e) { console.warn('[VC] Supabase hangup signal failed:', e); }
  1889. }
  1890.  
  1891. // Now clean up local media and state
  1892. vcCleanupMedia();
  1893. vcResetState();
  1894. toast('Call ended', '');
  1895. }
  1896.  
  1897. // Split cleanup into timers + media so we can call them independently
  1898. function vcCleanupTimers() {
  1899. if (vcPollTimer) { clearInterval(vcPollTimer); vcPollTimer = null; }
  1900. if (vcHangupPollTimer) { clearInterval(vcHangupPollTimer); vcHangupPollTimer = null; }
  1901. if (vcIceBatchTimer) { clearTimeout(vcIceBatchTimer); vcIceBatchTimer = null; }
  1902. if (vcStatusChannel) { sb.removeChannel(vcStatusChannel).catch(() => {}); vcStatusChannel = null; }
  1903. }
  1904. function vcCleanupMedia() {
  1905. if (vcLocalStream) { vcLocalStream.getTracks().forEach(t => t.stop()); vcLocalStream = null; }
  1906. if (vcPeerConnection) { try { vcPeerConnection.close(); } catch (e) {} vcPeerConnection = null; }
  1907. const lv = document.getElementById('vc-local-video'); if (lv) lv.srcObject = null;
  1908. const rv = document.getElementById('vc-remote-video'); if (rv) rv.srcObject = null;
  1909. }
  1910. function vcResetState() {
  1911. vcCurrentCallId = null;
  1912. vcCurrentGasCode = null;
  1913. vcCurrentPeerId = null;
  1914. vcCurrentPeerName = '';
  1915. vcCallActive = false;
  1916. vcHangupSignalled = false;
  1917. vcIsHangingUp = false;
  1918. vcIceBatchQueue = [];
  1919. vcKnownCallerIce = 0;
  1920. vcKnownCalleeIce = 0;
  1921. }
  1922.  
  1923. function vcToggleMute() {
  1924. if (!vcLocalStream) return;
  1925. vcAudioMuted = !vcAudioMuted;
  1926. vcLocalStream.getAudioTracks().forEach(t => t.enabled = !vcAudioMuted);
  1927. const btn = document.getElementById('vc-mute-btn');
  1928. btn.innerHTML = vcAudioMuted ? '<i class="fas fa-microphone-slash"></i> Unmute' : '<i class="fas fa-microphone"></i> Mute';
  1929. btn.classList.toggle('active', vcAudioMuted);
  1930. }
  1931. function vcToggleVideo() {
  1932. if (!vcLocalStream) return;
  1933. vcVideoStopped = !vcVideoStopped;
  1934. vcLocalStream.getVideoTracks().forEach(t => t.enabled = !vcVideoStopped);
  1935. const btn = document.getElementById('vc-video-btn');
  1936. btn.innerHTML = vcVideoStopped ? '<i class="fas fa-video-slash"></i> Start Video' : '<i class="fas fa-video"></i> Stop Video';
  1937. btn.classList.toggle('active', vcVideoStopped);
  1938. }
  1939.  
  1940. // ==================== UI HELPERS ====================
  1941. function showWelcome() { stopMsgPoll(); document.getElementById('welcome-screen').style.display = 'flex'; document.getElementById('chat-view').style.display = 'none'; document.getElementById('friends-view').style.display = 'none'; }
  1942. function showChatView() { document.getElementById('welcome-screen').style.display = 'none'; document.getElementById('chat-view').style.display = 'flex'; document.getElementById('friends-view').style.display = 'none'; }
  1943. function showFriendsPanel() { document.getElementById('welcome-screen').style.display = 'none'; document.getElementById('chat-view').style.display = 'none'; document.getElementById('friends-view').style.display = 'flex'; }
  1944. function closeModal(id) { document.getElementById(id).style.display = 'none'; }
  1945. function openLightbox(url) { document.getElementById('lb-img').src = url; document.getElementById('lightbox').style.display = 'flex'; }
  1946. function closeLightbox() { document.getElementById('lightbox').style.display = 'none'; }
  1947. 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'; }
  1948. async function openCSVPreview(url, name) {
  1949. document.getElementById('csv-title').textContent = '📊 ' + (name || 'CSV File');
  1950. const wrap = document.getElementById('csv-wrap');
  1951. wrap.innerHTML = '<div style="padding:20px;color:var(--text-muted)">Loading...</div>';
  1952. document.getElementById('csv-modal').style.display = 'flex';
  1953. try {
  1954. const text = await (await fetch(url)).text();
  1955. const rows = text.trim().split('\n').map(r => {
  1956. const cells = []; let cur = '', inQ = false;
  1957. for (let c of r) { if (c === '"') inQ = !inQ; else if (c === ',' && !inQ) { cells.push(cur.trim()); cur = ''; } else cur += c; }
  1958. cells.push(cur.trim()); return cells;
  1959. });
  1960. const heads = rows[0] || []; const body = rows.slice(1, 51);
  1961. 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>` : ''}`;
  1962. } catch { wrap.innerHTML = '<div style="padding:20px;color:var(--red)">Failed to load CSV.</div>'; }
  1963. }
  1964. async function refreshAllVisibleAvatars() {
  1965. if (VIEW === 'dm' && CDM) await openDM(CDM);
  1966. else if (VIEW === 'channel' && CC) await selectChannel(CC);
  1967. else if (VIEW === 'home') await loadDMSidebar();
  1968. }
  1969. function initContextMenu() {
  1970. const menu = document.getElementById('ctx-menu');
  1971. document.addEventListener('contextmenu', e => {
  1972. const img = e.target.closest('.msg-img');
  1973. if (!img) return;
  1974. e.preventDefault(); currentContextTarget = img.src;
  1975. menu.style.display = 'block'; menu.style.left = e.pageX + 'px'; menu.style.top = e.pageY + 'px';
  1976. });
  1977. document.addEventListener('click', () => menu.style.display = 'none');
  1978. menu.addEventListener('click', e => {
  1979. const action = e.target.dataset.action;
  1980. if (action === 'download' && currentContextTarget) { const a = document.createElement('a'); a.href = currentContextTarget; a.download = ''; a.click(); }
  1981. else if (action === 'copy-link' && currentContextTarget) { navigator.clipboard?.writeText(currentContextTarget); toast('Link copied!', 'success'); }
  1982. menu.style.display = 'none';
  1983. });
  1984. }
  1985. document.querySelectorAll('#settings-modal .modal-tab').forEach(tab => {
  1986. tab.addEventListener('click', () => {
  1987. const stab = tab.dataset.stab;
  1988. document.querySelectorAll('#settings-modal .modal-tab').forEach(t => t.classList.remove('active'));
  1989. tab.classList.add('active');
  1990. document.querySelectorAll('#settings-modal .stab-content').forEach(c => c.style.display = 'none');
  1991. document.getElementById(`stab-${stab}`).style.display = 'block';
  1992. if (stab === 'blocked') renderBlockedList();
  1993. });
  1994. });
  1995. document.addEventListener('keydown', e => {
  1996. if (e.key === 'Escape') {
  1997. closeLightbox();
  1998. ['demo-modal','csv-modal','server-modal','channel-modal','server-settings-modal','settings-modal','gif-modal','admin-members-modal','invite-server-modal'].forEach(id => {
  1999. const el = document.getElementById(id);
  2000. if (el && el.style.display !== 'none') closeModal(id);
  2001. });
  2002. // ESC on video call modal = hang up
  2003. const vcm = document.getElementById('video-call-modal');
  2004. if (vcm && vcm.style.display !== 'none') vcHangUp();
  2005. }
  2006. });
  2007. window.addEventListener('beforeunload', () => {
  2008. // Fire hangup signals synchronously on page close
  2009. if (vcCurrentCallId || vcCurrentGasCode) {
  2010. const callId = vcCurrentCallId;
  2011. const gasCode = vcCurrentGasCode;
  2012. if (gasCode && !vcHangupSignalled) {
  2013. vcHangupSignalled = true;
  2014. navigator.sendBeacon && navigator.sendBeacon(GAS_URL, JSON.stringify({ action: 'signalHangup', code: gasCode }));
  2015. }
  2016. if (callId) {
  2017. // Best-effort — may not complete on unload
  2018. sb.from('video_calls').update({ status: 'ended' }).eq('id', callId).catch(() => {});
  2019. }
  2020. }
  2021. if (msgSub) sb.removeChannel(msgSub).catch(() => {});
  2022. if (pBadgeTimer) clearInterval(pBadgeTimer);
  2023. if (msgPollTimer) clearInterval(msgPollTimer);
  2024. if (timeoutInterval) clearInterval(timeoutInterval);
  2025. if (vcCallSubscription) sb.removeChannel(vcCallSubscription).catch(() => {});
  2026. if (vcStatusChannel) sb.removeChannel(vcStatusChannel).catch(() => {});
  2027. if (activityInterval) clearInterval(activityInterval);
  2028. if (incomingPollInterval) clearInterval(incomingPollInterval);
  2029. });
  2030. init();
  2031. </body>
  2032. </html>
RAW Paste Data Copied