diff --git a/src/components/UnifiedSettings.ts b/src/components/UnifiedSettings.ts index c671c9612..e0b018911 100644 --- a/src/components/UnifiedSettings.ts +++ b/src/components/UnifiedSettings.ts @@ -8,6 +8,7 @@ import type { MapProvider } from '@/config/basemap'; import { escapeHtml } from '@/utils/sanitize'; import type { PanelConfig } from '@/types'; import { renderPreferences } from '@/services/preferences-content'; +import { renderNotificationsSettings, type NotificationsSettingsResult } from '@/services/notifications-settings'; import { getAuthState } from '@/services/auth-state'; import { track } from '@/services/analytics'; import { isEntitled } from '@/services/entitlements'; @@ -39,7 +40,7 @@ export interface UnifiedSettingsConfig { onMapProviderChange?: (provider: MapProvider) => void; } -type TabId = 'settings' | 'panels' | 'sources' | 'api-keys'; +type TabId = 'settings' | 'panels' | 'sources' | 'notifications' | 'api-keys'; export class UnifiedSettings { private overlay: HTMLElement; @@ -51,6 +52,8 @@ export class UnifiedSettings { private panelFilter = ''; private escapeHandler: (e: KeyboardEvent) => void; private prefsCleanup: (() => void) | null = null; + private notifCleanup: (() => void) | null = null; + private pendingNotifs: NotificationsSettingsResult | null = null; private draftPanelSettings: Record = {}; private panelsJustSaved = false; private savedTimeout: ReturnType | null = null; @@ -224,6 +227,9 @@ export class UnifiedSettings { this.overlay.classList.remove('active'); this.prefsCleanup?.(); this.prefsCleanup = null; + this.notifCleanup?.(); + this.notifCleanup = null; + this.pendingNotifs = null; this.resetPanelDraft(); localStorage.removeItem('wm-settings-open'); document.removeEventListener('keydown', this.escapeHandler); @@ -248,6 +254,9 @@ export class UnifiedSettings { if (this.savedTimeout) clearTimeout(this.savedTimeout); this.prefsCleanup?.(); this.prefsCleanup = null; + this.notifCleanup?.(); + this.notifCleanup = null; + this.pendingNotifs = null; document.removeEventListener('keydown', this.escapeHandler); this.overlay.remove(); } @@ -255,13 +264,21 @@ export class UnifiedSettings { private render(): void { this.prefsCleanup?.(); this.prefsCleanup = null; + this.notifCleanup?.(); + this.notifCleanup = null; + this.pendingNotifs = null; const tabClass = (id: TabId) => `unified-settings-tab${this.activeTab === id ? ' active' : ''}`; + const isSignedIn = !this.config.isDesktopApp && (getAuthState().user !== null); const prefs = renderPreferences({ isDesktopApp: this.config.isDesktopApp, onMapProviderChange: this.config.onMapProviderChange, - isSignedIn: !this.config.isDesktopApp && (getAuthState().user !== null), + isSignedIn, }); + const showNotificationsTab = !this.config.isDesktopApp; + const notifs = showNotificationsTab + ? renderNotificationsSettings({ isSignedIn }) + : null; this.overlay.innerHTML = `
@@ -307,6 +325,11 @@ export class UnifiedSettings {
+ ${notifs ? ` +
+ ${notifs.html} +
+ ` : ''} ${getAuthState().user ? `
@@ -333,6 +356,12 @@ export class UnifiedSettings { this.prefsCleanup = prefs.attach(settingsPanel as HTMLElement); } + // Defer notifications attach until the tab is first activated — + // otherwise Pro users pay a getChannelsData() fetch on every modal + // open even if they never visit this tab. + this.pendingNotifs = notifs; + if (this.activeTab === 'notifications') this.attachNotificationsTab(); + const closeBtn = this.overlay.querySelector('.unified-settings-close'); if (closeBtn) { closeBtn.addEventListener('click', (e) => { @@ -376,6 +405,18 @@ export class UnifiedSettings { if (tab === 'api-keys') { void this.loadApiKeys(); } + + if (tab === 'notifications') { + this.attachNotificationsTab(); + } + } + + private attachNotificationsTab(): void { + if (this.notifCleanup || !this.pendingNotifs) return; + const notifPanel = this.overlay.querySelector('#us-tab-panel-notifications'); + if (notifPanel) { + this.notifCleanup = this.pendingNotifs.attach(notifPanel as HTMLElement); + } } private renderUpgradeSection(): string { diff --git a/src/locales/en.json b/src/locales/en.json index 4868c741b..a7771b82f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -221,6 +221,7 @@ "tabSettings": "Settings", "tabPanels": "Panels", "tabSources": "Sources", + "tabNotifications": "Notifications", "languageLabel": "Language", "sourceRegionAll": "All", "sourceRegionWorldwide": "Worldwide", diff --git a/src/services/notifications-settings.ts b/src/services/notifications-settings.ts new file mode 100644 index 000000000..472a90cb2 --- /dev/null +++ b/src/services/notifications-settings.ts @@ -0,0 +1,785 @@ +import { escapeHtml } from '@/utils/sanitize'; +import { renderSVG } from 'uqr'; +import { + getChannelsData, + createPairingToken, + setEmailChannel, + setWebhookChannel, + startSlackOAuth, + startDiscordOAuth, + deleteChannel, + saveAlertRules, + setQuietHours, + setDigestSettings, + type NotificationChannel, + type ChannelType, + type QuietHoursOverride, + type DigestMode, +} from '@/services/notification-channels'; +import { getCurrentClerkUser } from '@/services/clerk'; +import { hasTier } from '@/services/entitlements'; +import { SITE_VARIANT } from '@/config/variant'; + +const QUIET_HOURS_BATCH_ENABLED = import.meta.env.VITE_QUIET_HOURS_BATCH_ENABLED !== '0'; +const DIGEST_CRON_ENABLED = import.meta.env.VITE_DIGEST_CRON_ENABLED !== '0'; + +export interface NotificationsSettingsHost { + isSignedIn?: boolean; +} + +export interface NotificationsSettingsResult { + html: string; + attach: (container: HTMLElement) => () => void; +} + +export function renderNotificationsSettings(host: NotificationsSettingsHost): NotificationsSettingsResult { + const isPro = !!host.isSignedIn && hasTier(1); + + let html = ''; + if (isPro) { + html += `
`; + html += `
Loading...
`; + html += ``; + html += `
`; + } else { + html += `
`; + html += `
Get real-time intelligence alerts delivered to Telegram, Slack, Discord, and Email with configurable sensitivity, quiet hours, and digest scheduling.
`; + html += ``; + html += `
`; + } + + return { + html, + attach(container: HTMLElement): () => void { + const ac = new AbortController(); + const { signal } = ac; + + if (!isPro) { + const upgradeBtn = container.querySelector('#usNotifUpgradeBtn'); + if (upgradeBtn) { + upgradeBtn.addEventListener('click', () => { + if (!host.isSignedIn) { + import('@/services/clerk').then(m => m.openSignIn()).catch(() => { + window.open('https://worldmonitor.app/pro', '_blank'); + }); + return; + } + import('@/services/checkout').then(m => import('@/config/products').then(p => m.startCheckout(p.DEFAULT_UPGRADE_PRODUCT))).catch(() => { + window.open('https://worldmonitor.app/pro', '_blank'); + }); + }, { signal }); + } + return () => ac.abort(); + } + + let notifPollInterval: ReturnType | null = null; + + function clearNotifPoll(): void { + if (notifPollInterval !== null) { + clearInterval(notifPollInterval); + notifPollInterval = null; + } + } + + signal.addEventListener('abort', clearNotifPoll); + + function channelIcon(type: ChannelType): string { + if (type === 'telegram') return ``; + if (type === 'email') return ``; + if (type === 'webhook') return ``; + if (type === 'discord') return ``; + return ``; + } + + const CHANNEL_LABELS: Record = { telegram: 'Telegram', email: 'Email', slack: 'Slack', discord: 'Discord', webhook: 'Webhook' }; + + function renderChannelRow(channel: NotificationChannel | null, type: ChannelType): string { + const icon = channelIcon(type); + const name = CHANNEL_LABELS[type]; + + if (channel?.verified) { + let sub: string; + let manageLink = ''; + if (type === 'telegram') { + sub = `@${escapeHtml(channel.chatId ?? 'connected')}`; + } else if (type === 'email') { + sub = escapeHtml(channel.email ?? 'connected'); + } else if (type === 'discord') { + sub = 'Connected'; + } else if (type === 'webhook') { + sub = channel.webhookLabel ? escapeHtml(channel.webhookLabel) : 'Connected'; + } else { + const rawCh = channel.slackChannelName ?? ''; + const ch = rawCh ? `#${escapeHtml(rawCh.startsWith('#') ? rawCh.slice(1) : rawCh)}` : 'connected'; + const team = channel.slackTeamName ? ` · ${escapeHtml(channel.slackTeamName)}` : ''; + sub = ch + team; + if (channel.slackConfigurationUrl) { + manageLink = `Manage`; + } + } + return `
+
${icon}
+
+
${name}
+
${sub}
+
+
+ Connected + ${manageLink} + +
+
`; + } + + if (type === 'telegram') { + return `
+
${icon}
+
+
${name}
+
Not connected
+
+
+ +
+
`; + } + + if (type === 'email') { + return `
+
${icon}
+
+
${name}
+
Use your account email
+
+
+ +
+
`; + } + + if (type === 'slack') { + return `
+
${icon}
+
+
${name}
+
Not connected
+
+
+ +
+
`; + } + + if (type === 'discord') { + return `
+
${icon}
+
+
${name}
+
Not connected
+
+
+ +
+
`; + } + + if (type === 'webhook') { + return `
+
${icon}
+
+
${name}
+
Send structured JSON to any HTTPS endpoint
+
+
+ +
+
`; + } + + return ''; + } + + const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone; + + function renderNotifContent(data: Awaited>): string { + const channelTypes: ChannelType[] = ['telegram', 'email', 'slack', 'discord', 'webhook']; + const alertRule = data.alertRules?.[0] ?? null; + const sensitivity = alertRule?.sensitivity ?? 'all'; + + let html = ''; + for (const type of channelTypes) { + const channel = data.channels.find(c => c.channelType === type) ?? null; + html += renderChannelRow(channel, type); + } + + const qhEnabled = alertRule?.quietHoursEnabled ?? false; + const qhStart = alertRule?.quietHoursStart ?? 22; + const qhEnd = alertRule?.quietHoursEnd ?? 7; + const qhOverride = alertRule?.quietHoursOverride ?? 'critical_only'; + + const digestMode = alertRule?.digestMode ?? 'realtime'; + const digestHour = alertRule?.digestHour ?? 8; + const aiDigestEnabled = alertRule?.aiDigestEnabled ?? true; + + const hourOptions = Array.from({ length: 24 }, (_, h) => { + const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; + return ``; + }).join(''); + const hourOptionsEnd = Array.from({ length: 24 }, (_, h) => { + const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; + return ``; + }).join(''); + const hourOptionsDigest = Array.from({ length: 24 }, (_, h) => { + const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; + return ``; + }).join(''); + + const TZ_LIST = [ + 'UTC', + 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', + 'America/Anchorage', 'America/Honolulu', 'America/Phoenix', + 'America/Toronto', 'America/Vancouver', 'America/Mexico_City', + 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires', 'America/Bogota', + 'America/Lima', 'America/Santiago', 'America/Caracas', + 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', + 'Europe/Rome', 'Europe/Amsterdam', 'Europe/Stockholm', 'Europe/Oslo', + 'Europe/Zurich', 'Europe/Warsaw', 'Europe/Athens', 'Europe/Bucharest', + 'Europe/Helsinki', 'Europe/Istanbul', 'Europe/Moscow', 'Europe/Kyiv', + 'Africa/Cairo', 'Africa/Nairobi', 'Africa/Lagos', 'Africa/Johannesburg', + 'Asia/Dubai', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Dhaka', + 'Asia/Bangkok', 'Asia/Singapore', 'Asia/Shanghai', 'Asia/Hong_Kong', + 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Manila', + 'Australia/Sydney', 'Australia/Brisbane', 'Australia/Perth', + 'Pacific/Auckland', 'Pacific/Fiji', + ]; + const makeTzOptions = (current: string) => { + const list = TZ_LIST.includes(current) ? TZ_LIST : [current, ...TZ_LIST]; + return list.map(tz => ``).join(''); + }; + + const isRealtime = !DIGEST_CRON_ENABLED || digestMode === 'realtime'; + const sharedTz = isRealtime + ? (alertRule?.quietHoursTimezone ?? alertRule?.digestTimezone ?? detectedTz) + : (alertRule?.digestTimezone ?? alertRule?.quietHoursTimezone ?? detectedTz); + + html += ` + ${!DIGEST_CRON_ENABLED ? '
Digest delivery is not yet active.
' : ''} + +
+ +
+
+
Enable notifications
+
Receive alerts for events matching your filters
+
+ +
+ + + +
+
+
Enable quiet hours
+
Suppress or batch non-critical alerts during set hours
+
+ +
+
+
+
+
From
+
+ +
+
To
+
+ +
+
+
During quiet hours
+ +
+
+
+
+
+
+
Send at
+
+ +
+
Also sends at ${((digestHour + 12) % 24) === 0 ? '12 AM' : ((digestHour + 12) % 24) < 12 ? `${(digestHour + 12) % 24} AM` : ((digestHour + 12) % 24) === 12 ? '12 PM' : `${((digestHour + 12) % 24) - 12} PM`}
+
+
+
AI executive summary
+
Prepend a personalized intelligence brief tailored to your watchlist and interests
+
+ +
+
+ + `; + return html; + } + + function reloadNotifSection(): void { + const loadingEl = container.querySelector('#usNotifLoading'); + const contentEl = container.querySelector('#usNotifContent'); + if (!loadingEl || !contentEl) return; + loadingEl.style.display = 'block'; + contentEl.style.display = 'none'; + if (signal.aborted) return; + getChannelsData().then((data) => { + if (signal.aborted) return; + contentEl.innerHTML = renderNotifContent(data); + loadingEl.style.display = 'none'; + contentEl.style.display = 'block'; + }).catch((err) => { + if (signal.aborted) return; + console.error('[notifications] Failed to load settings:', err); + if (loadingEl) loadingEl.textContent = 'Failed to load notification settings.'; + }); + } + + reloadNotifSection(); + + function saveRuleWithNewChannel(newChannel: ChannelType): void { + const enabledEl = container.querySelector('#usNotifEnabled'); + const sensitivityEl = container.querySelector('#usNotifSensitivity'); + if (!enabledEl) return; + const enabled = enabledEl.checked; + const sensitivity = (sensitivityEl?.value ?? 'all') as 'all' | 'high' | 'critical'; + const existing = Array.from(container.querySelectorAll('[data-channel-type]')) + .filter(el => el.classList.contains('us-notif-ch-on')) + .map(el => el.dataset.channelType as ChannelType); + const channels = [...new Set([...existing, newChannel])]; + const aiEl = container.querySelector('#usAiDigestEnabled'); + void saveAlertRules({ variant: SITE_VARIANT, enabled, eventTypes: [], sensitivity, channels, aiDigestEnabled: aiEl?.checked ?? true }); + } + + let slackOAuthPopup: Window | null = null; + let discordOAuthPopup: Window | null = null; + let alertRuleDebounceTimer: ReturnType | null = null; + let qhDebounceTimer: ReturnType | null = null; + let digestDebounceTimer: ReturnType | null = null; + signal.addEventListener('abort', () => { + if (alertRuleDebounceTimer !== null) { + clearTimeout(alertRuleDebounceTimer); + alertRuleDebounceTimer = null; + } + if (qhDebounceTimer !== null) { + clearTimeout(qhDebounceTimer); + qhDebounceTimer = null; + } + if (digestDebounceTimer !== null) { + clearTimeout(digestDebounceTimer); + digestDebounceTimer = null; + } + }); + + const saveQuietHours = () => { + if (qhDebounceTimer) clearTimeout(qhDebounceTimer); + qhDebounceTimer = setTimeout(() => { + const enabledEl = container.querySelector('#usQhEnabled'); + const startEl = container.querySelector('#usQhStart'); + const endEl = container.querySelector('#usQhEnd'); + const tzEl = container.querySelector('#usSharedTimezone'); + const overrideEl = container.querySelector('#usQhOverride'); + void setQuietHours({ + variant: SITE_VARIANT, + quietHoursEnabled: enabledEl?.checked ?? false, + quietHoursStart: startEl ? Number(startEl.value) : 22, + quietHoursEnd: endEl ? Number(endEl.value) : 7, + quietHoursTimezone: tzEl?.value || detectedTz, + quietHoursOverride: (overrideEl?.value ?? 'critical_only') as QuietHoursOverride, + }); + }, 800); + }; + + const saveDigestSettings = () => { + if (digestDebounceTimer) clearTimeout(digestDebounceTimer); + digestDebounceTimer = setTimeout(() => { + const modeEl = container.querySelector('#usDigestMode'); + const hourEl = container.querySelector('#usDigestHour'); + const tzEl = container.querySelector('#usSharedTimezone'); + void setDigestSettings({ + variant: SITE_VARIANT, + digestMode: (modeEl?.value ?? 'realtime') as DigestMode, + digestHour: hourEl ? Number(hourEl.value) : 8, + digestTimezone: tzEl?.value || detectedTz, + }); + }, 800); + }; + + container.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + if (target.id === 'usQhEnabled') { + const details = container.querySelector('#usQhDetails'); + if (details) details.style.display = target.checked ? '' : 'none'; + saveQuietHours(); + return; + } + if (target.id === 'usQhStart' || target.id === 'usQhEnd' || target.id === 'usQhOverride') { + saveQuietHours(); + return; + } + if (target.id === 'usDigestMode') { + const isRt = target.value === 'realtime'; + const realtimeSection = container.querySelector('#usRealtimeSection'); + const digestDetails = container.querySelector('#usDigestDetails'); + const twiceHint = container.querySelector('#usTwiceDailyHint'); + if (realtimeSection) realtimeSection.style.display = isRt ? '' : 'none'; + if (digestDetails) digestDetails.style.display = isRt ? 'none' : ''; + if (twiceHint) twiceHint.style.display = target.value === 'twice_daily' ? '' : 'none'; + saveDigestSettings(); + if (!isRt) { + const enabledEl = container.querySelector('#usNotifEnabled'); + if (enabledEl && !enabledEl.checked) { + enabledEl.checked = true; + enabledEl.dispatchEvent(new Event('change', { bubbles: true })); + } + } + return; + } + if (target.id === 'usDigestHour') { + const twiceHint = container.querySelector('#usTwiceDailyHint'); + if (twiceHint) { + const h = (Number(target.value) + 12) % 24; + twiceHint.textContent = `Also sends at ${h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`}`; + } + saveDigestSettings(); + return; + } + if (target.id === 'usSharedTimezone') { + saveQuietHours(); + saveDigestSettings(); + return; + } + if (target.id === 'usAiDigestEnabled') { + if (alertRuleDebounceTimer) clearTimeout(alertRuleDebounceTimer); + alertRuleDebounceTimer = setTimeout(() => { + const enabledEl = container.querySelector('#usNotifEnabled'); + const sensitivityEl = container.querySelector('#usNotifSensitivity'); + const enabled = enabledEl?.checked ?? false; + const sensitivity = (sensitivityEl?.value ?? 'all') as 'all' | 'high' | 'critical'; + const connectedChannelTypes = Array.from( + container.querySelectorAll('[data-channel-type]'), + ) + .filter(el => el.classList.contains('us-notif-ch-on')) + .map(el => el.dataset.channelType as ChannelType); + void saveAlertRules({ + variant: SITE_VARIANT, + enabled, + eventTypes: [], + sensitivity, + channels: connectedChannelTypes, + aiDigestEnabled: target.checked, + }); + }, 500); + return; + } + if (target.id === 'usNotifEnabled' || target.id === 'usNotifSensitivity') { + if (alertRuleDebounceTimer) clearTimeout(alertRuleDebounceTimer); + alertRuleDebounceTimer = setTimeout(() => { + const enabledEl = container.querySelector('#usNotifEnabled'); + const sensitivityEl = container.querySelector('#usNotifSensitivity'); + const enabled = enabledEl?.checked ?? false; + const sensitivity = (sensitivityEl?.value ?? 'all') as 'all' | 'high' | 'critical'; + const connectedChannelTypes = Array.from( + container.querySelectorAll('[data-channel-type]'), + ) + .filter(el => el.classList.contains('us-notif-ch-on')) + .map(el => el.dataset.channelType as ChannelType); + const aiDigestEl = container.querySelector('#usAiDigestEnabled'); + void saveAlertRules({ + variant: SITE_VARIANT, + enabled, + eventTypes: [], + sensitivity, + channels: connectedChannelTypes, + aiDigestEnabled: aiDigestEl?.checked ?? true, + }); + }, 1000); + } + }, { signal }); + + container.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + + if (target.closest('.us-notif-tg-copy-btn')) { + const btn = target.closest('.us-notif-tg-copy-btn') as HTMLButtonElement; + const cmd = btn.dataset.cmd ?? ''; + const markCopied = () => { + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = 'Copy'; }, 2000); + }; + const execFallback = () => { + const ta = document.createElement('textarea'); + ta.value = cmd; + ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); markCopied(); } catch { /* ignore */ } + document.body.removeChild(ta); + }; + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(cmd).then(markCopied).catch(execFallback); + } else { + execFallback(); + } + return; + } + + const startTelegramPairing = (rowEl: HTMLElement) => { + rowEl.innerHTML = `
${channelIcon('telegram')}
Telegram
Generating code…
`; + createPairingToken().then(({ token, expiresAt }) => { + if (signal.aborted) return; + const botUsername = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_TELEGRAM_BOT_USERNAME as string | undefined) ?? 'WorldMonitorBot'; + const deepLink = `https://t.me/${String(botUsername)}?start=${token}`; + const startCmd = `/start ${token}`; + const secsLeft = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); + const qrSvg = renderSVG(deepLink, { ecc: 'M', border: 1 }); + rowEl.innerHTML = ` +
${channelIcon('telegram')}
+
+
Connect Telegram
+
Open the bot. If Telegram doesn't send the code automatically, paste this command.
+
+
+ Open Telegram +
+ ${escapeHtml(startCmd)} + +
+
+
${qrSvg}
+
+
+
+ Waiting… ${secsLeft}s +
+ `; + let remaining = secsLeft; + clearNotifPoll(); + notifPollInterval = setInterval(() => { + if (signal.aborted) { clearNotifPoll(); return; } + remaining -= 3; + const countdownEl = container.querySelector('#usTgCountdown'); + if (countdownEl) countdownEl.textContent = `Waiting… ${Math.max(0, remaining)}s`; + const expired = remaining <= 0; + if (expired) { + clearNotifPoll(); + rowEl.innerHTML = ` +
${channelIcon('telegram')}
+
+
Telegram
+
Code expired
+
+
+ +
+ `; + return; + } + getChannelsData().then((data) => { + const tg = data.channels.find(c => c.channelType === 'telegram'); + if (tg?.verified) { + clearNotifPoll(); + saveRuleWithNewChannel('telegram'); + reloadNotifSection(); + } + }).catch(() => {}); + }, 3000); + }).catch(() => { + rowEl.innerHTML = `
${channelIcon('telegram')}
Telegram
Failed to generate code
`; + }); + }; + + if (target.closest('#usConnectTelegram') || target.closest('.us-notif-tg-regen')) { + const rowEl = target.closest('.us-notif-ch-row') as HTMLElement | null; + if (!rowEl) return; + startTelegramPairing(rowEl); + return; + } + + if (target.closest('#usConnectEmail')) { + const user = getCurrentClerkUser(); + const email = user?.email; + if (!email) { + const rowEl = target.closest('.us-notif-ch-row') as HTMLElement | null; + if (rowEl) { + rowEl.querySelector('.us-notif-error')?.remove(); + rowEl.insertAdjacentHTML('beforeend', 'No email found on your account'); + } + return; + } + setEmailChannel(email).then(() => { + if (!signal.aborted) { saveRuleWithNewChannel('email'); reloadNotifSection(); } + }).catch(() => {}); + return; + } + + if (target.closest('#usConnectSlack')) { + const btn = target.closest('#usConnectSlack'); + if (slackOAuthPopup && !slackOAuthPopup.closed) { + slackOAuthPopup.focus(); + return; + } + if (btn) btn.textContent = 'Connecting…'; + startSlackOAuth().then((oauthUrl) => { + if (signal.aborted) return; + const popup = window.open(oauthUrl, 'slack-oauth', 'width=600,height=700,menubar=no,toolbar=no'); + if (!popup) { + if (btn) btn.textContent = 'Add to Slack'; + const rowEl = btn?.closest('[data-channel-type="slack"]'); + if (rowEl) { + rowEl.querySelector('.us-notif-error')?.remove(); + rowEl.insertAdjacentHTML('beforeend', 'Popup blocked — please allow popups for this site, then try again.'); + } + } else { + slackOAuthPopup = popup; + } + }).catch(() => { + if (btn && !signal.aborted) btn.textContent = 'Add to Slack'; + }); + return; + } + + if (target.closest('#usConnectDiscord')) { + const btn = target.closest('#usConnectDiscord'); + if (discordOAuthPopup && !discordOAuthPopup.closed) { + discordOAuthPopup.focus(); + return; + } + if (btn) btn.textContent = 'Connecting…'; + startDiscordOAuth().then((oauthUrl) => { + if (signal.aborted) return; + const popup = window.open(oauthUrl, 'discord-oauth', 'width=600,height=700,menubar=no,toolbar=no'); + if (!popup) { + if (btn) btn.textContent = 'Connect Discord'; + const rowEl = btn?.closest('[data-channel-type="discord"]'); + if (rowEl) { + rowEl.querySelector('.us-notif-error')?.remove(); + rowEl.insertAdjacentHTML('beforeend', 'Popup blocked — please allow popups for this site, then try again.'); + } + } else { + discordOAuthPopup = popup; + } + }).catch(() => { + if (btn && !signal.aborted) btn.textContent = 'Connect Discord'; + }); + return; + } + + if (target.closest('#usConnectWebhook')) { + const rowEl = target.closest('[data-channel-type="webhook"]'); + if (!rowEl) return; + rowEl.querySelector('.us-notif-ch-actions')!.innerHTML = ` +
+ + +
+ + +
+
`; + const urlInput = rowEl.querySelector('#usWebhookUrl'); + urlInput?.focus(); + return; + } + if (target.closest('#usWebhookSave')) { + const urlInput = container.querySelector('#usWebhookUrl'); + const labelInput = container.querySelector('#usWebhookLabel'); + const url = urlInput?.value?.trim() ?? ''; + if (!url || !url.startsWith('https://')) { + urlInput?.classList.add('us-notif-input-error'); + return; + } + const saveBtn = target.closest('#usWebhookSave'); + if (saveBtn) saveBtn.textContent = 'Saving...'; + setWebhookChannel(url, labelInput?.value?.trim() || undefined).then(() => { + if (!signal.aborted) { saveRuleWithNewChannel('webhook'); reloadNotifSection(); } + }).catch(() => { + if (saveBtn && !signal.aborted) saveBtn.textContent = 'Save'; + }); + return; + } + if (target.closest('#usWebhookCancel')) { + reloadNotifSection(); + return; + } + + const disconnectBtn = target.closest('.us-notif-disconnect[data-channel]'); + if (disconnectBtn?.dataset.channel) { + const channelType = disconnectBtn.dataset.channel as ChannelType; + deleteChannel(channelType).then(() => { + if (!signal.aborted) reloadNotifSection(); + }).catch(() => {}); + return; + } + }, { signal }); + + const onMessage = (e: MessageEvent): void => { + const trustedOrigin = e.origin === window.location.origin || + e.origin === 'https://worldmonitor.app' || + e.origin === 'https://www.worldmonitor.app' || + e.origin.endsWith('.worldmonitor.app'); + const fromSlack = slackOAuthPopup !== null && e.source === slackOAuthPopup; + const fromDiscord = discordOAuthPopup !== null && e.source === discordOAuthPopup; + if (!trustedOrigin || (!fromSlack && !fromDiscord)) return; + if (e.data?.type === 'wm:slack_connected') { + if (!signal.aborted) { saveRuleWithNewChannel('slack'); reloadNotifSection(); } + } else if (e.data?.type === 'wm:slack_error') { + const rowEl = container.querySelector('[data-channel-type="slack"]'); + if (rowEl) { + rowEl.querySelector('.us-notif-error')?.remove(); + rowEl.insertAdjacentHTML('beforeend', `Slack connection failed: ${escapeHtml(String(e.data.error ?? 'unknown'))}`); + const btn = rowEl.querySelector('#usConnectSlack'); + if (btn) btn.textContent = 'Add to Slack'; + } + } else if (e.data?.type === 'wm:discord_connected') { + if (!signal.aborted) { saveRuleWithNewChannel('discord'); reloadNotifSection(); } + } else if (e.data?.type === 'wm:discord_error') { + const rowEl = container.querySelector('[data-channel-type="discord"]'); + if (rowEl) { + rowEl.querySelector('.us-notif-error')?.remove(); + rowEl.insertAdjacentHTML('beforeend', `Discord connection failed: ${escapeHtml(String(e.data.error ?? 'unknown'))}`); + const btn = rowEl.querySelector('#usConnectDiscord'); + if (btn) btn.textContent = 'Connect Discord'; + } + } + }; + window.addEventListener('message', onMessage, { signal }); + + return () => ac.abort(); + }, + }; +} diff --git a/src/services/preferences-content.ts b/src/services/preferences-content.ts index f49169ff8..47e58e0cd 100644 --- a/src/services/preferences-content.ts +++ b/src/services/preferences-content.ts @@ -7,28 +7,8 @@ import type { StreamQuality } from '@/services/ai-flow-settings'; import { getThemePreference, setThemePreference, type ThemePreference } from '@/utils/theme-manager'; import { getFontFamily, setFontFamily, type FontFamily } from '@/services/font-settings'; import { escapeHtml } from '@/utils/sanitize'; -import { renderSVG } from 'uqr'; import { trackLanguageChange } from '@/services/analytics'; import { exportSettings, importSettings, type ImportResult } from '@/utils/settings-persistence'; -import { - getChannelsData, - createPairingToken, - setEmailChannel, - setWebhookChannel, - startSlackOAuth, - startDiscordOAuth, - deleteChannel, - saveAlertRules, - setQuietHours, - setDigestSettings, - type NotificationChannel, - type ChannelType, - type QuietHoursOverride, - type DigestMode, -} from '@/services/notification-channels'; -import { getCurrentClerkUser } from '@/services/clerk'; -import { hasTier } from '@/services/entitlements'; -import { SITE_VARIANT } from '@/config/variant'; import { getSyncState, getLastSyncAt, syncNow, isCloudSyncEnabled } from '@/utils/cloud-prefs-sync'; const SYNC_STATE_LABELS: Record = { @@ -39,13 +19,6 @@ const SYNC_STATE_COLORS: Record = { synced: 'var(--color-ok, #34d399)', pending: 'var(--color-warn, #fbbf24)', syncing: 'var(--color-warn, #fbbf24)', conflict: 'var(--color-error, #f87171)', offline: 'var(--text-faint, #888)', 'signed-out': 'var(--text-faint, #888)', error: 'var(--color-error, #f87171)', }; -// When VITE_QUIET_HOURS_BATCH_ENABLED=0 the relay does not honour batch_on_wake. -// Hide that option so users cannot select a mode that silently behaves as critical_only. -const QUIET_HOURS_BATCH_ENABLED = import.meta.env.VITE_QUIET_HOURS_BATCH_ENABLED !== '0'; -// When VITE_DIGEST_CRON_ENABLED=0 the Railway cron has not been deployed yet. -// Hide non-realtime digest options so users cannot enter a blackhole state -// where the relay skips their rule and the cron never runs. -const DIGEST_CRON_ENABLED = import.meta.env.VITE_DIGEST_CRON_ENABLED !== '0'; import { loadFrameworkLibrary, saveImportedFramework, @@ -404,27 +377,6 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { `; html += `
`; - // ── Notifications group (web-only) ── - // Three states: (a) confirmed PRO → full UI, (b) everything else → locked [PRO] section. - // When entitlements haven't loaded yet (null), show locked to avoid flashing full UI to free users. - if (!host.isDesktopApp) { - if (host.isSignedIn && hasTier(1)) { - html += `
`; - html += `Notifications`; - html += `
`; - html += `
Loading...
`; - html += ``; - html += `
`; - } else { - html += `
`; - html += `Notifications PRO`; - html += `
`; - html += `
Get real-time intelligence alerts delivered to Telegram, Slack, Discord, and Email with configurable sensitivity, quiet hours, and digest scheduling.
`; - html += ``; - html += `
`; - } - } - // AI status footer (web-only) if (!host.isDesktopApp) { html += ``; @@ -691,748 +643,6 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { signal.addEventListener('abort', () => clearInterval(syncPollId)); } - // ── Notifications section: locked [PRO] upgrade button ── - if (!host.isDesktopApp && !(host.isSignedIn && hasTier(1))) { - const upgradeBtn = container.querySelector('#usNotifUpgradeBtn'); - if (upgradeBtn) { - upgradeBtn.addEventListener('click', () => { - if (!host.isSignedIn) { - import('@/services/clerk').then(m => m.openSignIn()).catch(() => { - window.open('https://worldmonitor.app/pro', '_blank'); - }); - return; - } - import('@/services/checkout').then(m => import('@/config/products').then(p => m.startCheckout(p.DEFAULT_UPGRADE_PRODUCT))).catch(() => { - window.open('https://worldmonitor.app/pro', '_blank'); - }); - }, { signal }); - } - } - // ── Notifications section: full PRO UI ── - if (!host.isDesktopApp && host.isSignedIn && hasTier(1)) { - let notifPollInterval: ReturnType | null = null; - - function clearNotifPoll(): void { - if (notifPollInterval !== null) { - clearInterval(notifPollInterval); - notifPollInterval = null; - } - } - - signal.addEventListener('abort', clearNotifPoll); - - function channelIcon(type: ChannelType): string { - if (type === 'telegram') return ``; - if (type === 'email') return ``; - if (type === 'webhook') return ``; - if (type === 'discord') return ``; - return ``; - } - - const CHANNEL_LABELS: Record = { telegram: 'Telegram', email: 'Email', slack: 'Slack', discord: 'Discord', webhook: 'Webhook' }; - - function renderChannelRow(channel: NotificationChannel | null, type: ChannelType): string { - const icon = channelIcon(type); - const name = CHANNEL_LABELS[type]; - - if (channel?.verified) { - let sub: string; - let manageLink = ''; - if (type === 'telegram') { - sub = `@${escapeHtml(channel.chatId ?? 'connected')}`; - } else if (type === 'email') { - sub = escapeHtml(channel.email ?? 'connected'); - } else if (type === 'discord') { - sub = 'Connected'; - } else if (type === 'webhook') { - sub = channel.webhookLabel ? escapeHtml(channel.webhookLabel) : 'Connected'; - } else { - // Slack: show #channel · team from OAuth metadata - const rawCh = channel.slackChannelName ?? ''; - const ch = rawCh ? `#${escapeHtml(rawCh.startsWith('#') ? rawCh.slice(1) : rawCh)}` : 'connected'; - const team = channel.slackTeamName ? ` · ${escapeHtml(channel.slackTeamName)}` : ''; - sub = ch + team; - if (channel.slackConfigurationUrl) { - manageLink = `Manage`; - } - } - return `
-
${icon}
-
-
${name}
-
${sub}
-
-
- Connected - ${manageLink} - -
-
`; - } - - if (type === 'telegram') { - return `
-
${icon}
-
-
${name}
-
Not connected
-
-
- -
-
`; - } - - if (type === 'email') { - return `
-
${icon}
-
-
${name}
-
Use your account email
-
-
- -
-
`; - } - - if (type === 'slack') { - return `
-
${icon}
-
-
${name}
-
Not connected
-
-
- -
-
`; - } - - if (type === 'discord') { - return `
-
${icon}
-
-
${name}
-
Not connected
-
-
- -
-
`; - } - - if (type === 'webhook') { - return `
-
${icon}
-
-
${name}
-
Send structured JSON to any HTTPS endpoint
-
-
- -
-
`; - } - - return ''; - } - - const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone; - - function renderNotifContent(data: Awaited>): string { - const channelTypes: ChannelType[] = ['telegram', 'email', 'slack', 'discord', 'webhook']; - const alertRule = data.alertRules?.[0] ?? null; - const sensitivity = alertRule?.sensitivity ?? 'all'; - - let html = ''; - for (const type of channelTypes) { - const channel = data.channels.find(c => c.channelType === type) ?? null; - html += renderChannelRow(channel, type); - } - - const qhEnabled = alertRule?.quietHoursEnabled ?? false; - const qhStart = alertRule?.quietHoursStart ?? 22; - const qhEnd = alertRule?.quietHoursEnd ?? 7; - const qhOverride = alertRule?.quietHoursOverride ?? 'critical_only'; - - const digestMode = alertRule?.digestMode ?? 'realtime'; - const digestHour = alertRule?.digestHour ?? 8; - const aiDigestEnabled = alertRule?.aiDigestEnabled ?? true; - - const hourOptions = Array.from({ length: 24 }, (_, h) => { - const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; - return ``; - }).join(''); - const hourOptionsEnd = Array.from({ length: 24 }, (_, h) => { - const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; - return ``; - }).join(''); - const hourOptionsDigest = Array.from({ length: 24 }, (_, h) => { - const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; - return ``; - }).join(''); - - const TZ_LIST = [ - 'UTC', - 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', - 'America/Anchorage', 'America/Honolulu', 'America/Phoenix', - 'America/Toronto', 'America/Vancouver', 'America/Mexico_City', - 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires', 'America/Bogota', - 'America/Lima', 'America/Santiago', 'America/Caracas', - 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', - 'Europe/Rome', 'Europe/Amsterdam', 'Europe/Stockholm', 'Europe/Oslo', - 'Europe/Zurich', 'Europe/Warsaw', 'Europe/Athens', 'Europe/Bucharest', - 'Europe/Helsinki', 'Europe/Istanbul', 'Europe/Moscow', 'Europe/Kyiv', - 'Africa/Cairo', 'Africa/Nairobi', 'Africa/Lagos', 'Africa/Johannesburg', - 'Asia/Dubai', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Dhaka', - 'Asia/Bangkok', 'Asia/Singapore', 'Asia/Shanghai', 'Asia/Hong_Kong', - 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Manila', - 'Australia/Sydney', 'Australia/Brisbane', 'Australia/Perth', - 'Pacific/Auckland', 'Pacific/Fiji', - ]; - const makeTzOptions = (current: string) => { - const list = TZ_LIST.includes(current) ? TZ_LIST : [current, ...TZ_LIST]; - return list.map(tz => ``).join(''); - }; - - const isRealtime = !DIGEST_CRON_ENABLED || digestMode === 'realtime'; - const sharedTz = isRealtime - ? (alertRule?.quietHoursTimezone ?? alertRule?.digestTimezone ?? detectedTz) - : (alertRule?.digestTimezone ?? alertRule?.quietHoursTimezone ?? detectedTz); - - html += ` - ${!DIGEST_CRON_ENABLED ? '
Digest delivery is not yet active.
' : ''} - -
- -
-
-
Enable notifications
-
Receive alerts for events matching your filters
-
- -
- - - -
-
-
Enable quiet hours
-
Suppress or batch non-critical alerts during set hours
-
- -
-
-
-
-
From
-
- -
-
To
-
- -
-
-
During quiet hours
- -
-
-
-
-
-
-
Send at
-
- -
-
Also sends at ${((digestHour + 12) % 24) === 0 ? '12 AM' : ((digestHour + 12) % 24) < 12 ? `${(digestHour + 12) % 24} AM` : ((digestHour + 12) % 24) === 12 ? '12 PM' : `${((digestHour + 12) % 24) - 12} PM`}
-
-
-
AI executive summary
-
Prepend a personalized intelligence brief tailored to your watchlist and interests
-
- -
-
- - `; - return html; - } - - function reloadNotifSection(): void { - const loadingEl = container.querySelector('#usNotifLoading'); - const contentEl = container.querySelector('#usNotifContent'); - if (!loadingEl || !contentEl) return; - loadingEl.style.display = 'block'; - contentEl.style.display = 'none'; - if (signal.aborted) return; - getChannelsData().then((data) => { - if (signal.aborted) return; - contentEl.innerHTML = renderNotifContent(data); - loadingEl.style.display = 'none'; - contentEl.style.display = 'block'; - }).catch((err) => { - if (signal.aborted) return; - console.error('[notifications] Failed to load settings:', err); - if (loadingEl) loadingEl.textContent = 'Failed to load notification settings.'; - }); - } - - reloadNotifSection(); - - // When a new channel is linked, auto-update the rule's channels list - // so it includes the new channel without requiring a manual toggle. - function saveRuleWithNewChannel(newChannel: ChannelType): void { - const enabledEl = container.querySelector('#usNotifEnabled'); - const sensitivityEl = container.querySelector('#usNotifSensitivity'); - if (!enabledEl) return; - const enabled = enabledEl.checked; - const sensitivity = (sensitivityEl?.value ?? 'all') as 'all' | 'high' | 'critical'; - const existing = Array.from(container.querySelectorAll('[data-channel-type]')) - .filter(el => el.classList.contains('us-notif-ch-on')) - .map(el => el.dataset.channelType as ChannelType); - const channels = [...new Set([...existing, newChannel])]; - const aiEl = container.querySelector('#usAiDigestEnabled'); - void saveAlertRules({ variant: SITE_VARIANT, enabled, eventTypes: [], sensitivity, channels, aiDigestEnabled: aiEl?.checked ?? true }); - } - - let slackOAuthPopup: Window | null = null; - let discordOAuthPopup: Window | null = null; - let alertRuleDebounceTimer: ReturnType | null = null; - let qhDebounceTimer: ReturnType | null = null; - let digestDebounceTimer: ReturnType | null = null; - signal.addEventListener('abort', () => { - if (alertRuleDebounceTimer !== null) { - clearTimeout(alertRuleDebounceTimer); - alertRuleDebounceTimer = null; - } - if (qhDebounceTimer !== null) { - clearTimeout(qhDebounceTimer); - qhDebounceTimer = null; - } - if (digestDebounceTimer !== null) { - clearTimeout(digestDebounceTimer); - digestDebounceTimer = null; - } - }); - - const saveQuietHours = () => { - if (qhDebounceTimer) clearTimeout(qhDebounceTimer); - qhDebounceTimer = setTimeout(() => { - const enabledEl = container.querySelector('#usQhEnabled'); - const startEl = container.querySelector('#usQhStart'); - const endEl = container.querySelector('#usQhEnd'); - const tzEl = container.querySelector('#usSharedTimezone'); - const overrideEl = container.querySelector('#usQhOverride'); - void setQuietHours({ - variant: SITE_VARIANT, - quietHoursEnabled: enabledEl?.checked ?? false, - quietHoursStart: startEl ? Number(startEl.value) : 22, - quietHoursEnd: endEl ? Number(endEl.value) : 7, - quietHoursTimezone: tzEl?.value || detectedTz, - quietHoursOverride: (overrideEl?.value ?? 'critical_only') as QuietHoursOverride, - }); - }, 800); - }; - - const saveDigestSettings = () => { - if (digestDebounceTimer) clearTimeout(digestDebounceTimer); - digestDebounceTimer = setTimeout(() => { - const modeEl = container.querySelector('#usDigestMode'); - const hourEl = container.querySelector('#usDigestHour'); - const tzEl = container.querySelector('#usSharedTimezone'); - void setDigestSettings({ - variant: SITE_VARIANT, - digestMode: (modeEl?.value ?? 'realtime') as DigestMode, - digestHour: hourEl ? Number(hourEl.value) : 8, - digestTimezone: tzEl?.value || detectedTz, - }); - }, 800); - }; - - container.addEventListener('change', (e) => { - const target = e.target as HTMLInputElement; - if (target.id === 'usQhEnabled') { - const details = container.querySelector('#usQhDetails'); - if (details) details.style.display = target.checked ? '' : 'none'; - saveQuietHours(); - return; - } - if (target.id === 'usQhStart' || target.id === 'usQhEnd' || target.id === 'usQhOverride') { - saveQuietHours(); - return; - } - if (target.id === 'usDigestMode') { - const isRt = target.value === 'realtime'; - const realtimeSection = container.querySelector('#usRealtimeSection'); - const digestDetails = container.querySelector('#usDigestDetails'); - const twiceHint = container.querySelector('#usTwiceDailyHint'); - if (realtimeSection) realtimeSection.style.display = isRt ? '' : 'none'; - if (digestDetails) digestDetails.style.display = isRt ? 'none' : ''; - if (twiceHint) twiceHint.style.display = target.value === 'twice_daily' ? '' : 'none'; - saveDigestSettings(); - // Switching to digest mode: auto-enable the alert rule so the - // backend schedules digests. The enable toggle is hidden in - // digest mode, so the user has no other way to turn it on. - if (!isRt) { - const enabledEl = container.querySelector('#usNotifEnabled'); - if (enabledEl && !enabledEl.checked) { - enabledEl.checked = true; - enabledEl.dispatchEvent(new Event('change', { bubbles: true })); - } - } - return; - } - if (target.id === 'usDigestHour') { - const twiceHint = container.querySelector('#usTwiceDailyHint'); - if (twiceHint) { - const h = (Number(target.value) + 12) % 24; - twiceHint.textContent = `Also sends at ${h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`}`; - } - saveDigestSettings(); - return; - } - if (target.id === 'usSharedTimezone') { - saveQuietHours(); - saveDigestSettings(); - return; - } - if (target.id === 'usAiDigestEnabled') { - if (alertRuleDebounceTimer) clearTimeout(alertRuleDebounceTimer); - alertRuleDebounceTimer = setTimeout(() => { - const enabledEl = container.querySelector('#usNotifEnabled'); - const sensitivityEl = container.querySelector('#usNotifSensitivity'); - const enabled = enabledEl?.checked ?? false; - const sensitivity = (sensitivityEl?.value ?? 'all') as 'all' | 'high' | 'critical'; - const connectedChannelTypes = Array.from( - container.querySelectorAll('[data-channel-type]'), - ) - .filter(el => el.classList.contains('us-notif-ch-on')) - .map(el => el.dataset.channelType as ChannelType); - void saveAlertRules({ - variant: SITE_VARIANT, - enabled, - eventTypes: [], - sensitivity, - channels: connectedChannelTypes, - aiDigestEnabled: target.checked, - }); - }, 500); - return; - } - if (target.id === 'usNotifEnabled' || target.id === 'usNotifSensitivity') { - if (alertRuleDebounceTimer) clearTimeout(alertRuleDebounceTimer); - alertRuleDebounceTimer = setTimeout(() => { - const enabledEl = container.querySelector('#usNotifEnabled'); - const sensitivityEl = container.querySelector('#usNotifSensitivity'); - const enabled = enabledEl?.checked ?? false; - const sensitivity = (sensitivityEl?.value ?? 'all') as 'all' | 'high' | 'critical'; - const connectedChannelTypes = Array.from( - container.querySelectorAll('[data-channel-type]'), - ) - .filter(el => el.classList.contains('us-notif-ch-on')) - .map(el => el.dataset.channelType as ChannelType); - const aiDigestEl = container.querySelector('#usAiDigestEnabled'); - void saveAlertRules({ - variant: SITE_VARIANT, - enabled, - eventTypes: [], - sensitivity, - channels: connectedChannelTypes, - aiDigestEnabled: aiDigestEl?.checked ?? true, - }); - }, 1000); - } - }, { signal }); - - container.addEventListener('click', (e) => { - const target = e.target as HTMLElement; - - if (target.closest('.us-notif-tg-copy-btn')) { - const btn = target.closest('.us-notif-tg-copy-btn') as HTMLButtonElement; - const cmd = btn.dataset.cmd ?? ''; - const markCopied = () => { - btn.textContent = 'Copied!'; - setTimeout(() => { btn.textContent = 'Copy'; }, 2000); - }; - const execFallback = () => { - const ta = document.createElement('textarea'); - ta.value = cmd; - ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none'; - document.body.appendChild(ta); - ta.select(); - try { document.execCommand('copy'); markCopied(); } catch { /* ignore */ } - document.body.removeChild(ta); - }; - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(cmd).then(markCopied).catch(execFallback); - } else { - execFallback(); - } - return; - } - - const startTelegramPairing = (rowEl: HTMLElement) => { - rowEl.innerHTML = `
${channelIcon('telegram')}
Telegram
Generating code…
`; - createPairingToken().then(({ token, expiresAt }) => { - if (signal.aborted) return; - const botUsername = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_TELEGRAM_BOT_USERNAME as string | undefined) ?? 'WorldMonitorBot'; - const deepLink = `https://t.me/${String(botUsername)}?start=${token}`; - const startCmd = `/start ${token}`; - const secsLeft = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); - const qrSvg = renderSVG(deepLink, { ecc: 'M', border: 1 }); - rowEl.innerHTML = ` -
${channelIcon('telegram')}
-
-
Connect Telegram
-
Open the bot. If Telegram doesn't send the code automatically, paste this command.
-
-
- Open Telegram -
- ${escapeHtml(startCmd)} - -
-
-
${qrSvg}
-
-
-
- Waiting… ${secsLeft}s -
- `; - let remaining = secsLeft; - clearNotifPoll(); - notifPollInterval = setInterval(() => { - if (signal.aborted) { clearNotifPoll(); return; } - remaining -= 3; - const countdownEl = container.querySelector('#usTgCountdown'); - if (countdownEl) countdownEl.textContent = `Waiting… ${Math.max(0, remaining)}s`; - const expired = remaining <= 0; - if (expired) { - clearNotifPoll(); - rowEl.innerHTML = ` -
${channelIcon('telegram')}
-
-
Telegram
-
Code expired
-
-
- -
- `; - return; - } - getChannelsData().then((data) => { - const tg = data.channels.find(c => c.channelType === 'telegram'); - if (tg?.verified) { - clearNotifPoll(); - saveRuleWithNewChannel('telegram'); - reloadNotifSection(); - } - }).catch(() => {}); - }, 3000); - }).catch(() => { - rowEl.innerHTML = `
${channelIcon('telegram')}
Telegram
Failed to generate code
`; - }); - }; - - if (target.closest('#usConnectTelegram') || target.closest('.us-notif-tg-regen')) { - const rowEl = target.closest('.us-notif-ch-row') as HTMLElement | null; - if (!rowEl) return; - startTelegramPairing(rowEl); - return; - } - - if (target.closest('#usConnectEmail')) { - const user = getCurrentClerkUser(); - const email = user?.email; - if (!email) { - const rowEl = target.closest('.us-notif-ch-row') as HTMLElement | null; - if (rowEl) { - rowEl.querySelector('.us-notif-error')?.remove(); - rowEl.insertAdjacentHTML('beforeend', 'No email found on your account'); - } - return; - } - setEmailChannel(email).then(() => { - if (!signal.aborted) { saveRuleWithNewChannel('email'); reloadNotifSection(); } - }).catch(() => {}); - return; - } - - if (target.closest('#usConnectSlack')) { - const btn = target.closest('#usConnectSlack'); - // Prevent double-open: reuse existing popup if still open - if (slackOAuthPopup && !slackOAuthPopup.closed) { - slackOAuthPopup.focus(); - return; - } - if (btn) btn.textContent = 'Connecting…'; - startSlackOAuth().then((oauthUrl) => { - if (signal.aborted) return; - const popup = window.open(oauthUrl, 'slack-oauth', 'width=600,height=700,menubar=no,toolbar=no'); - if (!popup) { - // Popup was blocked — redirect-to-Slack fallback doesn't work because - // the callback page expects window.opener and has no way to return to - // settings after approval. Show a clear instruction instead. - if (btn) btn.textContent = 'Add to Slack'; - const rowEl = btn?.closest('[data-channel-type="slack"]'); - if (rowEl) { - rowEl.querySelector('.us-notif-error')?.remove(); - rowEl.insertAdjacentHTML('beforeend', 'Popup blocked — please allow popups for this site, then try again.'); - } - } else { - slackOAuthPopup = popup; - } - }).catch(() => { - if (btn && !signal.aborted) btn.textContent = 'Add to Slack'; - }); - return; - } - - if (target.closest('#usConnectDiscord')) { - const btn = target.closest('#usConnectDiscord'); - if (discordOAuthPopup && !discordOAuthPopup.closed) { - discordOAuthPopup.focus(); - return; - } - if (btn) btn.textContent = 'Connecting…'; - startDiscordOAuth().then((oauthUrl) => { - if (signal.aborted) return; - const popup = window.open(oauthUrl, 'discord-oauth', 'width=600,height=700,menubar=no,toolbar=no'); - if (!popup) { - if (btn) btn.textContent = 'Connect Discord'; - const rowEl = btn?.closest('[data-channel-type="discord"]'); - if (rowEl) { - rowEl.querySelector('.us-notif-error')?.remove(); - rowEl.insertAdjacentHTML('beforeend', 'Popup blocked — please allow popups for this site, then try again.'); - } - } else { - discordOAuthPopup = popup; - } - }).catch(() => { - if (btn && !signal.aborted) btn.textContent = 'Connect Discord'; - }); - return; - } - - if (target.closest('#usConnectWebhook')) { - const rowEl = target.closest('[data-channel-type="webhook"]'); - if (!rowEl) return; - rowEl.querySelector('.us-notif-ch-actions')!.innerHTML = ` -
- - -
- - -
-
`; - const urlInput = rowEl.querySelector('#usWebhookUrl'); - urlInput?.focus(); - return; - } - if (target.closest('#usWebhookSave')) { - const urlInput = container.querySelector('#usWebhookUrl'); - const labelInput = container.querySelector('#usWebhookLabel'); - const url = urlInput?.value?.trim() ?? ''; - if (!url || !url.startsWith('https://')) { - urlInput?.classList.add('us-notif-input-error'); - return; - } - const saveBtn = target.closest('#usWebhookSave'); - if (saveBtn) saveBtn.textContent = 'Saving...'; - setWebhookChannel(url, labelInput?.value?.trim() || undefined).then(() => { - if (!signal.aborted) { saveRuleWithNewChannel('webhook'); reloadNotifSection(); } - }).catch(() => { - if (saveBtn && !signal.aborted) saveBtn.textContent = 'Save'; - }); - return; - } - if (target.closest('#usWebhookCancel')) { - reloadNotifSection(); - return; - } - - const disconnectBtn = target.closest('.us-notif-disconnect[data-channel]'); - if (disconnectBtn?.dataset.channel) { - const channelType = disconnectBtn.dataset.channel as ChannelType; - deleteChannel(channelType).then(() => { - if (!signal.aborted) reloadNotifSection(); - }).catch(() => {}); - return; - } - }, { signal }); - - // Listen for OAuth popup completion - const onMessage = (e: MessageEvent): void => { - // Bind trust to both: (1) a WM-owned origin (callback is always on worldmonitor.app, - // but settings may be open on a different *.worldmonitor.app subdomain) and - // (2) the exact popup window we opened — prevents any sibling subdomain from - // forging wm:slack_connected and triggering saveRuleWithNewChannel. - const trustedOrigin = e.origin === window.location.origin || - e.origin === 'https://worldmonitor.app' || - e.origin === 'https://www.worldmonitor.app' || - e.origin.endsWith('.worldmonitor.app'); - const fromSlack = slackOAuthPopup !== null && e.source === slackOAuthPopup; - const fromDiscord = discordOAuthPopup !== null && e.source === discordOAuthPopup; - if (!trustedOrigin || (!fromSlack && !fromDiscord)) return; - if (e.data?.type === 'wm:slack_connected') { - if (!signal.aborted) { saveRuleWithNewChannel('slack'); reloadNotifSection(); } - } else if (e.data?.type === 'wm:slack_error') { - const rowEl = container.querySelector('[data-channel-type="slack"]'); - if (rowEl) { - rowEl.querySelector('.us-notif-error')?.remove(); - rowEl.insertAdjacentHTML('beforeend', `Slack connection failed: ${escapeHtml(String(e.data.error ?? 'unknown'))}`); - const btn = rowEl.querySelector('#usConnectSlack'); - if (btn) btn.textContent = 'Add to Slack'; - } - } else if (e.data?.type === 'wm:discord_connected') { - if (!signal.aborted) { saveRuleWithNewChannel('discord'); reloadNotifSection(); } - } else if (e.data?.type === 'wm:discord_error') { - const rowEl = container.querySelector('[data-channel-type="discord"]'); - if (rowEl) { - rowEl.querySelector('.us-notif-error')?.remove(); - rowEl.insertAdjacentHTML('beforeend', `Discord connection failed: ${escapeHtml(String(e.data.error ?? 'unknown'))}`); - const btn = rowEl.querySelector('#usConnectDiscord'); - if (btn) btn.textContent = 'Connect Discord'; - } - } - }; - window.addEventListener('message', onMessage, { signal }); - } - return () => ac.abort(); }, }; diff --git a/tests/digest-rollout-flags.test.mjs b/tests/digest-rollout-flags.test.mjs index dfbe7eb19..b755ff24b 100644 --- a/tests/digest-rollout-flags.test.mjs +++ b/tests/digest-rollout-flags.test.mjs @@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const prefSrc = readFileSync( - resolve(__dirname, '../src/services/preferences-content.ts'), + resolve(__dirname, '../src/services/notifications-settings.ts'), 'utf-8', ); const alertRulesSrc = readFileSync( diff --git a/tests/quiet-hours-rollout-flags.test.mjs b/tests/quiet-hours-rollout-flags.test.mjs index b348e2970..624e5deb1 100644 --- a/tests/quiet-hours-rollout-flags.test.mjs +++ b/tests/quiet-hours-rollout-flags.test.mjs @@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const prefSrc = readFileSync( - resolve(__dirname, '../src/services/preferences-content.ts'), + resolve(__dirname, '../src/services/notifications-settings.ts'), 'utf-8', ); const alertRulesSrc = readFileSync(