PageSourceSearch

https://smrtbld.ai/js/pages/settings/notifications.js

js smrtbld.ai collected 2026-10-05 15:09:12 UTC 28,885 bytes, 684 lines download raw bytes

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.