fix: harden frontend browser compatibility

This commit is contained in:
Graham McIntire 2026-03-23 13:41:16 -05:00
parent 6e47d3b15c
commit d2fd0d181f
No known key found for this signature in database
5 changed files with 73 additions and 42 deletions

View file

@ -217,6 +217,10 @@ const applyTheme = (theme: string | null) => {
// Apply initial theme from localStorage // Apply initial theme from localStorage
applyTheme(localStorage.getItem("theme") || "auto"); 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 // Handle theme changes dispatched from LiveView via JS.dispatch
window.addEventListener("phx:set-theme", ((e: CustomEvent<{ theme: string }>) => { window.addEventListener("phx:set-theme", ((e: CustomEvent<{ theme: string }>) => {
const theme = e.detail.theme; const theme = e.detail.theme;
@ -226,14 +230,20 @@ window.addEventListener("phx:set-theme", ((e: CustomEvent<{ theme: string }>) =>
}) as EventListener); }) as EventListener);
// Listen for system theme changes when auto is selected // Listen for system theme changes when auto is selected
window const handleSystemThemeChange = () => {
.matchMedia("(prefers-color-scheme: dark)") if (localStorage.getItem("theme") === "auto") {
.addEventListener("change", () => { applyTheme("auto");
if (localStorage.getItem("theme") === "auto") { window.dispatchEvent(new CustomEvent("themeChanged"));
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, { const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 5000, longPollFallbackMs: 5000,

View file

@ -2,7 +2,7 @@
interface TimeAgoHookContext { interface TimeAgoHookContext {
el: HTMLElement; el: HTMLElement;
timer: ReturnType<typeof setInterval> | null; timer: ReturnType<typeof setTimeout> | null;
} }
const TimeAgoHook = { const TimeAgoHook = {
@ -22,14 +22,19 @@ const TimeAgoHook = {
}; };
function startTimer(this: TimeAgoHookContext) { function startTimer(this: TimeAgoHookContext) {
const updateTimeAgo = () => { const timestampStr = this.el.dataset.timestamp;
const timestampStr = this.el.dataset.timestamp; if (!timestampStr) {
if (!timestampStr) return; return;
}
const timestamp = new Date(timestampStr); const timestampMs = new Date(timestampStr).getTime();
const now = new Date(); if (Number.isNaN(timestampMs)) {
const diffMs = now.getTime() - timestamp.getTime(); return;
const diffSeconds = Math.floor(diffMs / 1000); }
const updateTimeAgo = () => {
const nowMs = Date.now();
const diffSeconds = Math.max(0, Math.floor((nowMs - timestampMs) / 1000));
if (diffSeconds < 60) { if (diffSeconds < 60) {
this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? "s" : ""} ago`; this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? "s" : ""} ago`;
@ -43,33 +48,35 @@ function startTimer(this: TimeAgoHookContext) {
const days = Math.floor(diffSeconds / 86400); const days = Math.floor(diffSeconds / 86400);
this.el.textContent = `${days} day${days !== 1 ? "s" : ""} ago`; this.el.textContent = `${days} day${days !== 1 ? "s" : ""} ago`;
} }
const nextDelay = getNextUpdateDelay(diffSeconds);
this.timer = setTimeout(updateTimeAgo, nextDelay);
}; };
updateTimeAgo(); 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) { function stopTimer(this: TimeAgoHookContext) {
if (this.timer) { if (this.timer) {
clearInterval(this.timer); clearTimeout(this.timer);
this.timer = null; 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; export default TimeAgoHook;

View file

@ -847,11 +847,15 @@ let MapAPRSMap = {
// Use Phoenix LiveView's built-in navigation // Use Phoenix LiveView's built-in navigation
const liveSocket = getLiveSocket(); const liveSocket = getLiveSocket();
if (liveSocket) { if (liveSocket) {
liveSocket.pushHistoryPatch(navLink.href, "push", navLink); try {
} else { liveSocket.pushHistoryPatch(navLink.href, "push", navLink);
// Fallback to regular navigation if LiveView socket not available return;
window.location.href = navLink.href; } 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;
} }
}; };

View file

@ -127,7 +127,16 @@ export function safePushEvent<T extends BaseEventPayload = BaseEventPayload>(pus
* Check if LiveView socket is available * Check if LiveView socket is available
*/ */
export function isLiveViewConnected(): boolean { 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 { export function getLiveSocket(): LiveSocket | null {
return typeof window !== 'undefined' ? window.liveSocket || null : null; return typeof window !== 'undefined' ? window.liveSocket || null : null;
} }

View file

@ -110,7 +110,8 @@ export interface MapState {
} }
export interface LiveSocket { export interface LiveSocket {
connected: boolean; connected?: boolean;
isConnected?: () => boolean;
pushHistoryPatch: (href: string, state: string, target: HTMLElement) => void; pushHistoryPatch: (href: string, state: string, target: HTMLElement) => void;
} }
@ -118,4 +119,4 @@ declare global {
interface Window { interface Window {
liveSocket?: LiveSocket; liveSocket?: LiveSocket;
} }
} }