diff --git a/js/app.js b/js/app.js index f33c9653..13c9b564 100644 --- a/js/app.js +++ b/js/app.js @@ -52,22 +52,20 @@ import { formatViewCount, ingestLocalViewEvent, } from "./viewCounter.js"; - -function truncateMiddle(text, maxLength = 72) { - if (!text || typeof text !== "string") { - return ""; - } - - if (text.length <= maxLength) { - return text; - } - - const ellipsis = "…"; - const charsToShow = maxLength - ellipsis.length; - const front = Math.ceil(charsToShow / 2); - const back = Math.floor(charsToShow / 2); - return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`; -} +import { + formatAbsoluteTimestamp as formatAbsoluteTimestampUtil, + formatTimeAgo as formatTimeAgoUtil, + truncateMiddle, +} from "./utils/formatters.js"; +import { + escapeHTML as escapeHtml, + removeTrackingScripts, +} from "./utils/domUtils.js"; +import { + readUrlHealthFromStorage, + removeUrlHealthFromStorage, + writeUrlHealthToStorage, +} from "./utils/storage.js"; function pointerArrayToKey(pointer) { if (!Array.isArray(pointer) || pointer.length < 2) { @@ -109,7 +107,6 @@ const ADMIN_DM_IMAGE_URL = const BITVID_WEBSITE_URL = "https://bitvid.network/"; const MAX_DISCUSSION_COUNT_VIDEOS = 24; const VIDEO_EVENT_KIND = 30078; -const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/; const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__"; /** * Local storage keys for cached profile metadata and saved authentication @@ -134,76 +131,9 @@ const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds -const URL_HEALTH_STORAGE_PREFIX = "bitvid:urlHealth:"; const urlHealthCache = new Map(); const urlHealthInFlight = new Map(); -function removeTrackingScripts(root) { - if (!root || typeof root.querySelectorAll !== "function") { - return; - } - - root.querySelectorAll("script[src]").forEach((script) => { - const src = script.getAttribute("src") || ""; - if (TRACKING_SCRIPT_PATTERN.test(src)) { - script.remove(); - } - }); -} - -function getUrlHealthStorageKey(eventId) { - return `${URL_HEALTH_STORAGE_PREFIX}${eventId}`; -} - -function readUrlHealthFromStorage(eventId) { - if (!eventId || typeof localStorage === "undefined") { - return null; - } - - try { - const raw = localStorage.getItem(getUrlHealthStorageKey(eventId)); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object") { - return parsed; - } - } catch (err) { - console.warn(`Failed to parse stored URL health for ${eventId}:`, err); - } - - removeUrlHealthFromStorage(eventId); - return null; -} - -function writeUrlHealthToStorage(eventId, entry) { - if (!eventId || typeof localStorage === "undefined") { - return; - } - - try { - localStorage.setItem( - getUrlHealthStorageKey(eventId), - JSON.stringify(entry) - ); - } catch (err) { - console.warn(`Failed to persist URL health for ${eventId}:`, err); - } -} - -function removeUrlHealthFromStorage(eventId) { - if (!eventId || typeof localStorage === "undefined") { - return; - } - - try { - localStorage.removeItem(getUrlHealthStorageKey(eventId)); - } catch (err) { - console.warn(`Failed to remove URL health for ${eventId}:`, err); - } -} - function isUrlHealthEntryFresh(entry, url) { if (!entry || typeof entry !== "object") { return false; @@ -11528,58 +11458,15 @@ class bitvidApp { * Format "time ago" for a given timestamp (in seconds). */ formatAbsoluteTimestamp(timestamp) { - if (!Number.isFinite(timestamp)) { - return "Unknown date"; - } - - const date = new Date(timestamp * 1000); - if (Number.isNaN(date.getTime())) { - return "Unknown date"; - } - - try { - return date.toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); - } catch (err) { - return date.toISOString(); - } + return formatAbsoluteTimestampUtil(timestamp); } formatTimeAgo(timestamp) { - const seconds = Math.floor(Date.now() / 1000 - timestamp); - const intervals = { - year: 31536000, - month: 2592000, - week: 604800, - day: 86400, - hour: 3600, - minute: 60, - }; - for (const [unit, secInUnit] of Object.entries(intervals)) { - const int = Math.floor(seconds / secInUnit); - if (int >= 1) { - return `${int} ${unit}${int > 1 ? "s" : ""} ago`; - } - } - return "just now"; + return formatTimeAgoUtil(timestamp); } escapeHTML(unsafe) { - if (unsafe === null || typeof unsafe === "undefined") { - return ""; - } - - return String(unsafe) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + return escapeHtml(unsafe); } showError(msg) { diff --git a/js/channelProfile.js b/js/channelProfile.js index 8e0b6dc1..d0111f0e 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -9,6 +9,7 @@ import { subscriptions } from "./subscriptions.js"; // <-- NEW import import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { accessControl } from "./accessControl.js"; +import { escapeHTML } from "./utils/domUtils.js"; let currentChannelHex = null; let currentChannelNpub = null; @@ -912,14 +913,3 @@ function dedupeToNewestByRoot(videos) { return Array.from(map.values()); } -/** - * Basic escaping to avoid XSS. - */ -function escapeHTML(unsafe = "") { - return unsafe - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} diff --git a/js/utils/domUtils.js b/js/utils/domUtils.js new file mode 100644 index 00000000..c41d0099 --- /dev/null +++ b/js/utils/domUtils.js @@ -0,0 +1,35 @@ +// js/utils/domUtils.js + +const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/; + +/** + * Removes script tags that match known tracking script patterns. + */ +export function removeTrackingScripts(root) { + if (!root || typeof root.querySelectorAll !== "function") { + return; + } + + root.querySelectorAll("script[src]").forEach((script) => { + const src = script.getAttribute("src") || ""; + if (TRACKING_SCRIPT_PATTERN.test(src)) { + script.remove(); + } + }); +} + +/** + * Escapes HTML entities in a string to prevent XSS when injecting content. + */ +export function escapeHTML(unsafe) { + if (unsafe === null || typeof unsafe === "undefined") { + return ""; + } + + return String(unsafe) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/js/utils/formatters.js b/js/utils/formatters.js new file mode 100644 index 00000000..020e0150 --- /dev/null +++ b/js/utils/formatters.js @@ -0,0 +1,70 @@ +// js/utils/formatters.js + +/** + * Shortens a string by replacing the middle with an ellipsis when it exceeds + * the desired length. Ensures the returned string never exceeds `maxLength`. + */ +export function truncateMiddle(text, maxLength = 72) { + if (!text || typeof text !== "string") { + return ""; + } + + if (text.length <= maxLength) { + return text; + } + + const ellipsis = "…"; + const charsToShow = maxLength - ellipsis.length; + const front = Math.ceil(charsToShow / 2); + const back = Math.floor(charsToShow / 2); + return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`; +} + +/** + * Formats a timestamp (in seconds) to a localized string. Falls back to + * ISO-8601 formatting if locale formatting fails. + */ +export function formatAbsoluteTimestamp(timestamp) { + if (!Number.isFinite(timestamp)) { + return "Unknown date"; + } + + const date = new Date(timestamp * 1000); + if (Number.isNaN(date.getTime())) { + return "Unknown date"; + } + + try { + return date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch (err) { + return date.toISOString(); + } +} + +/** + * Returns a human readable "time ago" string for a timestamp (in seconds). + */ +export function formatTimeAgo(timestamp) { + const seconds = Math.floor(Date.now() / 1000 - timestamp); + const intervals = { + year: 31536000, + month: 2592000, + week: 604800, + day: 86400, + hour: 3600, + minute: 60, + }; + for (const [unit, secInUnit] of Object.entries(intervals)) { + const int = Math.floor(seconds / secInUnit); + if (int >= 1) { + return `${int} ${unit}${int > 1 ? "s" : ""} ago`; + } + } + return "just now"; +} diff --git a/js/utils/storage.js b/js/utils/storage.js new file mode 100644 index 00000000..7bd5766f --- /dev/null +++ b/js/utils/storage.js @@ -0,0 +1,56 @@ +// js/utils/storage.js + +const URL_HEALTH_STORAGE_PREFIX = "bitvid:urlHealth:"; + +export function getUrlHealthStorageKey(eventId) { + return `${URL_HEALTH_STORAGE_PREFIX}${eventId}`; +} + +export function readUrlHealthFromStorage(eventId) { + if (!eventId || typeof localStorage === "undefined") { + return null; + } + + try { + const raw = localStorage.getItem(getUrlHealthStorageKey(eventId)); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + return parsed; + } + } catch (err) { + console.warn(`Failed to parse stored URL health for ${eventId}:`, err); + } + + removeUrlHealthFromStorage(eventId); + return null; +} + +export function writeUrlHealthToStorage(eventId, entry) { + if (!eventId || typeof localStorage === "undefined") { + return; + } + + try { + localStorage.setItem( + getUrlHealthStorageKey(eventId), + JSON.stringify(entry) + ); + } catch (err) { + console.warn(`Failed to persist URL health for ${eventId}:`, err); + } +} + +export function removeUrlHealthFromStorage(eventId) { + if (!eventId || typeof localStorage === "undefined") { + return; + } + + try { + localStorage.removeItem(getUrlHealthStorageKey(eventId)); + } catch (err) { + console.warn(`Failed to remove URL health for ${eventId}:`, err); + } +}