index.html (126785B)
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 <!-- Firebase SDKs --> 22 <script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-app-compat.js"></script> 23 <script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-firestore-compat.js"></script> 24 25 <style> 26 :root { 27 /* Light Theme Tokens */ 28 --md-sys-color-primary: #006C4C; 29 --md-sys-color-on-primary: #FFFFFF; 30 --md-sys-color-primary-container: #89F8C7; 31 --md-sys-color-on-primary-container: #002114; 32 33 --md-sys-color-secondary: #4D6357; 34 --md-sys-color-on-secondary: #FFFFFF; 35 --md-sys-color-secondary-container: #CFE9D9; 36 --md-sys-color-on-secondary-container: #092016; 37 38 --md-sys-color-surface: #FBFDF9; 39 --md-sys-color-surface-dim: #D9DBD9; 40 --md-sys-color-surface-container: #ECEEEB; 41 --md-sys-color-surface-container-high: #E6E8E5; 42 --md-sys-color-surface-container-highest: #E1E3E0; 43 44 --md-sys-color-on-surface: #191C1A; 45 --md-sys-color-on-surface-variant: #404944; 46 --md-sys-color-outline: #707974; 47 --md-sys-color-outline-variant: #BFC9C2; 48 49 --md-sys-color-error: #BA1A1A; 50 --md-sys-color-error-container: #FFDAD6; 51 --md-sys-color-on-error-container: #410002; 52 53 /* WhatsApp Authentic Colors */ 54 --chat-bubble-me: #DCF8C6; 55 --chat-bubble-them: #FFFFFF; 56 --chat-bg-color: #ECE5DD; 57 --chat-bg-image-opacity: 0.08; 58 --search-highlight: #ffeb3b; 59 60 /* Elevations & Shapes */ 61 --elevation-1: 0px 1px 3px 1px rgba(0,0,0,0.15), 0px 1px 2px 0px rgba(0,0,0,0.3); 62 --elevation-2: 0px 2px 6px 2px rgba(0,0,0,0.15), 0px 1px 2px 0px rgba(0,0,0,0.3); 63 64 /* Settings */ 65 --base-font-size: 15px; 66 } 67 68 /* Dark Theme Tokens */ 69 [data-theme="dark"] { 70 --md-sys-color-primary: #89F8C7; 71 --md-sys-color-on-primary: #003828; 72 --md-sys-color-primary-container: #005138; 73 --md-sys-color-on-primary-container: #89F8C7; 74 75 --md-sys-color-secondary: #BACCB3; 76 --md-sys-color-on-secondary: #25342D; 77 --md-sys-color-secondary-container: #3B4B43; 78 --md-sys-color-on-secondary-container: #D6E8D8; 79 80 --md-sys-color-surface: #111412; 81 --md-sys-color-surface-dim: #111412; 82 --md-sys-color-surface-container: #1E211F; 83 --md-sys-color-surface-container-high: #282B29; 84 --md-sys-color-surface-container-highest: #333534; 85 86 --md-sys-color-on-surface: #E2E3DD; 87 --md-sys-color-on-surface-variant: #C2C8C5; 88 --md-sys-color-outline: #8C938F; 89 --md-sys-color-outline-variant: #424945; 90 91 --md-sys-color-error: #FFB4AB; 92 --md-sys-color-error-container: #93000A; 93 --md-sys-color-on-error-container: #FFDAD6; 94 95 --chat-bubble-me: #005C4B; 96 --chat-bubble-them: #202C33; 97 --chat-bg-color: #0B141A; 98 --chat-bg-image-opacity: 0.05; 99 --search-highlight: #6b6200; 100 } 101 102 * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } 103 104 body, *:not(.material-symbols-rounded) { 105 font-family: 'Lexend', sans-serif !important; 106 } 107 body { 108 background-color: var(--md-sys-color-surface); 109 color: var(--md-sys-color-on-surface); 110 height: 100vh; 111 overflow: hidden; 112 display: flex; 113 justify-content: center; 114 transition: background-color 0.3s, color 0.3s; 115 } 116 117 /* --- ANIMATIONS --- */ 118 .hidden { display: none !important; } 119 .fade-in { animation: fadeIn 0.4s cubic-bezier(0.2, 0.0, 0.0, 1.0); } 120 @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } 121 .ripple-surface { position: relative; overflow: hidden; transform: translate3d(0, 0, 0); transition: transform 0.1s; } 122 .ripple-surface:active { transform: scale(0.98); } 123 124 @keyframes scaleIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } 125 @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } 126 .animate-enter { animation: scaleIn 0.3s cubic-bezier(0.2, 0.0, 0, 1.0); } 127 .shake { animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both; } 128 @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); } } 129 130 /* --- M3 STAGGERED ENTRANCE --- */ 131 @keyframes fadeInUp { 132 from { opacity: 0; transform: translateY(30px); } 133 to { opacity: 1; transform: translateY(0); } 134 } 135 .anim-up { opacity: 0; animation: fadeInUp 0.6s cubic-bezier(0.2, 0, 0, 1) forwards; } 136 .delay-1 { animation-delay: 0.1s; } 137 .delay-2 { animation-delay: 0.2s; } 138 .delay-3 { animation-delay: 0.3s; } 139 .delay-4 { animation-delay: 0.4s; } 140 .delay-5 { animation-delay: 0.5s; } 141 142 /* --- AUTH SCREEN --- */ 143 #auth-screen { 144 position: fixed; inset: 0; z-index: 999; 145 background: radial-gradient(circle at 0% 0%, #C1E8FB 0%, transparent 50%), 146 radial-gradient(circle at 100% 100%, #89F8C7 0%, transparent 50%), 147 #FBFDF9; 148 display: flex; flex-direction: column; align-items: center; justify-content: center; 149 padding: 24px; height: 100dvh; 150 transition: opacity 0.4s cubic-bezier(0.2, 0, 0, 1); 151 } 152 153 [data-theme="dark"] #auth-screen { 154 background: radial-gradient(circle at 0% 0%, #001F29 0%, transparent 50%), 155 radial-gradient(circle at 100% 100%, #002114 0%, transparent 50%), 156 #111412; 157 } 158 159 .auth-card { 160 background: rgba(251, 253, 249, 0.7); 161 backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); 162 border-radius: 28px; 163 padding: 32px 24px; 164 width: 90%; max-width: 380px; 165 box-shadow: var(--elevation-2); 166 display: flex; flex-direction: column; gap: 20px; 167 position: relative; 168 } 169 170 [data-theme="dark"] .auth-card { 171 background: rgba(30, 33, 31, 0.7); 172 } 173 174 .brand-icon { 175 width: 64px; height: 64px; 176 display: flex; align-items: center; justify-content: center; 177 margin: 0 auto 8px; 178 } 179 .brand-icon img { width: 100%; height: 100%; object-fit: contain; } 180 181 /* Custom Spinner for the Boot Loader */ 182 [data-theme="dark"] #authLoader .spinner { 183 border-color: rgba(255,255,255,0.1) !important; 184 border-top-color: var(--md-sys-color-primary) !important; 185 } 186 187 /* --- LOCK SCREEN (PIN/BIO) --- */ 188 #lock-screen { 189 position: fixed; inset: 0; z-index: 2000; 190 background: var(--md-sys-color-surface); 191 display: flex; flex-direction: column; align-items: center; justify-content: space-between; 192 padding: 48px 24px; opacity: 0; visibility: hidden; transition: opacity 0.3s; 193 } 194 #lock-screen.active { opacity: 1; visibility: visible; } 195 196 .lock-header { text-align: center; margin-top: 4vh; } 197 .lock-icon { width: 64px; height: 64px; margin-bottom: 16px; color: var(--md-sys-color-primary); } 198 .lock-title { font-family: 'Lexend', sans-serif; font-size: 24px; margin-bottom: 8px; } 199 .lock-sub { color: var(--md-sys-color-on-surface-variant); font-size: 14px; } 200 201 /* PIN Dots */ 202 .pin-dots { display: flex; gap: 16px; margin: 32px 0; justify-content: center; } 203 .pin-dot { 204 width: 14px; height: 14px; border-radius: 50%; 205 border: 1px solid var(--md-sys-color-outline); 206 transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); 207 } 208 .pin-dot.filled { background: var(--md-sys-color-on-surface); border-color: var(--md-sys-color-on-surface); transform: scale(1.1); } 209 .pin-dot.error { border-color: var(--md-sys-color-error); background: var(--md-sys-color-error); } 210 211 /* Native Keypad */ 212 .keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px 32px; margin-bottom: 2vh; } 213 .key-btn { 214 width: 72px; height: 72px; border-radius: 50%; border: none; background: transparent; 215 font-size: 28px; font-weight: 400; color: var(--md-sys-color-on-surface); 216 display: flex; align-items: center; justify-content: center; cursor: pointer; 217 transition: background 0.2s; font-family: 'Lexend', sans-serif; 218 } 219 .key-btn:active { background: var(--md-sys-color-surface-container-high); } 220 .key-backspace { font-size: 24px; color: var(--md-sys-color-on-surface-variant); } 221 222 .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; } 223 224 /* --- LOCKOUT UI --- */ 225 .lockout-container { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; width: 100%; animation: fadeIn 0.3s; flex: 1; } 226 .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; } 227 .lockout-icon { font-size: 40px; } 228 .lockout-title { font-family: 'Lexend', sans-serif; font-size: 28px; margin-bottom: 8px; color: var(--md-sys-color-on-surface); } 229 .lockout-msg { color: var(--md-sys-color-on-surface-variant); margin-bottom: 32px; font-size: 16px; } 230 .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; } 231 .lockout-sub { margin-top: 16px; color: var(--md-sys-color-on-surface-variant); font-size: 14px; } 232 .lockout-actions { margin-top: 24px; display: flex; gap: 12px; } 233 .locked-mode #pinUI, .locked-mode #bioUI, .locked-mode .forgot-pin-btn { display: none !important; } 234 235 /* --- UI ELEMENTS --- */ 236 .m3-field { 237 position: relative; margin-bottom: 0; 238 background: var(--md-sys-color-surface-container-highest); 239 border-radius: 4px 4px 0 0; border-bottom: 1px solid var(--md-sys-color-outline); 240 transition: background 0.3s; height: 56px; 241 } 242 .m3-field:hover { background: rgba(29, 25, 43, 0.08); } 243 .m3-field:focus-within { border-bottom: 2px solid var(--md-sys-color-primary); } 244 .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; } 245 .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); } 246 .m3-input:focus ~ .m3-label, .m3-input:not(:placeholder-shown) ~ .m3-label { top: 8px; font-size: 12px; color: var(--md-sys-color-primary); } 247 .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; } 248 .eye-toggle:hover { background: rgba(0,0,0,0.05); color: var(--md-sys-color-on-surface); } 249 250 .m3-btn { 251 width: 100%; height: 48px; border-radius: 24px; border: none; outline: none; cursor: pointer; 252 font-family: 'Lexend', sans-serif; font-weight: 500; font-size: 14px; 253 display: flex; align-items: center; justify-content: center; gap: 8px; 254 transition: 0.2s; position: relative; overflow: hidden; 255 } 256 .m3-btn.filled { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); box-shadow: none; } 257 .m3-btn.filled:hover { box-shadow: var(--elevation-1); } 258 .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; } 259 260 .spinner { width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; border-top-color: white; animation: spin 0.8s linear infinite; display: none; } 261 .m3-btn.loading .btn-text { visibility: hidden; } 262 .m3-btn.loading .btn-icon { display: none; } 263 .m3-btn.loading .spinner { display: block; position: absolute; } 264 @keyframes spin { to { transform: rotate(360deg); } } 265 266 /* --- APP LAYOUT --- */ 267 #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; } 268 #app-layout.visible { opacity: 1; visibility: visible; } 269 270 /* SIDEBAR */ 271 .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; } 272 .sidebar-header { padding: 16px 20px; display: flex; justify-content: space-between; align-items: center; } 273 .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; } 274 .icon-btn:hover { background: rgba(128,128,128,0.1); color: var(--md-sys-color-on-surface); } 275 .search-container { padding: 0 16px 12px; } 276 .search-bar { background: var(--md-sys-color-surface-container-high); border-radius: 28px; height: 48px; display: flex; align-items: center; padding: 0 16px; } 277 .search-bar input { border: none; background: transparent; outline: none; flex: 1; margin-left: 12px; font-size: 16px; color: var(--md-sys-color-on-surface); } 278 .contact-list { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 8px; } 279 .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; } 280 .contact-item:hover { background: rgba(128,128,128,0.05); } 281 .contact-item.active { background: var(--md-sys-color-secondary-container); } 282 .contact-item.active .contact-name { color: var(--md-sys-color-on-secondary-container); font-weight: 700; } 283 .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; } 284 .contact-info { flex: 1; min-width: 0; } 285 .contact-name { font-size: 16px; font-weight: 500; color: var(--md-sys-color-on-surface); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 286 .contact-sub { font-size: 14px; color: var(--md-sys-color-on-surface-variant); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 287 288 /* CHAT AREA */ 289 .chat-area { flex: 1; display: flex; flex-direction: column; position: relative; background-color: var(--chat-bg-color); transition: background-color 0.3s; overflow: hidden; } 290 .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 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='0.08' fill-rule='evenodd'/%3E%3C/svg%3E"); } 291 .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; } 292 293 .chat-content { 294 flex: 1; overflow-y: auto; padding: 16px; z-index: 1; 295 display: flex; flex-direction: column; gap: 4px; 296 scroll-behavior: auto !important; 297 transition: padding-bottom 0.4s cubic-bezier(0.2, 0, 0, 1); 298 } 299 300 /* Forces bottom-up alignment when content is sparse */ 301 .chat-content::before { 302 content: ''; 303 flex: 1 1 auto; 304 } 305 306 .chat-content.syncing { 307 padding-bottom: 72px; /* Cinematic push up */ 308 } 309 310 .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; } 311 .msg-in { align-self: flex-start; background: var(--chat-bubble-them); border-radius: 0 16px 16px 16px; color: var(--md-sys-color-on-surface); } 312 .msg-out { align-self: flex-end; background: var(--chat-bubble-me); border-radius: 16px 0 16px 16px; color: var(--md-sys-color-on-surface); } 313 314 .msg-out.group-top { border-bottom-right-radius: 4px; margin-bottom: 2px; } 315 .msg-out.group-mid { border-top-right-radius: 4px; border-bottom-right-radius: 4px; margin-bottom: 2px; } 316 .msg-out.group-bot { border-top-right-radius: 4px; margin-top: 0; } 317 .msg-in.group-top { border-bottom-left-radius: 4px; margin-bottom: 2px; } 318 .msg-in.group-mid { border-top-left-radius: 4px; border-bottom-left-radius: 4px; margin-bottom: 2px; } 319 .msg-in.group-bot { border-top-left-radius: 4px; margin-top: 0; } 320 321 .msg-sender { font-size: 12px; font-weight: 700; color: var(--md-sys-color-primary); margin-bottom: 2px; } 322 .msg-text { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } 323 .msg-meta { font-size: 10px; opacity: 0.7; align-self: flex-end; margin-top: 2px; } 324 325 /* DATE DIVIDERS */ 326 .chat-date-group { display: flex; flex-direction: column; gap: 4px; position: relative; } 327 .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; } 328 329 .highlight { background-color: var(--search-highlight); color: black; border-radius: 2px; padding: 0 2px; } 330 331 /* Sync Loading Spinner (Cinematic UI) */ 332 .sync-spinner-wrapper { 333 position: absolute; bottom: 16px; left: 0; right: 0; 334 display: flex; justify-content: center; 335 pointer-events: none; z-index: 10; 336 transform: translateY(150%); opacity: 0; 337 transition: all 0.4s cubic-bezier(0.2, 0, 0, 1); 338 } 339 .sync-spinner-wrapper.active { 340 transform: translateY(0); opacity: 1; 341 } 342 .sync-chip { 343 background: var(--md-sys-color-surface-container-highest); 344 color: var(--md-sys-color-on-surface); 345 padding: 8px 16px; border-radius: 24px; 346 display: flex; align-items: center; gap: 10px; 347 font-size: 13px; font-weight: 500; 348 box-shadow: var(--elevation-2); 349 border: 1px solid var(--md-sys-color-outline-variant); 350 } 351 .sync-chip .spinner { 352 border-color: rgba(0,0,0,0.1); 353 border-top-color: var(--md-sys-color-primary); 354 width: 16px; height: 16px; 355 display: block; position: static; 356 } 357 [data-theme="dark"] .sync-chip .spinner { 358 border-color: rgba(255,255,255,0.1); 359 border-top-color: var(--md-sys-color-primary); 360 } 361 362 /* Search In Chat */ 363 .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); } 364 .in-chat-search input { border: none; background: transparent; outline: none; flex: 1; color: var(--md-sys-color-on-surface); } 365 366 /* Menu */ 367 .menu-container { position: relative; } 368 .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; } 369 .dropdown-menu.show { display: flex; } 370 .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; } 371 .menu-item:hover { background: rgba(128,128,128,0.1); } 372 373 /* Scroll To Bottom FAB */ 374 .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; } 375 .scroll-fab.visible { opacity: 1; transform: translateY(0); pointer-events: auto; } 376 .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); } 377 378 /* Offline Indicator */ 379 .offline-banner { position: fixed; top: 0; left: 50%; transform: translateX(-50%); background: var(--md-sys-color-error); color: var(--md-sys-color-on-error-container); 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); } 380 381 /* Calendar Modal */ 382 .calendar-card { background: var(--md-sys-color-surface); padding: 0; border-radius: 28px; width: 320px; overflow: hidden; display: flex; flex-direction: column; } 383 .cal-header { background: var(--md-sys-color-surface-container-high); padding: 16px 24px; } 384 .cal-header h3 { font-size: 14px; color: var(--md-sys-color-on-surface-variant); font-weight: 500; } 385 .cal-header h2 { font-size: 32px; color: var(--md-sys-color-on-surface); font-weight: 400; margin-top: 4px; } 386 .cal-grid { padding: 16px 12px 0; } 387 .cal-nav { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding: 0 12px; } 388 .cal-days { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; margin-bottom: 8px; } 389 .cal-day-label { font-size: 12px; color: var(--md-sys-color-on-surface-variant); width: 36px; } 390 .cal-dates { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 16px; } 391 .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); } 392 .cal-date:hover { background: var(--md-sys-color-surface-container-high); } 393 .cal-date.active { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); } 394 .cal-date.empty { pointer-events: none; } 395 396 /* Settings Modal */ 397 .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; } 398 .modal-overlay.active { opacity: 1; visibility: visible; } 399 .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); } 400 .settings-header { padding: 20px 24px; border-bottom: 1px solid var(--md-sys-color-outline-variant); display: flex; justify-content: space-between; align-items: center; } 401 .settings-content { flex: 1; overflow-y: auto; padding: 24px; } 402 .setting-section { margin-bottom: 32px; } 403 .setting-title { font-size: 14px; font-weight: 500; color: var(--md-sys-color-primary); margin-bottom: 16px; text-transform: uppercase; letter-spacing: 1px; } 404 .setting-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } 405 .setting-label h4 { font-size: 16px; font-weight: 400; margin-bottom: 4px; } 406 .setting-label p { font-size: 14px; color: var(--md-sys-color-on-surface-variant); } 407 408 .switch { position: relative; display: inline-block; width: 50px; height: 28px; } 409 .switch input { opacity: 0; width: 0; height: 0; } 410 .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); } 411 .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; } 412 input:checked + .slider { background-color: var(--md-sys-color-primary); border-color: var(--md-sys-color-primary); } 413 input:checked + .slider:before { transform: translateX(20px); background-color: var(--md-sys-color-on-primary); } 414 415 .faq-item { border-bottom: 1px solid var(--md-sys-color-outline-variant); } 416 .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; } 417 .faq-answer { display: none; padding-bottom: 16px; color: var(--md-sys-color-on-surface-variant); line-height: 1.5; } 418 .faq-item.open .faq-answer { display: block; } 419 420 .bg-selector { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 10px; } 421 .bg-option { height: 60px; border-radius: 8px; cursor: pointer; border: 2px solid transparent; background-size: cover; position: relative; overflow: hidden; } 422 .bg-option.selected { border-color: var(--md-sys-color-primary); } 423 .bg-1 { background-color: #ECE5DD; } 424 .bg-2 { background-color: #d1d7db; } 425 .bg-3 { background-color: #e5ddd5; } 426 .bg-4 { background-color: #ffffff; } 427 428 .install-sheet { display: none; } 429 .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; } 430 .install-card h4 { margin-bottom: 4px; } 431 .install-card p { font-size: 12px; } 432 .install-card .m3-btn { width: auto; height: 36px; padding: 0 16px; font-size: 14px; white-space: nowrap; flex-shrink: 0; box-shadow: none; } 433 434 @media (max-width: 768px) { 435 .sidebar { width: 100%; flex: 1; border-right: none; background: radial-gradient(circle at 0% 0%, #c1e8fb49 0%, transparent 50%), radial-gradient(circle at 100% 100%, #89f8c86b 0%, transparent 50%), #fbfdf96c; } 436 [data-theme="dark"] .sidebar { background: radial-gradient(circle at 0% 0%, #001F29 0%, transparent 50%), radial-gradient(circle at 100% 100%, #002114 0%, transparent 50%), #111412; } 437 .chat-area { position: fixed; inset: 0; z-index: 20; transform: translateX(100%); transition: transform 0.3s; background-color: var(--chat-bg-color); } 438 .chat-area.active { transform: translateX(0); } 439 .back-btn { display: flex !important; margin-right: 8px; } 440 .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); } 441 .install-sheet.visible { transform: translateY(0); } 442 .sheet-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } 443 .sheet-content { text-align: center; } 444 .sheet-actions { display: flex; flex-direction: column; gap: 12px; margin-top: 24px; } 445 } 446 .back-btn { display: none; margin-right: 8px; } 447 448 .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); } 449 .modal-overlay.active .modal-card { transform: scale(1); } 450 .modal-card.utility { width: 320px; padding: 24px; border-radius: 28px; } 451 .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; } 452 .modal-actions .m3-btn { width: auto; min-width: 80px; height: 40px; padding: 0 16px; } 453 .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; } 454 .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; } 455 @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } 456 .skel-contact { display: flex; align-items: center; padding: 12px 16px; gap: 16px; width: 100%; } 457 .skel-avatar { width: 48px; height: 48px; border-radius: 50%; } 458 .skel-text-col { flex: 1; display: flex; flex-direction: column; gap: 8px; } 459 .skel-msg-group { display: flex; flex-direction: column; gap: 12px; padding: 20px; width: 100%; } 460 .skel-bubble { height: 40px; border-radius: 12px; width: 40%; margin-bottom: 8px;} 461 .skel-left { align-self: flex-start; border-bottom-left-radius: 0; } 462 .skel-right { align-self: flex-end; border-bottom-right-radius: 0; width: 50%; } 463 464 /* --- SETTINGS SIDEBAR --- */ 465 .settings-sidebar { 466 position: fixed; 467 top: 0; left: 0; bottom: 0; 468 width: 400px; 469 background: var(--md-sys-color-surface); 470 z-index: 2000; 471 display: flex; 472 flex-direction: column; 473 border-right: 1px solid var(--md-sys-color-outline-variant); 474 box-shadow: var(--elevation-2); 475 transform: translateX(-100%); 476 transition: transform 0.4s cubic-bezier(0.2, 0, 0, 1); 477 } 478 479 .settings-sidebar.active { 480 transform: translateX(0); 481 } 482 483 /* Mobile full-width override */ 484 @media (max-width: 768px) { 485 .settings-sidebar { 486 width: 100%; 487 border-right: none; 488 } 489 } 490 491 @media (max-width: 768px) { 492 /* Pin all utility modals to the bottom */ 493 .modal-card.utility { 494 position: absolute; 495 bottom: 0; 496 width: 100% !important; 497 max-width: 100%; 498 border-radius: 28px 28px 0 0; 499 padding: 32px 24px 24px; 500 transform: translateY(100%) scale(1); 501 animation: none; /* Override the desktop scaleIn */ 502 transition: transform 0.4s cubic-bezier(0.2, 0, 0, 1); 503 } 504 505 /* Add the M3 drag handle indicator */ 506 .modal-card.utility::before { 507 content: ''; 508 position: absolute; 509 top: 12px; 510 left: 50%; 511 transform: translateX(-50%); 512 width: 32px; 513 height: 4px; 514 border-radius: 2px; 515 background: var(--md-sys-color-outline-variant); 516 } 517 518 .modal-overlay.active .modal-card.utility { 519 transform: translateY(0) scale(1); 520 } 521 } 522 523 </style> 524 </head> 525 <body> 526 527 <div id="offlineIndicator" class="offline-banner hidden"> 528 <span class="material-symbols-rounded" style="font-size: 16px;">cloud_off</span> You are offline 529 </div> 530 531 <!-- AUTH SCREEN --> 532 <div id="auth-screen"> 533 <div class="auth-card"> 534 535 <!-- Dynamic Initial Loader --> 536 <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;"> 537 <div class="spinner" style="display:block; position:static; border-color:rgba(0,0,0,0.1); border-top-color:var(--md-sys-color-primary); width:36px; height:36px;"></div> 538 <p style="margin-top:20px; color:var(--md-sys-color-on-surface-variant); font-size: 15px; font-family:'Lexend', sans-serif;">Authenticating session...</p> 539 </div> 540 541 <!-- Login Fields (Hidden initially) --> 542 <div id="authForm" class="hidden"> 543 <div style="text-align: center;" class="anim-up delay-1"> 544 <div class="brand-icon"> 545 <img src="/assets/icon.svg" alt="App Logo"> 546 </div> 547 <h1 style="font-family:'Lexend', sans-serif; font-size:28px; margin:8px 0; font-weight:400;">Welcome Back</h1> 548 <p style="color:var(--md-sys-color-on-surface-variant); font-size:14px;">Sign in to your chat log</p> 549 </div> 550 551 <div class="m3-field anim-up delay-2" style="margin-top: 12px;"> 552 <input type="text" id="username" class="m3-input" placeholder=" "> 553 <label class="m3-label">Username</label> 554 </div> 555 556 <div class="m3-field anim-up delay-3" style="margin-top: 12px;"> 557 <input type="password" id="password" class="m3-input" placeholder=" "> 558 <label class="m3-label">Password</label> 559 <button class="eye-toggle" onclick="togglePasswordVisibility('password', 'passIcon')"> 560 <span class="material-symbols-rounded" id="passIcon">visibility</span> 561 </button> 562 </div> 563 564 <div class="anim-up delay-4" style="display:flex; align-items:center; padding-left:4px; margin-top: 12px;"> 565 <div style="display:flex; align-items:center; gap:8px;"> 566 <input type="checkbox" id="rememberMe" style="width:18px; height:18px; accent-color: var(--md-sys-color-primary);"> 567 <label for="rememberMe" style="font-size: 14px; color: var(--md-sys-color-on-surface);">Remember me</label> 568 </div> 569 </div> 570 571 <div class="anim-up delay-5" style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"> 572 <button class="m3-btn filled" id="loginBtn" onclick="attemptLogin()"> 573 <span class="btn-text">Sign In</span> 574 <span class="material-symbols-rounded btn-icon" style="font-size:20px;">arrow_forward</span> 575 <div class="spinner"></div> 576 </button> 577 <p id="authError" style="color: var(--md-sys-color-error); font-size: 14px; opacity: 0; text-align: center;">Invalid Credentials</p> 578 </div> 579 </div> 580 581 </div> 582 </div> 583 584 <!-- LOCK SCREEN --> 585 <div id="lock-screen"> 586 <div id="lockoutUI" class="hidden lockout-container"> 587 <div class="lockout-icon-bg"><span class="material-symbols-rounded lockout-icon" id="lockoutIcon">lock_clock</span></div> 588 <h2 class="lockout-title" id="lockoutTitle">Locked</h2> 589 <p class="lockout-msg" id="lockoutMsg">Too many attempts.</p> 590 <div class="lockout-timer" id="lockoutTimer">00:30</div> 591 <p class="lockout-sub" id="lockoutSub">Try again later</p> 592 <div id="permanentLockActions" class="hidden lockout-actions"> 593 <button class="m3-btn filled" onclick="showForgotPin()" style="background:var(--md-sys-color-error); width:auto; padding:0 32px;">Forgot PIN</button> 594 </div> 595 </div> 596 597 <div class="lock-header" id="lockHeader"> 598 <span class="material-symbols-rounded lock-icon" id="lockIcon">lock</span> 599 <div class="lock-title" id="lockTitle">Enter PIN</div> 600 <div class="lock-sub" id="lockSub">Protected by App Lock</div> 601 </div> 602 603 <div id="pinUI"> 604 <div class="pin-dots" id="pinDots"> 605 <div class="pin-dot"></div><div class="pin-dot"></div><div class="pin-dot"></div> 606 <div class="pin-dot"></div><div class="pin-dot"></div><div class="pin-dot"></div> 607 </div> 608 <div class="keypad"> 609 <button class="key-btn ripple-surface" onclick="pressKey(1)">1</button> 610 <button class="key-btn ripple-surface" onclick="pressKey(2)">2</button> 611 <button class="key-btn ripple-surface" onclick="pressKey(3)">3</button> 612 <button class="key-btn ripple-surface" onclick="pressKey(4)">4</button> 613 <button class="key-btn ripple-surface" onclick="pressKey(5)">5</button> 614 <button class="key-btn ripple-surface" onclick="pressKey(6)">6</button> 615 <button class="key-btn ripple-surface" onclick="pressKey(7)">7</button> 616 <button class="key-btn ripple-surface" onclick="pressKey(8)">8</button> 617 <button class="key-btn ripple-surface" onclick="pressKey(9)">9</button> 618 <button class="key-btn ripple-surface" style="visibility:hidden"> </button> 619 <button class="key-btn ripple-surface" onclick="pressKey(0)">0</button> 620 <button class="key-btn ripple-surface" onclick="pressKey(-1)"><span class="material-symbols-rounded key-backspace">backspace</span></button> 621 </div> 622 </div> 623 624 <div id="bioUI" class="hidden" style="flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center;"> 625 <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;"> 626 <span class="material-symbols-rounded" style="font-size:48px;">fingerprint</span> 627 </div> 628 <p style="margin-top:16px; color:var(--md-sys-color-on-surface-variant);">Tap to Unlock</p> 629 </div> 630 631 <div style="text-align: center;"><button class="forgot-pin-btn" onclick="showForgotPin()">Forgot PIN / Use Password</button></div> 632 </div> 633 634 <!-- MAIN APP --> 635 <div id="app-layout"> 636 <aside class="sidebar" id="sidebar"> 637 <div class="sidebar-header"> 638 <div style="display: flex; align-items: center; gap: 12px; padding: 12px 0;"> 639 <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> 640 <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.1.5</span> 641 </div> 642 <div style="display:flex; gap: 8px;"> 643 <button class="icon-btn ripple-surface" onclick="refreshApp()"><span class="material-symbols-rounded">refresh</span></button> 644 <button class="icon-btn ripple-surface" onclick="openSettings()" title="Settings"><span class="material-symbols-rounded">settings</span></button> 645 </div> 646 </div> 647 <div class="search-container"> 648 <div class="search-bar"> 649 <span class="material-symbols-rounded" style="color: var(--md-sys-color-on-surface-variant);">search</span> 650 <input type="text" id="searchInput" placeholder="Search..."> 651 </div> 652 </div> 653 <div class="contact-list" id="contactList"></div> 654 </aside> 655 656 <main class="chat-area" id="chatArea"> 657 <header class="chat-header"> 658 <button class="icon-btn back-btn ripple-surface" onclick="closeChatMobile()"><span class="material-symbols-rounded">arrow_back</span></button> 659 <div id="chatHeaderContent" style="display:flex; align-items:center; gap:12px; flex:1; opacity:0;"> 660 <div class="avatar" id="headerAvatar">A</div> 661 <div style="flex:1;"> 662 <div id="headerName" style="font-weight: 500; font-size: 16px;">Alice</div> 663 <div id="headerId" style="font-size: 12px; opacity: 0.7;">Online</div> 664 </div> 665 <div class="menu-container"> 666 <button class="icon-btn ripple-surface" onclick="toggleChatMenu(event)"><span class="material-symbols-rounded">more_vert</span></button> 667 <div class="dropdown-menu" id="chatMenu"> 668 <button class="menu-item" onclick="toggleChatSearch()"><span class="material-symbols-rounded">search</span>Search in Chat</button> 669 <button class="menu-item" onclick="openDateSearch()"><span class="material-symbols-rounded">calendar_month</span>Search by Date</button> 670 <button class="menu-item" onclick="showRenameModal()"><span class="material-symbols-rounded">edit</span>Rename</button> 671 <button class="menu-item" onclick="downloadChat()"><span class="material-symbols-rounded">download</span>Download</button> 672 </div> 673 </div> 674 </div> 675 </header> 676 677 <div id="inChatSearch" class="in-chat-search hidden"> 678 <input type="text" id="msgSearchInput" placeholder="Find message..."> 679 <div id="searchCount" style="font-size:12px; margin-right:8px;"></div> 680 <button class="icon-btn" style="width:30px; height:30px;" onclick="toggleChatSearch()"><span class="material-symbols-rounded">close</span></button> 681 </div> 682 683 <!-- Cinematic Loading UI --> 684 <div class="sync-spinner-wrapper" id="syncSpinnerWrap"> 685 <div class="sync-chip"> 686 <div class="spinner"></div> 687 <span>Syncing messages...</span> 688 </div> 689 </div> 690 691 <div class="chat-bg-pattern"></div> 692 693 <div class="chat-content" id="messagesContainer"> 694 <div class="empty-state"> 695 <span class="material-symbols-rounded" style="font-size: 64px; opacity: 0.2;">chat</span> 696 <p>Select a conversation</p> 697 </div> 698 </div> 699 700 <button class="scroll-fab" id="scrollFab" onclick="scrollToBottom('smooth')"> 701 <span class="material-symbols-rounded">arrow_downward</span> 702 <span id="newMsgBadge" class="new-msg-badge hidden"></span> 703 </button> 704 </main> 705 </div> 706 707 <!-- MODALS SECTION --> 708 <!-- SETTINGS SIDEBAR --> 709 <div id="settingsSidebar" class="settings-sidebar"> 710 <div class="settings-header" style="height: 64px; padding: 0 16px; justify-content: flex-start; gap: 24px; background: var(--md-sys-color-surface-container-high);"> 711 <button class="icon-btn ripple-surface" onclick="closeSettingsSidebar()"> 712 <span class="material-symbols-rounded">arrow_back</span> 713 </button> 714 <h2 style="font-family:'Lexend'; font-size: 20px; font-weight: 500; margin: 0;">Settings</h2> 715 </div> 716 717 <div class="settings-content"> 718 <div id="installAppSection" class="install-card hidden"> 719 <div><h4>Install App</h4><p>Get the app for better experience</p></div> 720 <button class="m3-btn filled" onclick="installPWA()">Install</button> 721 </div> 722 723 <div class="setting-section hidden" id="mobileSecuritySection"> 724 <div class="setting-title">App Security</div> 725 <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> 726 <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> 727 </div> 728 729 <div class="setting-section"> 730 <div class="setting-title">Profile</div> 731 <div class="m3-field"><input type="text" id="profileName" class="m3-input" placeholder=" " onchange="saveProfile()"><label class="m3-label">Your Name</label></div> 732 <div class="m3-field"><input type="text" id="profilePhone" class="m3-input" placeholder=" " onchange="saveProfile()"><label class="m3-label">Phone Number</label></div> 733 </div> 734 735 <div class="setting-section"> 736 <div class="setting-title">Appearance</div> 737 <div class="setting-row"><div class="setting-label"><h4>Dark Mode</h4><p>Easier on the eyes</p></div><label class="switch"><input type="checkbox" id="darkModeToggle" onchange="toggleTheme()"><span class="slider"></span></label></div> 738 <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> 739 <div class="setting-label" style="margin-bottom:8px"><h4>Chat Wallpaper</h4></div> 740 <div class="bg-selector"> 741 <div class="bg-option bg-1 selected" onclick="changeBg(1)"></div> 742 <div class="bg-option bg-2" onclick="changeBg(2)"></div> 743 <div class="bg-option bg-3" onclick="changeBg(3)"></div> 744 <div class="bg-option bg-4" onclick="changeBg(4)"></div> 745 </div> 746 </div> 747 748 <div class="setting-section"> 749 <div class="setting-title">Storage and Data</div> 750 <div class="setting-row"> 751 <div class="setting-label"> 752 <h4>Local Storage Used</h4> 753 <p id="localStorageSizeDisplay">Calculating...</p> 754 </div> 755 </div> 756 <div class="setting-row"> 757 <div class="setting-label"> 758 <h4>Clear Cache</h4> 759 <p>Delete all messages, cached chats, and media records locally.</p> 760 </div> 761 <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> 762 </div> 763 </div> 764 765 <div class="setting-section"> 766 <div class="setting-title">Help & Support</div> 767 <div id="faqContainer"></div> 768 <div style="margin-top:24px; display:flex; gap:12px;"> 769 <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> 770 </div> 771 <div style="margin-top:24px; display:flex; gap:12px;"> 772 <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> 773 </div> 774 </div> 775 776 <button class="m3-btn filled" style="background:var(--md-sys-color-error); margin-top: 16px;" onclick="showLogoutModal()">Log Out</button> 777 </div> 778 </div> 779 780 <!-- UTILITY MODALS --> 781 <div id="logoutModal" class="modal-overlay"> 782 <div class="modal-card utility"> 783 <h3 class="modal-title">Log Out?</h3> 784 <p style="margin:16px 0; color:var(--md-sys-color-on-surface-variant);">Session will end.</p> 785 <div class="modal-actions"> 786 <button class="m3-btn text" onclick="closeModal('logoutModal')">Cancel</button> 787 <button class="m3-btn filled" onclick="handleLogout()">Logout</button> 788 </div> 789 </div> 790 </div> 791 792 <div id="forgotPinModal" class="modal-overlay"> 793 <div class="modal-card utility"> 794 <h3 class="modal-title">Reset App Lock</h3> 795 <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> 796 <div class="m3-field" style="margin-top:16px;"><input type="text" id="resetUsername" class="m3-input" placeholder=" "><label class="m3-label">Username</label></div> 797 <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> 798 <p id="resetError" style="color:var(--md-sys-color-error); font-size:12px; margin-bottom:12px; display:none;">Invalid credentials</p> 799 <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> 800 </div> 801 </div> 802 803 <div id="renameModal" class="modal-overlay"> 804 <div class="modal-card utility"> 805 <h3 class="modal-title">Rename Chat</h3> 806 <div class="m3-field" style="margin-top:16px;"><input type="text" id="renameInput" class="m3-input"><label class="m3-label">New Name</label></div> 807 <div class="modal-actions"><button class="m3-btn text" onclick="closeModal('renameModal')">Cancel</button><button class="m3-btn filled" onclick="saveRename()">Save</button></div> 808 </div> 809 </div> 810 811 <!-- APP OPTIONS MODAL --> 812 <div id="appOptionsModal" class="modal-overlay"> 813 <div class="modal-card utility"> 814 <h3 class="modal-title">App Options</h3> 815 <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> 816 <div style="display:flex; flex-direction:column; gap:12px;"> 817 <button class="m3-btn filled" onclick="executePageRefresh()"> 818 <span class="material-symbols-rounded" style="font-size:20px;">refresh</span> Reload App 819 </button> 820 <button class="m3-btn text" style="color:var(--md-sys-color-error);" onclick="openHardReset()"> 821 <span class="material-symbols-rounded" style="font-size:20px;">delete_forever</span> Hard Reset 822 </button> 823 <button class="m3-btn text" onclick="closeModal('appOptionsModal')">Cancel</button> 824 </div> 825 </div> 826 </div> 827 828 <!-- HARD REFRESH MODAL --> 829 <div id="refreshModal" class="modal-overlay"> 830 <div class="modal-card utility"> 831 <h3 class="modal-title">System Sync</h3> 832 833 <div id="refreshAuthStep"> 834 <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> 835 <div class="m3-field" style="margin-top:16px;"> 836 <input type="text" id="refreshUsername" class="m3-input" placeholder=" "><label class="m3-label">Username</label> 837 </div> 838 <div class="m3-field"> 839 <input type="password" id="refreshPassword" class="m3-input" placeholder=" "> 840 <label class="m3-label">Password</label> 841 <button class="eye-toggle" onclick="togglePasswordVisibility('refreshPassword', 'refreshPassIcon')"><span class="material-symbols-rounded" id="refreshPassIcon">visibility</span></button> 842 </div> 843 <p id="refreshError" style="color:var(--md-sys-color-error); font-size:12px; margin-bottom:12px; display:none;">Invalid credentials</p> 844 <div class="modal-actions"> 845 <button class="m3-btn text" onclick="closeModal('refreshModal')">Cancel</button> 846 <button class="m3-btn filled" id="refreshBtn" onclick="startHardRefresh()"> 847 <span class="btn-text">Sync</span><div class="spinner"></div> 848 </button> 849 </div> 850 </div> 851 852 <div id="refreshProgressStep" class="hidden" style="text-align:center; padding: 16px 0;"> 853 <p id="refreshStatusText" style="font-size:14px; font-weight:500; margin-bottom:16px; color:var(--md-sys-color-on-surface);">Starting sync...</p> 854 <div style="width:100%; height:8px; background:var(--md-sys-color-surface-container-highest); border-radius:4px; overflow:hidden;"> 855 <div id="refreshProgressBar" style="width:0%; height:100%; background:var(--md-sys-color-primary); transition:width 0.3s ease;"></div> 856 </div> 857 <p id="refreshPercentText" style="font-size:12px; color:var(--md-sys-color-on-surface-variant); margin-top:8px;">0%</p> 858 </div> 859 </div> 860 </div> 861 862 <div id="notFoundModal" class="modal-overlay"> 863 <div class="modal-card utility"> 864 <h3 class="modal-title">No Messages Found</h3> 865 <p style="margin:16px 0; color:var(--md-sys-color-on-surface-variant);">There are no messages from this date onwards.</p> 866 <div class="modal-actions"><button class="m3-btn filled" onclick="closeModal('notFoundModal')">OK</button></div> 867 </div> 868 </div> 869 870 <div id="pinSetupModal" class="modal-overlay"> 871 <div class="modal-card utility" style="text-align:center;"> 872 <h3 class="modal-title" id="pinSetupTitle">Set 6-Digit PIN</h3> 873 <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> 874 <p style="color:var(--md-sys-color-on-surface-variant); font-size:12px;" id="pinSetupMsg">Use the number pad below</p> 875 <div class="keypad" style="transform: scale(0.9); margin-bottom:0;"> 876 <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> 877 <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> 878 <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> 879 <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> 880 </div> 881 <div class="modal-actions" style="justify-content:center; margin-top:8px;"><button class="m3-btn text" onclick="cancelPinSetup()">Cancel</button></div> 882 </div> 883 </div> 884 885 <div id="calendarModal" class="modal-overlay"> 886 <div class="calendar-card animate-enter"> 887 <div class="cal-header"><h3>Select date</h3><h2 id="calSelectedDateStr">Tue, Oct 24</h2></div> 888 <div class="cal-grid"> 889 <div class="cal-nav"> 890 <span id="calMonthYear" style="font-weight:500">October 2023</span> 891 <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> 892 </div> 893 <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> 894 <div class="cal-dates" id="calDatesGrid"></div> 895 </div> 896 <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> 897 </div> 898 </div> 899 900 <div id="installSheet" class="install-sheet"> 901 <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> 902 <div class="sheet-content"> 903 <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> 904 <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> 905 <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> 906 </div> 907 </div> 908 909 <!-- MAIN APP SCRIPT --> 910 <script> 911 const RENDER_BACKEND_URL = ""; 912 const firebaseConfig = { 913 apiKey: "", 914 authDomain: "", 915 projectId: "", 916 storageBucket: "", 917 messagingSenderId: "", 918 appId: "" 919 }; 920 921 firebase.initializeApp(firebaseConfig); 922 const db = firebase.firestore(); 923 let activeChatId = null; 924 let isCurrentChatGroup = false; 925 let deferredPrompt; 926 let currentMessages = []; 927 928 // --- INDEXED DB CACHING --- 929 const DB_NAME = 'WPLoggerDB'; 930 const DB_VERSION = 2; // Incremented for chat list cache 931 let dbInstance = null; 932 let isIdbSupported = true; 933 934 function initIndexedDB() { 935 return new Promise((resolve) => { 936 if (!window.indexedDB) { 937 isIdbSupported = false; 938 resolve(); 939 return; 940 } 941 942 let isResolved = false; 943 const safeResolve = () => { 944 if(!isResolved) { 945 isResolved = true; 946 resolve(); 947 } 948 }; 949 950 // Safety timeout: Prevent infinite hangs on mobile browsers 951 const fallbackTimeout = setTimeout(() => { 952 console.warn('IDB init timed out'); 953 isIdbSupported = false; 954 safeResolve(); 955 }, 3000); 956 957 try { 958 const req = indexedDB.open(DB_NAME, DB_VERSION); 959 req.onupgradeneeded = (e) => { 960 const database = e.target.result; 961 if (!database.objectStoreNames.contains('messages')) { 962 const store = database.createObjectStore('messages', { keyPath: 'id' }); 963 store.createIndex('chatId', 'chatId', { unique: false }); 964 store.createIndex('timestamp', 'timestamp', { unique: false }); 965 } 966 if (!database.objectStoreNames.contains('chats')) { 967 database.createObjectStore('chats', { keyPath: 'id' }); 968 } 969 }; 970 req.onsuccess = (e) => { 971 clearTimeout(fallbackTimeout); 972 dbInstance = e.target.result; 973 974 // Close connection if another tab/process requests an upgrade, prevents blocking 975 dbInstance.onversionchange = () => { 976 dbInstance.close(); 977 dbInstance = null; 978 }; 979 980 safeResolve(); 981 }; 982 req.onerror = (e) => { 983 clearTimeout(fallbackTimeout); 984 console.warn('IDB init error', e); 985 isIdbSupported = false; 986 safeResolve(); 987 }; 988 req.onblocked = (e) => { 989 console.warn('IDB init blocked. Waiting for other connections to close...', e); 990 // We rely on fallbackTimeout if it never unblocks 991 }; 992 } catch (e) { 993 clearTimeout(fallbackTimeout); 994 console.warn('IDB open exception', e); 995 isIdbSupported = false; 996 safeResolve(); 997 } 998 }); 999 } 1000 1001 async function getCachedMessages(chatId) { 1002 if(!isIdbSupported || !dbInstance) return []; 1003 return new Promise((resolve) => { 1004 try { 1005 const tx = dbInstance.transaction('messages', 'readonly'); 1006 const store = tx.objectStore('messages'); 1007 const index = store.index('chatId'); 1008 const req = index.getAll(chatId); 1009 req.onsuccess = () => resolve(req.result || []); 1010 req.onerror = () => resolve([]); 1011 } catch(e) { resolve([]); } 1012 }); 1013 } 1014 1015 async function saveCachedMessages(messages) { 1016 if(!isIdbSupported || !dbInstance) return; 1017 return new Promise((resolve) => { 1018 try { 1019 const tx = dbInstance.transaction('messages', 'readwrite'); 1020 const store = tx.objectStore('messages'); 1021 messages.forEach(m => store.put(m)); 1022 tx.oncomplete = () => resolve(); 1023 tx.onerror = () => resolve(); 1024 } catch(e) { resolve(); } 1025 }); 1026 } 1027 1028 async function getCachedChats() { 1029 if(!isIdbSupported || !dbInstance) return []; 1030 return new Promise((resolve) => { 1031 try { 1032 const tx = dbInstance.transaction('chats', 'readonly'); 1033 const store = tx.objectStore('chats'); 1034 const req = store.getAll(); 1035 req.onsuccess = () => resolve(req.result || []); 1036 req.onerror = () => resolve([]); 1037 } catch(e) { resolve([]); } 1038 }); 1039 } 1040 1041 async function saveCachedChats(chats) { 1042 if(!isIdbSupported || !dbInstance) return; 1043 return new Promise((resolve) => { 1044 try { 1045 const tx = dbInstance.transaction('chats', 'readwrite'); 1046 const store = tx.objectStore('chats'); 1047 chats.forEach(c => store.put(c)); 1048 tx.oncomplete = () => resolve(); 1049 tx.onerror = () => resolve(); 1050 } catch(e) { resolve(); } 1051 }); 1052 } 1053 1054 // --- SECURITY VARS --- 1055 let enteredPin = ""; let setupPin = ""; let setupStep = 0; let isLocked = false; 1056 let failAttempts = parseInt(localStorage.getItem('wp_fail_attempts') || '0'); 1057 let lockUntil = parseInt(localStorage.getItem('wp_lock_until') || '0'); 1058 let lockoutTimerInterval; 1059 let calCurrentDate = new Date(); let calSelectedDate = new Date(); 1060 1061 const arrayBufferToBase64 = (buffer) => { 1062 let binary = ''; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; 1063 for (let i = 0; i < len; i++) binary += String.fromCharCode(bytes[i]); 1064 return window.btoa(binary); 1065 }; 1066 const base64ToArrayBuffer = (base64) => { 1067 const binary_string = window.atob(base64); const len = binary_string.length; const bytes = new Uint8Array(len); 1068 for (let i = 0; i < len; i++) bytes[i] = binary_string.charCodeAt(i); 1069 return bytes.buffer; 1070 }; 1071 1072 function formatDividerDate(ts) { 1073 const date = new Date(ts * 1000); const today = new Date(); const yesterday = new Date(); 1074 yesterday.setDate(yesterday.getDate() - 1); 1075 if (date.toDateString() === today.toDateString()) return "Today"; 1076 if (date.toDateString() === yesterday.toDateString()) return "Yesterday"; 1077 return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); 1078 } 1079 1080 // HTML Sanitizer to prevent Code Injection via chat messages 1081 function escapeHTML(str) { 1082 if (!str) return ''; 1083 return str.replace(/[&<>'"]/g, 1084 tag => ({ 1085 '&': '&', 1086 '<': '<', 1087 '>': '>', 1088 "'": ''', 1089 '"': '"' 1090 }[tag] || tag) 1091 ); 1092 } 1093 1094 // --- PWA INSTALLATION --- 1095 if ('serviceWorker' in navigator && (window.location.protocol === 'https:' || window.location.protocol === 'http:')) { 1096 navigator.serviceWorker.register('/assets/sw-wpChat.js').catch(err => { 1097 console.warn('SW Register Fail:', err); 1098 }); 1099 // Reconnect Sync trigger 1100 window.addEventListener('online', () => { 1101 if (navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage('sync_on_reconnect'); 1102 }); 1103 } 1104 1105 window.addEventListener('beforeinstallprompt', (e) => { 1106 e.preventDefault(); deferredPrompt = e; 1107 document.getElementById('installAppSection').classList.remove('hidden'); 1108 if (window.innerWidth <= 768) { 1109 setTimeout(() => document.getElementById('installSheet').classList.add('visible'), 2000); 1110 } 1111 }); 1112 1113 async function installPWA() { 1114 if (!deferredPrompt) return; 1115 deferredPrompt.prompt(); 1116 const { outcome } = await deferredPrompt.userChoice; 1117 if (outcome === 'accepted') { 1118 closeInstallSheet(); 1119 document.getElementById('installAppSection').classList.add('hidden'); 1120 } 1121 deferredPrompt = null; 1122 } 1123 1124 function closeInstallSheet() { document.getElementById('installSheet').classList.remove('visible'); } 1125 1126 // --- BOOT SEQUENCE --- 1127 function isMobileApp() { 1128 const isStandalone = (window.matchMedia('(display-mode: standalone)').matches) || (window.navigator.standalone) || document.referrer.includes('android-app://'); 1129 return isStandalone && window.innerWidth <= 768; 1130 } 1131 1132 window.onload = async () => { 1133 await initIndexedDB(); 1134 1135 if (!navigator.onLine) { 1136 document.getElementById('offlineIndicator').classList.remove('hidden'); 1137 } 1138 window.addEventListener('online', () => { 1139 document.getElementById('offlineIndicator').classList.add('hidden'); 1140 }); 1141 window.addEventListener('offline', () => { 1142 document.getElementById('offlineIndicator').classList.remove('hidden'); 1143 }); 1144 1145 if (localStorage.getItem('theme') === 'dark') { 1146 document.body.setAttribute('data-theme', 'dark'); 1147 document.getElementById('darkModeToggle').checked = true; 1148 } 1149 document.getElementById('profileName').value = localStorage.getItem('profileName') || ''; 1150 document.getElementById('profilePhone').value = localStorage.getItem('profilePhone') || ''; 1151 const bg = localStorage.getItem('chatBg') || 1; 1152 changeBg(bg, false); 1153 populateFAQ(); 1154 1155 // Setup Auth & App Routing with smooth transitions 1156 const authValid = localStorage.getItem('wp_auth_expiry') > Date.now(); 1157 1158 if (authValid) { 1159 // If auth is valid, fade out the auth screen completely and show the app 1160 document.getElementById('auth-screen').style.opacity = '0'; 1161 1162 setTimeout(() => { 1163 document.getElementById('auth-screen').style.display = 'none'; 1164 if (isMobileApp()) { 1165 const hasPin = localStorage.getItem('wp_app_pin'); 1166 const useBio = localStorage.getItem('wp_app_bio') === 'true'; 1167 document.getElementById('mobileSecuritySection').classList.remove('hidden'); 1168 document.getElementById('pinLockToggle').checked = !!hasPin; 1169 document.getElementById('bioLockToggle').checked = useBio; 1170 1171 if (hasPin || useBio) { 1172 initLockScreen(hasPin, useBio); 1173 } else { 1174 document.getElementById('app-layout').classList.add('visible'); 1175 loadChats(); 1176 } 1177 } else { 1178 document.getElementById('app-layout').classList.add('visible'); 1179 loadChats(); 1180 } 1181 }, 400); // Wait for CSS opacity transition 1182 } else { 1183 // If not valid, hide the spinner and reveal the login form 1184 document.getElementById('authLoader').classList.add('hidden'); 1185 document.getElementById('authForm').classList.remove('hidden'); 1186 document.getElementById('authForm').classList.add('fade-in'); 1187 1188 if (isMobileApp()) { 1189 document.getElementById('mobileSecuritySection').classList.remove('hidden'); 1190 } 1191 } 1192 1193 document.getElementById('messagesContainer').addEventListener('scroll', function() { 1194 const fab = document.getElementById('scrollFab'); 1195 if (this.scrollHeight - this.scrollTop - this.clientHeight > 500) { 1196 fab.classList.add('visible'); 1197 } else { 1198 fab.classList.remove('visible'); 1199 document.getElementById('newMsgBadge').classList.add('hidden'); 1200 } 1201 }); 1202 1203 document.querySelectorAll('input[type="text"], input[type="password"]').forEach(input => { 1204 input.addEventListener('focus', () => { 1205 if (window.innerWidth <= 768) { 1206 setTimeout(() => input.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300); 1207 } 1208 }); 1209 }); 1210 }; 1211 1212 // --- LOCK SCREEN LOGIC --- 1213 function initLockScreen(hasPin, useBio) { 1214 const lockScreen = document.getElementById('lock-screen'); 1215 lockScreen.classList.add('active'); isLocked = true; enteredPin = ""; 1216 updatePinDots(0); 1217 if (checkLockoutState()) return; 1218 1219 if(hasPin) { 1220 document.getElementById('pinUI').classList.remove('hidden'); document.getElementById('bioUI').classList.add('hidden'); 1221 document.getElementById('lockTitle').innerText = "Enter PIN"; document.getElementById('lockIcon').innerText = "lock"; 1222 } else if (useBio && !hasPin) { 1223 document.getElementById('pinUI').classList.add('hidden'); document.getElementById('bioUI').classList.remove('hidden'); 1224 document.getElementById('lockTitle').innerText = "Scan Fingerprint"; document.getElementById('lockIcon').innerText = "fingerprint"; 1225 triggerBiometricAuth(); 1226 } 1227 } 1228 1229 function checkLockoutState() { 1230 const now = Date.now(); 1231 if (failAttempts >= 5) { renderPermanentLock(); return true; } 1232 if (lockUntil > now) { startLockoutTimer(); return true; } 1233 if (lockUntil > 0 && lockUntil <= now) { 1234 lockUntil = 0; localStorage.setItem('wp_lock_until', '0'); 1235 document.getElementById('lockoutUI').classList.add('hidden'); 1236 document.getElementById('lockHeader').classList.remove('hidden'); 1237 document.getElementById('lock-screen').classList.remove('locked-mode'); 1238 } 1239 return false; 1240 } 1241 1242 function handleFailedAttempt() { 1243 failAttempts++; localStorage.setItem('wp_fail_attempts', failAttempts); 1244 if (failAttempts === 3 || failAttempts === 4) { 1245 lockUntil = Date.now() + (30 * 1000); localStorage.setItem('wp_lock_until', lockUntil); 1246 startLockoutTimer(); 1247 } else if (failAttempts >= 5) { 1248 renderPermanentLock(); 1249 } else { 1250 document.getElementById('pinDots').classList.add('shake'); updatePinDots(6, true); 1251 setTimeout(() => { document.getElementById('pinDots').classList.remove('shake'); enteredPin = ""; updatePinDots(0); }, 400); 1252 } 1253 } 1254 1255 function startLockoutTimer() { 1256 const ui = document.getElementById('lockoutUI'); const screen = document.getElementById('lock-screen'); const timerDisplay = document.getElementById('lockoutTimer'); 1257 ui.classList.remove('hidden'); document.getElementById('lockHeader').classList.add('hidden'); screen.classList.add('locked-mode'); 1258 document.getElementById('lockoutTitle').innerText = "Locked"; document.getElementById('lockoutMsg').innerText = `Too many attempts (${failAttempts})`; 1259 document.getElementById('lockoutSub').innerText = "Try again later"; document.getElementById('permanentLockActions').classList.add('hidden'); 1260 document.getElementById('lockoutTimer').classList.remove('hidden'); 1261 if(lockoutTimerInterval) clearInterval(lockoutTimerInterval); 1262 1263 lockoutTimerInterval = setInterval(() => { 1264 const left = Math.ceil((lockUntil - Date.now()) / 1000); 1265 if (left <= 0) { 1266 clearInterval(lockoutTimerInterval); timerDisplay.innerText = "00:00"; 1267 initLockScreen(localStorage.getItem('wp_app_pin'), localStorage.getItem('wp_app_bio') === 'true'); 1268 document.getElementById('lockoutUI').classList.add('hidden'); document.getElementById('lockHeader').classList.remove('hidden'); 1269 screen.classList.remove('locked-mode'); 1270 } else { 1271 timerDisplay.innerText = `00:${left < 10 ? '0'+left : left}`; 1272 } 1273 }, 1000); 1274 } 1275 1276 function renderPermanentLock() { 1277 document.getElementById('lockoutUI').classList.remove('hidden'); 1278 document.getElementById('lockHeader').classList.add('hidden'); 1279 document.getElementById('lock-screen').classList.add('locked-mode'); 1280 document.getElementById('lockoutIcon').innerText = "gpp_bad"; 1281 document.getElementById('lockoutTitle').innerText = "Security Lockout"; 1282 document.getElementById('lockoutMsg').innerText = "Maximum attempts exceeded. App is locked."; 1283 document.getElementById('lockoutSub').innerText = "Use Forgot Password to reset."; 1284 document.getElementById('lockoutTimer').classList.add('hidden'); 1285 document.getElementById('permanentLockActions').classList.remove('hidden'); 1286 } 1287 1288 function pressKey(num) { 1289 if(num === -1) enteredPin = enteredPin.slice(0, -1); 1290 else if (enteredPin.length < 6) enteredPin += num; 1291 updatePinDots(enteredPin.length); 1292 if(enteredPin.length === 6) verifyPin(); 1293 } 1294 1295 function updatePinDots(count, isError=false) { 1296 document.querySelectorAll('#pinDots .pin-dot').forEach((d, i) => { 1297 if(isError) { d.classList.add('error'); setTimeout(() => d.classList.remove('error'), 400); } 1298 else { d.classList.remove('error'); if(i < count) d.classList.add('filled'); else d.classList.remove('filled'); } 1299 }); 1300 } 1301 1302 function verifyPin() { 1303 if(checkLockoutState()) return; 1304 const storedPin = localStorage.getItem('wp_app_pin'); 1305 if(storedPin && enteredPin === storedPin) { 1306 failAttempts = 0; localStorage.setItem('wp_fail_attempts', '0'); localStorage.setItem('wp_lock_until', '0'); 1307 if (localStorage.getItem('wp_app_bio') === 'true') { 1308 document.getElementById('pinUI').classList.add('hidden'); document.getElementById('bioUI').classList.remove('hidden'); 1309 document.getElementById('lockTitle').innerText = "Verify Identity"; document.getElementById('lockIcon').innerText = "fingerprint"; 1310 triggerBiometricAuth(); 1311 } else unlockApp(); 1312 } else handleFailedAttempt(); 1313 } 1314 1315 async function triggerBiometricAuth() { 1316 if(checkLockoutState()) return; 1317 if(!document.getElementById('bioUI').classList.contains('hidden')) document.getElementById('lockTitle').innerText = "Verify Identity"; 1318 try { 1319 const storedId = localStorage.getItem('wp_bio_id'); 1320 const options = { publicKey: { challenge: new Uint8Array([1, 2, 3, 4]), timeout: 60000, userVerification: "required" } }; 1321 if (storedId) options.publicKey.allowCredentials = [{ type: 'public-key', id: base64ToArrayBuffer(storedId), transports: ['internal'] }]; 1322 const credential = await navigator.credentials.get(options); 1323 if(credential) { failAttempts = 0; localStorage.setItem('wp_fail_attempts', '0'); localStorage.setItem('wp_lock_until', '0'); unlockApp(); } 1324 } catch (e) { console.warn("Bio failed", e); } 1325 } 1326 1327 function unlockApp() { 1328 isLocked = false; 1329 document.getElementById('lock-screen').classList.remove('active'); 1330 document.getElementById('app-layout').classList.add('visible'); 1331 loadChats(); 1332 } 1333 1334 // --- SECURITY SETTINGS --- 1335 function togglePinSetting() { 1336 if(document.getElementById('pinLockToggle').checked) openPinSetup(); else localStorage.removeItem('wp_app_pin'); 1337 } 1338 1339 function toggleBioSetting() { 1340 if(document.getElementById('bioLockToggle').checked) registerBiometric(); 1341 else { localStorage.removeItem('wp_app_bio'); localStorage.removeItem('wp_bio_id'); } 1342 } 1343 1344 function openPinSetup() { 1345 document.getElementById('pinSetupModal').classList.add('active'); 1346 setupStep = 1; setupPin = ""; enteredPin = ""; updateSetupDots(0); 1347 document.getElementById('pinSetupTitle').innerText = "Set 6-Digit PIN"; 1348 document.getElementById('pinSetupMsg').innerText = "Enter a new PIN"; 1349 } 1350 1351 function updateSetupDots(count) { 1352 document.querySelectorAll('#setupDots .pin-dot').forEach((d, i) => i < count ? d.classList.add('filled') : d.classList.remove('filled')); 1353 } 1354 1355 function pressSetupKey(num) { 1356 if(num === -1) enteredPin = enteredPin.slice(0, -1); 1357 else if (enteredPin.length < 6) enteredPin += num; 1358 updateSetupDots(enteredPin.length); 1359 1360 if(enteredPin.length === 6) { 1361 if(setupStep === 1) { 1362 setupPin = enteredPin; enteredPin = ""; setupStep = 2; 1363 setTimeout(() => { updateSetupDots(0); document.getElementById('pinSetupTitle').innerText = "Confirm PIN"; document.getElementById('pinSetupMsg').innerText = "Re-enter to confirm"; }, 200); 1364 } else if (setupStep === 2) { 1365 if(enteredPin === setupPin) { 1366 localStorage.setItem('wp_app_pin', setupPin); 1367 closeModal('pinSetupModal'); enteredPin = ""; 1368 } else { 1369 const dots = document.getElementById('setupDots'); dots.classList.add('shake'); 1370 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); 1371 } 1372 } 1373 } 1374 } 1375 1376 function cancelPinSetup() { document.getElementById('pinLockToggle').checked = false; closeModal('pinSetupModal'); } 1377 1378 async function registerBiometric() { 1379 try { 1380 const iconPath = new URL('/assets/icon.svg', window.location.href).toString(); 1381 const credential = await navigator.credentials.create({ 1382 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 } 1383 }); 1384 1385 if(credential) { 1386 localStorage.setItem('wp_bio_id', arrayBufferToBase64(credential.rawId)); 1387 localStorage.setItem('wp_app_bio', 'true'); 1388 } else throw new Error(); 1389 } catch (e) { document.getElementById('bioLockToggle').checked = false; } 1390 } 1391 1392 function showForgotPin() { document.getElementById('forgotPinModal').classList.add('active'); } 1393 1394 async function attemptPinReset() { 1395 const btn = document.getElementById('resetBtn'); const err = document.getElementById('resetError'); 1396 btn.classList.add('loading'); btn.disabled = true; err.style.display = 'none'; 1397 1398 try { 1399 const response = await fetch(`${RENDER_BACKEND_URL}/api/verify`, { 1400 method: 'POST', headers: {'Content-Type': 'application/json'}, 1401 body: JSON.stringify({ username: document.getElementById('resetUsername').value, password: document.getElementById('resetPassword').value }) 1402 }); 1403 const data = await response.json(); 1404 1405 if(data.success) { 1406 ['wp_app_pin','wp_app_bio','wp_bio_id','wp_fail_attempts','wp_lock_until'].forEach(k => localStorage.removeItem(k)); 1407 failAttempts = 0; lockUntil = 0; 1408 document.getElementById('pinLockToggle').checked = false; document.getElementById('bioLockToggle').checked = false; 1409 document.getElementById('lockoutUI').classList.add('hidden'); document.getElementById('lockHeader').classList.remove('hidden'); 1410 document.getElementById('lock-screen').classList.remove('locked-mode'); 1411 closeModal('forgotPinModal'); unlockApp(); 1412 } else throw new Error(); 1413 } catch (e) { err.style.display = 'block'; } finally { btn.classList.remove('loading'); btn.disabled = false; } 1414 } 1415 1416 function togglePasswordVisibility(id, iconId) { 1417 const input = document.getElementById(id); const icon = document.getElementById(iconId); 1418 if (input.type === 'password') { input.type = 'text'; icon.innerText = 'visibility_off'; } 1419 else { input.type = 'password'; icon.innerText = 'visibility'; } 1420 } 1421 1422 async function attemptLogin() { 1423 const btn = document.getElementById('loginBtn'); const err = document.getElementById('authError'); 1424 btn.classList.add('loading'); btn.disabled = true; err.style.opacity = '0'; 1425 try { 1426 const response = await fetch(`${RENDER_BACKEND_URL}/api/verify`, { 1427 method: 'POST', headers: {'Content-Type': 'application/json'}, 1428 body: JSON.stringify({ username: document.getElementById('username').value, password: document.getElementById('password').value }) 1429 }); 1430 const data = await response.json(); 1431 if(data.success) { 1432 if(document.getElementById('rememberMe').checked) localStorage.setItem('wp_auth_expiry', Date.now() + (3 * 24 * 60 * 60 * 1000)); 1433 else localStorage.setItem('wp_auth_expiry', Date.now() + (1 * 60 * 60 * 1000)); // default 1 hr fallback 1434 revealApp(); 1435 } else throw new Error(); 1436 } catch (e) { err.style.opacity = '1'; btn.classList.remove('loading'); btn.disabled = false; } 1437 } 1438 1439 function revealApp() { 1440 document.getElementById('auth-screen').style.opacity = '0'; 1441 setTimeout(() => { 1442 document.getElementById('auth-screen').style.display = 'none'; 1443 document.getElementById('app-layout').classList.add('visible'); 1444 loadChats(); 1445 }, 400); 1446 } 1447 1448 function openSettings() { 1449 document.getElementById('settingsSidebar').classList.add('active'); 1450 calculateLocalStorageUsage(); 1451 } 1452 1453 function closeSettingsSidebar() { 1454 document.getElementById('settingsSidebar').classList.remove('active'); 1455 } 1456 1457 async function clearAppCacheData() { 1458 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.")) { 1459 closeSettingsSidebar(); // Call the new close function 1460 document.getElementById('refreshModal').classList.add('active'); 1461 document.getElementById('refreshAuthStep').classList.add('hidden'); 1462 document.getElementById('refreshProgressStep').classList.remove('hidden'); 1463 1464 await executeHardRefresh(); 1465 } 1466 } 1467 function closeModal(id) { document.getElementById(id).classList.remove('active'); } 1468 function toggleTheme() { 1469 const isDark = document.getElementById('darkModeToggle').checked; 1470 document.body.setAttribute('data-theme', isDark ? 'dark' : 'light'); 1471 localStorage.setItem('theme', isDark ? 'dark' : 'light'); 1472 } 1473 function changeFontSize(val) { document.documentElement.style.setProperty('--base-font-size', `${val}px`); } 1474 function changeBg(id, save=true) { 1475 document.querySelectorAll('.bg-option').forEach(el => el.classList.remove('selected')); 1476 const colors = ['#ECE5DD', '#d1d7db', '#e5ddd5', '#ffffff']; 1477 document.documentElement.style.setProperty('--chat-bg-color', colors[id-1]); 1478 const option = document.querySelector(`.bg-${id}`); 1479 if(option) option.classList.add('selected'); 1480 if(save) localStorage.setItem('chatBg', id); 1481 } 1482 function saveProfile() { localStorage.setItem('profileName', document.getElementById('profileName').value); localStorage.setItem('profilePhone', document.getElementById('profilePhone').value); } 1483 function populateFAQ() { 1484 const faqs = [ 1485 {q: "Is this secure?", a: "Yes, data lives in your private Firebase. No one else can access it."}, 1486 {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."}, 1487 {q: "Does my phone need to be online?", a: "No. Once linked, your Render server handles logging even if your phone is powered off."}, 1488 {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."}, 1489 {q: "Does it save photos/videos?", a: "No. To keep the project free (Firebase offers limited storage), the logger only saves text content. Media files would fill up your free tier storage very quickly."}, 1490 {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."} 1491 ]; 1492 const container = document.getElementById('faqContainer'); 1493 faqs.forEach(item => { 1494 const div = document.createElement('div'); div.className = 'faq-item'; 1495 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>`; 1496 container.appendChild(div); 1497 }); 1498 } 1499 1500 function toggleChatMenu(event) { if(event) event.stopPropagation(); document.getElementById('chatMenu').classList.toggle('show'); } 1501 document.addEventListener('click', function(e) { const menu = document.getElementById('chatMenu'); if(menu.classList.contains('show') && !e.target.closest('.menu-container')) menu.classList.remove('show'); }); 1502 1503 function toggleChatSearch() { 1504 const searchContainer = document.getElementById('inChatSearch'); 1505 searchContainer.classList.toggle('hidden'); 1506 document.getElementById('chatMenu').classList.remove('show'); 1507 1508 const input = document.getElementById('msgSearchInput'); 1509 if (searchContainer.classList.contains('hidden')) { 1510 // Clear and trigger input event to reset highlights when closed 1511 input.value = ''; 1512 input.dispatchEvent(new Event('input')); 1513 } else { 1514 input.focus(); 1515 } 1516 } 1517 1518 document.getElementById('msgSearchInput').addEventListener('input', (e) => { 1519 const term = e.target.value.toLowerCase(); 1520 let count = 0; 1521 1522 document.querySelectorAll('.msg-bubble').forEach(b => { 1523 const textDiv = b.querySelector('.msg-text'); 1524 if(textDiv) { 1525 // Back up the original HTML on first search so we don't destroy structure 1526 if (!textDiv.hasAttribute('data-orig')) { 1527 textDiv.setAttribute('data-orig', textDiv.innerHTML); 1528 } 1529 1530 const originalHTML = textDiv.getAttribute('data-orig'); 1531 const rawText = textDiv.textContent || ""; 1532 1533 if(term && rawText.toLowerCase().includes(term)) { 1534 b.style.display = 'flex'; 1535 // Negative lookahead regex: Highlights term ONLY if it's outside of HTML tags (<...>) 1536 const safeTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 1537 const regex = new RegExp(`(?![^<]*>)(${safeTerm})`, 'gi'); 1538 1539 textDiv.innerHTML = originalHTML.replace(regex, '<span class="highlight">$1</span>'); 1540 count++; 1541 } else { 1542 b.style.display = term ? 'none' : 'flex'; 1543 textDiv.innerHTML = originalHTML; // Restore clean HTML 1544 } 1545 } 1546 }); 1547 document.getElementById('searchCount').innerText = term ? `${count} found` : ''; 1548 }); 1549 1550 async function loadChats() { 1551 const list = document.getElementById('contactList'); 1552 1553 // Render initial cache instantly before networking kicks in 1554 const cachedChats = await getCachedChats(); 1555 if (cachedChats && cachedChats.length > 0) { 1556 // Ensure it's correctly sorted descending by activity in cache 1557 cachedChats.sort((a,b) => b.lastActive - a.lastActive); 1558 renderChatList(cachedChats); 1559 } else { 1560 list.innerHTML = ''; 1561 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>`; 1562 } 1563 1564 if (!navigator.onLine) return; // Skip snapshot if offline 1565 1566 // Prevent duplicating listeners 1567 if(window.chatListUnsub) window.chatListUnsub(); 1568 1569 // Use real-time listener instead of one-time .get() 1570 window.chatListUnsub = db.collection('Chats').onSnapshot(async snap => { 1571 const groups = {}; 1572 const excluded = ['']; 1573 1574 snap.forEach(doc => { 1575 const d = doc.data(); 1576 if (!d.lastActive || excluded.includes(doc.id)) return; 1577 const phone = d.phoneNumber || doc.id.split('@')[0]; 1578 if (!groups[phone]) groups[phone] = { main: {id:doc.id, ...d}, ids: [doc.id] }; 1579 else { 1580 groups[phone].ids.push(doc.id); 1581 if (d.lastActive > groups[phone].main.lastActive) groups[phone].main = {id:doc.id, ...d}; 1582 } 1583 }); 1584 1585 // Sort descending by time 1586 const chats = Object.values(groups).map(g => ({...g.main, ids: g.ids})).sort((a,b) => b.lastActive - a.lastActive); 1587 1588 await saveCachedChats(chats); 1589 renderChatList(chats); 1590 }, err => { 1591 console.error("Chat list sync error:", err); 1592 }); 1593 } 1594 1595 function renderChatList(chats) { 1596 const list = document.getElementById('contactList'); 1597 list.innerHTML = chats.length === 0 ? `<div style="text-align:center; padding:32px; color:var(--md-sys-color-outline);">No chats found</div>` : ''; 1598 1599 chats.forEach(c => { 1600 const el = document.createElement('div'); 1601 el.className = 'contact-item ripple-surface'; 1602 1603 // Priority: Custom Name > Display Name > Phone Number > Raw ID 1604 const primaryName = c.customName || c.displayName || c.phoneNumber || c.id; 1605 const isGroup = c.id.includes('@g.us'); 1606 1607 // Structure: Avatar | [ Name Time ] 1608 // | [ Checkmark/Sender + Msg ] 1609 el.innerHTML = ` 1610 <div class="avatar">${isGroup ? '<span class="material-symbols-rounded">group</span>' : escapeHTML(primaryName[0]).toUpperCase()}</div> 1611 <div class="contact-info" style="min-width: 0;"> 1612 <div class="contact-name" style="display:flex; justify-content:space-between; align-items:baseline; width:100%;"> 1613 <span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;">${escapeHTML(primaryName)}</span> 1614 <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> 1615 </div> 1616 <div class="contact-sub preview-text" style="display:flex; align-items:center; margin-top:2px; color:var(--md-sys-color-on-surface-variant);"> 1617 <span class="preview-content" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; width:100%;"> 1618 <span style="opacity:0.5; font-size: 13px;">Syncing...</span> 1619 </span> 1620 </div> 1621 </div> 1622 `; 1623 1624 el.onclick = () => openChat(c.ids, primaryName, el, isGroup, c.phoneNumber || c.id); 1625 list.appendChild(el); 1626 1627 // Fetch the latest message asynchronously to prevent blocking the UI render 1628 const updatePreview = async () => { 1629 let allMsgs = []; 1630 for (const id of c.ids) { 1631 const msgs = await getCachedMessages(id); 1632 allMsgs = allMsgs.concat(msgs); 1633 } 1634 1635 // Sort descending to grab the absolute latest message in local cache 1636 allMsgs.sort((a, b) => b.timestamp - a.timestamp); 1637 let lastMsg = allMsgs.length > 0 ? allMsgs[0] : null; 1638 1639 // REAL-TIME PREVIEW SYNC: 1640 // If the server tells us this chat was active *after* our cached latest message, fetch the missing real-time snippet 1641 if (navigator.onLine && (!lastMsg || c.lastActive > lastMsg.timestamp)) { 1642 try { 1643 let latestOnline = null; 1644 for (const id of c.ids) { 1645 const snap = await db.collection('Chats').doc(id).collection('Messages') 1646 .orderBy('timestamp', 'desc').limit(1).get(); 1647 1648 if (!snap.empty) { 1649 const data = snap.docs[0].data(); 1650 if (!latestOnline || data.timestamp > latestOnline.timestamp) { 1651 latestOnline = data; 1652 } 1653 } 1654 } 1655 if (latestOnline && (!lastMsg || latestOnline.timestamp > lastMsg.timestamp)) { 1656 lastMsg = latestOnline; 1657 } 1658 } catch(err) { 1659 console.warn("Failed fetching realtime preview snippet", err); 1660 } 1661 } 1662 1663 const contentSpan = el.querySelector('.preview-content'); 1664 const timeSpan = el.querySelector('.chat-time'); 1665 1666 if (lastMsg) { 1667 // Format Timestamp: Time if today, Date if older 1668 const msgDate = new Date(lastMsg.timestamp * 1000); 1669 const today = new Date(); 1670 const isToday = msgDate.toDateString() === today.toDateString(); 1671 1672 timeSpan.innerText = isToday 1673 ? msgDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) 1674 : msgDate.toLocaleDateString([], { month: 'short', day: 'numeric' }); 1675 1676 // Format Prefix: Double ticks for outgoing, Sender Name for incoming groups 1677 let prefix = ''; 1678 if (lastMsg.fromMe) { 1679 prefix = `<span class="material-symbols-rounded" style="font-size: 16px; vertical-align: text-bottom; margin-right: 4px; color: var(--md-sys-color-outline);">done_all</span>`; 1680 } else if (isGroup && lastMsg.senderName && lastMsg.senderName !== "Unknown") { 1681 prefix = `<span style="font-weight: 500;">${escapeHTML(lastMsg.senderName)}:</span> `; 1682 } 1683 1684 // Construct the final line properly escaped 1685 const safePreviewText = escapeHTML(lastMsg.text || 'Media'); 1686 contentSpan.innerHTML = `${prefix}${safePreviewText}`; 1687 } else { 1688 contentSpan.innerHTML = `<span style="font-style: italic; opacity: 0.7;">No messages</span>`; 1689 } 1690 }; 1691 1692 updatePreview(); 1693 }); 1694 } 1695 1696 // --- UPDATED OPEN CHAT: DELTA SYNC + IDB + CINEMATIC UI --- 1697 async function openChat(ids, name, el, isGroup, displayId) { 1698 activeChatId = ids[0]; 1699 isCurrentChatGroup = isGroup; 1700 document.getElementById('headerName').innerText = name; 1701 document.getElementById('chatHeaderContent').style.opacity = '1'; 1702 document.getElementById('headerId').innerText = displayId; 1703 document.getElementById('headerAvatar').innerHTML = isGroup ? `<span class="material-symbols-rounded">group</span>` : escapeHTML(name[0]).toUpperCase(); 1704 1705 if (window.matchMedia('(max-width: 768px)').matches) { 1706 window.history.pushState({chatOpen: true}, ""); 1707 document.getElementById('sidebar').classList.add('inactive'); 1708 document.getElementById('chatArea').classList.add('active'); 1709 } 1710 1711 if(window.unsub) window.unsub.forEach(u => u()); 1712 window.unsub = []; 1713 1714 const container = document.getElementById('messagesContainer'); 1715 const spinnerWrap = document.getElementById('syncSpinnerWrap'); 1716 1717 // Show cinematic sync UI 1718 spinnerWrap.classList.add('active'); 1719 container.classList.add('syncing'); 1720 1721 // 1. Fetch from Cache 1722 let cachedMsgs = []; 1723 for (const id of ids) { 1724 const msgs = await getCachedMessages(id); 1725 cachedMsgs = cachedMsgs.concat(msgs); 1726 } 1727 1728 const msgsMap = new Map(); 1729 cachedMsgs.forEach(m => msgsMap.set(m.id, m)); 1730 1731 // Render Cache Immediately 1732 if (cachedMsgs.length > 0) { 1733 renderMsgs(msgsMap); 1734 setTimeout(() => scrollToBottom('auto'), 50); // Instantly jump to bottom on load 1735 } else { 1736 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>`; 1737 } 1738 1739 // 2. Delta Sync with Firebase 1740 let lastTs = 0; 1741 if (cachedMsgs.length > 0) { 1742 lastTs = Math.max(...cachedMsgs.map(m => m.timestamp)); 1743 } 1744 1745 // Artificial delay to let spinner animation glide smoothly if cache was instant 1746 await new Promise(r => setTimeout(r, 400)); 1747 1748 let pendingSyncs = ids.length; 1749 1750 ids.forEach(id => { 1751 let query = db.collection('Chats').doc(id).collection('Messages').orderBy('timestamp'); 1752 if (lastTs > 0) { 1753 query = query.where('timestamp', '>', lastTs); 1754 } 1755 1756 let isFirstSync = true; 1757 window.unsub.push(query.onSnapshot(async snap => { 1758 let isNew = false; 1759 const newToCache = []; 1760 snap.forEach(doc => { 1761 const data = doc.data(); 1762 data.chatId = id; 1763 msgsMap.set(data.id, data); 1764 newToCache.push(data); 1765 isNew = true; 1766 }); 1767 1768 const c = document.getElementById('messagesContainer'); 1769 const wasAtBottom = (c.scrollHeight - c.scrollTop - c.clientHeight) <= 100; 1770 1771 if (newToCache.length > 0) await saveCachedMessages(newToCache); 1772 if (isNew || cachedMsgs.length === 0) { 1773 renderMsgs(msgsMap); 1774 1775 if (isFirstSync || wasAtBottom) { 1776 setTimeout(() => scrollToBottom(isFirstSync ? 'auto' : 'smooth'), 50); 1777 } else if (!isFirstSync && isNew && !wasAtBottom) { 1778 document.getElementById('newMsgBadge').classList.remove('hidden'); 1779 document.getElementById('scrollFab').classList.add('visible'); 1780 } 1781 } 1782 1783 if (isFirstSync) { 1784 isFirstSync = false; 1785 pendingSyncs--; 1786 if (pendingSyncs <= 0) { 1787 spinnerWrap.classList.remove('active'); 1788 container.classList.remove('syncing'); 1789 } 1790 } 1791 }, err => { 1792 console.error("Sync error:", err); 1793 if (isFirstSync) { 1794 isFirstSync = false; 1795 pendingSyncs--; 1796 if (pendingSyncs <= 0) { 1797 spinnerWrap.classList.remove('active'); 1798 container.classList.remove('syncing'); 1799 } 1800 } 1801 })); 1802 }); 1803 } 1804 1805 function renderMsgs(map) { 1806 const container = document.getElementById('messagesContainer'); 1807 container.innerHTML = ''; 1808 currentMessages = Array.from(map.values()).sort((a,b) => a.timestamp - b.timestamp); 1809 1810 if(currentMessages.length === 0) { 1811 container.innerHTML = '<div class="empty-state"><p>No messages recorded</p></div>'; 1812 return; 1813 } 1814 1815 let lastDateStr = ""; let currentGroup = null; 1816 1817 currentMessages.forEach((m, index) => { 1818 const date = new Date(m.timestamp * 1000); 1819 const dateStr = date.toDateString(); 1820 1821 if(dateStr !== lastDateStr) { 1822 if(currentGroup) container.appendChild(currentGroup); 1823 currentGroup = document.createElement('div'); 1824 currentGroup.className = 'chat-date-group'; 1825 const div = document.createElement('div'); div.className = 'date-divider'; div.innerText = formatDividerDate(m.timestamp); 1826 currentGroup.appendChild(div); 1827 lastDateStr = dateStr; 1828 } 1829 1830 const div = document.createElement('div'); 1831 div.className = `msg-bubble ${m.fromMe ? 'msg-out' : 'msg-in'}`; 1832 1833 const nextMsg = currentMessages[index + 1]; const prevMsg = currentMessages[index - 1]; 1834 const isSamePrev = (prevMsg && prevMsg.fromMe === m.fromMe && (!isCurrentChatGroup || prevMsg.senderName === m.senderName) && new Date(prevMsg.timestamp*1000).toDateString() === dateStr); 1835 const isSameNext = (nextMsg && nextMsg.fromMe === m.fromMe && (!isCurrentChatGroup || nextMsg.senderName === m.senderName) && new Date(nextMsg.timestamp*1000).toDateString() === dateStr); 1836 1837 if (isSamePrev && isSameNext) div.classList.add('group-mid'); else if (isSamePrev) div.classList.add('group-bot'); else if (isSameNext) div.classList.add('group-top'); 1838 1839 const showSenderName = !m.fromMe && isCurrentChatGroup && m.senderName && m.senderName !== "Unknown" && !isSamePrev; 1840 div.id = `msg-${m.timestamp}`; div.dataset.date = dateStr; 1841 1842 // Escape all text first so HTML code isn't rendered 1843 const safeText = escapeHTML(m.text || ''); 1844 // Find URLs and wrap them in an anchor tag safely 1845 const urlRegex = /(https?:\/\/[^\s]+)/g; 1846 const formattedText = safeText ? safeText.replace(urlRegex, url => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: var(--md-sys-color-primary); text-decoration: underline;">${url}</a>`) : ''; 1847 1848 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>`; 1849 if (currentGroup) currentGroup.appendChild(div); 1850 }); 1851 1852 if(currentGroup) container.appendChild(currentGroup); 1853 // Auto-scroll logic is now handled strictly in openChat based on user's scroll position 1854 } 1855 1856 function scrollToBottom(behavior = 'auto') { 1857 const c = document.getElementById('messagesContainer'); 1858 c.scrollTo({ top: c.scrollHeight, behavior: behavior }); 1859 document.getElementById('newMsgBadge').classList.add('hidden'); 1860 } 1861 1862 // --- CALENDAR LOGIC --- 1863 function openDateSearch() { document.getElementById('chatMenu').classList.remove('show'); document.getElementById('calendarModal').classList.add('active'); renderCalendar(calCurrentDate); } 1864 1865 function renderCalendar(date) { 1866 const year = date.getFullYear(); const month = date.getMonth(); 1867 document.getElementById('calMonthYear').innerText = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(date); 1868 const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); 1869 const grid = document.getElementById('calDatesGrid'); grid.innerHTML = ''; 1870 for(let i=0; i<firstDay; i++) grid.innerHTML += `<div class="cal-date empty"></div>`; 1871 for(let d=1; d<=daysInMonth; d++) { 1872 const dayEl = document.createElement('div'); dayEl.className = 'cal-date ripple-surface'; dayEl.innerText = d; 1873 if(calSelectedDate && calSelectedDate.getDate() === d && calSelectedDate.getMonth() === month && calSelectedDate.getFullYear() === year) dayEl.classList.add('active'); 1874 dayEl.onclick = () => selectDate(new Date(year, month, d)); grid.appendChild(dayEl); 1875 } 1876 } 1877 1878 function changeCalMonth(delta) { calCurrentDate.setMonth(calCurrentDate.getMonth() + delta); renderCalendar(calCurrentDate); } 1879 function selectDate(date) { calSelectedDate = date; document.getElementById('calSelectedDateStr').innerText = date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); renderCalendar(calCurrentDate); } 1880 1881 function jumpToDate() { 1882 if(!currentMessages.length) { closeModal('calendarModal'); return; } 1883 const targetTs = calSelectedDate.setHours(0,0,0,0) / 1000; 1884 const msg = currentMessages.find(m => m.timestamp >= targetTs); 1885 closeModal('calendarModal'); 1886 if(msg) { 1887 const el = document.getElementById(`msg-${msg.timestamp}`); 1888 if(el) { el.scrollIntoView({behavior: 'smooth', block: 'center'}); el.style.backgroundColor = 'var(--search-highlight)'; setTimeout(() => el.style.backgroundColor = '', 2000); } 1889 } else document.getElementById('notFoundModal').classList.add('active'); 1890 } 1891 1892 function handleLogout() { localStorage.removeItem('wp_auth_expiry'); localStorage.removeItem('wp_app_pin'); localStorage.removeItem('wp_app_bio'); location.reload(); } 1893 function closeChatMobile() { window.history.back(); } 1894 window.addEventListener('popstate', () => { document.getElementById('chatArea').classList.remove('remove'); document.getElementById('chatArea').classList.remove('active'); }); 1895 1896 function downloadChat() { 1897 if(!currentMessages.length) return; 1898 const text = currentMessages.map(m => `[${new Date(m.timestamp*1000).toLocaleString()}] ${m.senderName}: ${m.text}`).join('\n'); 1899 const blob = new Blob([text], {type:'text/plain'}); 1900 const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'chat.txt'; a.click(); 1901 } 1902 1903 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'); } 1904 async function saveRename() { const name = document.getElementById('renameInput').value; if(name) { await db.collection('Chats').doc(activeChatId).set({customName: name}, {merge:true}); document.getElementById('headerName').innerText = name; closeModal('renameModal'); loadChats(); } } 1905 function showLogoutModal() { document.getElementById('logoutModal').classList.add('active'); } 1906 1907 document.getElementById('searchInput').addEventListener('input', (e) => { 1908 const val = e.target.value.toLowerCase(); 1909 document.querySelectorAll('.contact-item').forEach(el => { el.style.display = el.innerText.toLowerCase().includes(val) ? 'flex' : 'none'; }); 1910 }); 1911 1912 function refreshApp() { 1913 document.getElementById('appOptionsModal').classList.add('active'); 1914 } 1915 1916 function executePageRefresh() { 1917 window.location.reload(true); 1918 } 1919 1920 function openHardReset() { 1921 closeModal('appOptionsModal'); 1922 document.getElementById('refreshAuthStep').classList.remove('hidden'); 1923 document.getElementById('refreshProgressStep').classList.add('hidden'); 1924 document.getElementById('refreshUsername').value = ''; 1925 document.getElementById('refreshPassword').value = ''; 1926 document.getElementById('refreshError').style.display = 'none'; 1927 document.getElementById('refreshModal').classList.add('active'); 1928 } 1929 1930 async function startHardRefresh() { 1931 const btn = document.getElementById('refreshBtn'); const err = document.getElementById('refreshError'); 1932 const u = document.getElementById('refreshUsername').value; const p = document.getElementById('refreshPassword').value; 1933 1934 btn.classList.add('loading'); btn.disabled = true; err.style.display = 'none'; 1935 1936 try { 1937 const response = await fetch(`${RENDER_BACKEND_URL}/api/verify`, { 1938 method: 'POST', headers: {'Content-Type': 'application/json'}, 1939 body: JSON.stringify({ username: u, password: p }) 1940 }); 1941 const data = await response.json(); 1942 1943 if (!data.success) throw new Error(); 1944 1945 document.getElementById('refreshAuthStep').classList.add('hidden'); 1946 document.getElementById('refreshProgressStep').classList.remove('hidden'); 1947 1948 await executeHardRefresh(); 1949 1950 } catch (e) { 1951 err.style.display = 'block'; 1952 btn.classList.remove('loading'); btn.disabled = false; 1953 } 1954 } 1955 1956 function updateRefreshProgress(percent, text) { 1957 document.getElementById('refreshProgressBar').style.width = `${percent}%`; 1958 document.getElementById('refreshPercentText').innerText = `${Math.floor(percent)}%`; 1959 document.getElementById('refreshStatusText').innerText = text; 1960 } 1961 1962 async function executeHardRefresh() { 1963 try { 1964 updateRefreshProgress(5, "Clearing caches..."); 1965 if ('caches' in window) { try { const keys = await caches.keys(); await Promise.all(keys.map(key => caches.delete(key))); } catch(e) {} } 1966 if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); for (let r of regs) await r.unregister(); } 1967 1968 updateRefreshProgress(10, "Clearing local storage..."); 1969 const theme = localStorage.getItem('theme'); 1970 const bg = localStorage.getItem('chatBg'); 1971 localStorage.clear(); 1972 if(theme) localStorage.setItem('theme', theme); 1973 if(bg) localStorage.setItem('chatBg', bg); 1974 1975 updateRefreshProgress(15, "Wiping database..."); 1976 if(dbInstance) { dbInstance.close(); dbInstance = null; } 1977 1978 // Slight delay helps mobile OS clear file locks before deletion 1979 await new Promise(r => setTimeout(r, 100)); 1980 1981 await new Promise(res => { 1982 try { 1983 const req = indexedDB.deleteDatabase(DB_NAME); 1984 req.onsuccess = res; 1985 req.onerror = res; 1986 req.onblocked = () => { 1987 console.warn("Delete DB blocked by another process."); 1988 res(); 1989 }; 1990 } catch(e) { res(); } 1991 }); 1992 1993 // Slight delay before reopening 1994 await new Promise(r => setTimeout(r, 200)); 1995 1996 updateRefreshProgress(25, "Rebuilding database..."); 1997 isIdbSupported = true; 1998 await initIndexedDB(); 1999 2000 updateRefreshProgress(30, "Fetching chat list..."); 2001 const snap = await db.collection('Chats').get(); 2002 const groups = {}; 2003 const excluded = ['917278779512@s.whatsapp.net', '201554426618024@lid']; 2004 2005 snap.forEach(doc => { 2006 const d = doc.data(); 2007 if (!d.lastActive || excluded.includes(doc.id)) return; 2008 const phone = d.phoneNumber || doc.id.split('@')[0]; 2009 if (!groups[phone]) groups[phone] = { main: {id:doc.id, ...d}, ids: [doc.id] }; 2010 else { 2011 groups[phone].ids.push(doc.id); 2012 if (d.lastActive > groups[phone].main.lastActive) groups[phone].main = {id:doc.id, ...d}; 2013 } 2014 }); 2015 2016 const chats = Object.values(groups).map(g => ({...g.main, ids: g.ids})).sort((a,b) => b.lastActive - a.lastActive); 2017 await saveCachedChats(chats); 2018 2019 const total = chats.length; 2020 let done = 0; 2021 2022 for (const chat of chats) { 2023 const name = chat.customName || chat.displayName || chat.phoneNumber || chat.id; 2024 updateRefreshProgress(30 + ((done / total) * 60), `Fetching: ${name}...`); 2025 2026 for (const id of chat.ids) { 2027 const msgSnap = await db.collection('Chats').doc(id).collection('Messages').get(); 2028 const msgs = []; 2029 msgSnap.forEach(doc => { 2030 const data = doc.data(); data.chatId = id; msgs.push(data); 2031 }); 2032 if (msgs.length > 0) await saveCachedMessages(msgs); 2033 } 2034 done++; 2035 } 2036 2037 updateRefreshProgress(100, "Sync complete! Restarting..."); 2038 setTimeout(() => window.location.reload(), 1000); 2039 2040 } catch (e) { 2041 console.error("Hard refresh error", e); 2042 updateRefreshProgress(100, "Error during sync. Reloading..."); 2043 setTimeout(() => window.location.reload(), 2000); 2044 } 2045 } 2046 // Function to accurately measure total LocalStorage space occupied 2047 function calculateLocalStorageUsage() { 2048 let totalBytes = 0; 2049 for (let key in localStorage) { 2050 if (!localStorage.hasOwnProperty(key)) continue; 2051 // Key length + string content value length multiplied by 2 bytes (UTF-16 characters standard layout size) 2052 totalBytes += ((key.length + localStorage[key].length) * 2); 2053 } 2054 2055 // Format into human readable metric layout outputs 2056 const sizeInKB = (totalBytes / 1024).toFixed(2); 2057 const displayElement = document.getElementById('localStorageSizeDisplay'); 2058 2059 if (displayElement) { 2060 if (sizeInKB >= 1024) { 2061 displayElement.innerText = `${(sizeInKB / 1024).toFixed(2)} MB occupied`; 2062 } else { 2063 displayElement.innerText = `${sizeInKB} KB occupied`; 2064 } 2065 } 2066 } 2067 </script> 2068 </body> 2069 </html>