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 =
`
-
-
+
+ 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