diff --git a/App/Misc/HTMLRenderingHelpers.swift b/App/Misc/HTMLRenderingHelpers.swift index 50f66aa98..8446fa055 100644 --- a/App/Misc/HTMLRenderingHelpers.swift +++ b/App/Misc/HTMLRenderingHelpers.swift @@ -6,6 +6,17 @@ import HTMLReader extension HTMLDocument { + // MARK: - Constants + + /// Number of post images to load immediately before deferring to lazy loading. + /// IMPORTANT: This value must match IMMEDIATELY_LOADED_IMAGE_COUNT constant in RenderView.js + private static let immediatelyLoadedImageCount = 10 + + /// 1x1 transparent GIF used as placeholder for lazy-loaded images + private static let transparentPixelPlaceholder = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + + // MARK: - HTML Processing Methods + /// Finds links that appear to be to Bluesky posts and adds a `data-bluesky-post` attribute to those links. func addAttributeToBlueskyLinks() { for a in nodes(matchingSelector: "a[href *= 'bsky.app']") { @@ -138,22 +149,56 @@ extension HTMLDocument { - Turns all non-smiley `` elements into `src` elements (if linkifyNonSmiles == true). - Adds .awful-smile to smilie elements. - Rewrites URLs for some external image hosts that have changed domains and/or URL schemes. + - Defers loading of post content images beyond the first 10 (lazy loading). */ func processImgTags(shouldLinkifyNonSmilies: Bool) { + var postContentImageCount = 0 + for img in nodes(matchingSelector: "img") { guard let src = img["src"], let url = URL(string: src) else { continue } - + let isSmilie = isSmilieURL(url) - + if isSmilie { img.toggleClass("awful-smile") - } else if let postimageURL = fixPostimageURL(url) { - img["src"] = postimageURL.absoluteString - } else if let waffleURL = randomwaffleURLForWaffleimagesURL(url) { - img["src"] = waffleURL.absoluteString + } else { + // Check if this is an avatar (has class="avatar") + let isAvatar = img["class"]?.contains("avatar") ?? false + + // Skip attachment.php files (require auth, handled elsewhere) + let isAttachment = url.lastPathComponent == "attachment.php" + + // Apply URL fixes first to get the final URL + var finalURL = src + if let postimageURL = fixPostimageURL(url) { + finalURL = postimageURL.absoluteString + } else if let waffleURL = randomwaffleURLForWaffleimagesURL(url) { + finalURL = waffleURL.absoluteString + } + + // Check if this is a data: URI (inline data that shouldn't be lazy-loaded) + let isDataURI = finalURL.starts(with: "data:") + + // Determine whether to load immediately or defer based on image type and count + if !isAvatar && !isAttachment && !isDataURI { + // This is a post content image (not avatar, not smilie, not attachment) + postContentImageCount += 1 + + if postContentImageCount > Self.immediatelyLoadedImageCount { + // Defer loading for images beyond the immediately loaded count (browser handles lazy loading) + img["loading"] = "lazy" + img["src"] = finalURL + } else { + // Load immediately + img["src"] = finalURL + } + } else { + // Avatars, attachments, and data URIs always load immediately + img["src"] = finalURL + } } if shouldLinkifyNonSmilies, !isSmilie { diff --git a/App/Resources/RenderView.js b/App/Resources/RenderView.js index 24f7ffac2..6cd4ab4f7 100644 --- a/App/Resources/RenderView.js +++ b/App/Resources/RenderView.js @@ -10,6 +10,140 @@ if (!window.Awful) { window.Awful = {}; } +// MARK: - Configuration Constants + +/// Number of post images to load immediately before lazy loading kicks in +/// IMPORTANT: This value must match immediatelyLoadedImageCount constant in HTMLRenderingHelpers.swift +const IMMEDIATELY_LOADED_IMAGE_COUNT = 10; + +/// How far ahead (in pixels) to start loading lazy content before it enters viewport +const LAZY_LOAD_LOOKAHEAD_DISTANCE = '600px'; + +/// CSS selectors used throughout the code +const SELECTORS = { + LOADING_IMAGES: 'section.postbody img[src]:not(.awful-smile):not(.awful-avatar):not([loading="lazy"])', + POST_ELEMENTS: 'post', + LOTTIE_PLAYERS: 'lottie-player' +}; + +/// Timeout configuration for image loading +const IMAGE_LOAD_TIMEOUT_CONFIG = { + /// Maximum number of checks for initial image loading timeout detection + /// 3 checks × 1000ms = 3 seconds max wait for images to start loading + maxImageChecks: 3, + + /// Milliseconds to wait before resetting retry button text after failed retry + /// 3000ms gives user time to read the error message before it resets + retryResetDelay: 3000, + + /// Milliseconds between timeout checks for image loading + /// 1000ms = 1 second interval for checking if images have started loading + connectionTimeout: 1000 +}; + +/// Timeout for tweet embedding via Twitter API +/// 5 seconds gives enough time for API response while preventing indefinite waiting +const TWEET_EMBED_TIMEOUT = 5000; + +/// Threshold for IntersectionObserver - triggers when even tiny fraction is visible +/// This minimal threshold ensures the observer fires as soon as element enters viewport +const INTERSECTION_THRESHOLD_MIN = 0.000001; + +// MARK: - Utility Functions + +/** + * Sets up a Lottie player to load ghost animation data. + * Helper to avoid code duplication for dead tweet/image badge initialization. + * Properly removes existing listeners before adding new ones to prevent accumulation. + * + * @param {HTMLElement} container - The container element containing lottie-player elements + */ +Awful.setupGhostLottiePlayer = function(container) { + const players = container.querySelectorAll(SELECTORS.LOTTIE_PLAYERS); + players.forEach((lottiePlayer) => { + if (lottiePlayer._ghostLoadHandler) { + lottiePlayer.removeEventListener("rendered", lottiePlayer._ghostLoadHandler); + } + + lottiePlayer._ghostLoadHandler = () => { + const ghostData = document.getElementById("ghost-json-data"); + if (ghostData) { + lottiePlayer.load(ghostData.innerText); + } + }; + + lottiePlayer.addEventListener("rendered", lottiePlayer._ghostLoadHandler); + }); +}; + +/** + * Sanitizes a URL to prevent XSS attacks. + * Ensures URLs don't contain dangerous protocols like javascript: or data:text/html + * + * @param {string} url - The URL to sanitize + * @returns {string} The sanitized URL or '#' if dangerous + */ +Awful.sanitizeURL = function(url) { + if (!url) return '#'; + + const urlLower = url.trim().toLowerCase(); + + // Block dangerous protocols + if (urlLower.startsWith('javascript:') || + urlLower.startsWith('vbscript:')) { + return '#'; + } + + // Block all data: URLs except safe image formats + if (urlLower.startsWith('data:') && + !urlLower.startsWith('data:image/')) { + return '#'; + } + + return url; +}; + +/** + * HTML-escapes a string to prevent XSS when used in HTML context. + * + * @param {string} str - The string to escape + * @returns {string} The escaped string + */ +Awful.escapeHTML = function(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +}; + +/** + * Helper for consistent error handling when images fail to load. + * Replaces failed images with dead image badges and optionally updates progress tracker. + * + * @param {Error} error - The error that occurred + * @param {string} url - The URL that failed to load + * @param {HTMLImageElement} img - The image element that failed + * @param {string} imageID - Unique ID for this image + * @param {boolean} enableGhost - Whether to show dead image badge + * @param {boolean} trackProgress - Whether to increment the progress tracker (default: true) + */ +Awful.handleImageLoadError = function(error, url, img, imageID, enableGhost, trackProgress = true) { + console.error(`Image load failed: ${error.message} - ${url}`); + + if (enableGhost && img.parentNode) { + const div = document.createElement('div'); + div.classList.add('dead-embed-container'); + div.innerHTML = Awful.deadImageBadgeHTML(url, imageID); + img.parentNode.replaceChild(div, img); + + // Use helper function to set up Lottie player (fixes code duplication) + Awful.setupGhostLottiePlayer(div); + } + + // Only increment progress for initially loaded images, not lazy-loaded ones + if (trackProgress) { + Awful.imageLoadTracker.incrementLoaded(); + } +}; /** Retrieves an OEmbed HTML fragment. @@ -34,6 +168,234 @@ Awful.fetchOEmbed = async function(url) { }); }; +// MARK: - Tweet Embedding Helper Functions + +/** + * Shows a dead tweet badge for a failed tweet. + * Centralizes the logic for displaying dead tweet badges to avoid code duplication + * between main embedding and retry functionality. + * + * @param {string} tweetID - The tweet ID + * @param {string} tweetURL - The original tweet URL + * @param {Element} containerToReplace - The element to replace with dead badge + */ +Awful.showDeadTweetBadge = function(tweetID, tweetURL, containerToReplace) { + if (!window.Awful.renderGhostTweets || !containerToReplace || !containerToReplace.parentNode) { + return; + } + + const div = document.createElement('div'); + div.classList.add('dead-tweet-container'); + div.innerHTML = Awful.deadTweetBadgeHTML(tweetURL, tweetID); + containerToReplace.parentNode.replaceChild(div, containerToReplace); + Awful.setupGhostLottiePlayer(div); +}; + +/** + * Calls Twitter widgets.load() with proper race condition handling. + * Checks if widgets.js is fully loaded (has widgets property), otherwise queues the call + * via twttr.ready() to ensure it executes after widgets.js finishes loading. + */ +Awful.loadTwitterWidgetsForEmbeds = function() { + if (!window.twttr) { + return; + } + + if (window.twttr.widgets) { + // Real widgets.js is loaded (has widgets property), call immediately + twttr.widgets.load(); + } else { + // widgets.js still loading (only has stub), queue the call + twttr.ready(function() { + twttr.widgets.load(); + }); + } +}; + +/** + * Fetches a single tweet via JSONP with timeout protection and comprehensive error handling. + * Centralizes all tweet fetching logic to ensure consistent behavior between main embedding + * and retry functionality. + * + * @param {string} tweetID - The tweet ID to fetch + * @param {Function} onSuccess - Called with (data, tweetID) on successful fetch + * @param {Function} onFailure - Called with (reason, tweetID, data) on failure + * reason can be: 'timeout', 'api_error', 'network' + * @returns {object} - Object with cleanup() function to manually abort the request + */ +Awful.fetchTweetOEmbed = function(tweetID, onSuccess, onFailure) { + // Create unique callback name to prevent collisions when same tweet appears in multiple posts + let callback = `jsonp_callback_${tweetID}`; + let counter = 0; + while (window[callback]) { + callback = `jsonp_callback_${tweetID}_${++counter}`; + } + + const script = document.createElement('script'); + const tweetTheme = Awful.tweetTheme(); + const validThemes = ['light', 'dark']; + const safeTheme = validThemes.includes(tweetTheme) ? tweetTheme : 'light'; + script.src = `https://api.twitter.com/1/statuses/oembed.json?id=${tweetID}&omit_script=true&dnt=true&theme=${safeTheme}&callback=${callback}`; + + let timedOut = false; + + let timeoutId = setTimeout(function() { + timedOut = true; + cleanUp(script); + console.error(`Tweet ${tweetID} embedding timed out after ${TWEET_EMBED_TIMEOUT}ms`); + if (onFailure) { + onFailure('timeout', tweetID); + } + }, TWEET_EMBED_TIMEOUT); + + // Track timeout for global cleanup on page unload + if (!Awful.tweetEmbedTimeouts) { + Awful.tweetEmbedTimeouts = []; + } + Awful.tweetEmbedTimeouts.push(timeoutId); + + window[callback] = function(data) { + if (timedOut) { + console.warn(`Ignoring late response for tweet ${tweetID}`); + return; + } + + clearTimeout(timeoutId); + cleanUp(script); + + // Validate response - check for data existence but don't inspect HTML content (iframe issues) + if (!data || !data.html || data.error) { + console.error(`Tweet ${tweetID} API returned error:`, data ? data.error : 'No data'); + if (onFailure) { + onFailure('api_error', tweetID, data); + } + return; + } + + // Success + if (onSuccess) { + onSuccess(data, tweetID); + } + }; + + script.onerror = function() { + if (timedOut) return; + cleanUp(this); + console.error(`Tweet ${tweetID} network error`); + if (onFailure) { + onFailure('network', tweetID); + } + }; + + function cleanUp(script) { + clearTimeout(timeoutId); + delete window[callback]; + if (script.parentNode) { + script.parentNode.removeChild(script); + } + } + + document.body.appendChild(script); + + return { cleanup: function() { cleanUp(script); } }; +}; + +/** + * Embeds tweets within a specific post element using Twitter's OEmbed API. + * Called by IntersectionObserver when a post enters the viewport. + * + * @param {Element} thisPostElement - The post element to process for tweet embeds + */ +Awful.embedTweetNow = function(thisPostElement) { + // Check if already processing or processed + if (thisPostElement.classList.contains("embed-processed") || + thisPostElement.classList.contains("embed-processing")) { + return; + } + + // Mark as processing to prevent duplicate IntersectionObserver calls during embedding + thisPostElement.classList.add("embed-processing"); + + const enableGhost = (window.Awful.renderGhostTweets == true); + const tweetLinks = thisPostElement.querySelectorAll('a[data-tweet-id]'); + + if (tweetLinks.length == 0) { + // No tweets to embed, mark as processed immediately + thisPostElement.classList.remove("embed-processing"); + thisPostElement.classList.add("embed-processed"); + return; + } + + // Group tweet links by ID for deduplication + const tweetIDsToLinks = {}; + Array.prototype.forEach.call(tweetLinks, function(a) { + // Skip tweets with NWS content (use optional chaining to avoid null reference errors) + if (a.parentElement?.querySelector('img.awful-smile[title=":nws:"]')) { + return; + } + const tweetID = a.dataset.tweetId; + if (!(tweetID in tweetIDsToLinks)) { + tweetIDsToLinks[tweetID] = []; + } + tweetIDsToLinks[tweetID].push(a); + }); + + // Track completion of tweets in this post - only mark as processed when ALL complete + let pendingTweets = Object.keys(tweetIDsToLinks).length; + + function markTweetComplete() { + pendingTweets--; + if (pendingTweets === 0) { + // All tweets done (success or failure) - now safe to mark as processed + thisPostElement.classList.remove("embed-processing"); + thisPostElement.classList.add("embed-processed"); + } + } + + // Fetch and embed each unique tweet using shared helper function + Object.keys(tweetIDsToLinks).forEach(function(tweetID) { + const tweetLinks = tweetIDsToLinks[tweetID]; + + // Get first link's URL for error messages + const firstLink = tweetLinks[0]; + const tweetURL = firstLink ? firstLink.href : ''; + + Awful.fetchTweetOEmbed( + tweetID, + // onSuccess callback + function(data, tweetID) { + // Replace all links for this tweet with embedded HTML + tweetIDsToLinks[tweetID].forEach(function(a) { + if (a.parentNode) { + const div = document.createElement('div'); + div.classList.add('tweet'); + div.innerHTML = data.html; + a.parentNode.replaceChild(div, a); + } + }); + + // Load Twitter widgets (with race condition fix) + Awful.loadTwitterWidgetsForEmbeds(); + + // Mark this tweet as complete + markTweetComplete(); + }, + // onFailure callback + function(reason, tweetID) { + // Show dead tweet badge for all links with this tweet ID + tweetIDsToLinks[tweetID].forEach(function(a) { + if (a.parentNode) { + Awful.showDeadTweetBadge(tweetID, tweetURL, a); + } + }); + + // Mark this tweet as complete (even though it failed) + markTweetComplete(); + } + ); + }); +}; + /** Callback for fetchOEmbed. @@ -82,145 +444,463 @@ Awful.embedBlueskyPosts = function() { }; /** - Turns apparent links to tweets into actual embedded tweets. + * Initializes lazy-loading tweet embeds using IntersectionObserver. + * Tweets are embedded as posts enter the viewport (with a 600px lookahead). + * Also sets up Lottie animation play/pause for ghost tweets in the viewport. */ Awful.embedTweets = function() { - Awful.loadTwitterWidgets(); - const enableGhost = (window.Awful.renderGhostTweets == true); + // Prevent concurrent setup to avoid race conditions where multiple calls could + // create duplicate observers and listeners. The flag is reset in the finally block + // to ensure it's always cleared even if errors occur. + if (Awful.embedTweetsInProgress) { + return; + } + Awful.embedTweetsInProgress = true; - // if ghost is enabled, add IntersectionObserver so that we know when to play and stop the animations + try { + // Clean up any existing observers/timers before setting up new ones + // This handles the case where embedTweets() is called multiple times on the same page + Awful.cleanupObservers(); + + Awful.loadTwitterWidgets(); + const enableGhost = (window.Awful.renderGhostTweets == true); + + // Set up IntersectionObserver for ghost Lottie animations (play/pause on scroll) if (enableGhost) { - const topMarginOffset = 0; - - let config = { - root: document.body.posts, - rootMargin: `${topMarginOffset}px 0px`, - threshold: 0.000001, + // Disconnect previous observer if it exists (prevents memory leak on re-render) + if (Awful.ghostLottieObserver) { + Awful.ghostLottieObserver.disconnect(); + } + + const ghostConfig = { + root: document.body.posts, + rootMargin: '0px', + threshold: INTERSECTION_THRESHOLD_MIN, }; - - let observer = new IntersectionObserver(function (posts, observer) { - // each element will be checked by the browser as scolling occurs - posts.forEach((post, index) => { - if (post.isIntersecting) { - const player = post.target.querySelectorAll("lottie-player"); - player.forEach((lottiePlayer) => { + + Awful.ghostLottieObserver = new IntersectionObserver(function(posts) { + posts.forEach((post) => { + const players = post.target.querySelectorAll(SELECTORS.LOTTIE_PLAYERS); + players.forEach((lottiePlayer) => { + if (post.isIntersecting) { lottiePlayer.play(); - // comment out when not testing - //console.log("Lottie playing."); - }); - } else { - // pause all lottie players if this post is not intersecting - const player = post.target.querySelectorAll("lottie-player"); - player.forEach((lottiePlayer) => { - lottiePlayer.pause(); - // this log is to confirm that pausing actually occurs while scrolling. comment out when not testing - //console.log("Lottie paused."); - }); - } + } else { + lottiePlayer.pause(); + } + }); }); - }, config); - - const viewbox = document.querySelectorAll("post"); - viewbox.forEach((post) => { - observer.observe(post); + }, ghostConfig); + + const postElements = document.querySelectorAll(SELECTORS.POST_ELEMENTS); + postElements.forEach((post) => { + Awful.ghostLottieObserver.observe(post); }); } - - var tweetLinks = document.querySelectorAll('a[data-tweet-id]'); - if (tweetLinks.length == 0) { - return; + + // Image loading and retry handling (works regardless of ghost feature being enabled) + Awful.applyTimeoutToLoadingImages(); + Awful.setupRetryHandler(); + Awful.setupLazyImageErrorHandling(); + + // Tweet retry handling + Awful.setupTweetRetryHandler(); + + // Set up lazy-loading IntersectionObserver for tweet embeds + // Tweets are loaded before entering the viewport based on LAZY_LOAD_LOOKAHEAD_DISTANCE + // Disconnect previous observer if it exists (prevents memory leak on re-render) + if (Awful.tweetLazyLoadObserver) { + Awful.tweetLazyLoadObserver.disconnect(); } - var tweetIDsToLinks = {}; - Array.prototype.forEach.call(tweetLinks, function(a) { - if (a.parentElement.querySelector('img.awful-smile[title=":nws:"]')) { - return; + const lazyLoadConfig = { + root: null, + rootMargin: `${LAZY_LOAD_LOOKAHEAD_DISTANCE} 0px`, + threshold: INTERSECTION_THRESHOLD_MIN, + }; + + Awful.tweetLazyLoadObserver = new IntersectionObserver(function(entries) { + entries.forEach((entry) => { + if (entry.isIntersecting) { + Awful.embedTweetNow(entry.target); + } + }); + }, lazyLoadConfig); + + // Observe all post elements for lazy loading + const posts = document.querySelectorAll(SELECTORS.POST_ELEMENTS); + posts.forEach((post) => { + Awful.tweetLazyLoadObserver.observe(post); + }); + + // Notify native side when tweets are loaded + if (window.twttr) { + twttr.ready(function() { + if (webkit.messageHandlers.didFinishLoadingTweets) { + twttr.events.bind('loaded', function() { + webkit.messageHandlers.didFinishLoadingTweets.postMessage({}); + }); + } + }); + } + + } finally { + // Always reset flag, even if an error occurs + Awful.embedTweetsInProgress = false; + } +}; + +// Image load progress tracker +Awful.imageLoadTracker = { + loaded: 0, + total: 0, + + initialize: function(totalCount) { + this.loaded = 0; + this.total = totalCount; + this.reportProgress(); // Always report - Swift side handles zero case correctly + }, + + incrementLoaded: function() { + this.loaded++; + this.reportProgress(); + }, + + reportProgress: function() { + if (window.webkit?.messageHandlers?.imageLoadProgress) { + webkit.messageHandlers.imageLoadProgress.postMessage({ + loaded: this.loaded, + total: this.total, + complete: this.loaded >= this.total + }); + } } - var tweetID = a.dataset.tweetId; - if (!(tweetID in tweetIDsToLinks)) { - tweetIDsToLinks[tweetID] = []; +}; + +/** + * Apply timeout detection to images that are loading normally (first 10). + * Monitors initial image loading and tracks progress for the loading view. + */ +Awful.applyTimeoutToLoadingImages = function() { + const enableGhost = Awful.renderGhostTweets || false; + + // Find post content images (excluding smilies, avatars, and lazy-loaded images) - these are the first 10 images + const loadingImages = document.querySelectorAll(SELECTORS.LOADING_IMAGES); + + // Count only the initially loading images (first 10), excluding attachment.php and data URLs + const initialImages = Array.from(loadingImages).filter(img => + !img.src.includes('attachment.php') && !img.src.startsWith('data:') + ); + const totalImages = initialImages.length; + + Awful.imageLoadTracker.initialize(totalImages); + + // Clear all existing timers before resetting array to prevent orphaned intervals + if (Awful.imageTimeoutCheckers) { + Awful.imageTimeoutCheckers.forEach(timer => clearInterval(timer)); } - tweetIDsToLinks[tweetID].push(a); - }); + Awful.imageTimeoutCheckers = []; - var totalFetchCount = Object.keys(tweetIDsToLinks).length; - var completedFetchCount = 0; + initialImages.forEach((img, index) => { + const imageID = `img-init-${index}`; + const imageURL = img.src; - Object.keys(tweetIDsToLinks).forEach(function(tweetID) { - var callback = `jsonp_callback_${tweetID}`; - var tweetTheme = Awful.tweetTheme(); + // img.complete is true for both successfully loaded AND failed images + // We discriminate using naturalHeight: >0 means success, ===0 means failure + if (img.complete && img.naturalHeight !== 0) { + Awful.imageLoadTracker.incrementLoaded(); + return; + } - var script = document.createElement('script'); - script.src = `https://api.twitter.com/1/statuses/oembed.json?id=${tweetID}&omit_script=true&dnt=true&theme=${tweetTheme}&callback=${callback}`; + // Track if we've already handled this image to prevent double-counting + let handled = false; + + const handleSuccess = () => { + if (handled) { + console.warn(`[Image Load] Duplicate success event for ${imageID} (already handled)`); + return; + } + handled = true; + Awful.imageLoadTracker.incrementLoaded(); + }; + + const handleFailure = () => { + if (handled) { + console.warn(`[Image Load] Duplicate failure event for ${imageID} (already handled)`); + return; + } + handled = true; + + if (enableGhost && img.parentNode) { + const div = document.createElement('div'); + div.classList.add('dead-embed-container'); + div.innerHTML = Awful.deadImageBadgeHTML(imageURL, imageID); + img.parentNode.replaceChild(div, img); + + // Use helper function to set up Lottie player (fixes code duplication) + Awful.setupGhostLottiePlayer(div); + } + + Awful.imageLoadTracker.incrementLoaded(); + }; + + // Set up timeout checker using config constants + let checkCount = 0; + const maxChecks = IMAGE_LOAD_TIMEOUT_CONFIG.maxImageChecks; + const checkInterval = IMAGE_LOAD_TIMEOUT_CONFIG.connectionTimeout; + + const timeoutChecker = setInterval(() => { + checkCount++; + + // If image loaded successfully + // Note: img.complete is true for both success and failure + // naturalHeight > 0 indicates successful load + if (img.complete && img.naturalHeight !== 0) { + clearInterval(timeoutChecker); + handleSuccess(); + return; + } + + // If image failed to load (error state) + // img.complete true + naturalHeight === 0 indicates load failure + if (img.complete && img.naturalHeight === 0) { + clearInterval(timeoutChecker); + handleFailure(); + return; + } + + // If we've checked enough times and it's still not loaded, timeout + if (checkCount >= maxChecks) { + clearInterval(timeoutChecker); + handleFailure(); + } + }, checkInterval); + + // Store timer for potential cleanup + Awful.imageTimeoutCheckers.push(timeoutChecker); + + // Also listen for load/error events to handle immediately + img.addEventListener('load', () => { + clearInterval(timeoutChecker); + handleSuccess(); + }, { once: true }); + + img.addEventListener('error', () => { + clearInterval(timeoutChecker); + handleFailure(); + }, { once: true }); + }); +}; - window[callback] = function(data) { - cleanUp(script); - - tweetIDsToLinks[tweetID].forEach(function(a) { - if (a.parentNode) { - var div = document.createElement('div'); - div.classList.add('tweet'); - div.innerHTML = data.html; - a.parentNode.replaceChild(div, a); +/** + * Setup retry click handler (using event delegation) - call once on page load. + * Allows users to retry loading failed images. + */ +Awful.setupRetryHandler = function() { + // Remove old event listener if it exists (prevents memory leak on page re-render) + if (Awful.retryClickHandler) { + document.removeEventListener('click', Awful.retryClickHandler); + } + + // Define handler function and store reference for cleanup + Awful.retryClickHandler = function(event) { + const retryLink = event.target; + if (retryLink.hasAttribute('data-retry-image')) { + event.preventDefault(); + + const imageURL = retryLink.getAttribute('data-retry-image'); + const container = retryLink.closest('.dead-embed-container'); + + if (container) { + // Update retry link to show "Retrying..." state + retryLink.textContent = 'Retrying...'; + retryLink.style.pointerEvents = 'none'; // Disable clicking during retry + + // Create new image element with native browser loading + const successImg = document.createElement('img'); + successImg.setAttribute('alt', ''); + + // Handle successful load + successImg.addEventListener('load', () => { + // Replace the dead badge container with the successful image + container.parentNode.replaceChild(successImg, container); + }, { once: true }); + + // Handle load failure + successImg.addEventListener('error', (error) => { + // FAILED - restore retry button with "Failed" feedback + console.error(`Retry failed: ${error.message || 'Unknown error'} - ${imageURL}`); + + retryLink.textContent = 'Retry Failed - Try Again'; + retryLink.style.pointerEvents = 'auto'; // Re-enable clicking + + // Reset to just "Retry" after configured delay + setTimeout(() => { + if (retryLink.textContent === 'Retry Failed - Try Again') { + retryLink.textContent = 'Retry'; + } + }, IMAGE_LOAD_TIMEOUT_CONFIG.retryResetDelay); + }, { once: true }); + + // Start loading (native browser handles everything) + successImg.src = imageURL; + } } - }); + }; + + // Register the event listener with stored reference + document.addEventListener('click', Awful.retryClickHandler, { once: false }); +}; - didCompleteFetch(); +/** + * Sets up a click event listener for retrying failed tweet embeds. + * Uses shared fetchTweetOEmbed helper for consistent timeout and error handling. + */ +Awful.setupTweetRetryHandler = function() { + // Remove old event listener if it exists (prevents memory leak on page re-render) + if (Awful.tweetRetryClickHandler) { + document.removeEventListener('click', Awful.tweetRetryClickHandler); + } + + // Define handler function and store reference for cleanup + Awful.tweetRetryClickHandler = function(event) { + const button = event.target; + if (button.hasAttribute('data-retry-tweet')) { + event.preventDefault(); + + const tweetID = button.getAttribute('data-retry-tweet'); + const tweetURL = button.getAttribute('data-tweet-url'); + const deadContainer = button.closest('.dead-tweet-container'); + + if (!deadContainer || !deadContainer.parentNode) { + return; + } + + // Validate URL is actually a Twitter/X URL (security check) + if (!tweetURL.match(/^https?:\/\/(www\.)?(twitter\.com|x\.com)\//)) { + console.error('Invalid tweet URL for retry:', tweetURL); + return; + } + + // Disable button during retry + button.disabled = true; + button.textContent = 'Retrying...'; + + // Create loading indicator + const loadingDiv = document.createElement('div'); + loadingDiv.className = 'tweet-loading'; + loadingDiv.textContent = 'Loading tweet...'; + deadContainer.parentNode.replaceChild(loadingDiv, deadContainer); + + // Use shared fetch function + Awful.fetchTweetOEmbed( + tweetID, + // onSuccess + function(data, tweetID) { + if (loadingDiv.parentNode) { + const div = document.createElement('div'); + div.classList.add('tweet'); + div.innerHTML = data.html; + loadingDiv.parentNode.replaceChild(div, loadingDiv); + + // Load Twitter widgets (using shared function) + Awful.loadTwitterWidgetsForEmbeds(); + } + }, + // onFailure + function(reason, tweetID) { + // Show dead badge again using shared function + if (loadingDiv.parentNode) { + Awful.showDeadTweetBadge(tweetID, tweetURL, loadingDiv); + } + } + ); + } }; - script.onerror = function() { - cleanUp(this); - console.error(`The embed markup for tweet ${tweetID} failed to load`); - - // when a tweet errors out, insert a floating ghost lottie in somber rememberence of the tweet that used to be - if (enableGhost) { - tweetIDsToLinks[tweetID].forEach(function(a) { - if (a.parentNode) { - var div = document.createElement('div'); - div.classList.add('dead-tweet-container'); - div.innerHTML = Awful.deadTweetBadgeHTML(a.href.toString(), `${tweetID}`); - a.parentNode.replaceChild(div, a); - - const player = div.querySelectorAll("lottie-player"); - player.forEach((lottiePlayer) => { - lottiePlayer.addEventListener("rendered", (e) => { - lottiePlayer.load(document.getElementById("ghost-json-data").innerText); - }); - }); - } - }); - } - - didCompleteFetch(); - }; + // Register the event listener with stored reference + document.addEventListener('click', Awful.tweetRetryClickHandler, { once: false }); +}; - function cleanUp(script) { - delete window[callback]; - if (script.parentNode) { - script.parentNode.removeChild(script); - } +/** + * Cleanup function to remove retry click handler and prevent memory leaks. + * Should be called when the view is destroyed or navigating away from the page. + */ +Awful.cleanupRetryHandler = function() { + if (Awful.retryClickHandler) { + document.removeEventListener('click', Awful.retryClickHandler); + Awful.retryClickHandler = null; } +}; - document.body.appendChild(script); - }); +/** + * Sets up error handling for lazy-loaded images (those with loading="lazy" attribute). + * Attaches error event listeners that display dead image badges when browser attempts + * to load the image and it fails (404, broken, etc.). Only triggers after browser + * attempts load - doesn't interfere with native lazy loading. + */ +Awful.setupLazyImageErrorHandling = function() { + const enableGhost = Awful.renderGhostTweets || false; + const lazyImages = document.querySelectorAll('section.postbody img[loading="lazy"]'); + + lazyImages.forEach((img, index) => { + const imageID = `lazy-error-${index}`; + + // Only attach error listener - don't interfere with lazy loading + img.addEventListener('error', function() { + // Browser attempted to load this image and it failed + const imageURL = img.src; + Awful.handleImageLoadError( + new Error("Lazy image load failed"), + imageURL, + img, + imageID, + enableGhost, + false // trackProgress = false, lazy images don't count toward progress + ); + }, { once: true }); + }); +}; - function didCompleteFetch() { - completedFetchCount += 1; +/** + * Cleanup function to clear all image timeout interval timers. + * Prevents timers from running after page navigation or view destruction. + */ +Awful.cleanupImageTimers = function() { + if (Awful.imageTimeoutCheckers) { + Awful.imageTimeoutCheckers.forEach(timer => clearInterval(timer)); + Awful.imageTimeoutCheckers = []; + } +}; - if (completedFetchCount == totalFetchCount) { - if (window.twttr) { - twttr.ready(function() { - twttr.widgets.load(); +/** + * Cleanup function to clear all tweet embedding timeout timers. + * Prevents timers from running after page navigation or view destruction. + */ +Awful.cleanupTweetTimers = function() { + if (Awful.tweetEmbedTimeouts) { + Awful.tweetEmbedTimeouts.forEach(function(timeoutId) { + clearTimeout(timeoutId); }); + Awful.tweetEmbedTimeouts = []; + } +}; - if (webkit.messageHandlers.didFinishLoadingTweets) { - twttr.events.bind('loaded', function() { - webkit.messageHandlers.didFinishLoadingTweets.postMessage({}); - }); - } - } +/** + * Cleanup function to disconnect all IntersectionObservers and prevent memory leaks. + * Should be called when the view is destroyed or navigating away from the page. + */ +Awful.cleanupObservers = function() { + if (Awful.ghostLottieObserver) { + Awful.ghostLottieObserver.disconnect(); + Awful.ghostLottieObserver = null; } - } + if (Awful.tweetLazyLoadObserver) { + Awful.tweetLazyLoadObserver.disconnect(); + Awful.tweetLazyLoadObserver = null; + } + + Awful.cleanupImageTimers(); + Awful.cleanupTweetTimers(); }; Awful.tweetTheme = function() { @@ -249,30 +929,31 @@ Awful.loadTwitterWidgets = function() { var script = document.createElement('script'); script.id = 'twitter-wjs'; script.src = "https://platform.twitter.com/widgets.js"; + + // Add error handler for widgets.js load failure + script.onerror = function() { + console.error('Failed to load Twitter widgets.js'); + // Set flag to prevent queuing more callbacks + if (window.twttr) { + window.twttr._failed = true; + } + }; + document.body.appendChild(script); window.twttr = { _e: [], + _failed: false, // Track load failure ready: function(f) { + if (window.twttr._failed) { + console.warn('Twitter widgets.js failed to load, skipping callback'); + return; + } twttr._e.push(f); } }; }; -/** - Loads the Lottie player library into the document - */ -Awful.loadLotties = function() { - if (document.getElementById('lottie-js')) { - return; - } - - var script = document.createElement('script'); - script.id = 'lottie-js'; - script.src = "awful-resource://lottie-player.js"; - document.body.appendChild(script); -}; - /** Scrolls the document past a fraction of the document. @@ -724,18 +1405,65 @@ Awful.setAnnouncementHTML = function(html) { Awful.deadTweetBadgeHTML = function(url, tweetID){ - // get twitter username from url - var tweeter = url.match(/(?:https?:\/\/)?(?:www\.)?twitter\.com\/(?:#!\/)?@?([^\/\?\s]*)/)[1]; - + // Sanitize URL to prevent XSS attacks + const safeURL = Awful.sanitizeURL(url); + + // get twitter username from url (with fallback for malformed URLs) + let tweeter = 'unknown'; + try { + const match = url.match(/(?:https?:\/\/)?(?:www\.)?twitter\.com\/(?:#!\/)?@?([^\/\?\s]*)/); + if (match && match[1]) { + tweeter = Awful.escapeHTML(match[1]); + } + } catch (e) { + console.error('Error parsing tweet URL:', e); + } + + // Escape tweetID for use in HTML attributes + const safeTweetID = Awful.escapeHTML(tweetID); + var html = `
- +
DEAD TWEET - @${tweeter} + @${tweeter} + Retry + `; + + return html; +}; + +// Dead Image Badge (similar to dead tweet) +Awful.deadImageBadgeHTML = function(url, imageID) { + // Sanitize URL to prevent XSS attacks + const safeURL = Awful.sanitizeURL(url); + + // Extract filename from URL and escape it + let filename = 'unknown'; + try { + const urlParts = url.split('/').pop().split('?')[0]; + if (urlParts) { + filename = Awful.escapeHTML(urlParts); + } + } catch (e) { + console.error('Error parsing image URL:', e); + } + + // Escape imageID for use in HTML attributes + const safeImageID = Awful.escapeHTML(imageID); + + var html = + `
+ + +
+ DEAD IMAGE + ${filename} + Retry `; - + return html; }; @@ -953,6 +1681,25 @@ Awful.embedGfycat = function() { } Awful.embedGfycat(); + +// Set up image loading if DOM is ready (DOMContentLoaded may have already fired) +// The early user script in RenderView.swift tracks when DOMContentLoaded fires +if (Awful.domContentLoadedFired) { + if (typeof Awful.applyTimeoutToLoadingImages === 'function') { + Awful.applyTimeoutToLoadingImages(); + Awful.setupRetryHandler(); + Awful.setupLazyImageErrorHandling(); + } +} else { + document.addEventListener('DOMContentLoaded', function() { + if (typeof Awful.applyTimeoutToLoadingImages === 'function') { + Awful.applyTimeoutToLoadingImages(); + Awful.setupRetryHandler(); + Awful.setupLazyImageErrorHandling(); + } + }); +} + // THIS SHOULD STAY AT THE BOTTOM OF THE FILE! // All done; tell the native side we're ready. window.webkit.messageHandlers.didRender.postMessage({}); diff --git a/App/View Controllers/Messages/MessageViewController.swift b/App/View Controllers/Messages/MessageViewController.swift index 2a3e889f9..2422a6826 100644 --- a/App/View Controllers/Messages/MessageViewController.swift +++ b/App/View Controllers/Messages/MessageViewController.swift @@ -251,7 +251,7 @@ final class MessageViewController: ViewController { .store(in: &cancellables) if privateMessage.innerHTML == nil || privateMessage.innerHTML?.isEmpty == true || privateMessage.from == nil { - let loadingView = LoadingView.loadingViewWithTheme(theme) + let loadingView = LoadingView.loadingViewWithTheme(theme, configuration: .hideStatusElements) self.loadingView = loadingView view.addSubview(loadingView) diff --git a/App/View Controllers/Posts/PostPreviewViewController.swift b/App/View Controllers/Posts/PostPreviewViewController.swift index 16bf03863..ca5809f98 100644 --- a/App/View Controllers/Posts/PostPreviewViewController.swift +++ b/App/View Controllers/Posts/PostPreviewViewController.swift @@ -179,8 +179,8 @@ final class PostPreviewViewController: ViewController { renderView.frame = CGRect(origin: .zero, size: view.bounds.size) renderView.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.insertSubview(renderView, at: 0) - - let loadingView = LoadingView.loadingViewWithTheme(theme) + + let loadingView = LoadingView.loadingViewWithTheme(theme, configuration: .hideStatusElements) self.loadingView = loadingView view.addSubview(loadingView) diff --git a/App/View Controllers/Posts/PostsPageView.swift b/App/View Controllers/Posts/PostsPageView.swift index 2e9519cb5..4d8c52f9f 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -23,8 +23,8 @@ final class PostsPageView: UIView { // MARK: Loading view - var loadingView: UIView? { - get { return loadingViewContainer.subviews.first } + var loadingView: LoadingView? { + get { return loadingViewContainer.subviews.first as? LoadingView } set { loadingViewContainer.subviews.forEach { $0.removeFromSuperview() } if let newValue = newValue { diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 31e12e867..e29dc6971 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -105,6 +105,7 @@ final class PostsPageViewController: ViewController { postsView.renderView.registerMessage(RenderView.BuiltInMessage.DidTapPostActionButton.self) postsView.renderView.registerMessage(RenderView.BuiltInMessage.DidTapAuthorHeader.self) postsView.renderView.registerMessage(RenderView.BuiltInMessage.FetchOEmbedFragment.self) + postsView.renderView.registerMessage(RenderView.BuiltInMessage.ImageLoadProgress.self) postsView.topBar.goToParentForum = { [unowned self] in guard let forum = self.thread.forum else { return } AppDelegate.instance.open(route: .forum(id: forum.forumID)) @@ -254,7 +255,10 @@ final class PostsPageViewController: ViewController { let initialTheme = theme let fetch = Task { - try await ForumsClient.shared.listPosts(in: thread, writtenBy: author, page: newPage, updateLastReadPost: updateLastReadPost) + await MainActor.run { + self.postsView.loadingView?.updateStatus("Fetching posts from server...") + } + return try await ForumsClient.shared.listPosts(in: thread, writtenBy: author, page: newPage, updateLastReadPost: updateLastReadPost) } networkOperation = fetch Task { [weak self] in @@ -300,6 +304,7 @@ final class PostsPageViewController: ViewController { self.scrollToFractionAfterLoading = self.postsView.renderView.scrollView.fractionalContentOffset.y } + self.postsView.loadingView?.updateStatus("Generating page...") self.renderPosts() self.updateUserInterface() @@ -416,6 +421,9 @@ final class PostsPageViewController: ViewController { Task { await postsView.renderView.eraseDocument() + await MainActor.run { + self.postsView.loadingView?.updateStatus("Rendering page...") + } self.postsView.renderView.render(html: html, baseURL: ForumsClient.shared.baseURL) } } @@ -662,7 +670,12 @@ final class PostsPageViewController: ViewController { private func showLoadingView() { guard postsView.loadingView == nil else { return } - postsView.loadingView = LoadingView.loadingViewWithTheme(theme) + let loadingView = LoadingView.loadingViewWithTheme(theme, configuration: .showStatusElements) + loadingView.updateStatus("Loading...") + loadingView.onDismiss = { [weak self] in + self?.clearLoadingMessage() + } + postsView.loadingView = loadingView } private func clearLoadingMessage() { @@ -1494,7 +1507,7 @@ final class PostsPageViewController: ViewController { if postsView.loadingView != nil { - postsView.loadingView = LoadingView.loadingViewWithTheme(theme) + postsView.loadingView = LoadingView.loadingViewWithTheme(theme, configuration: .showStatusElements) } let appearance = UIToolbarAppearance() @@ -1743,9 +1756,7 @@ extension PostsPageViewController: RenderViewDelegate { view.embedTweets() } - if frogAndGhostEnabled { - view.loadLottiePlayer() - } + // Note: Image loading tracking is set up automatically via DOMContentLoaded event in RenderView.js webViewDidLoadOnce = true @@ -1769,7 +1780,8 @@ extension PostsPageViewController: RenderViewDelegate { postsView.renderView.scrollToFractionalOffset(fractionalOffset) } - clearLoadingMessage() + // Note: Loading view is now dismissed when image loading completes (via ImageLoadProgress message) + // or when user taps (X) button } func didReceive(message: RenderViewMessage, in view: RenderView) { @@ -1792,6 +1804,20 @@ extension PostsPageViewController: RenderViewDelegate { case let message as RenderView.BuiltInMessage.FetchOEmbedFragment: fetchOEmbed(url: message.url, id: message.id) + case let message as RenderView.BuiltInMessage.ImageLoadProgress: + if message.total == 0 { + // No images to load, dismiss immediately + clearLoadingMessage() + } else { + let statusText = "Downloading images: \(message.loaded)/\(message.total)" + postsView.loadingView?.updateStatus(statusText) + + // Dismiss loading view when all images are done + if message.complete { + clearLoadingMessage() + } + } + case is FYADFlagRequest: fetchNewFlag() diff --git a/App/View Controllers/Threads/ThreadPreviewViewController.swift b/App/View Controllers/Threads/ThreadPreviewViewController.swift index 0e09dabf8..5b8c8034a 100644 --- a/App/View Controllers/Threads/ThreadPreviewViewController.swift +++ b/App/View Controllers/Threads/ThreadPreviewViewController.swift @@ -187,8 +187,8 @@ final class ThreadPreviewViewController: ViewController { threadCell.autoresizingMask = .flexibleWidth renderView.scrollView.addSubview(threadCell) - - let loadingView = LoadingView.loadingViewWithTheme(theme) + + let loadingView = LoadingView.loadingViewWithTheme(theme, configuration: .hideStatusElements) self.loadingView = loadingView view.addSubview(loadingView) diff --git a/App/Views/LoadingView.swift b/App/Views/LoadingView.swift index f8b597543..4efc3d2e0 100644 --- a/App/Views/LoadingView.swift +++ b/App/Views/LoadingView.swift @@ -7,40 +7,66 @@ import FLAnimatedImage import UIKit import Lottie +/// Configuration for LoadingView behavior +enum LoadingViewConfiguration { + /// Shows status text and exit button after delay (for thread pages) + case showStatusElements + + /// Never shows status text or exit button (for previews and messages) + case hideStatusElements +} + /// A view that covers its superview with an indeterminate progress indicator. class LoadingView: UIView { + + // MARK: - Constants + + /// Duration in seconds before showing the exit button and status messages. + /// 3 seconds gives users time to see loading begin while preventing accidental early dismissal. + fileprivate static let statusElementsVisibilityDelay: TimeInterval = 3.0 + + // MARK: - Properties + fileprivate let theme: Theme? - - fileprivate init(theme: Theme?) { + let configuration: LoadingViewConfiguration + + fileprivate init(theme: Theme?, configuration: LoadingViewConfiguration) { self.theme = theme + self.configuration = configuration super.init(frame: .zero) } - + convenience init() { - self.init(theme: nil) + self.init(theme: nil, configuration: .showStatusElements) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - class func loadingViewWithTheme(_ theme: Theme) -> LoadingView { + class func loadingViewWithTheme(_ theme: Theme, configuration: LoadingViewConfiguration = .showStatusElements) -> LoadingView { switch theme[string: "postsLoadingViewType"] { case "Macinyos"?: - return MacinyosLoadingView(theme: theme) + return MacinyosLoadingView(theme: theme, configuration: configuration) case "Winpos95"?: - return Winpos95LoadingView(theme: theme) + return Winpos95LoadingView(theme: theme, configuration: configuration) case "YOSPOS"?: - return YOSPOSLoadingView(theme: theme) + return YOSPOSLoadingView(theme: theme, configuration: configuration) default: - return DefaultLoadingView(theme: theme) + return DefaultLoadingView(theme: theme, configuration: configuration) } } - + + var onDismiss: (() -> Void)? + + func updateStatus(_ text: String) { + // Override in subclasses + } + fileprivate func retheme() { // nop } - + override func willMove(toSuperview newSuperview: UIView?) { guard let newSuperview = newSuperview else { return } frame = CGRect(origin: .zero, size: newSuperview.bounds.size) @@ -52,65 +78,150 @@ class LoadingView: UIView { private class DefaultLoadingView: LoadingView { private let animationView: LottieAnimationView - - override init(theme: Theme?) { + private let statusLabel: UILabel + private let showNowButton: UIButton + private var visibilityTimer: Timer? + + override init(theme: Theme?, configuration: LoadingViewConfiguration) { animationView = LottieAnimationView( animation: LottieAnimation.named("mainthrobber60"), configuration: LottieConfiguration(renderingEngine: .mainThread)) - super.init(theme: theme) + statusLabel = UILabel() + showNowButton = UIButton(type: .system) + super.init(theme: theme, configuration: configuration) + + // Setup animation view animationView.currentFrame = 0 animationView.contentMode = .scaleAspectFit animationView.animationSpeed = 1 animationView.isOpaque = true animationView.translatesAutoresizingMaskIntoConstraints = false - addSubview(animationView) - - animationView.widthAnchor.constraint(equalToConstant: 90).isActive = true - animationView.heightAnchor.constraint(equalToConstant: 90).isActive = true - animationView.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true - animationView.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true - + + // Setup status label + statusLabel.text = "Loading..." + statusLabel.font = .preferredFont(forTextStyle: .subheadline) + statusLabel.textAlignment = .center + statusLabel.translatesAutoresizingMaskIntoConstraints = false + statusLabel.alpha = 0 // Initially hidden + addSubview(statusLabel) + + // Setup Show Now button as X in circle icon + let xCircleImage = UIImage(systemName: "xmark.circle.fill") + showNowButton.setImage(xCircleImage, for: .normal) + showNowButton.addTarget(self, action: #selector(showNowTapped), for: .touchUpInside) + showNowButton.translatesAutoresizingMaskIntoConstraints = false + showNowButton.contentHorizontalAlignment = .fill + showNowButton.contentVerticalAlignment = .fill + showNowButton.alpha = 0 // Initially hidden + addSubview(showNowButton) + + // Layout constraints + NSLayoutConstraint.activate([ + // Animation centered, shifted up + animationView.widthAnchor.constraint(equalToConstant: 90), + animationView.heightAnchor.constraint(equalToConstant: 90), + animationView.centerXAnchor.constraint(equalTo: centerXAnchor), + // Center animation slightly above true center for better visual balance with status text below + animationView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -40), + + // Status label below animation + statusLabel.topAnchor.constraint(equalTo: animationView.bottomAnchor, constant: 16), + statusLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 20), + trailingAnchor.constraint(greaterThanOrEqualTo: statusLabel.trailingAnchor, constant: 20), + + // Button below status (X icon) + showNowButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 16), + showNowButton.centerXAnchor.constraint(equalTo: centerXAnchor), + showNowButton.widthAnchor.constraint(equalToConstant: 32), + showNowButton.heightAnchor.constraint(equalToConstant: 32) + ]) + animationView.play(fromFrame: 0, toFrame: 25, loopMode: .playOnce, completion: { [weak self] (finished) in if finished { // first animation complete! start second one and loop self?.animationView.play(fromFrame: 25, toFrame: .infinity, loopMode: .loop, completion: nil) - } else { - // animation cancelled } + // If not finished, animation was cancelled - no action needed }) } - + + @objc private func showNowTapped() { + onDismiss?() + } + + override func updateStatus(_ text: String) { + statusLabel.text = text + } + + deinit { + visibilityTimer?.invalidate() + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func retheme() { super.retheme() - + backgroundColor = theme?[uicolor: "postsLoadingViewTintColor"] if let tintColor = theme?[uicolor: "tintColor"] { animationView.setValueProvider( ColorValueProvider(tintColor.lottieColorValue), keypath: "**.Fill 1.Color" ) + showNowButton.tintColor = tintColor + } + + // Apply text color to status label + if let textColor = theme?[uicolor: "listTextColor"] { + statusLabel.textColor = textColor } } fileprivate override func willMove(toSuperview newSuperview: UIView?) { super.willMove(toSuperview: newSuperview) - + + if newSuperview != nil { + // Only start timer if configuration allows status elements + guard configuration == .showStatusElements else { return } + + // Invalidate any existing timer first to prevent race conditions + visibilityTimer?.invalidate() + // Start timer to show status and button after delay + visibilityTimer = Timer.scheduledTimer(withTimeInterval: LoadingView.statusElementsVisibilityDelay, repeats: false) { [weak self] _ in + self?.showStatusElements() + } + } else { + // Clean up timer when view is removed + visibilityTimer?.invalidate() + visibilityTimer = nil + } + } + + private func showStatusElements() { + // Check that view is still in hierarchy before animating (prevents race condition) + guard superview != nil else { return } + + UIView.animate(withDuration: 0.3) { [weak self] in + // Double-check during animation block + guard self?.superview != nil else { return } + self?.statusLabel.alpha = 1.0 + self?.showNowButton.alpha = 1.0 + } } } private class YOSPOSLoadingView: LoadingView { let label = UILabel() fileprivate var timer: Timer? - - override init(theme: Theme?) { - super.init(theme: theme) + + override init(theme: Theme?, configuration: LoadingViewConfiguration) { + super.init(theme: theme, configuration: configuration) backgroundColor = .black @@ -174,9 +285,9 @@ private class YOSPOSLoadingView: LoadingView { private class MacinyosLoadingView: LoadingView { let imageView = UIImageView() - - override init(theme: Theme?) { - super.init(theme: theme) + + override init(theme: Theme?, configuration: LoadingViewConfiguration) { + super.init(theme: theme, configuration: configuration) if let wallpaper = UIImage(named: "macinyos-wallpaper") { backgroundColor = UIColor(patternImage: wallpaper) @@ -205,9 +316,9 @@ private class Winpos95LoadingView: LoadingView { let imageView = FLAnimatedImageView() var centerXConstraint: NSLayoutConstraint! var centerYConstraint: NSLayoutConstraint! - - override init(theme: Theme?) { - super.init(theme: theme) + + override init(theme: Theme?, configuration: LoadingViewConfiguration) { + super.init(theme: theme, configuration: configuration) backgroundColor = UIColor(red: 0.067, green: 0.502, blue: 0.502, alpha: 1) diff --git a/App/Views/RenderView.swift b/App/Views/RenderView.swift index 2bc995bea..a353f29a9 100644 --- a/App/Views/RenderView.swift +++ b/App/Views/RenderView.swift @@ -29,8 +29,31 @@ final class RenderView: UIView { private lazy var webView: WKWebView = { let configuration = WKWebViewConfiguration() - + let bundle = Bundle(for: RenderView.self) + + // Conditionally load lottie-player.js if frog and ghost animations are enabled + let frogAndGhostEnabled = FoilDefaultStorage(Settings.frogAndGhostEnabled).wrappedValue + if frogAndGhostEnabled { + configuration.userContentController.addUserScript({ + let url = bundle.url(forResource: "lottie-player.js", withExtension: nil)! + let script = try! String(contentsOf: url) + return WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + }()) + } + + // Inject early script to track when DOMContentLoaded fires + configuration.userContentController.addUserScript({ + let script = """ + if (!window.Awful) { window.Awful = {}; } + window.Awful.domContentLoadedFired = false; + document.addEventListener('DOMContentLoaded', function() { + window.Awful.domContentLoadedFired = true; + }); + """ + return WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true) + }()) + configuration.userContentController.addUserScript({ let url = bundle.url(forResource: "RenderView.js", withExtension: nil)! let script = try! String(contentsOf: url) @@ -176,7 +199,10 @@ extension RenderView: WKScriptMessageHandler { } func userContentController(_ userContentController: WKUserContentController, didReceive rawMessage: WKScriptMessage) { - logger.debug("received message from JavaScript: \(rawMessage.name)") + // Skip logging high-frequency progress updates to reduce console noise + if rawMessage.name != "imageLoadProgress" { + logger.debug("received message from JavaScript: \(rawMessage.name)") + } guard let messageType = registeredMessages[rawMessage.name] else { logger.warning("ignoring unexpected message from JavaScript: \(rawMessage.name). Did you forget to register a message type with the RenderView?") @@ -260,13 +286,13 @@ extension RenderView: WKScriptMessageHandler { struct FetchOEmbedFragment: RenderViewMessage { static let messageName = "fetchOEmbedFragment" - + /// An opaque `id` to use when calling back with the response. let id: String - + /// The OEmbed URL to fetch. let url: URL - + init?(rawMessage: WKScriptMessage, in renderView: RenderView) { assert(rawMessage.name == Self.messageName) guard let body = rawMessage.body as? [String: Any], @@ -274,11 +300,38 @@ extension RenderView: WKScriptMessageHandler { let rawURL = body["url"] as? String, let url = URL(string: rawURL) else { return nil } - + self.id = id self.url = url } } + + /// Sent from the web view to report image loading progress. + struct ImageLoadProgress: RenderViewMessage { + static let messageName = "imageLoadProgress" + + /// Number of images loaded so far + let loaded: Int + + /// Total number of images to load + let total: Int + + /// Whether all images have finished loading + let complete: Bool + + init?(rawMessage: WKScriptMessage, in renderView: RenderView) { + assert(rawMessage.name == Self.messageName) + guard let body = rawMessage.body as? [String: Any], + let loaded = body["loaded"] as? Int, + let total = body["total"] as? Int, + let complete = body["complete"] as? Bool + else { return nil } + + self.loaded = loaded + self.total = total + self.complete = complete + } + } } } @@ -325,17 +378,39 @@ extension RenderView { } } } - - func loadLottiePlayer() { + + // MARK: - Private Helpers + + /// Helper to evaluate Awful JavaScript functions with consistent error handling. + private func evalAwfulFunction(_ functionName: String) { Task { do { - try await webView.eval("if (window.Awful) Awful.loadLotties()") + try await webView.eval("if (window.Awful) { Awful.\(functionName)(); }") } catch { - self.mentionError(error, explanation: "could not evaluate loadLotties") + self.mentionError(error, explanation: "could not evaluate \(functionName)") } } } + // MARK: - Image Loading Methods + + /// Applies timeout detection to images that are loading immediately (first 10 images). + /// + /// Monitors download progress and replaces stalled images with dead image badges. + /// This function should be called after the page has loaded to set up monitoring + /// for the initial batch of images. + func applyTimeoutToLoadingImages() { + evalAwfulFunction("applyTimeoutToLoadingImages") + } + + /// Sets up click handler for retry links on failed images. + /// + /// Allows users to retry loading images that timed out or failed to load. + /// The retry mechanism uses the same timeout detection as initial image loading. + func setupRetryHandler() { + evalAwfulFunction("setupRetryHandler") + } + /// iOS 15 and transparent webviews = dark "missing" scroll thumbs, regardless of settings applied /// webview must be transparent to prevent white flashes during content refreshes. setting opaque to true in viewDidAppear helped, but still sometimes produced white flashes. /// instead, we toggle the webview to opaque while it's being scrolled and return it to transparent seconds after diff --git a/AwfulTheming/Sources/AwfulTheming/Stylesheets/_dead-tweet-ghost.less b/AwfulTheming/Sources/AwfulTheming/Stylesheets/_dead-tweet-ghost.less index fd2383e45..82b559092 100644 --- a/AwfulTheming/Sources/AwfulTheming/Stylesheets/_dead-tweet-ghost.less +++ b/AwfulTheming/Sources/AwfulTheming/Stylesheets/_dead-tweet-ghost.less @@ -1,5 +1,6 @@ -.dead-tweet(@tweetLinkColor, @backgroundColor, @ghostColor) { - .dead-tweet-container { +// Generic mixin for all dead embeds (tweets, images, etc.) +.dead-embed(@linkColor, @backgroundColor, @ghostColor) { + .dead-embed-container { display: grid; grid-column: 2; place-items: start; @@ -14,7 +15,7 @@ width: 6em; } - .dead-tweet-title { + .dead-embed-title { grid-column: 2; grid-row: 3; place-items: start; @@ -24,16 +25,29 @@ font-weight: 600; } - .dead-tweet-link { + .dead-embed-link { grid-column: 2; grid-row: 4; place-items: start; padding-right: 23px; - color: @tweetLinkColor; + color: @linkColor; font: -apple-system-headline; font-weight: 450; } + .dead-embed-retry { + grid-column: 2; + grid-row: 5; + place-items: start; + padding-right: 23px; + padding-top: 0.5em; + color: @linkColor; + font: -apple-system-body; + font-weight: 600; + text-decoration: underline; + cursor: pointer; + } + .ghost-fill { fill: @ghostColor; } @@ -45,4 +59,22 @@ .ghost-stroke { stroke: @ghostColor; } +} + +// Backward-compatible wrapper for dead tweets +.dead-tweet(@tweetLinkColor, @backgroundColor, @ghostColor) { + .dead-embed(@tweetLinkColor, @backgroundColor, @ghostColor); + + // Aliases for backward compatibility with existing dead tweet code + .dead-tweet-container { + .dead-embed-container(); + } + + .dead-tweet-title { + .dead-embed-title(); + } + + .dead-tweet-link { + .dead-embed-link(); + } } \ No newline at end of file