From d2fd0d181fbd0678f2c33028e3bab1072be178e8 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 23 Mar 2026 13:41:16 -0500 Subject: [PATCH] fix: harden frontend browser compatibility --- assets/js/app.ts | 26 +++++++++----- assets/js/hooks/time_ago_hook.ts | 59 ++++++++++++++++++-------------- assets/js/map.ts | 12 ++++--- assets/js/map_helpers.ts | 13 +++++-- assets/js/types/map.d.ts | 5 +-- 5 files changed, 73 insertions(+), 42 deletions(-) diff --git a/assets/js/app.ts b/assets/js/app.ts index 7e00848..80b2661 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -217,6 +217,10 @@ const applyTheme = (theme: string | null) => { // Apply initial theme from localStorage applyTheme(localStorage.getItem("theme") || "auto"); +const colorSchemeQuery = window.matchMedia + ? window.matchMedia("(prefers-color-scheme: dark)") + : null; + // Handle theme changes dispatched from LiveView via JS.dispatch window.addEventListener("phx:set-theme", ((e: CustomEvent<{ theme: string }>) => { const theme = e.detail.theme; @@ -226,14 +230,20 @@ window.addEventListener("phx:set-theme", ((e: CustomEvent<{ theme: string }>) => }) as EventListener); // Listen for system theme changes when auto is selected -window - .matchMedia("(prefers-color-scheme: dark)") - .addEventListener("change", () => { - if (localStorage.getItem("theme") === "auto") { - applyTheme("auto"); - window.dispatchEvent(new CustomEvent("themeChanged")); - } - }); +const handleSystemThemeChange = () => { + if (localStorage.getItem("theme") === "auto") { + applyTheme("auto"); + window.dispatchEvent(new CustomEvent("themeChanged")); + } +}; + +if (colorSchemeQuery) { + if (typeof colorSchemeQuery.addEventListener === "function") { + colorSchemeQuery.addEventListener("change", handleSystemThemeChange); + } else if (typeof (colorSchemeQuery as MediaQueryList).addListener === "function") { + (colorSchemeQuery as MediaQueryList).addListener(handleSystemThemeChange); + } +} const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 5000, diff --git a/assets/js/hooks/time_ago_hook.ts b/assets/js/hooks/time_ago_hook.ts index cd28b2d..929cd65 100644 --- a/assets/js/hooks/time_ago_hook.ts +++ b/assets/js/hooks/time_ago_hook.ts @@ -2,7 +2,7 @@ interface TimeAgoHookContext { el: HTMLElement; - timer: ReturnType | null; + timer: ReturnType | null; } const TimeAgoHook = { @@ -22,14 +22,19 @@ const TimeAgoHook = { }; function startTimer(this: TimeAgoHookContext) { - const updateTimeAgo = () => { - const timestampStr = this.el.dataset.timestamp; - if (!timestampStr) return; + const timestampStr = this.el.dataset.timestamp; + if (!timestampStr) { + return; + } - const timestamp = new Date(timestampStr); - const now = new Date(); - const diffMs = now.getTime() - timestamp.getTime(); - const diffSeconds = Math.floor(diffMs / 1000); + const timestampMs = new Date(timestampStr).getTime(); + if (Number.isNaN(timestampMs)) { + return; + } + + const updateTimeAgo = () => { + const nowMs = Date.now(); + const diffSeconds = Math.max(0, Math.floor((nowMs - timestampMs) / 1000)); if (diffSeconds < 60) { this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? "s" : ""} ago`; @@ -43,33 +48,35 @@ function startTimer(this: TimeAgoHookContext) { const days = Math.floor(diffSeconds / 86400); this.el.textContent = `${days} day${days !== 1 ? "s" : ""} ago`; } + + const nextDelay = getNextUpdateDelay(diffSeconds); + this.timer = setTimeout(updateTimeAgo, nextDelay); }; updateTimeAgo(); - - const timestampStr = this.el.dataset.timestamp; - if (timestampStr) { - const timestamp = new Date(timestampStr); - const age = Date.now() - timestamp.getTime(); - - let interval: number; - if (age < 60000) { - interval = 1000; - } else if (age < 3600000) { - interval = 60000; - } else { - interval = 300000; - } - - this.timer = setInterval(updateTimeAgo, interval); - } } function stopTimer(this: TimeAgoHookContext) { if (this.timer) { - clearInterval(this.timer); + clearTimeout(this.timer); this.timer = null; } } +function getNextUpdateDelay(diffSeconds: number): number { + if (diffSeconds < 60) { + return 1000; + } + + if (diffSeconds < 3600) { + return (60 - (diffSeconds % 60)) * 1000; + } + + if (diffSeconds < 86400) { + return (3600 - (diffSeconds % 3600)) * 1000; + } + + return (86400 - (diffSeconds % 86400)) * 1000; +} + export default TimeAgoHook; diff --git a/assets/js/map.ts b/assets/js/map.ts index 9c56cde..06b3133 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -847,11 +847,15 @@ let MapAPRSMap = { // Use Phoenix LiveView's built-in navigation const liveSocket = getLiveSocket(); if (liveSocket) { - liveSocket.pushHistoryPatch(navLink.href, "push", navLink); - } else { - // Fallback to regular navigation if LiveView socket not available - window.location.href = navLink.href; + try { + liveSocket.pushHistoryPatch(navLink.href, "push", navLink); + return; + } catch (error) { + console.warn("LiveView navigation failed, falling back to full navigation", error); + } } + // Fallback to regular navigation if LiveView socket is not available or patching fails + window.location.href = navLink.href; } }; diff --git a/assets/js/map_helpers.ts b/assets/js/map_helpers.ts index f190123..5a00e70 100644 --- a/assets/js/map_helpers.ts +++ b/assets/js/map_helpers.ts @@ -127,7 +127,16 @@ export function safePushEvent(pus * Check if LiveView socket is available */ export function isLiveViewConnected(): boolean { - return typeof window !== 'undefined' && !!window.liveSocket; + const liveSocket = getLiveSocket(); + if (!liveSocket) { + return false; + } + + if (typeof liveSocket.isConnected === "function") { + return liveSocket.isConnected(); + } + + return liveSocket.connected !== false; } /** @@ -135,4 +144,4 @@ export function isLiveViewConnected(): boolean { */ export function getLiveSocket(): LiveSocket | null { return typeof window !== 'undefined' ? window.liveSocket || null : null; -} \ No newline at end of file +} diff --git a/assets/js/types/map.d.ts b/assets/js/types/map.d.ts index 77844ca..06cb94c 100644 --- a/assets/js/types/map.d.ts +++ b/assets/js/types/map.d.ts @@ -110,7 +110,8 @@ export interface MapState { } export interface LiveSocket { - connected: boolean; + connected?: boolean; + isConnected?: () => boolean; pushHistoryPatch: (href: string, state: string, target: HTMLElement) => void; } @@ -118,4 +119,4 @@ declare global { interface Window { liveSocket?: LiveSocket; } -} \ No newline at end of file +}