index.html (139695B)
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 6 <title>WhatsApp Logger</title> 7 8 <!-- PWA Manifest --> 9 <link rel="manifest" href="/assets/manifest-wpChat.json"> 10 <meta name="theme-color" content="#006C4C"> 11 12 <!-- SVG Favicon --> 13 <link rel="icon" href="/assets/icon.svg"> 14 15 <!-- Fonts & Icons --> 16 <link rel="preconnect" href="https://fonts.googleapis.com"> 17 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 18 <link href="https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap" rel="stylesheet"> 19 <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0,0" /> 20 21 <!-- Trusted Types Bypass for CSP --> 22 <script> 23 if (window.trustedTypes && trustedTypes.createPolicy && !trustedTypes.defaultPolicy) { 24 trustedTypes.createPolicy('default', { 25 createHTML: string => string, 26 createScriptURL: string => string, 27 createScript: string => string, 28 }); 29 } 30 </script> 31 32 <style> 33 /* Base / Default Theme (Catppuccin Latte - Light) */ 34 :root { 35 --md-sys-color-primary: #40a02b; /* Green */ 36 --md-sys-color-on-primary: #eff1f5; /* Base */ 37 --md-sys-color-primary-container: #a6d189; /* Lighter Green approximation for container */ 38 --md-sys-color-on-primary-container: #1e1e2e; 39 40 --md-sys-color-secondary: #179299; /* Teal */ 41 --md-sys-color-on-secondary: #eff1f5; 42 --md-sys-color-secondary-container: #99d1db; /* Sky used as container */ 43 --md-sys-color-on-secondary-container: #4c4f69; 44 45 --md-sys-color-surface: #eff1f5; /* Base */ 46 --md-sys-color-surface-dim: #e6e9ef; /* Mantle */ 47 --md-sys-color-surface-container: #dce0e8; /* Crust */ 48 --md-sys-color-surface-container-high: #ccd0da; /* Surface0 */ 49 --md-sys-color-surface-container-highest: #bcc0cc; /* Surface1 */ 50 51 --md-sys-color-on-surface: #4c4f69; /* Text */ 52 --md-sys-color-on-surface-variant: #5c5f77; /* Subtext1 */ 53 --md-sys-color-outline: #8c8fa1; /* Overlay1 */ 54 --md-sys-color-outline-variant: #acb0be; /* Surface2 */ 55 56 --md-sys-color-error: #d20f39; /* Red */ 57 --md-sys-color-error-container: #ea76cb; /* Pink approx */ 58 --md-sys-color-on-error-container: #eff1f5; 59 --md-sys-color-on-error: #eff1f5; /* Fixed text for errors */ 60 61 --chat-bubble-me: #a6d189; /* Green/Primary approx for light */ 62 --chat-on-bubble-me: #4c4f69; 63 --chat-bubble-them: #ffffff; 64 --chat-on-bubble-them: #4c4f69; 65 --chat-bg-color: #e6e9ef; /* Mantle */ 66 --chat-bg-image-opacity: 0.05; 67 --search-highlight: #df8e1d; /* Yellow */ 68 69 /* Elevations & Shapes */ 70 --elevation-1: 0px 1px 3px 1px rgba(0,0,0,0.15), 0px 1px 2px 0px rgba(0,0,0,0.3); 71 --elevation-2: 0px 2px 6px 2px rgba(0,0,0,0.15), 0px 1px 2px 0px rgba(0,0,0,0.3); 72 73 /* Settings */ 74 --base-font-size: 15px; 75 } 76 77 /* Dark Theme Tokens (Catppuccin Mocha) */ 78 [data-theme="mocha"] { 79 --md-sys-color-primary: #a6e3a1; /* Green */ 80 --md-sys-color-on-primary: #1e1e2e; /* Base */ 81 --md-sys-color-primary-container: #40a02b; /* Darker green */ 82 --md-sys-color-on-primary-container: #a6e3a1; 83 84 --md-sys-color-secondary: #94e2d5; /* Teal */ 85 --md-sys-color-on-secondary: #1e1e2e; 86 --md-sys-color-secondary-container: #89dceb; /* Sky */ 87 --md-sys-color-on-secondary-container: #1e1e2e; 88 89 --md-sys-color-surface: #1e1e2e; /* Base */ 90 --md-sys-color-surface-dim: #181825; /* Mantle */ 91 --md-sys-color-surface-container: #11111b; /* Crust */ 92 --md-sys-color-surface-container-high: #313244; /* Surface0 */ 93 --md-sys-color-surface-container-highest: #45475a; /* Surface1 */ 94 95 --md-sys-color-on-surface: #cdd6f4; /* Text */ 96 --md-sys-color-on-surface-variant: #bac2de; /* Subtext1 */ 97 --md-sys-color-outline: #7f849c; /* Overlay1 */ 98 --md-sys-color-outline-variant: #585b70; /* Surface2 */ 99 100 --md-sys-color-error: #f38ba8; /* Red */ 101 --md-sys-color-error-container: #d20f39; 102 --md-sys-color-on-error-container: #f38ba8; 103 --md-sys-color-on-error: #1e1e2e; /* Fixed text for errors */ 104 105 --chat-bubble-me: #a6e3a1; 106 --chat-on-bubble-me: #1e1e2e; 107 --chat-bubble-them: #313244; /* Surface0 */ 108 --chat-on-bubble-them: #cdd6f4; 109 --chat-bg-color: #181825; /* Mantle */ 110 --chat-bg-image-opacity: 0.03; 111 --search-highlight: #f9e2af; /* Yellow */ 112 } 113 114 /* True Black / AMOLED Theme */ 115 [data-theme="black"] { 116 --md-sys-color-primary: #a6e3a1; 117 --md-sys-color-on-primary: #000000; 118 --md-sys-color-primary-container: #40a02b; 119 --md-sys-color-on-primary-container: #a6e3a1; 120 121 --md-sys-color-secondary: #94e2d5; 122 --md-sys-color-on-secondary: #000000; 123 --md-sys-color-secondary-container: #89dceb; 124 --md-sys-color-on-secondary-container: #000000; 125 126 /* The real differences for AMOLED */ 127 --md-sys-color-surface: #000000; 128 --md-sys-color-surface-dim: #000000; 129 --md-sys-color-surface-container: #000000; 130 --md-sys-color-surface-container-high: #1e1e2e; /* Still use Mocha base for slight elevation */ 131 --md-sys-color-surface-container-highest: #313244; 132 133 --md-sys-color-on-surface: #cdd6f4; 134 --md-sys-color-on-surface-variant: #bac2de; 135 --md-sys-color-outline: #7f849c; 136 --md-sys-color-outline-variant: #585b70; 137 138 --md-sys-color-error: #f38ba8; 139 --md-sys-color-error-container: #d20f39; 140 --md-sys-color-on-error-container: #f38ba8; 141 --md-sys-color-on-error: #000000; /* Fixed text for errors */ 142 143 --chat-bubble-me: #a6e3a1; 144 --chat-on-bubble-me: #000000; 145 --chat-bubble-them: #1e1e2e; 146 --chat-on-bubble-them: #cdd6f4; 147 --chat-bg-color: #000000; 148 --chat-bg-image-opacity: 0.05; 149 --search-highlight: #f9e2af; 150 } 151 152 * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } 153 154 body, *:not(.material-symbols-rounded) { 155 font-family: 'Lexend', sans-serif !important; 156 } 157 body { 158 background-color: var(--md-sys-color-surface); 159 color: var(--md-sys-color-on-surface); 160 height: 100vh; 161 overflow: hidden; 162 display: flex; 163 justify-content: center; 164 transition: background-color 0.3s, color 0.3s; 165 } 166 167 /* --- ANIMATIONS --- */ 168 .hidden { display: none !important; } 169 .fade-in { animation: fadeIn 0.4s cubic-bezier(0.2, 0.0, 0.0, 1.0); } 170 @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } 171 .ripple-surface { position: relative; overflow: hidden; transform: translate3d(0, 0, 0); transition: transform 0.1s; } 172 .ripple-surface:active { transform: scale(0.98); } 173 174 @keyframes scaleIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } 175 @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } 176 .animate-enter { animation: scaleIn 0.3s cubic-bezier(0.2, 0.0, 0, 1.0); } 177 .shake { animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both; } 178 @keyframes shake { 10%, 90% { transform: translate3d(-1px, 0, 0); } 20%, 80% { transform: translate3d(2px, 0, 0); } 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } 40%, 60% { transform: translate3d(4px, 0, 0); } } 179 180 /* --- M3 STAGGERED ENTRANCE --- */ 181 @keyframes fadeInUp { 182 from { opacity: 0; transform: translateY(30px); } 183 to { opacity: 1; transform: translateY(0); } 184 } 185 .anim-up { opacity: 0; animation: fadeInUp 0.6s cubic-bezier(0.2, 0, 0, 1) forwards; } 186 .delay-1 { animation-delay: 0.1s; } 187 .delay-2 { animation-delay: 0.2s; } 188 .delay-3 { animation-delay: 0.3s; } 189 .delay-4 { animation-delay: 0.4s; } 190 .delay-5 { animation-delay: 0.5s; } 191 192 /* --- AUTH SCREEN --- */ 193 #auth-screen { 194 position: fixed; inset: 0; z-index: 999; 195 background-color: var(--md-sys-color-surface-dim); 196 display: flex; flex-direction: column; align-items: center; justify-content: center; 197 padding: 24px; height: 100dvh; 198 transition: opacity 0.4s cubic-bezier(0.2, 0, 0, 1); 199 } 200 201 .auth-card { 202 background: var(--md-sys-color-surface); 203 border-radius: 28px; 204 padding: 32px 24px; 205 width: 90%; max-width: 380px; 206 box-shadow: var(--elevation-2); 207 border: 1px solid var(--md-sys-color-outline-variant); 208 display: flex; flex-direction: column; gap: 20px; 209 position: relative; 210 } 211 212 .brand-icon { 213 width: 64px; height: 64px; 214 display: flex; align-items: center; justify-content: center; 215 margin: 0 auto 8px; 216 } 217 .brand-icon img { width: 100%; height: 100%; object-fit: contain; } 218 219 /* Custom Spinner for the Boot Loader */ 220 #authLoader .spinner { 221 border-color: rgba(128,128,128,0.2) !important; 222 border-top-color: var(--md-sys-color-primary) !important; 223 } 224 225 /* --- LOCK SCREEN (PIN/BIO) --- */ 226 #lock-screen { 227 position: fixed; inset: 0; z-index: 2000; 228 background: var(--md-sys-color-surface); 229 display: flex; flex-direction: column; align-items: center; justify-content: space-between; 230 padding: 48px 24px; opacity: 0; visibility: hidden; transition: opacity 0.3s; 231 } 232 #lock-screen.active { opacity: 1; visibility: visible; } 233 234 .lock-header { text-align: center; margin-top: 4vh; } 235 .lock-icon { width: 64px; height: 64px; margin-bottom: 16px; color: var(--md-sys-color-primary); } 236 .lock-title { font-family: 'Lexend', sans-serif; font-size: 24px; margin-bottom: 8px; } 237 .lock-sub { color: var(--md-sys-color-on-surface-variant); font-size: 14px; } 238 239 /* PIN Dots */ 240 .pin-dots { display: flex; gap: 16px; margin: 32px 0; justify-content: center; } 241 .pin-dot { 242 width: 14px; height: 14px; border-radius: 50%; 243 border: 1px solid var(--md-sys-color-outline); 244 transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); 245 } 246 .pin-dot.filled { background: var(--md-sys-color-on-surface); border-color: var(--md-sys-color-on-surface); transform: scale(1.1); } 247 .pin-dot.error { border-color: var(--md-sys-color-error); background: var(--md-sys-color-error); } 248 249 /* Native Keypad */ 250 .keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px 32px; margin-bottom: 2vh; } 251 .key-btn { 252 width: 72px; height: 72px; border-radius: 50%; border: none; background: transparent; 253 font-size: 28px; font-weight: 400; color: var(--md-sys-color-on-surface); 254 display: flex; align-items: center; justify-content: center; cursor: pointer; 255 transition: background 0.2s; font-family: 'Lexend', sans-serif; 256 } 257 .key-btn:active { background: var(--md-sys-color-surface-container-high); } 258 .key-backspace { font-size: 24px; color: var(--md-sys-color-on-surface-variant); } 259 260 .forgot-pin-btn { margin-top: 16px; color: var(--md-sys-color-primary); background: none; border: none; font-weight: 500; font-size: 14px; padding: 8px 16px; border-radius: 20px; cursor: pointer; } 261 262 /* --- LOCKOUT UI --- */ 263 .lockout-container { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; width: 100%; animation: fadeIn 0.3s; flex: 1; } 264 .lockout-icon-bg { width: 80px; height: 80px; border-radius: 50%; background: var(--md-sys-color-error-container); color: var(--md-sys-color-on-error-container); display: flex; align-items: center; justify-content: center; margin-bottom: 24px; } 265 .lockout-icon { font-size: 40px; } 266 .lockout-title { font-family: 'Lexend', sans-serif; font-size: 28px; margin-bottom: 8px; color: var(--md-sys-color-on-surface); } 267 .lockout-msg { color: var(--md-sys-color-on-surface-variant); margin-bottom: 32px; font-size: 16px; } 268 .lockout-timer { font-family: 'Lexend', sans-serif; font-size: 56px; font-weight: 400; color: var(--md-sys-color-error); font-feature-settings: "tnum"; margin-bottom: 8px; } 269 .lockout-sub { margin-top: 16px; color: var(--md-sys-color-on-surface-variant); font-size: 14px; } 270 .lockout-actions { margin-top: 24px; display: flex; gap: 12px; } 271 .locked-mode #pinUI, .locked-mode #bioUI, .locked-mode .forgot-pin-btn { display: none !important; } 272 273 /* --- UI ELEMENTS --- */ 274 .m3-field { 275 position: relative; margin-bottom: 0; 276 background: var(--md-sys-color-surface-container-highest); 277 border-radius: 4px 4px 0 0; border-bottom: 1px solid var(--md-sys-color-outline); 278 transition: background 0.3s; height: 56px; 279 } 280 .m3-field:hover { background: rgba(128, 128, 128, 0.08); } 281 .m3-field:focus-within { border-bottom: 2px solid var(--md-sys-color-primary); } 282 .m3-input { width: 100%; height: 100%; padding: 24px 16px 8px 16px; border: none; background: transparent; outline: none; font-size: 16px; color: var(--md-sys-color-on-surface); font-family: inherit; } 283 .m3-label { position: absolute; left: 16px; top: 18px; color: var(--md-sys-color-on-surface-variant); font-size: 16px; pointer-events: none; transition: 0.2s cubic-bezier(0.2, 0, 0, 1); } 284 .m3-input:focus ~ .m3-label, .m3-input:not(:placeholder-shown) ~ .m3-label { top: 8px; font-size: 12px; color: var(--md-sys-color-primary); } 285 .eye-toggle { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); border: none; background: none; cursor: pointer; color: var(--md-sys-color-on-surface-variant); padding: 8px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background 0.2s; } 286 .eye-toggle:hover { background: rgba(128,128,128,0.1); color: var(--md-sys-color-on-surface); } 287 288 .m3-btn { 289 width: 100%; height: 48px; border-radius: 24px; border: none; outline: none; cursor: pointer; 290 font-family: 'Lexend', sans-serif; font-weight: 500; font-size: 14px; 291 display: flex; align-items: center; justify-content: center; gap: 8px; 292 transition: 0.2s; position: relative; overflow: hidden; 293 background: transparent; color: var(--md-sys-color-primary); 294 } 295 .m3-btn.filled { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); box-shadow: none; } 296 .m3-btn.filled:hover { box-shadow: var(--elevation-1); opacity: 0.9;} 297 .m3-btn:disabled { opacity: 0.38; cursor: not-allowed; background: var(--md-sys-color-on-surface); color: var(--md-sys-color-surface); box-shadow: none; } 298 299 .spinner { width: 20px; height: 20px; border: 2px solid rgba(128,128,128,0.3); border-radius: 50%; border-top-color: inherit; animation: spin 0.8s linear infinite; display: none; } 300 .m3-btn.loading .btn-text { visibility: hidden; } 301 .m3-btn.loading .btn-icon { display: none; } 302 .m3-btn.loading .spinner { display: block; position: absolute; } 303 @keyframes spin { to { transform: rotate(360deg); } } 304 305 .m3-select { 306 appearance: none; 307 width: 100%; height: 56px; 308 background: var(--md-sys-color-surface-container-highest); 309 border: none; border-bottom: 1px solid var(--md-sys-color-outline); 310 border-radius: 4px 4px 0 0; 311 padding: 8px 16px; 312 color: var(--md-sys-color-on-surface); 313 font-family: inherit; font-size: 16px; 314 outline: none; cursor: pointer; 315 } 316 .m3-select:focus { border-bottom: 2px solid var(--md-sys-color-primary); } 317 .select-wrapper { position: relative; } 318 .select-wrapper::after { 319 content: 'expand_more'; 320 font-family: 'Material Symbols Rounded'; 321 position: absolute; right: 16px; top: 50%; transform: translateY(-50%); 322 color: var(--md-sys-color-on-surface-variant); pointer-events: none; 323 } 324 325 /* --- APP LAYOUT --- */ 326 #app-layout { display: flex; width: 100%; height: 100%; max-width: 1600px; background: var(--md-sys-color-surface); opacity: 0; transition: opacity 0.6s ease-in-out; visibility: hidden; } 327 #app-layout.visible { opacity: 1; visibility: visible; } 328 329 /* SIDEBAR */ 330 .sidebar { width: 400px; display: flex; flex-direction: column; border-right: 1px solid var(--md-sys-color-outline-variant); background: var(--md-sys-color-surface); z-index: 10; } 331 .sidebar-header { padding: 16px 20px; display: flex; justify-content: space-between; align-items: center; } 332 .icon-btn { width: 40px; height: 40px; border-radius: 50%; border: none; background: transparent; cursor: pointer; color: var(--md-sys-color-on-surface-variant); display: flex; align-items: center; justify-content: center; transition: 0.2s; } 333 .icon-btn:hover { background: rgba(128,128,128,0.1); color: var(--md-sys-color-on-surface); } 334 .search-container { padding: 0 16px 12px; } 335 .search-bar { background: var(--md-sys-color-surface-container-high); border-radius: 28px; height: 48px; display: flex; align-items: center; padding: 0 16px; } 336 .search-bar input { border: none; background: transparent; outline: none; flex: 1; margin-left: 12px; font-size: 16px; color: var(--md-sys-color-on-surface); } 337 .contact-list { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 8px; } 338 .contact-item { display: flex; align-items: center; padding: 12px 16px; border-radius: 16px; cursor: pointer; margin-bottom: 2px; animation: slideUp 0.3s cubic-bezier(0.2, 0.0, 0, 1.0) backwards; } 339 .contact-item:hover { background: rgba(128,128,128,0.05); } 340 .contact-item.active { background: var(--md-sys-color-secondary-container); } 341 .contact-item.active .contact-name { color: var(--md-sys-color-on-secondary-container); font-weight: 700; } 342 .avatar { width: 48px; height: 48px; border-radius: 50%; background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 500; flex-shrink: 0; margin-right: 16px; } 343 .contact-info { flex: 1; min-width: 0; } 344 .contact-name { font-size: 16px; font-weight: 500; color: var(--md-sys-color-on-surface); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 345 .contact-sub { font-size: 14px; color: var(--md-sys-color-on-surface-variant); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 346 347 /* CHAT AREA */ 348 .chat-area { flex: 1; display: flex; flex-direction: column; position: relative; background-color: var(--chat-bg-color); transition: background-color 0.3s; overflow: hidden; } 349 .chat-bg-pattern { position: absolute; inset: 0; opacity: var(--chat-bg-image-opacity); z-index: 0; pointer-events: none; background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='%236e7874' fill-opacity='1' fill-rule='evenodd'/%3E%3C/svg%3E"); } 350 .chat-header { height: 64px; background: var(--md-sys-color-surface); display: flex; align-items: center; padding: 0 16px; border-bottom: 1px solid var(--md-sys-color-outline-variant); z-index: 5; } 351 352 .chat-content { 353 flex: 1; overflow-y: auto; padding: 16px; z-index: 1; 354 display: flex; flex-direction: column; gap: 4px; 355 scroll-behavior: auto !important; 356 transition: padding-bottom 0.4s cubic-bezier(0.2, 0, 0, 1); 357 } 358 359 /* Forces bottom-up alignment when content is sparse */ 360 .chat-content::before { 361 content: ''; 362 flex: 1 1 auto; 363 } 364 365 .chat-content.syncing { 366 padding-bottom: 72px; /* Cinematic push up */ 367 } 368 369 .msg-bubble { max-width: 80%; padding: 8px 12px; position: relative; font-size: var(--base-font-size); line-height: 1.4; box-shadow: 0 1px 2px rgba(0,0,0,0.1); display: flex; flex-direction: column; animation: scaleIn 0.2s cubic-bezier(0.2, 0.0, 0, 1.0) backwards; } 370 .msg-in { align-self: flex-start; background: var(--chat-bubble-them); border-radius: 0 16px 16px 16px; color: var(--chat-on-bubble-them, var(--md-sys-color-on-surface)); } 371 .msg-out { align-self: flex-end; background: var(--chat-bubble-me); border-radius: 16px 0 16px 16px; color: var(--chat-on-bubble-me, var(--md-sys-color-on-surface)); } 372 373 .msg-out.group-top { border-bottom-right-radius: 4px; margin-bottom: 2px; } 374 .msg-out.group-mid { border-top-right-radius: 4px; border-bottom-right-radius: 4px; margin-bottom: 2px; } 375 .msg-out.group-bot { border-top-right-radius: 4px; margin-top: 0; } 376 .msg-in.group-top { border-bottom-left-radius: 4px; margin-bottom: 2px; } 377 .msg-in.group-mid { border-top-left-radius: 4px; border-bottom-left-radius: 4px; margin-bottom: 2px; } 378 .msg-in.group-bot { border-top-left-radius: 4px; margin-top: 0; } 379 380 .msg-sender { font-size: 12px; font-weight: 700; color: var(--md-sys-color-primary); margin-bottom: 2px; } 381 .msg-text { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } 382 .msg-meta { font-size: 10px; opacity: 0.7; align-self: flex-end; margin-top: 2px; } 383 384 /* DATE DIVIDERS */ 385 .chat-date-group { display: flex; flex-direction: column; gap: 4px; position: relative; } 386 .date-divider { align-self: center; margin: 16px 0; background: var(--md-sys-color-surface-container-high); color: var(--md-sys-color-on-surface-variant); font-size: 12px; font-weight: 500; padding: 6px 12px; border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); position: sticky; top: 8px; z-index: 2; } 387 388 .highlight { background-color: var(--search-highlight); color: black; border-radius: 2px; padding: 0 2px; } 389 390 /* Sync Loading Spinner (Cinematic UI) */ 391 .sync-spinner-wrapper { 392 position: absolute; bottom: 16px; left: 0; right: 0; 393 display: flex; justify-content: center; 394 pointer-events: none; z-index: 10; 395 transform: translateY(150%); opacity: 0; 396 transition: all 0.4s cubic-bezier(0.2, 0, 0, 1); 397 } 398 .sync-spinner-wrapper.active { 399 transform: translateY(0); opacity: 1; 400 } 401 .sync-chip { 402 background: var(--md-sys-color-surface-container-highest); 403 color: var(--md-sys-color-on-surface); 404 padding: 8px 16px; border-radius: 24px; 405 display: flex; align-items: center; gap: 10px; 406 font-size: 13px; font-weight: 500; 407 box-shadow: var(--elevation-2); 408 border: 1px solid var(--md-sys-color-outline-variant); 409 } 410 .sync-chip .spinner { 411 border-color: rgba(128,128,128,0.2); 412 border-top-color: var(--md-sys-color-primary); 413 width: 16px; height: 16px; 414 display: block; position: static; 415 } 416 417 /* Search In Chat */ 418 .in-chat-search { background: var(--md-sys-color-surface-container); padding: 8px 16px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--md-sys-color-outline-variant); animation: slideUp 0.3s cubic-bezier(0.2, 0.0, 0, 1.0); } 419 .in-chat-search input { border: none; background: transparent; outline: none; flex: 1; color: var(--md-sys-color-on-surface); } 420 421 /* Menu */ 422 .menu-container { position: relative; } 423 .dropdown-menu { position: absolute; top: 100%; right: 0; background: var(--md-sys-color-surface-container-high); border-radius: 12px; padding: 8px 0; min-width: 200px; display: none; flex-direction: column; z-index: 100; box-shadow: 0 4px 8px rgba(0,0,0,0.2); animation: scaleIn 0.2s cubic-bezier(0.2, 0.0, 0, 1.0); transform-origin: top right; } 424 .dropdown-menu.show { display: flex; } 425 .menu-item { padding: 12px 16px; display: flex; align-items: center; gap: 12px; color: var(--md-sys-color-on-surface); background: none; border: none; font-family: inherit; font-size: 14px; cursor: pointer; text-align: left; } 426 .menu-item:hover { background: rgba(128,128,128,0.1); } 427 428 /* Scroll To Bottom FAB */ 429 .scroll-fab { position: fixed; bottom: 24px; right: 24px; width: 40px; height: 40px; border-radius: 50%; background: var(--md-sys-color-surface-container-high); color: var(--md-sys-color-on-surface-variant); border: 1px solid var(--md-sys-color-outline-variant); box-shadow: 0 2px 6px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 20; opacity: 0; transform: translateY(20px); pointer-events: none; transition: 0.3s; } 430 .scroll-fab.visible { opacity: 1; transform: translateY(0); pointer-events: auto; } 431 .new-msg-badge { position: absolute; top: -2px; right: -2px; width: 12px; height: 12px; background: var(--md-sys-color-error); border-radius: 50%; border: 2px solid var(--md-sys-color-surface-container-high); } 432 433 /* Offline Indicator */ 434 .offline-banner { position: fixed; top: 0; left: 50%; transform: translateX(-50%); background: var(--md-sys-color-error); color: var(--md-sys-color-on-error); padding: 4px 16px; border-radius: 0 0 8px 8px; font-size: 12px; z-index: 9999; display: flex; align-items: center; gap: 6px; box-shadow: var(--elevation-1); } 435 436 /* Calendar Modal */ 437 .calendar-card { background: var(--md-sys-color-surface); padding: 0; border-radius: 28px; width: 320px; overflow: hidden; display: flex; flex-direction: column; } 438 .cal-header { background: var(--md-sys-color-surface-container-high); padding: 16px 24px; } 439 .cal-header h3 { font-size: 14px; color: var(--md-sys-color-on-surface-variant); font-weight: 500; } 440 .cal-header h2 { font-size: 32px; color: var(--md-sys-color-on-surface); font-weight: 400; margin-top: 4px; } 441 .cal-grid { padding: 16px 12px 0; } 442 .cal-nav { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding: 0 12px; } 443 .cal-days { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; margin-bottom: 8px; } 444 .cal-day-label { font-size: 12px; color: var(--md-sys-color-on-surface-variant); width: 36px; } 445 .cal-dates { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 16px; } 446 .cal-date { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 14px; cursor: pointer; color: var(--md-sys-color-on-surface); } 447 .cal-date:hover { background: var(--md-sys-color-surface-container-high); } 448 .cal-date.active { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); } 449 .cal-date.empty { pointer-events: none; } 450 451 /* Settings Modal */ 452 .modal-overlay { position: fixed; inset: 0; z-index: 2000; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; opacity: 0; visibility: hidden; transition: 0.3s; } 453 .modal-overlay.active { opacity: 1; visibility: visible; } 454 .settings-card { background: var(--md-sys-color-surface); width: 90%; max-width: 600px; height: 80vh; border-radius: 28px; display: flex; flex-direction: column; overflow: hidden; animation: scaleIn 0.3s var(--anim-enter); } 455 .settings-header { padding: 20px 24px; border-bottom: 1px solid var(--md-sys-color-outline-variant); display: flex; justify-content: space-between; align-items: center; } 456 .settings-content { flex: 1; overflow-y: auto; padding: 24px; } 457 .setting-section { margin-bottom: 32px; } 458 .setting-title { font-size: 14px; font-weight: 500; color: var(--md-sys-color-primary); margin-bottom: 16px; text-transform: uppercase; letter-spacing: 1px; } 459 .setting-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } 460 .setting-label h4 { font-size: 16px; font-weight: 400; margin-bottom: 4px; } 461 .setting-label p { font-size: 14px; color: var(--md-sys-color-on-surface-variant); } 462 463 .switch { position: relative; display: inline-block; width: 50px; height: 28px; } 464 .switch input { opacity: 0; width: 0; height: 0; } 465 .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--md-sys-color-surface-container-high); border-radius: 34px; transition: .4s; border: 1px solid var(--md-sys-color-outline); } 466 .slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 4px; bottom: 3px; background-color: var(--md-sys-color-outline); border-radius: 50%; transition: .4s; } 467 input:checked + .slider { background-color: var(--md-sys-color-primary); border-color: var(--md-sys-color-primary); } 468 input:checked + .slider:before { transform: translateX(20px); background-color: var(--md-sys-color-on-primary); } 469 470 .faq-item { border-bottom: 1px solid var(--md-sys-color-outline-variant); } 471 .faq-question { width: 100%; padding: 16px 0; text-align: left; background: none; border: none; font-size: 16px; color: var(--md-sys-color-on-surface); cursor: pointer; display: flex; justify-content: space-between; } 472 .faq-answer { display: none; padding-bottom: 16px; color: var(--md-sys-color-on-surface-variant); line-height: 1.5; } 473 .faq-item.open .faq-answer { display: block; } 474 475 .install-sheet { display: none; } 476 .install-card { background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); padding: 16px; border-radius: 12px; margin-bottom: 32px; display: flex; align-items: center; justify-content: space-between; } 477 .install-card h4 { margin-bottom: 4px; } 478 .install-card p { font-size: 12px; } 479 .install-card .m3-btn { width: auto; height: 36px; padding: 0 16px; font-size: 14px; white-space: nowrap; flex-shrink: 0; box-shadow: none; } 480 481 @media (max-width: 768px) { 482 .sidebar { width: 100%; flex: 1; border-right: none; } 483 .chat-area { position: fixed; inset: 0; z-index: 20; transform: translateX(100%); transition: transform 0.3s; background-color: var(--chat-bg-color); } 484 .chat-area.active { transform: translateX(0); } 485 .back-btn { display: flex !important; margin-right: 8px; } 486 .install-sheet { display: block; position: fixed; bottom: 0; left: 0; right: 0; z-index: 3000; background: var(--md-sys-color-surface); border-top-left-radius: 28px; border-top-right-radius: 28px; padding: 24px; box-shadow: 0 -4px 20px rgba(0,0,0,0.15); transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1); } 487 .install-sheet.visible { transform: translateY(0); } 488 .sheet-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } 489 .sheet-content { text-align: center; } 490 .sheet-actions { display: flex; flex-direction: column; gap: 12px; margin-top: 24px; } 491 } 492 .back-btn { display: none; margin-right: 8px; } 493 494 .modal-card { background-color: var(--md-sys-color-surface); padding: 24px; border-radius: 28px; width: 90%; max-width: 320px; transform: scale(0.9); transition: 0.3s cubic-bezier(0.2, 0, 0, 1); animation: scaleIn 0.3s var(--anim-enter); } 495 .modal-overlay.active .modal-card { transform: scale(1); } 496 .modal-card.utility { width: 320px; padding: 24px; border-radius: 28px; } 497 .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; } 498 .modal-actions .m3-btn { width: auto; min-width: 80px; height: 40px; padding: 0 16px; } 499 .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; width: 100%; color: var(--md-sys-color-outline); text-align: center; animation: fadeIn 0.5s; } 500 .skeleton { background: linear-gradient(90deg, var(--md-sys-color-surface-container-high) 25%, var(--md-sys-color-surface-container) 50%, var(--md-sys-color-surface-container-high) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite linear; border-radius: 4px; } 501 @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } 502 .skel-contact { display: flex; align-items: center; padding: 12px 16px; gap: 16px; width: 100%; } 503 .skel-avatar { width: 48px; height: 48px; border-radius: 50%; } 504 .skel-text-col { flex: 1; display: flex; flex-direction: column; gap: 8px; } 505 .skel-msg-group { display: flex; flex-direction: column; gap: 12px; padding: 20px; width: 100%; } 506 .skel-bubble { height: 40px; border-radius: 12px; width: 40%; margin-bottom: 8px;} 507 .skel-left { align-self: flex-start; border-bottom-left-radius: 0; } 508 .skel-right { align-self: flex-end; border-bottom-right-radius: 0; width: 50%; } 509 510 /* --- SETTINGS SIDEBAR --- */ 511 .settings-sidebar { 512 position: fixed; 513 top: 0; left: 0; bottom: 0; 514 width: 400px; 515 background: var(--md-sys-color-surface); 516 z-index: 2000; 517 display: flex; 518 flex-direction: column; 519 border-right: 1px solid var(--md-sys-color-outline-variant); 520 box-shadow: var(--elevation-2); 521 transform: translateX(-100%); 522 transition: transform 0.4s cubic-bezier(0.2, 0, 0, 1); 523 } 524 525 .settings-sidebar.active { 526 transform: translateX(0); 527 } 528 529 /* Mobile full-width override */ 530 @media (max-width: 768px) { 531 .settings-sidebar { 532 width: 100%; 533 border-right: none; 534 } 535 } 536 537 @media (max-width: 768px) { 538 /* Pin all utility modals to the bottom */ 539 .modal-card.utility { 540 position: absolute; 541 bottom: 0; 542 width: 100% !important; 543 max-width: 100%; 544 border-radius: 28px 28px 0 0; 545 padding: 32px 24px 24px; 546 transform: translateY(100%) scale(1); 547 animation: none; /* Override the desktop scaleIn */ 548 transition: transform 0.4s cubic-bezier(0.2, 0, 0, 1); 549 } 550 551 /* Add the M3 drag handle indicator */ 552 .modal-card.utility::before { 553 content: ''; 554 position: absolute; 555 top: 12px; 556 left: 50%; 557 transform: translateX(-50%); 558 width: 32px; 559 height: 4px; 560 border-radius: 2px; 561 background: var(--md-sys-color-outline-variant); 562 } 563 564 .modal-overlay.active .modal-card.utility { 565 transform: translateY(0) scale(1); 566 } 567 } 568 569 /* Logs view CSS */ 570 .logs-container { max-height: 150px; overflow-y: auto; background: var(--md-sys-color-surface-container); color: var(--md-sys-color-primary); font-family: monospace; font-size: 12px; padding: 12px; border-radius: 8px; margin-top: 8px; border: 1px solid var(--md-sys-color-outline-variant); } 571 .logs-container .error { color: var(--md-sys-color-error); } 572 .logs-container div { margin-bottom: 4px; } 573 574 </style> 575 </head> 576 <body> 577 578 <div id="offlineIndicator" class="offline-banner hidden"> 579 <span class="material-symbols-rounded" style="font-size: 16px;">cloud_off</span> You are offline 580 </div> 581 582 <!-- AUTH SCREEN --> 583 <div id="auth-screen"> 584 <div class="auth-card"> 585 586 <!-- Dynamic Initial Loader --> 587 <div id="authLoader" style="text-align: center; padding: 24px; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; min-height: 200px;"> 588 <div class="spinner" style="display:block; position:static; width:36px; height:36px;"></div> 589 <p style="margin-top:20px; color:var(--md-sys-color-on-surface-variant); font-size: 15px; font-family:'Lexend', sans-serif;">Authenticating session...</p> 590 </div> 591 592 <!-- Login Fields (Hidden initially) --> 593 <div id="authForm" class="hidden"> 594 <div style="text-align: center;" class="anim-up delay-1"> 595 <div class="brand-icon"> 596 <img src="/assets/icon.svg" alt="App Logo"> 597 </div> 598 <h1 style="font-family:'Lexend', sans-serif; font-size:28px; margin:8px 0; font-weight:400;">Welcome Back</h1> 599 <p style="color:var(--md-sys-color-on-surface-variant); font-size:14px;">Sign in to your chat log</p> 600 </div> 601 602 <div class="m3-field anim-up delay-2" style="margin-top: 12px;"> 603 <input type="text" id="username" class="m3-input" placeholder=" "> 604 <label class="m3-label">Username</label> 605 </div> 606 607 <div class="m3-field anim-up delay-3" style="margin-top: 12px;"> 608 <input type="password" id="password" class="m3-input" placeholder=" "> 609 <label class="m3-label">Password</label> 610 <button class="eye-toggle" onclick="togglePasswordVisibility('password', 'passIcon')"> 611 <span class="material-symbols-rounded" id="passIcon">visibility</span> 612 </button> 613 </div> 614 615 <div class="anim-up delay-4" style="display:flex; align-items:center; padding-left:4px; margin-top: 12px;"> 616 <div style="display:flex; align-items:center; gap:8px;"> 617 <input type="checkbox" id="rememberMe" style="width:18px; height:18px; accent-color: var(--md-sys-color-primary);"> 618 <label for="rememberMe" style="font-size: 14px; color: var(--md-sys-color-on-surface);">Remember me</label> 619 </div> 620 </div> 621 622 <div class="anim-up delay-5" style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"> 623 <button class="m3-btn filled" id="loginBtn" onclick="attemptLogin()"> 624 <span class="btn-text">Sign In</span> 625 <span class="material-symbols-rounded btn-icon" style="font-size:20px;">arrow_forward</span> 626 <div class="spinner"></div> 627 </button> 628 <p id="authError" style="color: var(--md-sys-color-error); font-size: 14px; opacity: 0; text-align: center;">Invalid Credentials</p> 629 </div> 630 </div> 631 632 </div> 633 </div> 634 635 <!-- LOCK SCREEN --> 636 <div id="lock-screen"> 637 <div id="lockoutUI" class="hidden lockout-container"> 638 <div class="lockout-icon-bg"><span class="material-symbols-rounded lockout-icon" id="lockoutIcon">lock_clock</span></div> 639 <h2 class="lockout-title" id="lockoutTitle">Locked</h2> 640 <p class="lockout-msg" id="lockoutMsg">Too many attempts.</p> 641 <div class="lockout-timer" id="lockoutTimer">00:30</div> 642 <p class="lockout-sub" id="lockoutSub">Try again later</p> 643 <div id="permanentLockActions" class="hidden lockout-actions"> 644 <button class="m3-btn filled" onclick="showForgotPin()" style="background:var(--md-sys-color-error); width:auto; padding:0 32px;">Forgot PIN</button> 645 </div> 646 </div> 647 648 <div class="lock-header" id="lockHeader"> 649 <span class="material-symbols-rounded lock-icon" id="lockIcon">lock</span> 650 <div class="lock-title" id="lockTitle">Enter PIN</div> 651 <div class="lock-sub" id="lockSub">Protected by App Lock</div> 652 </div> 653 654 <div id="pinUI"> 655 <div class="pin-dots" id="pinDots"> 656 <div class="pin-dot"></div><div class="pin-dot"></div><div class="pin-dot"></div> 657 <div class="pin-dot"></div><div class="pin-dot"></div><div class="pin-dot"></div> 658 </div> 659 <div class="keypad"> 660 <button class="key-btn ripple-surface" onclick="pressKey(1)">1</button> 661 <button class="key-btn ripple-surface" onclick="pressKey(2)">2</button> 662 <button class="key-btn ripple-surface" onclick="pressKey(3)">3</button> 663 <button class="key-btn ripple-surface" onclick="pressKey(4)">4</button> 664 <button class="key-btn ripple-surface" onclick="pressKey(5)">5</button> 665 <button class="key-btn ripple-surface" onclick="pressKey(6)">6</button> 666 <button class="key-btn ripple-surface" onclick="pressKey(7)">7</button> 667 <button class="key-btn ripple-surface" onclick="pressKey(8)">8</button> 668 <button class="key-btn ripple-surface" onclick="pressKey(9)">9</button> 669 <button class="key-btn ripple-surface" style="visibility:hidden"> </button> 670 <button class="key-btn ripple-surface" onclick="pressKey(0)">0</button> 671 <button class="key-btn ripple-surface" onclick="pressKey(-1)"><span class="material-symbols-rounded key-backspace">backspace</span></button> 672 </div> 673 </div> 674 675 <div id="bioUI" class="hidden" style="flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center;"> 676 <div class="ripple-surface" onclick="triggerBiometricAuth()" style="width:96px; height:96px; border-radius:50%; background:var(--md-sys-color-secondary-container); color:var(--md-sys-color-on-secondary-container); display:flex; align-items:center; justify-content:center; cursor:pointer;"> 677 <span class="material-symbols-rounded" style="font-size:48px;">fingerprint</span> 678 </div> 679 <p style="margin-top:16px; color:var(--md-sys-color-on-surface-variant);">Tap to Unlock</p> 680 </div> 681 682 <div style="text-align: center;"><button class="forgot-pin-btn" onclick="showForgotPin()">Forgot PIN / Use Password</button></div> 683 </div> 684 685 <!-- MAIN APP --> 686 <div id="app-layout"> 687 <aside class="sidebar" id="sidebar"> 688 <div class="sidebar-header"> 689 <div style="display: flex; align-items: center; gap: 12px; padding: 12px 0;"> 690 <h2 style="font-family: 'Lexend', sans-serif; font-size: 20px; font-weight: 500; color: var(--md-sys-color-on-surface); margin: 0;">Chat Log</h2> 691 <span style="font-family: 'Lexend', sans-serif; font-size: 12px; font-weight: 500; color: var(--md-sys-color-on-surface-variant); background-color: var(--md-sys-color-surface-container-high); padding: 2px 8px; border-radius: 100px; display: inline-flex; align-items: center;">v4.2.3</span> 692 </div> 693 <div style="display:flex; gap: 8px;"> 694 <button class="icon-btn ripple-surface" onclick="refreshApp()"><span class="material-symbols-rounded">refresh</span></button> 695 <button class="icon-btn ripple-surface" onclick="openSettings()" title="Settings"><span class="material-symbols-rounded">settings</span></button> 696 </div> 697 </div> 698 <div class="search-container"> 699 <div class="search-bar"> 700 <span class="material-symbols-rounded" style="color: var(--md-sys-color-on-surface-variant);">search</span> 701 <input type="text" id="searchInput" placeholder="Search..."> 702 </div> 703 </div> 704 <div class="contact-list" id="contactList"></div> 705 </aside> 706 707 <main class="chat-area" id="chatArea"> 708 <header class="chat-header"> 709 <button class="icon-btn back-btn ripple-surface" onclick="closeChatMobile()"><span class="material-symbols-rounded">arrow_back</span></button> 710 <div id="chatHeaderContent" style="display:flex; align-items:center; gap:12px; flex:1; opacity:0;"> 711 <div class="avatar" id="headerAvatar">A</div> 712 <div style="flex:1;"> 713 <div id="headerName" style="font-weight: 500; font-size: 16px;">Alice</div> 714 <div id="headerId" style="font-size: 12px; opacity: 0.7;">Online</div> 715 </div> 716 <div class="menu-container"> 717 <button class="icon-btn ripple-surface" onclick="toggleChatMenu(event)"><span class="material-symbols-rounded">more_vert</span></button> 718 <div class="dropdown-menu" id="chatMenu"> 719 <button class="menu-item" onclick="toggleChatSearch()"><span class="material-symbols-rounded">search</span>Search in Chat</button> 720 <button class="menu-item" onclick="openDateSearch()"><span class="material-symbols-rounded">calendar_month</span>Search by Date</button> 721 <button class="menu-item" onclick="showRenameModal()"><span class="material-symbols-rounded">edit</span>Rename</button> 722 <button class="menu-item" onclick="downloadChat()"><span class="material-symbols-rounded">download</span>Download</button> 723 </div> 724 </div> 725 </div> 726 </header> 727 728 <div id="inChatSearch" class="in-chat-search hidden"> 729 <input type="text" id="msgSearchInput" placeholder="Find message..."> 730 <div id="searchCount" style="font-size:12px; margin-right:8px;"></div> 731 <button class="icon-btn" style="width:30px; height:30px;" onclick="toggleChatSearch()"><span class="material-symbols-rounded">close</span></button> 732 </div> 733 734 <div class="chat-bg-pattern"></div> 735 736 <div class="chat-content" id="messagesContainer"> 737 <div class="empty-state"> 738 <span class="material-symbols-rounded" style="font-size: 64px; opacity: 0.2;">chat</span> 739 <p>Select a conversation</p> 740 </div> 741 </div> 742 743 <button class="scroll-fab" id="scrollFab" onclick="scrollToBottom('smooth')"> 744 <span class="material-symbols-rounded">arrow_downward</span> 745 <span id="newMsgBadge" class="new-msg-badge hidden"></span> 746 </button> 747 </main> 748 </div> 749 750 <!-- Global Sync Indicator --> 751 <div class="sync-spinner-wrapper" id="globalSyncSpinner"> 752 <div class="sync-chip"> 753 <div class="spinner"></div> 754 <span>Syncing fresh messages...</span> 755 </div> 756 </div> 757 758 <!-- MODALS SECTION --> 759 <!-- SETTINGS SIDEBAR --> 760 <div id="settingsSidebar" class="settings-sidebar"> 761 <div class="settings-header" style="height: 64px; padding: 0 16px; justify-content: flex-start; gap: 24px; background: var(--md-sys-color-surface-container-high);"> 762 <button class="icon-btn ripple-surface" onclick="closeSettingsSidebar()"> 763 <span class="material-symbols-rounded">arrow_back</span> 764 </button> 765 <h2 style="font-family:'Lexend'; font-size: 20px; font-weight: 500; margin: 0;">Settings</h2> 766 </div> 767 768 <div class="settings-content"> 769 <div id="installAppSection" class="install-card hidden"> 770 <div><h4>Install App</h4><p>Get the app for better experience</p></div> 771 <button class="m3-btn filled" onclick="installPWA()">Install</button> 772 </div> 773 774 <div class="setting-section hidden" id="mobileSecuritySection"> 775 <div class="setting-title">App Security</div> 776 <div class="setting-row"><div class="setting-label"><h4>PIN Lock</h4><p>Require PIN on startup</p></div><label class="switch"><input type="checkbox" id="pinLockToggle" onchange="togglePinSetting()"><span class="slider"></span></label></div> 777 <div class="setting-row"><div class="setting-label"><h4>Fingerprint Lock</h4><p>Use biometrics</p></div><label class="switch"><input type="checkbox" id="bioLockToggle" onchange="toggleBioSetting()"><span class="slider"></span></label></div> 778 </div> 779 780 <div class="setting-section"> 781 <div class="setting-title">Profile</div> 782 <div class="m3-field"><input type="text" id="profileName" class="m3-input" placeholder=" " onchange="saveProfile()"><label class="m3-label">Your Name</label></div> 783 <div class="m3-field"><input type="text" id="profilePhone" class="m3-input" placeholder=" " onchange="saveProfile()"><label class="m3-label">Phone Number</label></div> 784 </div> 785 786 <div class="setting-section"> 787 <div class="setting-title">Appearance</div> 788 <div class="setting-row" style="display: block;"> 789 <div class="setting-label" style="margin-bottom: 8px;"> 790 <h4>Theme</h4> 791 <p>Select your preferred color palette</p> 792 </div> 793 <div class="select-wrapper"> 794 <select id="themeSelector" class="m3-select" onchange="changeTheme(this.value)"> 795 <option value="latte">Latte (Light)</option> 796 <option value="mocha">Mocha (Dark)</option> 797 <option value="black">Amoled (True Black)</option> 798 </select> 799 </div> 800 </div> 801 <div class="setting-row"><div class="setting-label"><h4>Text Size</h4><p>Adjust message font size</p></div><input type="range" min="12" max="20" value="15" oninput="changeFontSize(this.value)"></div> 802 </div> 803 804 <div class="setting-section"> 805 <div class="setting-title">Storage and Data</div> 806 <div class="setting-row"> 807 <div class="setting-label"> 808 <h4>Local Storage Used</h4> 809 <p id="localStorageSizeDisplay">Calculating...</p> 810 </div> 811 </div> 812 <div class="setting-row"> 813 <div class="setting-label"> 814 <h4>Clear Cache</h4> 815 <p>Delete all messages, cached chats, and media records locally.</p> 816 </div> 817 <button class="m3-btn filled" style="background: var(--md-sys-color-error); width: auto; padding: 0 16px; height: 36px; font-size: 13px;" onclick="clearAppCacheData()">Clear</button> 818 </div> 819 </div> 820 821 <!-- SYSTEM LOGS SECTION --> 822 <div class="setting-section" id="logsSection"> 823 <div class="setting-title">System Diagnostics</div> 824 <div class="setting-row"> 825 <div class="setting-label"> 826 <h4>Live Server Logs</h4> 827 <p>Real-time events from your connected instance.</p> 828 </div> 829 <label class="switch"><input type="checkbox" id="logsToggle" onchange="toggleLogs()"><span class="slider"></span></label> 830 </div> 831 <div id="logsViewer" class="logs-container hidden"></div> 832 </div> 833 834 <div class="setting-section"> 835 <div class="setting-title">Help & Support</div> 836 <div id="faqContainer"></div> 837 <div style="margin-top:24px; display:flex; gap:12px;"> 838 <button class="m3-btn filled" style="background:var(--md-sys-color-secondary-container); color:var(--md-sys-color-on-secondary-container); cursor: pointer;" onclick="window.open('https://amit.is-a.dev/logger#faq', '_blank')">View FAQ</button> 839 </div> 840 <div style="margin-top:24px; display:flex; gap:12px;"> 841 <button class="m3-btn filled" style="background:var(--md-sys-color-secondary-container); color:var(--md-sys-color-on-secondary-container); cursor: pointer;" onclick="window.open('https://amit.is-a.dev/logger', '_blank')">View Docs</button> 842 </div> 843 </div> 844 845 <button class="m3-btn filled" style="background:var(--md-sys-color-error); margin-top: 16px;" onclick="showLogoutModal()">Log Out</button> 846 </div> 847 </div> 848 849 <!-- UTILITY MODALS --> 850 <div id="logoutModal" class="modal-overlay"> 851 <div class="modal-card utility"> 852 <h3 class="modal-title">Log Out?</h3> 853 <p style="margin:16px 0; color:var(--md-sys-color-on-surface-variant);">Session will end.</p> 854 <div class="modal-actions"> 855 <button class="m3-btn text" onclick="closeModal('logoutModal')">Cancel</button> 856 <button class="m3-btn filled" onclick="handleLogout()">Logout</button> 857 </div> 858 </div> 859 </div> 860 861 <div id="forgotPinModal" class="modal-overlay"> 862 <div class="modal-card utility"> 863 <h3 class="modal-title">Reset App Lock</h3> 864 <p style="margin-top:8px; color:var(--md-sys-color-on-surface-variant); font-size:14px;">Enter your server credentials to reset local security.</p> 865 <div class="m3-field" style="margin-top:16px;"><input type="text" id="resetUsername" class="m3-input" placeholder=" "><label class="m3-label">Username</label></div> 866 <div class="m3-field"><input type="password" id="resetPassword" class="m3-input" placeholder=" "><label class="m3-label">Password</label><button class="eye-toggle" onclick="togglePasswordVisibility('resetPassword', 'resetPassIcon')"><span class="material-symbols-rounded" id="resetPassIcon">visibility</span></button></div> 867 <p id="resetError" style="color:var(--md-sys-color-error); font-size:12px; margin-bottom:12px; display:none;">Invalid credentials</p> 868 <div class="modal-actions"><button class="m3-btn text" onclick="closeModal('forgotPinModal')">Cancel</button><button class="m3-btn filled" id="resetBtn" onclick="attemptPinReset()"><span class="btn-text">Reset</span><div class="spinner"></div></button></div> 869 </div> 870 </div> 871 872 <div id="renameModal" class="modal-overlay"> 873 <div class="modal-card utility"> 874 <h3 class="modal-title">Rename Chat</h3> 875 <div class="m3-field" style="margin-top:16px;"><input type="text" id="renameInput" class="m3-input"><label class="m3-label">New Name</label></div> 876 <div class="modal-actions"><button class="m3-btn text" onclick="closeModal('renameModal')">Cancel</button><button class="m3-btn filled" onclick="saveRename()">Save</button></div> 877 </div> 878 </div> 879 880 <!-- APP OPTIONS MODAL --> 881 <div id="appOptionsModal" class="modal-overlay"> 882 <div class="modal-card utility"> 883 <h3 class="modal-title">App Options</h3> 884 <p style="margin:8px 0 16px; color:var(--md-sys-color-on-surface-variant); font-size:14px;">Choose how you want to refresh the app.</p> 885 <div style="display:flex; flex-direction:column; gap:12px;"> 886 <button class="m3-btn filled" onclick="executePageRefresh()"> 887 <span class="material-symbols-rounded" style="font-size:20px;">refresh</span> Reload App 888 </button> 889 <button class="m3-btn text" style="color:var(--md-sys-color-error);" onclick="openHardReset()"> 890 <span class="material-symbols-rounded" style="font-size:20px;">delete_forever</span> Hard Reset 891 </button> 892 <button class="m3-btn text" onclick="closeModal('appOptionsModal')">Cancel</button> 893 </div> 894 </div> 895 </div> 896 897 <!-- HARD REFRESH MODAL --> 898 <div id="refreshModal" class="modal-overlay"> 899 <div class="modal-card utility"> 900 <h3 class="modal-title">System Sync</h3> 901 902 <div id="refreshAuthStep"> 903 <p style="margin-top:8px; color:var(--md-sys-color-on-surface-variant); font-size:14px;">Enter credentials to perform a full hard sync. This will clear the cache and pull all data.</p> 904 <div class="m3-field" style="margin-top:16px;"> 905 <input type="text" id="refreshUsername" class="m3-input" placeholder=" "><label class="m3-label">Username</label> 906 </div> 907 <div class="m3-field"> 908 <input type="password" id="refreshPassword" class="m3-input" placeholder=" "> 909 <label class="m3-label">Password</label> 910 <button class="eye-toggle" onclick="togglePasswordVisibility('refreshPassword', 'refreshPassIcon')"><span class="material-symbols-rounded" id="refreshPassIcon">visibility</span></button> 911 </div> 912 <p id="refreshError" style="color:var(--md-sys-color-error); font-size:12px; margin-bottom:12px; display:none;">Invalid credentials</p> 913 <div class="modal-actions"> 914 <button class="m3-btn text" onclick="closeModal('refreshModal')">Cancel</button> 915 <button class="m3-btn filled" id="refreshBtn" onclick="startHardRefresh()"> 916 <span class="btn-text">Sync</span><div class="spinner"></div> 917 </button> 918 </div> 919 </div> 920 921 <div id="refreshProgressStep" class="hidden" style="text-align:center; padding: 16px 0;"> 922 <p id="refreshStatusText" style="font-size:14px; font-weight:500; margin-bottom:16px; color:var(--md-sys-color-on-surface);">Starting sync...</p> 923 <div style="width:100%; height:8px; background:var(--md-sys-color-surface-container-highest); border-radius:4px; overflow:hidden;"> 924 <div id="refreshProgressBar" style="width:0%; height:100%; background:var(--md-sys-color-primary); transition:width 0.3s ease;"></div> 925 </div> 926 <p id="refreshPercentText" style="font-size:12px; color:var(--md-sys-color-on-surface-variant); margin-top:8px;">0%</p> 927 </div> 928 </div> 929 </div> 930 931 <div id="notFoundModal" class="modal-overlay"> 932 <div class="modal-card utility"> 933 <h3 class="modal-title">No Messages Found</h3> 934 <p style="margin:16px 0; color:var(--md-sys-color-on-surface-variant);">There are no messages from this date onwards.</p> 935 <div class="modal-actions"><button class="m3-btn filled" onclick="closeModal('notFoundModal')">OK</button></div> 936 </div> 937 </div> 938 939 <div id="pinSetupModal" class="modal-overlay"> 940 <div class="modal-card utility" style="text-align:center;"> 941 <h3 class="modal-title" id="pinSetupTitle">Set 6-Digit PIN</h3> 942 <div class="pin-dots" id="setupDots" style="margin: 24px 0;"><div class="pin-dot"></div><div class="pin-dot"></div><div class="pin-dot"></div><div class="pin-dot"></div><div class="pin-dot"></div><div class="pin-dot"></div></div> 943 <p style="color:var(--md-sys-color-on-surface-variant); font-size:12px;" id="pinSetupMsg">Use the number pad below</p> 944 <div class="keypad" style="transform: scale(0.9); margin-bottom:0;"> 945 <button class="key-btn ripple-surface" onclick="pressSetupKey(1)">1</button><button class="key-btn ripple-surface" onclick="pressSetupKey(2)">2</button><button class="key-btn ripple-surface" onclick="pressSetupKey(3)">3</button> 946 <button class="key-btn ripple-surface" onclick="pressSetupKey(4)">4</button><button class="key-btn ripple-surface" onclick="pressSetupKey(5)">5</button><button class="key-btn ripple-surface" onclick="pressSetupKey(6)">6</button> 947 <button class="key-btn ripple-surface" onclick="pressSetupKey(7)">7</button><button class="key-btn ripple-surface" onclick="pressSetupKey(8)">8</button><button class="key-btn ripple-surface" onclick="pressSetupKey(9)">9</button> 948 <button class="key-btn ripple-surface" style="visibility:hidden"> </button><button class="key-btn ripple-surface" onclick="pressSetupKey(0)">0</button><button class="key-btn ripple-surface" onclick="pressSetupKey(-1)"><span class="material-symbols-rounded key-backspace">backspace</span></button> 949 </div> 950 <div class="modal-actions" style="justify-content:center; margin-top:8px;"><button class="m3-btn text" onclick="cancelPinSetup()">Cancel</button></div> 951 </div> 952 </div> 953 954 <div id="calendarModal" class="modal-overlay"> 955 <div class="calendar-card animate-enter"> 956 <div class="cal-header"><h3>Select date</h3><h2 id="calSelectedDateStr">Tue, Oct 24</h2></div> 957 <div class="cal-grid"> 958 <div class="cal-nav"> 959 <span id="calMonthYear" style="font-weight:500">October 2023</span> 960 <div style="display:flex; gap:12px;"><button class="icon-btn" onclick="changeCalMonth(-1)"><span class="material-symbols-rounded">chevron_left</span></button><button class="icon-btn" onclick="changeCalMonth(1)"><span class="material-symbols-rounded">chevron_right</span></button></div> 961 </div> 962 <div class="cal-days"><span class="cal-day-label">S</span><span class="cal-day-label">M</span><span class="cal-day-label">T</span><span class="cal-day-label">W</span><span class="cal-day-label">T</span><span class="cal-day-label">F</span><span class="cal-day-label">S</span></div> 963 <div class="cal-dates" id="calDatesGrid"></div> 964 </div> 965 <div class="modal-actions" style="padding: 16px 24px;"><button class="m3-btn text" onclick="closeModal('calendarModal')">Cancel</button><button class="m3-btn filled" onclick="jumpToDate()" style="width:auto; padding:0 24px; height:40px;">OK</button></div> 966 </div> 967 </div> 968 969 <div id="installSheet" class="install-sheet"> 970 <div class="sheet-header"><h3 style="font-family:'Lexend', sans-serif; margin: 0;">Install App</h3><button class="icon-btn" onclick="closeInstallSheet()"><span class="material-symbols-rounded">close</span></button></div> 971 <div class="sheet-content"> 972 <div style="width:64px; height:64px; margin:0 auto 16px; display:flex; align-items:center; justify-content:center;"><img src="/assets/icon.svg" alt="App Logo" style="width:100%; height:100%; object-fit: contain;"></div> 973 <p style="color:var(--md-sys-color-on-surface-variant); margin-bottom:24px;">Install WhatsApp Logger App for a better experience, full screen view, and enhanced security.</p> 974 <div class="sheet-actions"><button class="m3-btn filled" onclick="installPWA()">Install App</button><button class="m3-btn text" onclick="closeInstallSheet()">Continue in Browser</button></div> 975 </div> 976 </div> 977 978 <!-- MAIN APP SCRIPT --> 979 <script> 980 // Catch global errors to prevent silent forever-spinners 981 window.onerror = function(msg, url, line) { 982 const loader = document.getElementById('authLoader'); 983 if(loader && !loader.classList.contains('hidden')) { 984 loader.innerHTML = `<div style="color:var(--md-sys-color-error); font-size:13px; text-align:left; padding:16px; border: 1px solid var(--md-sys-color-error); border-radius: 8px;"> 985 <strong>System Error:</strong><br>${msg}<br>Line: ${line} 986 </div>`; 987 } 988 }; 989 990 const RENDER_BACKEND_URL = ""; 991 992 let activeChatId = null; 993 let activeChatIds = []; 994 let isCurrentChatGroup = false; 995 let deferredPrompt; 996 let currentMessages = []; 997 let rawChatsMap = new Map(); 998 999 // --- SSE CONNECTION STATE --- 1000 let chatEventSource = null; 1001 let chatEventRetryCount = 0; 1002 1003 let syncEventSource = null; 1004 let syncEventRetryCount = 0; 1005 1006 // --- INDEXED DB CACHING --- 1007 const DB_NAME = 'WPLoggerDB'; 1008 const DB_VERSION = 2; 1009 let dbInstance = null; 1010 let isIdbSupported = true; 1011 let maxCachedTimestamp = 0; 1012 1013 function initIndexedDB() { 1014 return new Promise((resolve) => { 1015 let idb; 1016 try { 1017 idb = window.indexedDB; 1018 } catch (e) { 1019 console.warn("IDB Access Denied:", e); 1020 isIdbSupported = false; 1021 resolve(); 1022 return; 1023 } 1024 1025 if (!idb) { 1026 isIdbSupported = false; 1027 resolve(); 1028 return; 1029 } 1030 1031 let isResolved = false; 1032 const safeResolve = () => { 1033 if(!isResolved) { 1034 isResolved = true; 1035 resolve(); 1036 } 1037 }; 1038 1039 const fallbackTimeout = setTimeout(() => { 1040 console.warn('IDB init timed out'); 1041 isIdbSupported = false; 1042 safeResolve(); 1043 }, 3000); 1044 1045 try { 1046 const req = indexedDB.open(DB_NAME, DB_VERSION); 1047 req.onupgradeneeded = (e) => { 1048 const database = e.target.result; 1049 if (!database.objectStoreNames.contains('messages')) { 1050 const store = database.createObjectStore('messages', { keyPath: 'id' }); 1051 store.createIndex('chatId', 'chatId', { unique: false }); 1052 store.createIndex('timestamp', 'timestamp', { unique: false }); 1053 } 1054 if (!database.objectStoreNames.contains('chats')) { 1055 database.createObjectStore('chats', { keyPath: 'id' }); 1056 } 1057 }; 1058 req.onsuccess = (e) => { 1059 clearTimeout(fallbackTimeout); 1060 dbInstance = e.target.result; 1061 1062 dbInstance.onversionchange = () => { 1063 dbInstance.close(); 1064 dbInstance = null; 1065 }; 1066 1067 safeResolve(); 1068 }; 1069 req.onerror = (e) => { 1070 clearTimeout(fallbackTimeout); 1071 console.warn('IDB init error', e); 1072 isIdbSupported = false; 1073 safeResolve(); 1074 }; 1075 req.onblocked = (e) => { 1076 console.warn('IDB init blocked. Waiting for other connections to close...'); 1077 }; 1078 } catch (e) { 1079 clearTimeout(fallbackTimeout); 1080 console.warn('IDB open exception', e); 1081 isIdbSupported = false; 1082 safeResolve(); 1083 } 1084 }); 1085 } 1086 1087 async function getCachedMessages(chatId) { 1088 if(!isIdbSupported || !dbInstance) return []; 1089 return new Promise((resolve) => { 1090 try { 1091 const tx = dbInstance.transaction('messages', 'readonly'); 1092 const store = tx.objectStore('messages'); 1093 const index = store.index('chatId'); 1094 const req = index.getAll(chatId); 1095 req.onsuccess = () => resolve(req.result || []); 1096 req.onerror = () => resolve([]); 1097 } catch(e) { resolve([]); } 1098 }); 1099 } 1100 1101 async function getMaxCachedTimestamp() { 1102 if(!isIdbSupported || !dbInstance) return 0; 1103 return new Promise((resolve) => { 1104 try { 1105 const tx = dbInstance.transaction('messages', 'readonly'); 1106 const store = tx.objectStore('messages'); 1107 const index = store.index('timestamp'); 1108 // Open a cursor going backwards to get the absolute highest timestamp stored 1109 const req = index.openCursor(null, 'prev'); 1110 req.onsuccess = (e) => { 1111 const cursor = e.target.result; 1112 if (cursor) resolve(cursor.key); 1113 else resolve(0); 1114 }; 1115 req.onerror = () => resolve(0); 1116 } catch(e) { resolve(0); } 1117 }); 1118 } 1119 1120 async function saveCachedMessages(messages) { 1121 if(!isIdbSupported || !dbInstance) return; 1122 return new Promise((resolve) => { 1123 try { 1124 const tx = dbInstance.transaction('messages', 'readwrite'); 1125 const store = tx.objectStore('messages'); 1126 messages.forEach(m => store.put(m)); 1127 tx.oncomplete = () => resolve(); 1128 tx.onerror = () => resolve(); 1129 } catch(e) { resolve(); } 1130 }); 1131 } 1132 1133 async function getCachedChats() { 1134 if(!isIdbSupported || !dbInstance) return []; 1135 return new Promise((resolve) => { 1136 try { 1137 const tx = dbInstance.transaction('chats', 'readonly'); 1138 const store = tx.objectStore('chats'); 1139 const req = store.getAll(); 1140 req.onsuccess = () => resolve(req.result || []); 1141 req.onerror = () => resolve([]); 1142 } catch(e) { resolve([]); } 1143 }); 1144 } 1145 1146 async function saveCachedChats(chats) { 1147 if(!isIdbSupported || !dbInstance) return; 1148 return new Promise((resolve) => { 1149 try { 1150 const tx = dbInstance.transaction('chats', 'readwrite'); 1151 const store = tx.objectStore('chats'); 1152 chats.forEach(c => store.put(c)); 1153 tx.oncomplete = () => resolve(); 1154 tx.onerror = () => resolve(); 1155 } catch(e) { resolve(); } 1156 }); 1157 } 1158 1159 // --- SECURITY VARS --- 1160 let enteredPin = ""; let setupPin = ""; let setupStep = 0; let isLocked = false; 1161 let failAttempts = parseInt(localStorage.getItem('wp_fail_attempts') || '0'); 1162 let lockUntil = parseInt(localStorage.getItem('wp_lock_until') || '0'); 1163 let lockoutTimerInterval; 1164 let calCurrentDate = new Date(); let calSelectedDate = new Date(); 1165 1166 const arrayBufferToBase64 = (buffer) => { 1167 let binary = ''; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; 1168 for (let i = 0; i < len; i++) binary += String.fromCharCode(bytes[i]); 1169 return window.btoa(binary); 1170 }; 1171 const base64ToArrayBuffer = (base64) => { 1172 const binary_string = window.atob(base64); const len = binary_string.length; const bytes = new Uint8Array(len); 1173 for (let i = 0; i < len; i++) bytes[i] = binary_string.charCodeAt(i); 1174 return bytes.buffer; 1175 }; 1176 1177 function formatDividerDate(ts) { 1178 const date = new Date(ts * 1000); const today = new Date(); const yesterday = new Date(); 1179 yesterday.setDate(yesterday.getDate() - 1); 1180 if (date.toDateString() === today.toDateString()) return "Today"; 1181 if (date.toDateString() === yesterday.toDateString()) return "Yesterday"; 1182 return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); 1183 } 1184 1185 function formatLastActive(ts) { 1186 if (!ts) return 'Unknown'; 1187 const date = new Date(ts * 1000); 1188 const today = new Date(); 1189 const yesterday = new Date(); 1190 yesterday.setDate(yesterday.getDate() - 1); 1191 1192 const timeOpts = { hour: '2-digit', minute: '2-digit' }; 1193 if (date.toDateString() === today.toDateString()) { 1194 return `Today at ${date.toLocaleTimeString([], timeOpts)}`; 1195 } else if (date.toDateString() === yesterday.toDateString()) { 1196 return `Yesterday at ${date.toLocaleTimeString([], timeOpts)}`; 1197 } 1198 return `${date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })} at ${date.toLocaleTimeString([], timeOpts)}`; 1199 } 1200 1201 // HTML Sanitizer to prevent Code Injection via chat messages 1202 function escapeHTML(str) { 1203 if (!str) return ''; 1204 return str.replace(/[&<>'"]/g, 1205 tag => ({ 1206 '&': '&', 1207 '<': '<', 1208 '>': '>', 1209 "'": ''', 1210 '"': '"' 1211 }[tag] || tag) 1212 ); 1213 } 1214 1215 // --- PWA INSTALLATION --- 1216 if ('serviceWorker' in navigator && (window.location.protocol === 'https:' || window.location.protocol === 'http:')) { 1217 navigator.serviceWorker.register('/assets/sw-wpChat.js').catch(err => { 1218 console.warn('SW Register Fail:', err); 1219 }); 1220 window.addEventListener('online', () => { 1221 if (navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage('sync_on_reconnect'); 1222 }); 1223 } 1224 1225 window.addEventListener('beforeinstallprompt', (e) => { 1226 e.preventDefault(); deferredPrompt = e; 1227 document.getElementById('installAppSection').classList.remove('hidden'); 1228 if (window.innerWidth <= 768) { 1229 setTimeout(() => document.getElementById('installSheet').classList.add('visible'), 2000); 1230 } 1231 }); 1232 1233 async function installPWA() { 1234 if (!deferredPrompt) return; 1235 deferredPrompt.prompt(); 1236 const { outcome } = await deferredPrompt.userChoice; 1237 if (outcome === 'accepted') { 1238 closeInstallSheet(); 1239 document.getElementById('installAppSection').classList.add('hidden'); 1240 } 1241 deferredPrompt = null; 1242 } 1243 1244 function closeInstallSheet() { document.getElementById('installSheet').classList.remove('visible'); } 1245 1246 // --- BOOT SEQUENCE --- 1247 function isMobileApp() { 1248 const isStandalone = (window.matchMedia('(display-mode: standalone)').matches) || (window.navigator.standalone) || document.referrer.includes('android-app://'); 1249 return isStandalone && window.innerWidth <= 768; 1250 } 1251 1252 window.onload = async () => { 1253 try { 1254 await initIndexedDB(); 1255 1256 if (!navigator.onLine) { 1257 document.getElementById('offlineIndicator').classList.remove('hidden'); 1258 } 1259 window.addEventListener('online', () => { 1260 document.getElementById('offlineIndicator').classList.add('hidden'); 1261 }); 1262 window.addEventListener('offline', () => { 1263 document.getElementById('offlineIndicator').classList.remove('hidden'); 1264 }); 1265 1266 // Apply saved theme 1267 const savedTheme = localStorage.getItem('theme') || 'latte'; 1268 document.body.setAttribute('data-theme', savedTheme); 1269 const themeSelector = document.getElementById('themeSelector'); 1270 if(themeSelector) themeSelector.value = savedTheme; 1271 1272 document.getElementById('profileName').value = localStorage.getItem('profileName') || ''; 1273 document.getElementById('profilePhone').value = localStorage.getItem('profilePhone') || ''; 1274 1275 populateFAQ(); 1276 1277 const authValid = localStorage.getItem('wp_api_token') && localStorage.getItem('wp_auth_expiry') > Date.now(); 1278 1279 if (authValid) { 1280 document.getElementById('auth-screen').style.opacity = '0'; 1281 1282 setTimeout(() => { 1283 document.getElementById('auth-screen').style.display = 'none'; 1284 if (isMobileApp()) { 1285 const hasPin = localStorage.getItem('wp_app_pin'); 1286 const useBio = localStorage.getItem('wp_app_bio') === 'true'; 1287 document.getElementById('mobileSecuritySection').classList.remove('hidden'); 1288 document.getElementById('pinLockToggle').checked = !!hasPin; 1289 document.getElementById('bioLockToggle').checked = useBio; 1290 1291 if (hasPin || useBio) { 1292 initLockScreen(hasPin, useBio); 1293 } else { 1294 document.getElementById('app-layout').classList.add('visible'); 1295 startAppSync(); 1296 } 1297 } else { 1298 document.getElementById('app-layout').classList.add('visible'); 1299 startAppSync(); 1300 } 1301 }, 400); 1302 } else { 1303 document.getElementById('authLoader').classList.add('hidden'); 1304 document.getElementById('authForm').classList.remove('hidden'); 1305 document.getElementById('authForm').classList.add('fade-in'); 1306 1307 if (isMobileApp()) { 1308 document.getElementById('mobileSecuritySection').classList.remove('hidden'); 1309 } 1310 } 1311 1312 document.getElementById('messagesContainer').addEventListener('scroll', function() { 1313 const fab = document.getElementById('scrollFab'); 1314 if (this.scrollHeight - this.scrollTop - this.clientHeight > 500) { 1315 fab.classList.add('visible'); 1316 } else { 1317 fab.classList.remove('visible'); 1318 document.getElementById('newMsgBadge').classList.add('hidden'); 1319 } 1320 }); 1321 1322 document.querySelectorAll('input[type="text"], input[type="password"]').forEach(input => { 1323 input.addEventListener('focus', () => { 1324 if (window.innerWidth <= 768) { 1325 setTimeout(() => input.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300); 1326 } 1327 }); 1328 }); 1329 } catch (err) { 1330 console.error("Critical Boot Error:", err); 1331 const loader = document.getElementById('authLoader'); 1332 if (loader) { 1333 loader.innerHTML = `<div style="color:var(--md-sys-color-error); padding: 16px; text-align: center;"> 1334 <span class="material-symbols-rounded" style="font-size: 32px; margin-bottom: 8px;">error</span> 1335 <p style="font-size: 14px; font-weight: 500;">App failed to load.</p> 1336 <p style="font-size: 12px; opacity: 0.8; margin-top: 4px;">${err.message}</p> 1337 </div>`; 1338 } 1339 } 1340 }; 1341 1342 async function startAppSync() { 1343 // Initiate the two main SSE streams 1344 loadChats(); 1345 connectGlobalSyncStream(); 1346 } 1347 1348 // --- LOCK SCREEN LOGIC --- 1349 function initLockScreen(hasPin, useBio) { 1350 const lockScreen = document.getElementById('lock-screen'); 1351 lockScreen.classList.add('active'); isLocked = true; enteredPin = ""; 1352 updatePinDots(0); 1353 if (checkLockoutState()) return; 1354 1355 if(hasPin) { 1356 document.getElementById('pinUI').classList.remove('hidden'); document.getElementById('bioUI').classList.add('hidden'); 1357 document.getElementById('lockTitle').innerText = "Enter PIN"; document.getElementById('lockIcon').innerText = "lock"; 1358 } else if (useBio && !hasPin) { 1359 document.getElementById('pinUI').classList.add('hidden'); document.getElementById('bioUI').classList.remove('hidden'); 1360 document.getElementById('lockTitle').innerText = "Scan Fingerprint"; document.getElementById('lockIcon').innerText = "fingerprint"; 1361 triggerBiometricAuth(); 1362 } 1363 } 1364 1365 function checkLockoutState() { 1366 const now = Date.now(); 1367 if (failAttempts >= 5) { renderPermanentLock(); return true; } 1368 if (lockUntil > now) { startLockoutTimer(); return true; } 1369 if (lockUntil > 0 && lockUntil <= now) { 1370 lockUntil = 0; localStorage.setItem('wp_lock_until', '0'); 1371 document.getElementById('lockoutUI').classList.add('hidden'); 1372 document.getElementById('lockHeader').classList.remove('hidden'); 1373 document.getElementById('lock-screen').classList.remove('locked-mode'); 1374 } 1375 return false; 1376 } 1377 1378 function handleFailedAttempt() { 1379 failAttempts++; localStorage.setItem('wp_fail_attempts', failAttempts); 1380 if (failAttempts === 3 || failAttempts === 4) { 1381 lockUntil = Date.now() + (30 * 1000); localStorage.setItem('wp_lock_until', lockUntil); 1382 startLockoutTimer(); 1383 } else if (failAttempts >= 5) { 1384 renderPermanentLock(); 1385 } else { 1386 document.getElementById('pinDots').classList.add('shake'); updatePinDots(6, true); 1387 setTimeout(() => { document.getElementById('pinDots').classList.remove('shake'); enteredPin = ""; updatePinDots(0); }, 400); 1388 } 1389 } 1390 1391 function startLockoutTimer() { 1392 const ui = document.getElementById('lockoutUI'); const screen = document.getElementById('lock-screen'); const timerDisplay = document.getElementById('lockoutTimer'); 1393 ui.classList.remove('hidden'); document.getElementById('lockHeader').classList.add('hidden'); screen.classList.add('locked-mode'); 1394 document.getElementById('lockoutTitle').innerText = "Locked"; document.getElementById('lockoutMsg').innerText = `Too many attempts (${failAttempts})`; 1395 document.getElementById('lockoutSub').innerText = "Try again later"; document.getElementById('permanentLockActions').classList.add('hidden'); 1396 document.getElementById('lockoutTimer').classList.remove('hidden'); 1397 if(lockoutTimerInterval) clearInterval(lockoutTimerInterval); 1398 1399 lockoutTimerInterval = setInterval(() => { 1400 const left = Math.ceil((lockUntil - Date.now()) / 1000); 1401 if (left <= 0) { 1402 clearInterval(lockoutTimerInterval); timerDisplay.innerText = "00:00"; 1403 initLockScreen(localStorage.getItem('wp_app_pin'), localStorage.getItem('wp_app_bio') === 'true'); 1404 document.getElementById('lockoutUI').classList.add('hidden'); document.getElementById('lockHeader').classList.remove('hidden'); 1405 screen.classList.remove('locked-mode'); 1406 } else { 1407 timerDisplay.innerText = `00:${left < 10 ? '0'+left : left}`; 1408 } 1409 }, 1000); 1410 } 1411 1412 function renderPermanentLock() { 1413 document.getElementById('lockoutUI').classList.remove('hidden'); 1414 document.getElementById('lockHeader').classList.add('hidden'); 1415 document.getElementById('lock-screen').classList.add('locked-mode'); 1416 document.getElementById('lockoutIcon').innerText = "gpp_bad"; 1417 document.getElementById('lockoutTitle').innerText = "Security Lockout"; 1418 document.getElementById('lockoutMsg').innerText = "Maximum attempts exceeded. App is locked."; 1419 document.getElementById('lockoutSub').innerText = "Use Forgot Password to reset."; 1420 document.getElementById('lockoutTimer').classList.add('hidden'); 1421 document.getElementById('permanentLockActions').classList.remove('hidden'); 1422 } 1423 1424 function pressKey(num) { 1425 if(num === -1) enteredPin = enteredPin.slice(0, -1); 1426 else if (enteredPin.length < 6) enteredPin += num; 1427 updatePinDots(enteredPin.length); 1428 if(enteredPin.length === 6) verifyPin(); 1429 } 1430 1431 function updatePinDots(count, isError=false) { 1432 document.querySelectorAll('#pinDots .pin-dot').forEach((d, i) => { 1433 if(isError) { d.classList.add('error'); setTimeout(() => d.classList.remove('error'), 400); } 1434 else { d.classList.remove('error'); if(i < count) d.classList.add('filled'); else d.classList.remove('filled'); } 1435 }); 1436 } 1437 1438 function verifyPin() { 1439 if(checkLockoutState()) return; 1440 const storedPin = localStorage.getItem('wp_app_pin'); 1441 if(storedPin && enteredPin === storedPin) { 1442 failAttempts = 0; localStorage.setItem('wp_fail_attempts', '0'); localStorage.setItem('wp_lock_until', '0'); 1443 if (localStorage.getItem('wp_app_bio') === 'true') { 1444 document.getElementById('pinUI').classList.add('hidden'); document.getElementById('bioUI').classList.remove('hidden'); 1445 document.getElementById('lockTitle').innerText = "Verify Identity"; document.getElementById('lockIcon').innerText = "fingerprint"; 1446 triggerBiometricAuth(); 1447 } else unlockApp(); 1448 } else handleFailedAttempt(); 1449 } 1450 1451 async function triggerBiometricAuth() { 1452 if(checkLockoutState()) return; 1453 if(!document.getElementById('bioUI').classList.contains('hidden')) document.getElementById('lockTitle').innerText = "Verify Identity"; 1454 try { 1455 const storedId = localStorage.getItem('wp_bio_id'); 1456 const options = { publicKey: { challenge: new Uint8Array([1, 2, 3, 4]), timeout: 60000, userVerification: "required" } }; 1457 if (storedId) options.publicKey.allowCredentials = [{ type: 'public-key', id: base64ToArrayBuffer(storedId), transports: ['internal'] }]; 1458 const credential = await navigator.credentials.get(options); 1459 if(credential) { failAttempts = 0; localStorage.setItem('wp_fail_attempts', '0'); localStorage.setItem('wp_lock_until', '0'); unlockApp(); } 1460 } catch (e) { console.warn("Bio failed", e); } 1461 } 1462 1463 function unlockApp() { 1464 isLocked = false; 1465 document.getElementById('lock-screen').classList.remove('active'); 1466 document.getElementById('app-layout').classList.add('visible'); 1467 startAppSync(); 1468 } 1469 1470 // --- SECURITY SETTINGS --- 1471 function togglePinSetting() { 1472 if(document.getElementById('pinLockToggle').checked) openPinSetup(); else localStorage.removeItem('wp_app_pin'); 1473 } 1474 1475 function toggleBioSetting() { 1476 if(document.getElementById('bioLockToggle').checked) registerBiometric(); 1477 else { localStorage.removeItem('wp_app_bio'); localStorage.removeItem('wp_bio_id'); } 1478 } 1479 1480 function openPinSetup() { 1481 document.getElementById('pinSetupModal').classList.add('active'); 1482 setupStep = 1; setupPin = ""; enteredPin = ""; updateSetupDots(0); 1483 document.getElementById('pinSetupTitle').innerText = "Set 6-Digit PIN"; 1484 document.getElementById('pinSetupMsg').innerText = "Enter a new PIN"; 1485 } 1486 1487 function updateSetupDots(count) { 1488 document.querySelectorAll('#setupDots .pin-dot').forEach((d, i) => i < count ? d.classList.add('filled') : d.classList.remove('filled')); 1489 } 1490 1491 function pressSetupKey(num) { 1492 if(num === -1) enteredPin = enteredPin.slice(0, -1); 1493 else if (enteredPin.length < 6) enteredPin += num; 1494 updateSetupDots(enteredPin.length); 1495 1496 if(enteredPin.length === 6) { 1497 if(setupStep === 1) { 1498 setupPin = enteredPin; enteredPin = ""; setupStep = 2; 1499 setTimeout(() => { updateSetupDots(0); document.getElementById('pinSetupTitle').innerText = "Confirm PIN"; document.getElementById('pinSetupMsg').innerText = "Re-enter to confirm"; }, 200); 1500 } else if (setupStep === 2) { 1501 if(enteredPin === setupPin) { 1502 localStorage.setItem('wp_app_pin', setupPin); 1503 closeModal('pinSetupModal'); enteredPin = ""; 1504 } else { 1505 const dots = document.getElementById('setupDots'); dots.classList.add('shake'); 1506 setTimeout(() => { dots.classList.remove('shake'); enteredPin = ""; setupPin = ""; setupStep = 1; updateSetupDots(0); document.getElementById('pinSetupTitle').innerText = "Mismatch. Try Again"; document.getElementById('pinSetupMsg').innerText = "Enter a new PIN"; }, 500); 1507 } 1508 } 1509 } 1510 } 1511 1512 function cancelPinSetup() { document.getElementById('pinLockToggle').checked = false; closeModal('pinSetupModal'); } 1513 1514 async function registerBiometric() { 1515 try { 1516 const iconPath = new URL('/assets/icon.svg', window.location.href).toString(); 1517 const credential = await navigator.credentials.create({ 1518 publicKey: { challenge: new Uint8Array([1, 2, 3, 4]), rp: { name: "WhatsApp Logger", icon: iconPath }, user: { id: new Uint8Array([1]), name: "User", displayName: "App User" }, pubKeyCredParams: [{type: "public-key", alg: -7}], authenticatorSelection: { authenticatorAttachment: "platform", userVerification: "required" }, timeout: 60000 } 1519 }); 1520 1521 if(credential) { 1522 localStorage.setItem('wp_bio_id', arrayBufferToBase64(credential.rawId)); 1523 localStorage.setItem('wp_app_bio', 'true'); 1524 } else throw new Error(); 1525 } catch (e) { document.getElementById('bioLockToggle').checked = false; } 1526 } 1527 1528 function showForgotPin() { document.getElementById('forgotPinModal').classList.add('active'); } 1529 1530 async function attemptPinReset() { 1531 const btn = document.getElementById('resetBtn'); const err = document.getElementById('resetError'); 1532 btn.classList.add('loading'); btn.disabled = true; err.style.display = 'none'; 1533 1534 try { 1535 const response = await fetch(`${RENDER_BACKEND_URL}/api/verify`, { 1536 method: 'POST', headers: {'Content-Type': 'application/json'}, 1537 body: JSON.stringify({ username: document.getElementById('resetUsername').value, password: document.getElementById('resetPassword').value }) 1538 }); 1539 const data = await response.json(); 1540 1541 if(data.success) { 1542 ['wp_app_pin','wp_app_bio','wp_bio_id','wp_fail_attempts','wp_lock_until'].forEach(k => localStorage.removeItem(k)); 1543 failAttempts = 0; lockUntil = 0; 1544 document.getElementById('pinLockToggle').checked = false; document.getElementById('bioLockToggle').checked = false; 1545 document.getElementById('lockoutUI').classList.add('hidden'); document.getElementById('lockHeader').classList.remove('hidden'); 1546 document.getElementById('lock-screen').classList.remove('locked-mode'); 1547 closeModal('forgotPinModal'); unlockApp(); 1548 } else throw new Error(); 1549 } catch (e) { err.style.display = 'block'; } finally { btn.classList.remove('loading'); btn.disabled = false; } 1550 } 1551 1552 function togglePasswordVisibility(id, iconId) { 1553 const input = document.getElementById(id); const icon = document.getElementById(iconId); 1554 if (input.type === 'password') { input.type = 'text'; icon.innerText = 'visibility_off'; } 1555 else { input.type = 'password'; icon.innerText = 'visibility'; } 1556 } 1557 1558 async function attemptLogin() { 1559 const btn = document.getElementById('loginBtn'); const err = document.getElementById('authError'); 1560 btn.classList.add('loading'); btn.disabled = true; err.style.opacity = '0'; 1561 try { 1562 const response = await fetch(`${RENDER_BACKEND_URL}/api/verify`, { 1563 method: 'POST', headers: {'Content-Type': 'application/json'}, 1564 body: JSON.stringify({ username: document.getElementById('username').value, password: document.getElementById('password').value }) 1565 }); 1566 const data = await response.json(); 1567 if(data.success && data.token) { 1568 localStorage.setItem('wp_api_token', data.token); 1569 1570 if(document.getElementById('rememberMe').checked) localStorage.setItem('wp_auth_expiry', Date.now() + (3 * 24 * 60 * 60 * 1000)); 1571 else localStorage.setItem('wp_auth_expiry', Date.now() + (1 * 60 * 60 * 1000)); 1572 1573 revealApp(); 1574 } else throw new Error(); 1575 } catch (e) { err.style.opacity = '1'; btn.classList.remove('loading'); btn.disabled = false; } 1576 } 1577 1578 function revealApp() { 1579 document.getElementById('auth-screen').style.opacity = '0'; 1580 setTimeout(() => { 1581 document.getElementById('auth-screen').style.display = 'none'; 1582 document.getElementById('app-layout').classList.add('visible'); 1583 startAppSync(); 1584 }, 400); 1585 } 1586 1587 function openSettings() { 1588 document.getElementById('settingsSidebar').classList.add('active'); 1589 calculateLocalStorageUsage(); 1590 } 1591 1592 function closeSettingsSidebar() { 1593 document.getElementById('settingsSidebar').classList.remove('active'); 1594 } 1595 1596 async function clearAppCacheData() { 1597 if (confirm("Are you absolutely sure you want to clear all cached data? This will clear your offline cache databases and instantly reload the workspace application.")) { 1598 closeSettingsSidebar(); 1599 document.getElementById('refreshModal').classList.add('active'); 1600 document.getElementById('refreshAuthStep').classList.add('hidden'); 1601 document.getElementById('refreshProgressStep').classList.remove('hidden'); 1602 1603 await executeHardRefresh(); 1604 } 1605 } 1606 function closeModal(id) { document.getElementById(id).classList.remove('active'); } 1607 1608 function changeTheme(themeName) { 1609 document.body.setAttribute('data-theme', themeName); 1610 localStorage.setItem('theme', themeName); 1611 } 1612 1613 function changeFontSize(val) { document.documentElement.style.setProperty('--base-font-size', `${val}px`); } 1614 1615 function saveProfile() { localStorage.setItem('profileName', document.getElementById('profileName').value); localStorage.setItem('profilePhone', document.getElementById('profilePhone').value); } 1616 function populateFAQ() { 1617 const faqs = [ 1618 {q: "Is this secure?", a: "Yes, data is secured on your own custom backend server and access is guarded by your API Token."}, 1619 {q: "Does it track deleted messages?", a: "Yes, once a message is received by the server, it is logged forever, even if the sender deletes it."}, 1620 {q: "Does my phone need to be online?", a: "No. Once linked, your Render server handles logging even if your phone is powered off."}, 1621 {q: "Is this legal?", a: "The tool uses standard WhatsApp Web protocols to function as a linked device. It is intended for personal archiving of your own conversations."}, 1622 {q: "Does it save photos/videos?", a: "No. To keep the project free, the logger only saves text content. Media files would fill up your free tier storage very quickly."}, 1623 {q: "Does it save View Once messages?", a: "No. WhatsApp does not send the content of `View Once` media to linked devices/web clients, so the logger cannot see them."} 1624 ]; 1625 const container = document.getElementById('faqContainer'); 1626 faqs.forEach(item => { 1627 const div = document.createElement('div'); div.className = 'faq-item'; 1628 div.innerHTML = `<button class="faq-question" onclick="this.parentElement.classList.toggle('open')">${item.q} <span class="material-symbols-rounded">expand_more</span></button><div class="faq-answer">${item.a}</div>`; 1629 container.appendChild(div); 1630 }); 1631 } 1632 1633 function toggleChatMenu(event) { if(event) event.stopPropagation(); document.getElementById('chatMenu').classList.toggle('show'); } 1634 document.addEventListener('click', function(e) { const menu = document.getElementById('chatMenu'); if(menu.classList.contains('show') && !e.target.closest('.menu-container')) menu.classList.remove('show'); }); 1635 1636 function toggleChatSearch() { 1637 const searchContainer = document.getElementById('inChatSearch'); 1638 searchContainer.classList.toggle('hidden'); 1639 document.getElementById('chatMenu').classList.remove('show'); 1640 1641 const input = document.getElementById('msgSearchInput'); 1642 if (searchContainer.classList.contains('hidden')) { 1643 input.value = ''; 1644 input.dispatchEvent(new Event('input')); 1645 } else { 1646 input.focus(); 1647 } 1648 } 1649 1650 document.getElementById('msgSearchInput').addEventListener('input', (e) => { 1651 const term = e.target.value.toLowerCase(); 1652 let count = 0; 1653 1654 document.querySelectorAll('.msg-bubble').forEach(b => { 1655 const textDiv = b.querySelector('.msg-text'); 1656 if(textDiv) { 1657 if (!textDiv.hasAttribute('data-orig')) { 1658 textDiv.setAttribute('data-orig', textDiv.innerHTML); 1659 } 1660 1661 const originalHTML = textDiv.getAttribute('data-orig'); 1662 const rawText = textDiv.textContent || ""; 1663 1664 if(term && rawText.toLowerCase().includes(term)) { 1665 b.style.display = 'flex'; 1666 const safeTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 1667 const regex = new RegExp(`(?![^<]*>)(${safeTerm})`, 'gi'); 1668 1669 textDiv.innerHTML = originalHTML.replace(regex, '<span class="highlight">$1</span>'); 1670 count++; 1671 } else { 1672 b.style.display = term ? 'none' : 'flex'; 1673 textDiv.innerHTML = originalHTML; 1674 } 1675 } 1676 }); 1677 document.getElementById('searchCount').innerText = term ? `${count} found` : ''; 1678 }); 1679 1680 // --- SSE: CHATS LIST --- 1681 async function loadChats() { 1682 const list = document.getElementById('contactList'); 1683 const token = localStorage.getItem('wp_api_token'); 1684 if(!token) return handleLogout(); 1685 1686 const cachedChats = await getCachedChats(); 1687 if (cachedChats && cachedChats.length > 0) { 1688 cachedChats.sort((a,b) => (b.lastActive || 0) - (a.lastActive || 0) || a.id.localeCompare(b.id)); 1689 renderChatList(cachedChats); 1690 } else { 1691 list.innerHTML = ''; 1692 for(let i=0; i<5; i++) list.innerHTML += `<div class="skel-contact fade-in"><div class="skeleton skel-avatar"></div><div class="skel-text-col"><div class="skeleton" style="width:60%; height:16px;"></div><div class="skeleton" style="width:40%; height:12px;"></div></div></div>`; 1693 } 1694 1695 if (!navigator.onLine) return; 1696 1697 if (chatEventSource) { 1698 chatEventSource.close(); 1699 chatEventSource = null; 1700 } 1701 1702 function connectChatsStream() { 1703 chatEventSource = new EventSource(`${RENDER_BACKEND_URL}/api/chats/stream?token=${token}`); 1704 1705 chatEventSource.addEventListener('initial', async (e) => { 1706 chatEventRetryCount = 0; 1707 try { 1708 const rawChats = JSON.parse(e.data); 1709 // Normalize subIds to ids to prevent silent crashes 1710 const chats = rawChats.map(g => ({ ...g, ids: g.subIds || g.ids })); 1711 1712 rawChatsMap.clear(); 1713 chats.forEach(g => { 1714 if (g.ids) { 1715 g.ids.forEach(id => { 1716 rawChatsMap.set(id, { id: id, lastActive: g.lastActive, phoneNumber: g.phoneNumber, customName: g.customName, displayName: g.displayName, preview: g.preview }); 1717 }); 1718 } 1719 }); 1720 1721 chats.sort((a,b) => (b.lastActive || 0) - (a.lastActive || 0) || a.id.localeCompare(b.id)); 1722 await saveCachedChats(chats); 1723 renderChatList(chats); 1724 } catch (err) { 1725 console.error("Chat list initial parse error:", err); 1726 } 1727 }); 1728 1729 chatEventSource.addEventListener('update', async (e) => { 1730 try { 1731 const changes = JSON.parse(e.data); 1732 let needsRebuild = false; 1733 1734 changes.forEach(change => { 1735 if (change.type === 'added' || change.type === 'modified') { 1736 rawChatsMap.set(change.doc.id, change.doc); 1737 needsRebuild = true; 1738 } else if (change.type === 'removed') { 1739 rawChatsMap.delete(change.doc.id); 1740 needsRebuild = true; 1741 } 1742 }); 1743 1744 if (needsRebuild) { 1745 const groups = {}; 1746 rawChatsMap.forEach((d, docId) => { 1747 const phone = d.phoneNumber || docId.split('@')[0]; 1748 if (!groups[phone]) { 1749 groups[phone] = { main: { id: docId, ...d }, ids: [docId] }; 1750 } else { 1751 groups[phone].ids.push(docId); 1752 if ((d.lastActive || 0) > (groups[phone].main.lastActive || 0)) { 1753 groups[phone].main = { id: docId, ...d }; 1754 } 1755 } 1756 }); 1757 1758 const chats = Object.values(groups).map(g => ({...g.main, ids: g.ids})).sort((a,b) => (b.lastActive || 0) - (a.lastActive || 0) || a.id.localeCompare(b.id)); 1759 await saveCachedChats(chats); 1760 renderChatList(chats); 1761 } 1762 } catch (err) { 1763 console.error("Chat list update parse error:", err); 1764 } 1765 }); 1766 1767 chatEventSource.onerror = (err) => { 1768 chatEventSource.close(); 1769 chatEventRetryCount++; 1770 const backoff = Math.min(1000 * Math.pow(2, chatEventRetryCount), 30000); 1771 setTimeout(connectChatsStream, backoff); 1772 }; 1773 } 1774 1775 connectChatsStream(); 1776 } 1777 1778 function renderChatList(chats) { 1779 const list = document.getElementById('contactList'); 1780 1781 if (chats.length === 0) { 1782 list.innerHTML = `<div style="text-align:center; padding:32px; color:var(--md-sys-color-outline);">No chats found</div>`; 1783 return; 1784 } 1785 1786 if (list.querySelector('.skel-contact') || list.innerHTML.includes('No chats found')) { 1787 list.innerHTML = ''; 1788 } 1789 1790 const currentIds = new Set(chats.map(c => 'chat-' + c.id.replace(/[^a-zA-Z0-9]/g, '-'))); 1791 1792 chats.forEach((c) => { 1793 const safeDomId = 'chat-' + c.id.replace(/[^a-zA-Z0-9]/g, '-'); 1794 let el = document.getElementById(safeDomId); 1795 1796 const primaryName = c.customName || c.displayName || c.phoneNumber || c.id; 1797 const isGroup = c.id.includes('@g.us'); 1798 1799 if (!el) { 1800 el = document.createElement('div'); 1801 el.id = safeDomId; 1802 el.className = 'contact-item ripple-surface'; 1803 el.innerHTML = ` 1804 <div class="avatar">${isGroup ? '<span class="material-symbols-rounded">group</span>' : escapeHTML(primaryName[0]).toUpperCase()}</div> 1805 <div class="contact-info" style="min-width: 0;"> 1806 <div class="contact-name" style="display:flex; justify-content:space-between; align-items:baseline; width:100%;"> 1807 <span class="name-text" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;">${escapeHTML(primaryName)}</span> 1808 <span class="chat-time" style="font-size: 12px; color: var(--md-sys-color-on-surface-variant); font-weight: 400; margin-left: 8px; flex-shrink: 0;"></span> 1809 </div> 1810 <div class="contact-sub preview-text" style="display:flex; align-items:center; margin-top:2px; color:var(--md-sys-color-on-surface-variant);"> 1811 <span class="preview-content" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; width:100%;"></span> 1812 </div> 1813 </div> 1814 `; 1815 el.dataset.lastActive = c.lastActive; 1816 } else { 1817 const nameSpan = el.querySelector('.name-text'); 1818 if (nameSpan.innerText !== primaryName) { 1819 nameSpan.innerText = primaryName; 1820 el.querySelector('.avatar').innerHTML = isGroup ? '<span class="material-symbols-rounded">group</span>' : escapeHTML(primaryName[0]).toUpperCase(); 1821 } 1822 el.dataset.lastActive = c.lastActive; 1823 } 1824 1825 list.appendChild(el); 1826 1827 el.onclick = () => openChat(c.ids, primaryName, el, isGroup, c.phoneNumber || c.id, c.lastActive); 1828 1829 // Update server-side preview directly without extra network reads 1830 const contentSpan = el.querySelector('.preview-content'); 1831 const timeSpan = el.querySelector('.chat-time'); 1832 1833 if (c.lastActive) { 1834 const msgDate = new Date(c.lastActive * 1000); 1835 const today = new Date(); 1836 const isToday = msgDate.toDateString() === today.toDateString(); 1837 1838 timeSpan.innerText = isToday 1839 ? msgDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) 1840 : msgDate.toLocaleDateString([], { month: 'short', day: 'numeric' }); 1841 1842 contentSpan.innerHTML = escapeHTML(c.preview || 'Media / Call'); 1843 } else { 1844 contentSpan.innerHTML = `<span style="font-style: italic; opacity: 0.7;">No messages</span>`; 1845 } 1846 1847 if (activeChatId && c.ids.includes(activeChatId)) { 1848 const baseId = c.phoneNumber || c.id; 1849 document.getElementById('headerId').innerText = `${baseId} • ${formatLastActive(c.lastActive)}`; 1850 } 1851 }); 1852 1853 Array.from(list.children).forEach(child => { 1854 if (child.id && child.id.startsWith('chat-') && !currentIds.has(child.id)) { 1855 list.removeChild(child); 1856 } 1857 }); 1858 } 1859 1860 // --- SSE: GLOBAL MESSAGES SYNC --- 1861 // Instead of a connection per chat, we open ONE connection globally to pull missing messages 1862 async function connectGlobalSyncStream() { 1863 const token = localStorage.getItem('wp_api_token'); 1864 if(!token || !navigator.onLine) return; 1865 1866 if(syncEventSource) { 1867 syncEventSource.close(); 1868 syncEventSource = null; 1869 } 1870 1871 maxCachedTimestamp = await getMaxCachedTimestamp(); 1872 1873 // Wait slightly for IDB to settle, show small UI if caching up 1874 const spinner = document.getElementById('globalSyncSpinner'); 1875 if(maxCachedTimestamp > 0) spinner.classList.add('active'); 1876 1877 syncEventSource = new EventSource(`${RENDER_BACKEND_URL}/api/sync/stream?since=${maxCachedTimestamp}&token=${token}`); 1878 1879 const handleSyncData = async (dataArray, eventType) => { 1880 const newToCache = []; 1881 let activeChatUpdated = false; 1882 1883 dataArray.forEach(item => { 1884 const data = eventType === 'initial' ? item : item.doc; 1885 if (eventType === 'update' && item.type === 'removed') return; 1886 1887 // The server must include chatId for this wildcard payload to work 1888 if(!data.chatId) return; 1889 1890 newToCache.push(data); 1891 1892 if (activeChatIds && activeChatIds.includes(data.chatId)) { 1893 activeChatUpdated = true; 1894 } 1895 }); 1896 1897 if (newToCache.length > 0) { 1898 await saveCachedMessages(newToCache); 1899 // Update our tracker 1900 const maxTs = Math.max(...newToCache.map(m => m.timestamp || 0)); 1901 if (maxTs > maxCachedTimestamp) maxCachedTimestamp = maxTs; 1902 } 1903 1904 // If the currently open chat received messages, re-render from cache 1905 if (activeChatUpdated && activeChatId) { 1906 refreshActiveChatUI(); 1907 } 1908 1909 if (eventType === 'initial') { 1910 spinner.classList.remove('active'); 1911 } 1912 }; 1913 1914 syncEventSource.addEventListener('initial', (e) => { 1915 syncEventRetryCount = 0; 1916 try { handleSyncData(JSON.parse(e.data), 'initial'); } catch(err) { console.error("Sync initial err", err); } 1917 }); 1918 1919 syncEventSource.addEventListener('update', (e) => { 1920 try { handleSyncData(JSON.parse(e.data), 'update'); } catch(err) { console.error("Sync update err", err); } 1921 }); 1922 1923 syncEventSource.onerror = (err) => { 1924 syncEventSource.close(); 1925 syncEventRetryCount++; 1926 spinner.classList.remove('active'); 1927 const backoff = Math.min(1000 * Math.pow(2, syncEventRetryCount), 60000); 1928 setTimeout(connectGlobalSyncStream, backoff); 1929 }; 1930 } 1931 1932 // --- LOCAL CHAT UI --- 1933 // opening a chat no longer reaches out to the server via SSE directly 1934 async function openChat(ids, name, el, isGroup, displayId, lastActive) { 1935 activeChatId = ids[0]; 1936 activeChatIds = ids; 1937 isCurrentChatGroup = isGroup; 1938 1939 document.getElementById('headerName').innerText = name; 1940 document.getElementById('chatHeaderContent').style.opacity = '1'; 1941 1942 const timeStr = lastActive ? formatLastActive(lastActive) : "Online"; 1943 document.getElementById('headerId').innerText = `${displayId} • ${timeStr}`; 1944 document.getElementById('headerAvatar').innerHTML = isGroup ? `<span class="material-symbols-rounded">group</span>` : escapeHTML(name[0]).toUpperCase(); 1945 1946 if (window.matchMedia('(max-width: 768px)').matches) { 1947 window.history.pushState({chatOpen: true}, ""); 1948 document.getElementById('sidebar').classList.add('inactive'); 1949 document.getElementById('chatArea').classList.add('active'); 1950 } 1951 1952 const container = document.getElementById('messagesContainer'); 1953 // Show skeleton loader initially before DB read completes 1954 container.innerHTML = `<div class="skel-msg-group"><div class="skeleton skel-bubble skel-left"></div><div class="skeleton skel-bubble skel-right" style="width:60%"></div><div class="skeleton skel-bubble skel-left" style="width:30%"></div></div>`; 1955 1956 await refreshActiveChatUI(true); 1957 } 1958 1959 async function refreshActiveChatUI(isInitialOpen = false) { 1960 if (!activeChatIds || activeChatIds.length === 0) return; 1961 1962 let cachedMsgs = []; 1963 for (const id of activeChatIds) { 1964 const msgs = await getCachedMessages(id); 1965 cachedMsgs = cachedMsgs.concat(msgs); 1966 } 1967 1968 const msgsMap = new Map(); 1969 cachedMsgs.forEach(m => msgsMap.set(m.id, m)); 1970 1971 const c = document.getElementById('messagesContainer'); 1972 const wasAtBottom = (c.scrollHeight - c.scrollTop - c.clientHeight) <= 100; 1973 1974 if (cachedMsgs.length > 0) { 1975 renderMsgs(msgsMap); 1976 1977 if (isInitialOpen || wasAtBottom) { 1978 if (isInitialOpen) { 1979 scrollToBottom('auto'); // Sync scroll prevents flashing top messages 1980 } else { 1981 setTimeout(() => scrollToBottom('smooth'), 50); // Smooth scroll for new live messages 1982 } 1983 } else if (!isInitialOpen && !wasAtBottom) { 1984 document.getElementById('newMsgBadge').classList.remove('hidden'); 1985 document.getElementById('scrollFab').classList.add('visible'); 1986 } 1987 } else { 1988 c.innerHTML = '<div class="empty-state"><p>No messages recorded</p></div>'; 1989 } 1990 } 1991 1992 function renderMsgs(map) { 1993 const container = document.getElementById('messagesContainer'); 1994 container.innerHTML = ''; 1995 currentMessages = Array.from(map.values()).sort((a,b) => a.timestamp - b.timestamp); 1996 1997 if(currentMessages.length === 0) { 1998 container.innerHTML = '<div class="empty-state"><p>No messages recorded</p></div>'; 1999 return; 2000 } 2001 2002 let lastDateStr = ""; let currentGroup = null; 2003 2004 currentMessages.forEach((m, index) => { 2005 const date = new Date(m.timestamp * 1000); 2006 const dateStr = date.toDateString(); 2007 2008 if(dateStr !== lastDateStr) { 2009 if(currentGroup) container.appendChild(currentGroup); 2010 currentGroup = document.createElement('div'); 2011 currentGroup.className = 'chat-date-group'; 2012 const div = document.createElement('div'); div.className = 'date-divider'; div.innerText = formatDividerDate(m.timestamp); 2013 currentGroup.appendChild(div); 2014 lastDateStr = dateStr; 2015 } 2016 2017 const div = document.createElement('div'); 2018 div.className = `msg-bubble ${m.fromMe ? 'msg-out' : 'msg-in'}`; 2019 2020 const nextMsg = currentMessages[index + 1]; const prevMsg = currentMessages[index - 1]; 2021 const isSamePrev = (prevMsg && prevMsg.fromMe === m.fromMe && (!isCurrentChatGroup || prevMsg.senderName === m.senderName) && new Date(prevMsg.timestamp*1000).toDateString() === dateStr); 2022 const isSameNext = (nextMsg && nextMsg.fromMe === m.fromMe && (!isCurrentChatGroup || nextMsg.senderName === m.senderName) && new Date(nextMsg.timestamp*1000).toDateString() === dateStr); 2023 2024 if (isSamePrev && isSameNext) div.classList.add('group-mid'); else if (isSamePrev) div.classList.add('group-bot'); else if (isSameNext) div.classList.add('group-top'); 2025 2026 const showSenderName = !m.fromMe && isCurrentChatGroup && m.senderName && m.senderName !== "Unknown" && !isSamePrev; 2027 div.id = `msg-${m.timestamp}`; div.dataset.date = dateStr; 2028 2029 const safeText = escapeHTML(m.text || ''); 2030 const urlRegex = /(https?:\/\/[^\s]+)/g; 2031 const formattedText = safeText ? safeText.replace(urlRegex, url => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline; font-weight: 600;">${url}</a>`) : ''; 2032 2033 div.innerHTML = `${showSenderName ? `<div class="msg-sender">${escapeHTML(m.senderName)}</div>` : ''}<div class="msg-text">${formattedText || 'Media'}</div><div class="msg-meta">${date.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</div>`; 2034 if (currentGroup) currentGroup.appendChild(div); 2035 }); 2036 2037 if(currentGroup) container.appendChild(currentGroup); 2038 } 2039 2040 function scrollToBottom(behavior = 'auto') { 2041 const c = document.getElementById('messagesContainer'); 2042 c.scrollTo({ top: c.scrollHeight, behavior: behavior }); 2043 document.getElementById('newMsgBadge').classList.add('hidden'); 2044 } 2045 2046 // --- CALENDAR LOGIC --- 2047 function openDateSearch() { document.getElementById('chatMenu').classList.remove('show'); document.getElementById('calendarModal').classList.add('active'); renderCalendar(calCurrentDate); } 2048 2049 function renderCalendar(date) { 2050 const year = date.getFullYear(); const month = date.getMonth(); 2051 document.getElementById('calMonthYear').innerText = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(date); 2052 const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); 2053 const grid = document.getElementById('calDatesGrid'); grid.innerHTML = ''; 2054 for(let i=0; i<firstDay; i++) grid.innerHTML += `<div class="cal-date empty"></div>`; 2055 for(let d=1; d<=daysInMonth; d++) { 2056 const dayEl = document.createElement('div'); dayEl.className = 'cal-date ripple-surface'; dayEl.innerText = d; 2057 if(calSelectedDate && calSelectedDate.getDate() === d && calSelectedDate.getMonth() === month && calSelectedDate.getFullYear() === year) dayEl.classList.add('active'); 2058 dayEl.onclick = () => selectDate(new Date(year, month, d)); grid.appendChild(dayEl); 2059 } 2060 } 2061 2062 function changeCalMonth(delta) { calCurrentDate.setMonth(calCurrentDate.getMonth() + delta); renderCalendar(calCurrentDate); } 2063 function selectDate(date) { calSelectedDate = date; document.getElementById('calSelectedDateStr').innerText = date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); renderCalendar(calCurrentDate); } 2064 2065 function jumpToDate() { 2066 if(!currentMessages.length) { closeModal('calendarModal'); return; } 2067 const targetTs = calSelectedDate.setHours(0,0,0,0) / 1000; 2068 const msg = currentMessages.find(m => m.timestamp >= targetTs); 2069 closeModal('calendarModal'); 2070 if(msg) { 2071 const el = document.getElementById(`msg-${msg.timestamp}`); 2072 if(el) { el.scrollIntoView({behavior: 'smooth', block: 'center'}); el.style.backgroundColor = 'var(--search-highlight)'; setTimeout(() => el.style.backgroundColor = '', 2000); } 2073 } else document.getElementById('notFoundModal').classList.add('active'); 2074 } 2075 2076 function handleLogout() { localStorage.removeItem('wp_api_token'); localStorage.removeItem('wp_auth_expiry'); localStorage.removeItem('wp_app_pin'); localStorage.removeItem('wp_app_bio'); location.reload(); } 2077 2078 function closeChatMobile() { 2079 window.history.back(); 2080 activeChatId = null; 2081 activeChatIds = []; 2082 } 2083 2084 window.addEventListener('popstate', () => { document.getElementById('chatArea').classList.remove('remove'); document.getElementById('chatArea').classList.remove('active'); }); 2085 2086 async function downloadChat() { 2087 if(!activeChatId || !activeChatIds || activeChatIds.length === 0) return; 2088 const token = localStorage.getItem('wp_api_token'); 2089 try { 2090 const res = await fetch(`${RENDER_BACKEND_URL}/api/export?token=${token}`, { headers: { 'Authorization': `Bearer ${token}` } }); 2091 2092 if (res.ok) { 2093 const exportData = await res.json(); 2094 2095 let allMessages = []; 2096 activeChatIds.forEach(id => { 2097 if (exportData.messages[id]) { 2098 allMessages = allMessages.concat(exportData.messages[id]); 2099 } 2100 }); 2101 2102 if (allMessages.length === 0) return; 2103 2104 // Sort by time 2105 allMessages.sort((a,b) => a.timestamp - b.timestamp); 2106 const text = allMessages.map(m => `[${new Date(m.timestamp*1000).toLocaleString()}] ${m.senderName}: ${m.text}`).join('\n'); 2107 2108 const blob = new Blob([text], {type:'text/plain'}); 2109 const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'chat.txt'; a.click(); 2110 } 2111 } catch (err) { 2112 console.error("Export download error:", err); 2113 } 2114 } 2115 2116 function showRenameModal() { if(!activeChatId) return; document.getElementById('renameModal').classList.add('active'); document.getElementById('renameInput').value = document.getElementById('headerName').innerText; document.getElementById('chatMenu').classList.remove('show'); } 2117 2118 async function saveRename() { 2119 const name = document.getElementById('renameInput').value; 2120 const token = localStorage.getItem('wp_api_token'); 2121 if(name && token) { 2122 await fetch(`${RENDER_BACKEND_URL}/api/rename`, { 2123 method: 'POST', 2124 headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, 2125 body: JSON.stringify({ id: activeChatId, customName: name }) 2126 }); 2127 2128 document.getElementById('headerName').innerText = name; 2129 closeModal('renameModal'); 2130 // Will automatically refresh in sidebar via SSE update hook 2131 } 2132 } 2133 2134 function showLogoutModal() { document.getElementById('logoutModal').classList.add('active'); } 2135 2136 document.getElementById('searchInput').addEventListener('input', (e) => { 2137 const val = e.target.value.toLowerCase(); 2138 document.querySelectorAll('.contact-item').forEach(el => { el.style.display = el.innerText.toLowerCase().includes(val) ? 'flex' : 'none'; }); 2139 }); 2140 2141 function refreshApp() { 2142 document.getElementById('appOptionsModal').classList.add('active'); 2143 } 2144 2145 function executePageRefresh() { 2146 window.location.reload(true); 2147 } 2148 2149 function openHardReset() { 2150 closeModal('appOptionsModal'); 2151 document.getElementById('refreshAuthStep').classList.remove('hidden'); 2152 document.getElementById('refreshProgressStep').classList.add('hidden'); 2153 document.getElementById('refreshUsername').value = ''; 2154 document.getElementById('refreshPassword').value = ''; 2155 document.getElementById('refreshError').style.display = 'none'; 2156 document.getElementById('refreshModal').classList.add('active'); 2157 } 2158 2159 async function startHardRefresh() { 2160 const btn = document.getElementById('refreshBtn'); const err = document.getElementById('refreshError'); 2161 const u = document.getElementById('refreshUsername').value; const p = document.getElementById('refreshPassword').value; 2162 2163 btn.classList.add('loading'); btn.disabled = true; err.style.display = 'none'; 2164 2165 try { 2166 const response = await fetch(`${RENDER_BACKEND_URL}/api/verify`, { 2167 method: 'POST', headers: {'Content-Type': 'application/json'}, 2168 body: JSON.stringify({ username: u, password: p }) 2169 }); 2170 const data = await response.json(); 2171 2172 if (!data.success) throw new Error(); 2173 2174 // Re-sync Token to be safe 2175 if (data.token) localStorage.setItem('wp_api_token', data.token); 2176 2177 document.getElementById('refreshAuthStep').classList.add('hidden'); 2178 document.getElementById('refreshProgressStep').classList.remove('hidden'); 2179 2180 await executeHardRefresh(); 2181 2182 } catch (e) { 2183 err.style.display = 'block'; 2184 btn.classList.remove('loading'); btn.disabled = false; 2185 } 2186 } 2187 2188 function updateRefreshProgress(percent, text) { 2189 document.getElementById('refreshProgressBar').style.width = `${percent}%`; 2190 document.getElementById('refreshPercentText').innerText = `${Math.floor(percent)}%`; 2191 document.getElementById('refreshStatusText').innerText = text; 2192 } 2193 2194 async function executeHardRefresh() { 2195 try { 2196 updateRefreshProgress(5, "Clearing caches..."); 2197 if ('caches' in window) { try { const keys = await caches.keys(); await Promise.all(keys.map(key => caches.delete(key))); } catch(e) {} } 2198 if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); for (let r of regs) await r.unregister(); } 2199 2200 updateRefreshProgress(10, "Clearing local storage..."); 2201 const theme = localStorage.getItem('theme'); 2202 const tkn = localStorage.getItem('wp_api_token'); 2203 localStorage.clear(); 2204 if(theme) localStorage.setItem('theme', theme); 2205 if(tkn) localStorage.setItem('wp_api_token', tkn); 2206 2207 updateRefreshProgress(15, "Wiping database..."); 2208 if(dbInstance) { dbInstance.close(); dbInstance = null; } 2209 2210 await new Promise(r => setTimeout(r, 100)); 2211 2212 await new Promise(res => { 2213 try { 2214 const req = indexedDB.deleteDatabase(DB_NAME); 2215 req.onsuccess = res; 2216 req.onerror = res; 2217 req.onblocked = () => { 2218 res(); 2219 }; 2220 } catch(e) { res(); } 2221 }); 2222 2223 await new Promise(r => setTimeout(r, 200)); 2224 2225 updateRefreshProgress(25, "Rebuilding database..."); 2226 isIdbSupported = true; 2227 await initIndexedDB(); 2228 2229 updateRefreshProgress(30, "Fetching chat list..."); 2230 2231 const token = localStorage.getItem('wp_api_token'); 2232 const res = await fetch(`${RENDER_BACKEND_URL}/api/export/chats`, { headers: { 'Authorization': `Bearer ${token}` } }); 2233 const exportData = await res.json(); 2234 2235 const chatsData = exportData.chats || []; 2236 const totalMessages = Math.max(1, exportData.totalMessages || 1); // Prevent division by zero 2237 const groups = {}; 2238 const excluded = ['917278779512@s.whatsapp.net', '201554426618024@lid']; 2239 2240 chatsData.forEach(d => { 2241 if (!d.lastActive || excluded.includes(d.id)) return; 2242 const phone = d.phoneNumber || d.id.split('@')[0]; 2243 if (!groups[phone]) groups[phone] = { main: d, ids: [d.id] }; 2244 else { 2245 groups[phone].ids.push(d.id); 2246 if (d.lastActive > groups[phone].main.lastActive) groups[phone].main = d; 2247 } 2248 }); 2249 2250 const chats = Object.values(groups).map(g => ({...g.main, ids: g.ids})).sort((a,b) => (b.lastActive || 0) - (a.lastActive || 0) || a.id.localeCompare(b.id)); 2251 await saveCachedChats(chats); 2252 2253 let messagesFetchedSoFar = 0; 2254 2255 for (const chat of chats) { 2256 const name = chat.customName || chat.displayName || chat.phoneNumber || chat.id; 2257 2258 let currentPercent = Math.min(99, 30 + ((messagesFetchedSoFar / totalMessages) * 70)); 2259 updateRefreshProgress(currentPercent, `Fetching: ${name}...`); 2260 2261 for (const id of chat.ids) { 2262 try { 2263 const msgRes = await fetch(`${RENDER_BACKEND_URL}/api/export/messages?chatId=${encodeURIComponent(id)}&token=${token}`, { headers: { 'Authorization': `Bearer ${token}` } }); 2264 if (msgRes.ok) { 2265 const msgData = await msgRes.json(); 2266 const msgs = msgData.messages || []; 2267 if (msgs.length > 0) { 2268 msgs.forEach(m => m.chatId = id); 2269 await saveCachedMessages(msgs); 2270 messagesFetchedSoFar += msgs.length; 2271 2272 currentPercent = Math.min(99, 30 + ((messagesFetchedSoFar / totalMessages) * 70)); 2273 updateRefreshProgress(currentPercent, `Fetching: ${name}...`); 2274 } 2275 } 2276 } catch (err) { 2277 console.warn(`Failed to fetch messages for ${id}`, err); 2278 } 2279 } 2280 } 2281 2282 updateRefreshProgress(100, "Sync complete! Restarting..."); 2283 setTimeout(() => window.location.reload(), 1000); 2284 2285 } catch (e) { 2286 console.error("Hard refresh error", e); 2287 updateRefreshProgress(100, "Error during sync. Reloading..."); 2288 setTimeout(() => window.location.reload(), 2000); 2289 } 2290 } 2291 2292 function calculateLocalStorageUsage() { 2293 let totalBytes = 0; 2294 for (let key in localStorage) { 2295 if (!localStorage.hasOwnProperty(key)) continue; 2296 totalBytes += ((key.length + localStorage[key].length) * 2); 2297 } 2298 2299 const sizeInKB = (totalBytes / 1024).toFixed(2); 2300 const displayElement = document.getElementById('localStorageSizeDisplay'); 2301 2302 if (displayElement) { 2303 if (sizeInKB >= 1024) { 2304 displayElement.innerText = `${(sizeInKB / 1024).toFixed(2)} MB occupied`; 2305 } else { 2306 displayElement.innerText = `${sizeInKB} KB occupied`; 2307 } 2308 } 2309 } 2310 2311 // --- OPTIONAL: DIAGNOSTICS LOGS --- 2312 let logsInterval = null; 2313 2314 function toggleLogs() { 2315 const isChecked = document.getElementById('logsToggle').checked; 2316 const viewer = document.getElementById('logsViewer'); 2317 const token = localStorage.getItem('wp_api_token'); 2318 2319 if (isChecked && token) { 2320 viewer.classList.remove('hidden'); 2321 fetchAndRenderLogs(token); 2322 logsInterval = setInterval(() => fetchAndRenderLogs(token), 10000); 2323 } else { 2324 viewer.classList.add('hidden'); 2325 if (logsInterval) clearInterval(logsInterval); 2326 } 2327 } 2328 2329 async function fetchAndRenderLogs(token) { 2330 try { 2331 const res = await fetch(`${RENDER_BACKEND_URL}/logs?format=json&token=${token}`); 2332 if (res.ok) { 2333 const logs = await res.json(); 2334 const viewer = document.getElementById('logsViewer'); 2335 viewer.innerHTML = ''; 2336 2337 logs.forEach(l => { 2338 const time = new Date(l.timestamp).toLocaleTimeString(); 2339 const div = document.createElement('div'); 2340 if (l.level === 'error') div.className = 'error'; 2341 div.innerText = `[${time}] [${l.level.toUpperCase()}] ${l.message}`; 2342 viewer.appendChild(div); 2343 }); 2344 2345 viewer.scrollTop = viewer.scrollHeight; 2346 } 2347 } catch (e) {} 2348 } 2349 2350 </script> 2351 </body> 2352 </html>