1/** 2 * Settings - Notifications Tab 3 * SMS phone verification, notification preferences, push notification status. 4 */ 5 6import * as notificationsApi from '../../api/notifications.js'; 7import { setLoading, afterRender, createActionRunner } from '../../utils/dom.js'; 8import { validateRequired, markError, clearError } from '../../utils/form-validation.js'; 9import { DatePicker } from '../../components/DatePicker.js'; 10import { formatPhoneNumber } from './utils.js'; 11 12// --------------------------------------------------------------------------- 13// Module state 14// --------------------------------------------------------------------------- 15 16let phoneState = { 17 currentPhone: null, 18 isVerified: false, 19 countdownInterval: null, 20 expiresAt: null 21}; 22 23let preferences = null; 24let preferenceGeneration = 0; 25let preferenceVisit = null; 26const preferenceActions = createActionRunner(); 27let pendingPreferenceMutation = null; 28 29// --------------------------------------------------------------------------- 30// Lifecycle 31// --------------------------------------------------------------------------- 32 33export async function init() { 34 destroy(); 35 const generation = preferenceGeneration; 36 const content = document.getElementById('settings-content'); 37 const visit = { generation, content, lifetime: new AbortController(), controls: new Map(), accepted: new Map(), publishing: false }; 38 preferenceVisit = visit; 39 if (pendingPreferenceMutation) await pendingPreferenceMutation; 40 if (!ownsPreferenceVisit(visit)) return; 41 42 try { 43 const loaded = await notificationsApi.getNotificationSettings({ signal: visit.lifetime.signal }); 44 if (!ownsPreferenceVisit(visit)) return; 45 preferences = loaded; 46 } catch (err) { 47 if (!ownsPreferenceVisit(visit) || err.name === 'AbortError') return; 48 console.error('[Notifications] Failed to load preferences:', err); 49 // Render with sensible defaults so the page isn't blank 50 preferences = { 51 smsPhoneNumber: '', 52 smsVerified: false, 53 smsServiceAvailable: false, 54 verificationPending: false, 55 enableSms: false, 56 notifyOnNewMessage: true, 57 notifyOnMessageReply: true, 58 notifyOnAssignment: true 59 }; 60 } 61 62 const tmpl = $.templates('#tpl-tab-notifications'); 63 content.innerHTML = tmpl.render(preferences); 64 afterRender(content); 65 visit.root = document.getElementById('tab-notifications'); 66 capturePreferenceControls(visit); 67 68 // Set phone state from fetched preferences 69 phoneState.isVerified = preferences.smsVerified || false; 70 phoneState.currentPhone = preferences.smsPhoneNumber || null; 71 72 // Format displayed phone number 73 const phoneInput = document.getElementById('smsPhoneInput'); 74 if (phoneInput && phoneState.currentPhone) { 75 phoneInput.value = formatPhoneNumber(phoneState.currentPhone); 76 } 77 78 checkPushStatus(); 79 initPushHandlers(); 80 initPhoneVerification(); 81 initPreferenceToggles(visit); 82 initSoundToggle(visit); 83 initQuietHours(visit); 84 initDeviceRegistration(); 85 loadDevices(); 86} 87 88export function destroy() { 89 ++preferenceGeneration; 90 preferenceVisit?.lifetime.abort(); 91 preferenceVisit = null; 92 if (phoneState.countdownInterval) { 93 clearInterval(phoneState.countdownInterval); 94 phoneState.countdownInterval = null; 95 } 96 phoneState = { currentPhone: null, isVerified: false, countdownInterval: null, expiresAt: null }; 97 preferences = null; 98} 99 100function ownsPreferenceVisit(visit) { 101 return preferenceVisit === visit && preferenceGeneration === visit.generation && !visit.lifetime.signal.aborted 102 && visit.content?.isConnected && document.getElementById('settings-content') === visit.content 103 && (!visit.root || (visit.root.isConnected && document.getElementById('tab-notifications') === visit.root)); 104} 105 106function capturePreferenceControls(visit) { 107 for (const id of ['toggleMessages', 'toggleReplies', 'toggleAssignments', 'toggleEnableSms',
108 'smsEscalationMinutes', 'toggleQuietHours', 'quietHoursStart', 'quietHoursEnd']) { 109 const control = document.getElementById(id); 110 if (!control) continue; 111 visit.controls.set(id, control); 112 visit.accepted.set(id, readPreferenceControl(control)); 113 const picker = DatePicker.getInstance(control); 114 if (picker) { 115 const refuseBusyInteraction = event => { 116 if (!ownsPreferenceVisit(visit) || !preferenceActions.isBusy()) return; 117 event.preventDefault(); 118 event.stopImmediatePropagation(); 119 restorePreferenceSnapshot(visit, visit.locked); 120 }; 121 picker.wrapper.addEventListener('click', refuseBusyInteraction, { capture: true, signal: visit.lifetime.signal }); 122 picker.wrapper.addEventListener('keydown', refuseBusyInteraction, { capture: true, signal: visit.lifetime.signal }); 123 } 124 } 125} 126 127function readPreferenceControl(control) { 128 return control.type === 'checkbox' || control.id.startsWith('toggle') ? control.checked : String(control.value); 129} 130 131function publishPreferenceControl(visit, control, value) { 132 if (typeof value === 'boolean') control.checked = value; 133 else { 134 const picker = DatePicker.getInstance(control); 135 visit.publishing = true; 136 try { if (picker) picker.setValue(value); else control.value = value; } 137 finally { visit.publishing = false; } 138 } 139} 140 141function restorePreferenceSnapshot(visit, snapshot) { 142 if (!ownsPreferenceVisit(visit) || !snapshot) return; 143 for (const [id, value] of snapshot) publishPreferenceControl(visit, visit.controls.get(id), value); 144 document.getElementById('quietHoursTimes')?.classList.toggle('hidden', !visit.controls.get('toggleQuietHours')?.checked); 145} 146 147function refusePreferenceChange(visit) { 148 if (!visit) return false; 149 if (visit.publishing || !ownsPreferenceVisit(visit)) return true; 150 if (!preferenceActions.isBusy()) return false; 151 restorePreferenceSnapshot(visit, visit.locked); 152 return true; 153} 154 155function paintPreferenceBusy(visit, busy) { 156 if (!ownsPreferenceVisit(visit)) return; 157 if (busy) { 158 visit.disabled = []; 159 visit.triggers = []; 160 for (const control of visit.controls.values()) { 161 const picker = DatePicker.getInstance(control); 162 picker?.close(); 163 for (const target of [control, picker?.textInput, picker?.clearBtn].filter(Boolean)) { 164 visit.disabled.push([target, target.disabled]); 165 target.disabled = true; 166 } 167 if (picker?.trigger) { 168 visit.triggers.push([picker.trigger, picker.trigger.getAttribute('aria-disabled'), picker.trigger.tabIndex]); 169 picker.trigger.setAttribute('aria-disabled', 'true'); 170 picker.trigger.tabIndex = -1; 171 } 172 } 173 } else { 174 for (const [target, disabled] of visit.disabled || []) target.disabled = disabled; 175 for (const [trigger, disabled, tabIndex] of visit.triggers || []) { 176 if (disabled === null) trigger.removeAttribute('aria-disabled'); else trigger.setAttribute('aria-disabled', disabled); 177 trigger.tabIndex = tabIndex; 178 } 179 } 180 document.getElementById('notificationPreferenceSaving')?.classList.toggle('hidden', !busy); 181} 182 183function savePreferenceChange(visit, payload, { rollback = [], successfulValues = {}, errorFields = [] } = {}) { 184 if (refusePreferenceChange(visit)) return; 185 // The server merges the full row; the lock and barrier therefore survive tab teardown (CA-297). 186 const mutation = preferenceActions.lock(async () => { 187 visit.locked = new Map([...visit.controls].map(([id, control]) => [id, readPreferenceControl(control)])); 188 paintPreferenceBusy(visit, true); 189 try { 190 await notificationsApi.updateNotificationSettings(payload); 191 if (!ownsPreferenceVisit(visit)) return; 192 for (const [id, value] of Object.entries(successfulValues)) publishPreferenceControl(visit, visit.controls.get(id), value); 193 for (const [id, control] of visit.controls) visit.accepted.set(id, readPreferenceControl(control)); 194 for (const id of errorFields) clearError(visit.root, visit.controls.get(id)); 195 } catch (err) { 196 if (!ownsPreferenceVisit(visit)) return; 197 for (const id of rollback) publishPreferenceControl(visit, visit.controls.get(id), visit.accepted.get(id)); 198 document.getElementById('quietHoursTimes')?.classList.toggle('hidden', !visit.controls.get('toggleQu
198ietHours')?.checked); 199 for (const id of errorFields) markError(visit.root, visit.controls.get(id), 'Not saved. Change this value to retry.'); 200 if (err.name !== 'AbortError') window.showToast?.('Save failed', 'error', { description: err?.message || 'Could not update preference' }); 201 } finally { 202 paintPreferenceBusy(visit, false); 203 } 204 }); 205 pendingPreferenceMutation = mutation; 206 void mutation.finally(() => { if (pendingPreferenceMutation === mutation) pendingPreferenceMutation = null; }); 207 return mutation; 208} 209 210// --------------------------------------------------------------------------- 211// Push Notifications (existing functionality) 212// --------------------------------------------------------------------------- 213 214/** 215 * Render the push status line + toggle from BOTH the browser permission and whether this 216 * device actually holds a subscription. 217 * 218 * Permission alone is not the truth. Signing out revokes this device's subscription (see 219 * clearAuth) while the permission stays `granted`, so a permission-only read would show 220 * "Enabled" in success green next to a device list that correctly says this browser is not 221 * registered â and the device genuinely cannot receive push. getRegistration() rather than 222 * .ready, which never settles when no service worker is registered. 223 */ 224async function checkPushStatus() { 225 const statusEl = document.getElementById('pushStatus'); 226 if (!statusEl) return; 227 228 if (!('Notification' in window)) { 229 statusEl.textContent = 'Not supported in this browser'; 230 statusEl.className = 'font-medium text-sb-warning'; 231 return; 232 } 233 234 const permission = Notification.permission; 235 if (permission === 'granted') { 236 let subscribed = false; 237 try { 238 const registration = await navigator.serviceWorker?.getRegistration(); 239 subscribed = Boolean(await registration?.pushManager?.getSubscription()); 240 } catch { /* no service worker or no push support â treat as not subscribed */ } 241 242 const toggle = document.getElementById('togglePush'); 243 if (subscribed) { 244 statusEl.textContent = 'Enabled'; 245 statusEl.className = 'font-medium text-sb-success'; 246 if (toggle) toggle.checked = true; 247 } else { 248 statusEl.textContent = 'Not registered on this device'; 249 statusEl.className = 'font-medium text-theme-secondary'; 250 if (toggle) toggle.checked = false; 251 } 252 } else if (permission === 'denied') { 253 statusEl.textContent = 'Blocked by browser'; 254 statusEl.className = 'font-medium text-sb-error'; 255 } else { 256 statusEl.textContent = 'Not enabled'; 257 statusEl.className = 'font-medium text-theme-secondary'; 258 } 259} 260 261function initPushHandlers() { 262 const pushToggle = document.getElementById('togglePush'); 263 pushToggle?.addEventListener('change', async () => { 264 if (pushToggle.checked && 'Notification' in window) { 265 const permission = await Notification.requestPermission(); 266 pushToggle.checked = permission === 'granted'; 267 await checkPushStatus(); 268 } 269 }); 270} 271 272function initDeviceRegistration() { 273 const registerBtn = document.getElementById('btnRegisterDevice'); 274 registerBtn?.addEventListener('click', async () => { 275 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { 276 window.showToast?.('Not supported', 'error', { 277 description: 'This browser does not support push notifications' 278 }); 279 return; 280 } 281 282 setLoading(registerBtn, true, 'Registering...'); 283 try { 284 const permission = await Notification.requestPermission(); 285 if (permission !== 'granted') { 286 window.showToast?.('Permission needed', 'warning', { 287 description: 'Allow notifications in your browser to register this device' 288 }); 289 return; 290 } 291 292 const { publicKey } = await notificationsApi.getVapidPublicKey(); 293 const registration = await navigator.serviceWorker.ready; 294 const subscription = await registration.pushManager.subscribe({ 295 userVisibleOnly: true, 296 applicationServerKey: urlBase64ToUint8Array(publicKey) 297 }); 298 299 await notificationsApi.registerPushSubscription(subscription.toJSON(), guessDeviceName()); 300 301 window.showToast?.('Device registered', 'success', { 302 description: 'This device will now receive push notifications' 303 }); 304 await checkPushStatus(); 305 await loadDevices(); 306 } catch (err) { 307 if (err.name === 'AbortError') return; 308 window.showToast?.('Registration failed', 'error', { 309 description: err?.message || 'Could not register this device' 310 }); 311 } finally {
312 setLoading(registerBtn, false); 313 } 314 }); 315 316 // Unregister buttons (delegated â the list re-renders) 317 document.getElementById('deviceList')?.addEventListener('click', async (e) => { 318 const btn = e.target.closest('[data-action="unregister-device"]'); 319 if (!btn) return; 320 const endpoint = btn.closest('[data-endpoint]')?.dataset.endpoint; 321 if (!endpoint) return; 322 323 setLoading(btn, true, 'Removing...'); 324 try { 325 await notificationsApi.unregisterPushSubscription(endpoint); 326 await loadDevices(); 327 } catch (err) { 328 if (err.name === 'AbortError') return; 329 window.showToast?.('Remove failed', 'error', { description: err?.message || 'Could not remove device' }); 330 setLoading(btn, false); 331 } 332 }); 333} 334 335async function loadDevices() { 336 const list = document.getElementById('deviceList'); 337 if (!list) return; 338 339 try { 340 const devices = await notificationsApi.getPushDevices(); 341 const rows = (devices || []).map(d => ({ 342 endpoint: d.endpoint, 343 deviceName: d.deviceName || 'Unknown device', 344 createdDisplay: d.createdDate ? new Date(d.createdDate).toLocaleDateString() : '', 345 lastUsedDisplay: d.lastUsedDate ? new Date(d.lastUsedDate).toLocaleDateString() : '' 346 })); 347 348 list.innerHTML = rows.length > 0 349 ? rows.map(r => $.templates('#tpl-settings-push-device').render(r)).join('') 350 : $.templates('#tpl-settings-push-device-empty').render({}); 351 afterRender(list); 352 } catch (err) { 353 // Push endpoints unavailable (e.g. VAPID not configured) â keep the placeholder 354 console.debug('[Notifications] Device list unavailable:', err?.message); 355 } 356} 357 358function guessDeviceName() { 359 const ua = navigator.userAgent; 360 const browser = /Edg\//.test(ua) ? 'Edge' 361 : /Chrome\//.test(ua) ? 'Chrome' 362 : /Firefox\//.test(ua) ? 'Firefox' 363 : /Safari\//.test(ua) ? 'Safari' : 'Browser'; 364 const os = /Windows/.test(ua) ? 'Windows' 365 : /Mac OS/.test(ua) ? 'macOS' 366 : /Android/.test(ua) ? 'Android' 367 : /iPhone|iPad/.test(ua) ? 'iOS' 368 : /Linux/.test(ua) ? 'Linux' : ''; 369 return os ? `${browser} on ${os}` : browser; 370} 371 372/** Convert a base64url VAPID key to the Uint8Array PushManager.subscribe expects. */ 373function urlBase64ToUint8Array(base64String) { 374 const padding = '='.repeat((4 - (base64String.length % 4)) % 4); 375 const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); 376 const rawData = window.atob(base64); 377 const outputArray = new Uint8Array(rawData.length); 378 for (let i = 0; i < rawData.length; ++i) { 379 outputArray[i] = rawData.charCodeAt(i); 380 } 381 return outputArray; 382} 383 384// --------------------------------------------------------------------------- 385// SMS Phone Verification 386// --------------------------------------------------------------------------- 387 388function initPhoneVerification() { 389 const phoneInput = document.getElementById('smsPhoneInput'); 390 const verifyBtn = document.getElementById('btnVerifyPhone'); 391 const submitCodeBtn = document.getElementById('btnSubmitCode'); 392 const resendBtn = document.getElementById('btnResendCode'); 393 const codeInput = document.getElementById('smsCodeInput'); 394 const testSmsBtn = document.getElementById('btnTestSms'); 395 396 if (!phoneInput) return; // SMS service not available, card not rendered 397 398 // Format phone number as user types 399 phoneInput.addEventListener('input', (e) => { 400 const formatted = formatPhoneNumber(e.target.value); 401 e.target.value = formatted; 402 403 // If user changes phone from verified number, reset to unverified 404 const rawPhone = formatted.replace(/\D/g, ''); 405 if (phoneState.isVerified && rawPhone !== phoneState.currentPhone?.replace(/\D/g, '')) { 406 updatePhoneVerificationUI(false); 407 } 408 }); 409 410 // Verify button â sends phone + verification code 411 verifyBtn?.addEventListener('click', async () => { 412 const phone = phoneInput.value.replace(/\D/g, ''); 413 if (phone.length !== 10) { 414 window.showToast?.('Invalid number', 'error', { 415 description: 'Please enter a valid 10-digit phone number' 416 }); 417 phoneInput.focus(); 418 return; 419 } 420 await sendVerificationCode(phone); 421 }); 422 423 // Code input â strip non-digits, auto-submit on 6 digits 424 codeInput?.addEventListener('input', (e) => { 425 e.target.value = e.target.value.replace(/\D/g, ''); 426 if (e.target.value.length === 6) { 427 submitCodeBtn?.click(); 428 } 429 }); 430
431 // Submit code button 432 submitCodeBtn?.addEventListener('click', async () => { 433 const code = codeInput?.value?.trim() || ''; 434 if (code.length !== 6) { 435 window.showToast?.('Invalid code', 'error', { 436 description: 'Please enter the 6-digit verification code' 437 }); 438 codeInput?.focus(); 439 return; 440 } 441 await verifyCode(code); 442 }); 443 444 // Resend code 445 resendBtn?.addEventListener('click', async () => { 446 const phone = phoneInput.value.replace(/\D/g, ''); 447 if (phone.length !== 10) { 448 window.showToast?.('Invalid number', 'error', { 449 description: 'Please enter a valid 10-digit phone number' 450 }); 451 return; 452 } 453 await sendVerificationCode(phone); 454 }); 455 456 // Test SMS 457 testSmsBtn?.addEventListener('click', async () => { 458 setLoading(testSmsBtn, true, 'Sending...'); 459 try { 460 await notificationsApi.sendTestSms(); 461 window.showToast?.('Test SMS sent!', 'success', { 462 description: 'Check your phone for the test message' 463 }); 464 } catch (err) { 465 if (err.name === 'AbortError') return; 466 window.showToast?.('Failed to send', 'error', { 467 description: err?.message || 'Could not send test SMS' 468 }); 469 } finally { 470 setLoading(testSmsBtn, false); 471 } 472 }); 473 474 // If verification was already pending when we loaded, show the code section 475 if (preferences?.verificationPending && !phoneState.isVerified) { 476 document.getElementById('smsVerificationSection')?.classList.remove('hidden'); 477 } 478} 479 480async function sendVerificationCode(phone) { 481 const verifyBtn = document.getElementById('btnVerifyPhone'); 482 const verificationSection = document.getElementById('smsVerificationSection'); 483 const codeInput = document.getElementById('smsCodeInput'); 484 485 setLoading(verifyBtn, true, 'Sending...'); 486 487 try { 488 await notificationsApi.updateSmsPhone(phone); 489 490 window.showToast?.('Verification code sent!', 'success', { 491 description: 'Check your phone for the 6-digit code' 492 }); 493 verificationSection?.classList.remove('hidden'); 494 if (codeInput) { 495 codeInput.value = ''; 496 codeInput.focus(); 497 } 498 startCountdown(300); // 5 minutes 499 } catch (err) { 500 if (err.name === 'AbortError') return; 501 window.showToast?.('Failed to send code', 'error', { 502 description: err?.message || 'Could not send verification code' 503 }); 504 } finally { 505 // Success path was leaving the button stuck on "Sending..." forever 506 setLoading(verifyBtn, false); 507 } 508} 509 510async function verifyCode(code) { 511 const submitBtn = document.getElementById('btnSubmitCode'); 512 const verificationSection = document.getElementById('smsVerificationSection'); 513 514 setLoading(submitBtn, true, 'Verifying...'); 515 516 try { 517 await notificationsApi.confirmSmsCode(code); 518 519 window.showToast?.('Phone verified!', 'success', { 520 description: 'SMS notifications are now available' 521 }); 522 523 phoneState.isVerified = true; 524 phoneState.currentPhone = document.getElementById('smsPhoneInput')?.value; 525 526 if (phoneState.countdownInterval) { 527 clearInterval(phoneState.countdownInterval); 528 phoneState.countdownInterval = null; 529 } 530 531 verificationSection?.classList.add('hidden'); 532 updatePhoneVerificationUI(true); 533 } catch (err) { 534 if (err.name === 'AbortError') return; 535 window.showToast?.('Verification failed', 'error', { 536 description: err?.message || 'Invalid or expired code' 537 }); 538 document.getElementById('smsCodeInput')?.focus(); 539 } finally { 540 // Section gets hidden on success, but a stuck spinner resurfaces if the user 541 // re-enters the verify flow (phone change) â always reset 542 setLoading(submitBtn, false); 543 } 544} 545 546function updatePhoneVerificationUI(isVerified) { 547 const verifyBtn = document.getElementById('btnVerifyPhone'); 548 const verifiedBadge = document.getElementById('smsVerifiedBadge'); 549 const testSmsBtn = document.getElementById('btnTestSms'); 550 const verificationSection = document.getElementById('smsVerificationSection'); 551 const phoneInput = document.getElementById('smsPhoneInput'); 552 const hasPhone = (phoneInput?.value?.replace(/\D/g, '') || '').length >= 10; 553 554 phoneState.isVerified = isVerified; 555 556 if (isVerified) { 557 verifyBtn?.classList.add('hidden'); 558 verifiedBadge?.classList.remove('hidden'); 559 testSmsBtn?.classList.remove('hidden'); 560 verificationSection?.classList.add('hidden'); 561 } else { 562 verifiedBadge?.classList.add('hidden'); 563 testSmsBtn?.classList.add('hidden'); 564 if (hasPhone) verifyBtn?.classList.remove('hidden'); 565 else verifyBtn?.classList.add('hidden'); 566 } 567 if (verificationSection?.parentElement) lucide.createIcons({ root: verificationSection.parentElement }); 568} 569 570function startCountdown(seconds) { 571 const countdownEl = document.getElementById('smsCountdownText'); 572 if (phoneState.countdownInterval) clearInterval(phoneState.countdownInterval); 573 574 phoneState.expiresAt = Date.now() + (seconds * 1000); 575 576 const update = () => { 577 const remaining = Math.max(0, Math.ceil((phoneState.expiresAt - Date.now()) / 1000)); 578 if (remaining <= 0) { 579 clearInterval(phoneState.countdownInterval); 580 phoneState.countdownInterval = null; 581 if (countdownEl) countdownEl.textContent = 'Code expired'; 582 return; 583 } 584 const mins = Math.floor(remaining / 60); 585 const secs = remaining % 60; 586 if (countdownEl) countdownEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`; 587 }; 588 589 update(); 590 phoneState.countdownInterval = setInterval(update, 1000); 591} 592 593// --------------------------------------------------------------------------- 594// Notification Preference Toggles (wired to backend)
595// --------------------------------------------------------------------------- 596 597function initPreferenceToggles(visit) { 598 wireToggle('toggleMessages', 'notifyOnNewMessage', visit); 599 wireToggle('toggleReplies', 'notifyOnMessageReply', visit); 600 wireToggle('toggleAssignments', 'notifyOnAssignment', visit); 601 wireToggle('toggleEnableSms', 'enableSms', visit); 602 603 // Refuse unreadable/fractional input; only readable integers reach the existing clamp (CA-135). 604 const escalationInput = document.getElementById('smsEscalationMinutes'); 605 const escalationRow = escalationInput?.closest('div'); 606 escalationInput?.addEventListener('change', async () => { 607 if (refusePreferenceChange(visit)) return; 608 const raw = escalationInput.value.trim(); 609 if (!validateRequired(escalationRow, [{ 610 el: escalationInput, label: 'SMS Backup Delay', numeric: { integer: true }, required: false, 611 }], { focusFirst: false })) return; 612 613 const minutes = raw === '' ? 15 : Math.min(60, Math.max(5, Number(raw))); 614 escalationInput.value = minutes; 615 return savePreferenceChange(visit, { smsEscalationMinutes: minutes }, { errorFields: ['smsEscalationMinutes'] }); 616 }, { signal: visit.lifetime.signal }); 617} 618 619function initSoundToggle(visit) { 620 const toggle = document.getElementById('toggleSound'); 621 if (!toggle) return; 622 623 // Sound preference is client-side (lives in the sb_notif localStorage blob the 624 // notification center reads before playing the chime) 625 try { 626 const stored = JSON.parse(localStorage.getItem('sb_notif') || '{}'); 627 toggle.checked = stored.settings?.soundEnabled !== false; 628 } catch { toggle.checked = true; } 629 630 toggle.addEventListener('change', () => { 631 if (visit && !ownsPreferenceVisit(visit)) return; 632 try { 633 const stored = JSON.parse(localStorage.getItem('sb_notif') || '{}'); 634 stored.settings = { ...(stored.settings || {}), soundEnabled: toggle.checked }; 635 localStorage.setItem('sb_notif', JSON.stringify(stored)); 636 } catch (err) { 637 console.error('[Notifications] Failed to save sound preference:', err); 638 } 639 }, visit ? { signal: visit.lifetime.signal } : undefined); 640} 641 642 643function initQuietHours(visit) { 644 const toggle = document.getElementById('toggleQuietHours'); 645 const timesRow = document.getElementById('quietHoursTimes'); 646 const startInput = document.getElementById('quietHoursStart'); 647 const endInput = document.getElementById('quietHoursEnd'); 648 if (!toggle) return; 649 650 toggle.addEventListener('change', async () => { 651 if (refusePreferenceChange(visit)) return; 652 const enabled = toggle.checked; 653 const start = startInput?.value || '22:00'; 654 const end = endInput?.value || '07:00'; 655 timesRow?.classList.toggle('hidden', !toggle.checked); 656 return savePreferenceChange(visit, { quietHoursEnabled: enabled, quietHoursStart: start, quietHoursEnd: end }, { 657 rollback: ['toggleQuietHours'], successfulValues: { quietHoursStart: start, quietHoursEnd: end }, 658 errorFields: ['quietHoursStart', 'quietHoursEnd'], 659 }); 660 }, { signal: visit.lifetime.signal }); 661 662 const saveTimes = async () => { 663 if (refusePreferenceChange(visit)) return; 664 if (!validateRequired(timesRow, [ 665 { el: startInput, label: 'Quiet hours start' }, { el: endInput, label: 'Quiet hours end' }, 666 ], { focusFirst: false })) return; 667 const start = startInput.value; 668 const end = endInput.value; 669 return savePreferenceChange(visit, { quietHoursStart: start, quietHoursEnd: end }, { errorFields: ['quietHoursStart', 'quietHoursEnd'] }); 670 }; 671 startInput?.addEventListener('change', saveTimes, { signal: visit.lifetime.signal }); 672 endInput?.addEventListener('change', saveTimes, { signal: visit.lifetime.signal }); 673} 674 675function wireToggle(toggleId, preferenceKey, visit) { 676 const toggle = document.getElementById(toggleId); 677 if (!toggle) return; 678 679 toggle.addEventListener('change', async () => { 680 if (refusePreferenceChange(visit)) return; 681 const newValue = toggle.checked; 682 return savePreferenceChange(visit, { [preferenceKey]: newValue }, { rollback: [toggleId] }); 683 }, { signal: visit.lifetime.signal }); 684}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.