From 0dd7ad815f0b05184a8b54bee6aba750a7b6ef20 Mon Sep 17 00:00:00 2001
From: commiekong <30882689+dfsm@users.noreply.github.com>
Date: Wed, 19 Nov 2025 20:45:23 +1100
Subject: [PATCH 01/13] LoadingView and Image Lazy Loading - Add an X button to
Loading View to allow exiting out of V spinner view early - First attempt to
add lazy loading to images - Attempt to add timeouts and in case of failure
set "Dead Image" badge similar to Dead Tweets
---
App/Misc/HTMLRenderingHelpers.swift | 40 +-
App/Resources/RenderView.js | 734 +++++++++++++++---
.../Posts/PostsPageView.swift | 4 +-
.../Posts/PostsPageViewController.swift | 33 +-
App/Views/LoadingView.swift | 81 +-
App/Views/RenderView.swift | 91 ++-
.../Stylesheets/_dead-tweet-ghost.less | 42 +-
7 files changed, 862 insertions(+), 163 deletions(-)
diff --git a/App/Misc/HTMLRenderingHelpers.swift b/App/Misc/HTMLRenderingHelpers.swift
index 50f66aa98..e3339e33b 100644
--- a/App/Misc/HTMLRenderingHelpers.swift
+++ b/App/Misc/HTMLRenderingHelpers.swift
@@ -138,8 +138,12 @@ 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
+ let initialLoadCount = 10 // First 10 post content images load immediately
+
for img in nodes(matchingSelector: "img") {
guard
let src = img["src"],
@@ -150,10 +154,38 @@ extension HTMLDocument {
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"
+
+ if !isAvatar && !isAttachment {
+ // This is a post content image (not avatar, not smilie, not attachment)
+ postContentImageCount += 1
+
+ if postContentImageCount > initialLoadCount {
+ // Defer loading for images beyond the first 10
+ img["data-lazy-src"] = src
+ img["src"] = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
+ }
+ }
+
+ // Apply URL fixes
+ if let postimageURL = fixPostimageURL(url) {
+ if postContentImageCount <= initialLoadCount || isAvatar {
+ img["src"] = postimageURL.absoluteString
+ } else {
+ img["data-lazy-src"] = postimageURL.absoluteString
+ }
+ } else if let waffleURL = randomwaffleURLForWaffleimagesURL(url) {
+ if postContentImageCount <= initialLoadCount || isAvatar {
+ img["src"] = waffleURL.absoluteString
+ } else {
+ img["data-lazy-src"] = waffleURL.absoluteString
+ }
+ }
}
if shouldLinkifyNonSmilies, !isSmilie {
diff --git a/App/Resources/RenderView.js b/App/Resources/RenderView.js
index 24f7ffac2..cbd63c38f 100644
--- a/App/Resources/RenderView.js
+++ b/App/Resources/RenderView.js
@@ -34,6 +34,100 @@ Awful.fetchOEmbed = async function(url) {
});
};
+/**
+ * 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) {
+ if (!thisPostElement.classList.contains("embed-processed")) {
+ thisPostElement.classList.add("embed-processed");
+
+ const enableGhost = (window.Awful.renderGhostTweets == true);
+ const tweetLinks = thisPostElement.querySelectorAll('a[data-tweet-id]');
+
+ if (tweetLinks.length == 0) {
+ return;
+ }
+
+ // Group tweet links by ID for deduplication
+ const tweetIDsToLinks = {};
+ Array.prototype.forEach.call(tweetLinks, function(a) {
+ // Skip tweets with NWS content
+ 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);
+ });
+
+ // Fetch and embed each unique tweet
+ Object.keys(tweetIDsToLinks).forEach(function(tweetID) {
+ const callback = `jsonp_callback_${tweetID}`;
+ const tweetTheme = Awful.tweetTheme();
+
+ const 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}`;
+
+ window[callback] = function(data) {
+ cleanUp(script);
+
+ // Replace all links for this tweet with the 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 to render the embedded tweets
+ if (window.twttr) {
+ twttr.widgets.load();
+ }
+ };
+
+ script.onerror = function() {
+ cleanUp(this);
+ console.error(`The embed markup for tweet ${tweetID} failed to load`);
+
+ // Replace failed tweets with ghost Lottie animation
+ if (enableGhost) {
+ tweetIDsToLinks[tweetID].forEach(function(a) {
+ if (a.parentNode) {
+ const 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", () => {
+ lottiePlayer.load(document.getElementById("ghost-json-data").innerText);
+ });
+ });
+ }
+ });
+ }
+ };
+
+ function cleanUp(script) {
+ delete window[callback];
+ if (script.parentNode) {
+ script.parentNode.removeChild(script);
+ }
+ }
+
+ document.body.appendChild(script);
+ });
+ }
+};
+
/**
Callback for fetchOEmbed.
@@ -82,145 +176,510 @@ 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);
- // if ghost is enabled, add IntersectionObserver so that we know when to play and stop the animations
+ // 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,
+ const ghostConfig = {
+ root: document.body.posts,
+ rootMargin: '0px',
+ threshold: 0.000001,
};
-
- 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) => {
+
+ const ghostObserver = new IntersectionObserver(function(posts) {
+ posts.forEach((post) => {
+ const players = post.target.querySelectorAll("lottie-player");
+ 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("post");
+ postElements.forEach((post) => {
+ ghostObserver.observe(post);
});
- }
-
- var tweetLinks = document.querySelectorAll('a[data-tweet-id]');
- if (tweetLinks.length == 0) {
- return;
+
+ // Apply timeout detection to initial images (first 10)
+ Awful.applyTimeoutToLoadingImages();
+
+ // Setup lazy loading for deferred images (11+)
+ Awful.setupImageLazyLoading();
+
+ // Setup retry handler
+ Awful.setupRetryHandler();
}
- var tweetIDsToLinks = {};
- Array.prototype.forEach.call(tweetLinks, function(a) {
- if (a.parentElement.querySelector('img.awful-smile[title=":nws:"]')) {
- return;
- }
- var tweetID = a.dataset.tweetId;
- if (!(tweetID in tweetIDsToLinks)) {
- tweetIDsToLinks[tweetID] = [];
- }
- tweetIDsToLinks[tweetID].push(a);
+ // Set up lazy-loading IntersectionObserver for tweet embeds
+ // 600px rootMargin means tweets are loaded ~600px before entering the viewport
+ const lazyLoadConfig = {
+ root: null,
+ rootMargin: '600px 0px',
+ threshold: 0.000001,
+ };
+
+ const lazyLoadObserver = 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("post");
+ posts.forEach((post) => {
+ lazyLoadObserver.observe(post);
});
- var totalFetchCount = Object.keys(tweetIDsToLinks).length;
- var completedFetchCount = 0;
+ // 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({});
+ });
+ }
+ });
+ }
+};
- Object.keys(tweetIDsToLinks).forEach(function(tweetID) {
- var callback = `jsonp_callback_${tweetID}`;
- var tweetTheme = Awful.tweetTheme();
+// Image load progress tracker
+Awful.imageLoadTracker = {
+ loaded: 0,
+ total: 0,
+
+ initialize: function(totalCount) {
+ this.loaded = 0;
+ this.total = totalCount;
+ this.reportProgress();
+ },
+
+ 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 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}`;
+// Image loading with smart timeout detection
+// Monitors download progress and only times out stalled connections
+Awful.loadImageWithProgressDetection = async function(url, img, imageID, enableGhost) {
+ const initialTimeout = 1000; // 1s timeout if no bytes received
+ const stallTimeout = 2500; // 2.5s timeout if download stalls
+ const heartbeatInterval = 500; // Check progress every 500ms
+
+ const controller = new AbortController();
+ let lastProgressTime = Date.now();
+ let totalBytes = 0;
+ let heartbeatTimer = null;
+
+ // Heartbeat check: abort if no progress
+ heartbeatTimer = setInterval(() => {
+ const timeSinceProgress = Date.now() - lastProgressTime;
+
+ if (totalBytes === 0 && timeSinceProgress > initialTimeout) {
+ // No bytes received at all - connection never started
+ console.warn(`Image timeout: no connection after ${initialTimeout}ms - ${url}`);
+ clearInterval(heartbeatTimer);
+ controller.abort();
+ } else if (totalBytes > 0 && timeSinceProgress > stallTimeout) {
+ // Download started but stalled
+ console.warn(`Image stalled: no progress for ${stallTimeout}ms after ${totalBytes} bytes - ${url}`);
+ clearInterval(heartbeatTimer);
+ controller.abort();
+ }
+ }, heartbeatInterval);
- window[callback] = function(data) {
- cleanUp(script);
+ try {
+ const response = await fetch(url, { signal: controller.signal });
- 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);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
}
- });
- didCompleteFetch();
- };
+ const reader = response.body.getReader();
+ const chunks = [];
- 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();
- };
+ while (true) {
+ const { done, value } = await reader.read();
- function cleanUp(script) {
- delete window[callback];
- if (script.parentNode) {
- script.parentNode.removeChild(script);
- }
+ if (done) break;
+
+ // Bytes received! Update progress tracking
+ chunks.push(value);
+ totalBytes += value.length;
+ lastProgressTime = Date.now();
+ }
+
+ clearInterval(heartbeatTimer);
+
+ // Success! Create blob and set image source
+ const blob = new Blob(chunks);
+ const objectURL = URL.createObjectURL(blob);
+ img.src = objectURL;
+
+ // Clean up object URL after image loads
+ img.onload = () => {
+ URL.revokeObjectURL(objectURL);
+ Awful.imageLoadTracker.incrementLoaded();
+ };
+
+ } catch (error) {
+ clearInterval(heartbeatTimer);
+
+ // Replace image with dead image badge
+ 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);
+
+ // Setup Lottie animation
+ const player = div.querySelector("lottie-player");
+ if (player) {
+ player.addEventListener("rendered", () => {
+ const ghostData = document.getElementById("ghost-json-data");
+ if (ghostData) {
+ player.load(ghostData.innerText);
+ }
+ });
+ }
+ }
+
+ console.error(`Image load failed: ${error.message} - ${url}`);
+ Awful.imageLoadTracker.incrementLoaded();
}
+};
- document.body.appendChild(script);
- });
+// Main function to apply timeout detection to all post images
+Awful.loadImagesWithTimeout = function() {
+ const enableGhost = Awful.renderGhostTweets || false;
+
+ // Only process images in post content, exclude avatars and smilies
+ const contentImages = document.querySelectorAll('section.postbody img:not(.awful-smile):not(.awful-avatar)');
- function didCompleteFetch() {
- completedFetchCount += 1;
+ contentImages.forEach((img, index) => {
+ const imageID = `img-${Date.now()}-${index}`;
- if (completedFetchCount == totalFetchCount) {
- if (window.twttr) {
- twttr.ready(function() {
- twttr.widgets.load();
+ // Skip if already loaded
+ if (img.complete && img.naturalHeight !== 0) {
+ return;
+ }
+
+ // Clear the src to prevent default loading, we'll handle it with fetch
+ const originalSrc = img.src;
+ img.removeAttribute('src');
+
+ // Load with progress detection
+ Awful.loadImageWithProgressDetection(originalSrc, img, imageID, enableGhost);
+ });
+
+ // Setup retry click handler (using event delegation)
+ document.addEventListener('click', function(event) {
+ const retryLink = event.target;
+ if (retryLink.hasAttribute('data-retry-image')) {
+ event.preventDefault();
+
+ const imageURL = retryLink.getAttribute('data-retry-image');
+ const imageID = retryLink.getAttribute('data-image-id');
+ const container = retryLink.closest('.dead-embed-container');
+
+ if (container) {
+ const img = document.createElement('img');
+ img.setAttribute('alt', '');
+ container.parentNode.replaceChild(img, container);
+
+ // Retry loading with timeout detection
+ Awful.loadImageWithProgressDetection(imageURL, img, imageID, enableGhost);
+ }
+ }
+ }, { once: false });
+};
+
+// Lazy loads post content images using IntersectionObserver (for images 11+)
+Awful.setupImageLazyLoading = function() {
+ const enableGhost = Awful.renderGhostTweets || false;
+
+ // IntersectionObserver with 600px lookahead (same as tweets)
+ const imageObserver = new IntersectionObserver(function(entries) {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ const img = entry.target;
+ const lazySrc = img.dataset.lazySrc;
+
+ if (lazySrc) {
+ // Skip attachment.php files (defensive check, shouldn't be lazy-loaded)
+ if (lazySrc.includes('attachment.php')) {
+ delete img.dataset.lazySrc;
+ img.src = lazySrc; // Load normally without timeout
+ imageObserver.unobserve(img);
+ return;
+ }
+
+ const imageID = `lazy-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
+ delete img.dataset.lazySrc;
+
+ // Load with smart timeout detection
+ Awful.loadImageWithProgressDetection(lazySrc, img, imageID, enableGhost);
+
+ imageObserver.unobserve(img);
+ }
+ }
});
+ }, {
+ rootMargin: '600px 0px', // Load 600px before entering viewport
+ threshold: 0.01
+ });
+
+ // Observe all lazy-loadable images
+ document.querySelectorAll('img[data-lazy-src]').forEach(img => {
+ imageObserver.observe(img);
+ });
+};
- if (webkit.messageHandlers.didFinishLoadingTweets) {
- twttr.events.bind('loaded', function() {
- webkit.messageHandlers.didFinishLoadingTweets.postMessage({});
- });
+// Apply timeout detection to images that are loading normally (first 10)
+Awful.applyTimeoutToLoadingImages = function() {
+ const enableGhost = Awful.renderGhostTweets || false;
+
+ // Find images with real src (not data-lazy-src) - these are the first 10 images
+ const loadingImages = document.querySelectorAll('section.postbody img[src]:not(.awful-smile):not(.awful-avatar):not([data-lazy-src])');
+
+ // 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;
+
+ // Initialize progress tracker (only tracks first 10 images, not lazy-loaded ones)
+ Awful.imageLoadTracker.initialize(totalImages);
+
+ loadingImages.forEach((img, index) => {
+ const imageID = `img-${Date.now()}-${index}`;
+ const imageURL = img.src;
+
+ // Skip data URLs (placeholders)
+ if (imageURL.startsWith('data:')) {
+ return;
}
- }
- }
- }
+
+ // Skip attachment.php files (require auth, handled elsewhere)
+ if (imageURL.includes('attachment.php')) {
+ return;
+ }
+
+ // Skip if already loaded (but count it)
+ if (img.complete && img.naturalHeight !== 0) {
+ Awful.imageLoadTracker.incrementLoaded();
+ return;
+ }
+
+ // Let browser load the image naturally, but monitor for timeout
+ // Track if we've already handled this image to prevent double-counting
+ let handled = false;
+
+ const handleSuccess = () => {
+ if (handled) return;
+ handled = true;
+ Awful.imageLoadTracker.incrementLoaded();
+ };
+
+ const handleFailure = () => {
+ if (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);
+
+ const player = div.querySelector("lottie-player");
+ if (player) {
+ player.addEventListener("rendered", () => {
+ const ghostData = document.getElementById("ghost-json-data");
+ if (ghostData) {
+ player.load(ghostData.innerText);
+ }
+ });
+ }
+ }
+
+ Awful.imageLoadTracker.incrementLoaded();
+ };
+
+ // Set up timeout checker
+ let checkCount = 0;
+ const maxChecks = 3; // Check 3 times (at 1s, 2s, 3s)
+ const checkInterval = 1000; // Check every 1 second
+
+ const timeoutChecker = setInterval(() => {
+ checkCount++;
+
+ // If image loaded successfully
+ if (img.complete && img.naturalHeight !== 0) {
+ clearInterval(timeoutChecker);
+ handleSuccess();
+ return;
+ }
+
+ // If image failed to load (error state)
+ if (img.complete && img.naturalHeight === 0) {
+ clearInterval(timeoutChecker);
+ handleFailure('failed');
+ return;
+ }
+
+ // If we've checked enough times and it's still not loaded, timeout
+ if (checkCount >= maxChecks) {
+ clearInterval(timeoutChecker);
+ handleFailure(`timed out after ${checkCount * checkInterval}ms`);
+ }
+ }, checkInterval);
+
+ // Also listen for load/error events to handle immediately
+ img.addEventListener('load', () => {
+ clearInterval(timeoutChecker);
+ handleSuccess();
+ }, { once: true });
+
+ img.addEventListener('error', () => {
+ clearInterval(timeoutChecker);
+ handleFailure('error event');
+ }, { once: true });
+ });
+};
+
+// Setup retry click handler (using event delegation) - call once on page load
+Awful.setupRetryHandler = function() {
+ document.addEventListener('click', 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 hidden image element to test loading
+ const img = document.createElement('img');
+ img.style.display = 'none';
+ container.parentNode.insertBefore(img, container);
+
+ // Custom retry with success/failure callbacks
+ const retryWithFeedback = async function() {
+ const initialTimeout = 1000;
+ const stallTimeout = 2500;
+ const heartbeatInterval = 500;
+
+ const controller = new AbortController();
+ let lastProgressTime = Date.now();
+ let totalBytes = 0;
+ let heartbeatTimer = null;
+
+ heartbeatTimer = setInterval(() => {
+ const timeSinceProgress = Date.now() - lastProgressTime;
+
+ if (totalBytes === 0 && timeSinceProgress > initialTimeout) {
+ console.warn(`Retry failed: no connection after ${initialTimeout}ms - ${imageURL}`);
+ clearInterval(heartbeatTimer);
+ controller.abort();
+ } else if (totalBytes > 0 && timeSinceProgress > stallTimeout) {
+ console.warn(`Retry failed: stalled after ${totalBytes} bytes - ${imageURL}`);
+ clearInterval(heartbeatTimer);
+ controller.abort();
+ }
+ }, heartbeatInterval);
+
+ try {
+ const response = await fetch(imageURL, { signal: controller.signal });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const reader = response.body.getReader();
+ const chunks = [];
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ chunks.push(value);
+ totalBytes += value.length;
+ lastProgressTime = Date.now();
+ }
+
+ clearInterval(heartbeatTimer);
+
+ // SUCCESS! Create blob and replace badge with image
+ const blob = new Blob(chunks);
+ const objectURL = URL.createObjectURL(blob);
+
+ const successImg = document.createElement('img');
+ successImg.setAttribute('alt', '');
+ successImg.src = objectURL;
+
+ successImg.onload = () => {
+ URL.revokeObjectURL(objectURL);
+ // Replace the container with the successful image
+ container.parentNode.replaceChild(successImg, container);
+ // Remove the hidden test image
+ if (img.parentNode) {
+ img.parentNode.removeChild(img);
+ }
+ };
+
+ } catch (error) {
+ clearInterval(heartbeatTimer);
+
+ // FAILED - restore retry button with "Failed" feedback
+ console.error(`Retry failed: ${error.message} - ${imageURL}`);
+
+ retryLink.textContent = 'Retry Failed - Try Again';
+ retryLink.style.pointerEvents = 'auto'; // Re-enable clicking
+
+ // Remove the hidden test image
+ if (img.parentNode) {
+ img.parentNode.removeChild(img);
+ }
+
+ // Reset to just "Retry" after 3 seconds
+ setTimeout(() => {
+ if (retryLink.textContent === 'Retry Failed - Try Again') {
+ retryLink.textContent = 'Retry';
+ }
+ }, 3000);
+ }
+ };
+
+ retryWithFeedback();
+ }
+ }
+ }, { once: false });
};
Awful.tweetTheme = function() {
@@ -259,20 +718,6 @@ Awful.loadTwitterWidgets = function() {
};
};
-/**
- 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.
@@ -726,7 +1171,7 @@ Awful.setAnnouncementHTML = function(html) {
Awful.deadTweetBadgeHTML = function(url, tweetID){
// get twitter username from url
var tweeter = url.match(/(?:https?:\/\/)?(?:www\.)?twitter\.com\/(?:#!\/)?@?([^\/\?\s]*)/)[1];
-
+
var html =
`
+ Retry
`;
return html;
From 9b5eba35b918064b44178863f95425d12d1d6eff Mon Sep 17 00:00:00 2001
From: commiekong <30882689+dfsm@users.noreply.github.com>
Date: Sun, 21 Dec 2025 12:11:24 +1100
Subject: [PATCH 13/13] Final code quality pass (added a new named constant for
connectionTimeout in RenderView.js). Added optional show/hide configuration
option for the newly introduced LoadingView status message and exit button.
This will now display for loading threads but not for loading while
previewing new posts or private messages.
---
App/Resources/RenderView.js | 14 ++++--
.../Messages/MessageViewController.swift | 2 +-
.../Posts/PostPreviewViewController.swift | 4 +-
.../Posts/PostsPageViewController.swift | 4 +-
.../Threads/ThreadPreviewViewController.swift | 4 +-
App/Views/LoadingView.swift | 50 ++++++++++++-------
6 files changed, 50 insertions(+), 28 deletions(-)
diff --git a/App/Resources/RenderView.js b/App/Resources/RenderView.js
index 922444fd6..6cd4ab4f7 100644
--- a/App/Resources/RenderView.js
+++ b/App/Resources/RenderView.js
@@ -34,13 +34,21 @@ const IMAGE_LOAD_TIMEOUT_CONFIG = {
/// 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
+ 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
/**
@@ -467,7 +475,7 @@ Awful.embedTweets = function() {
const ghostConfig = {
root: document.body.posts,
rootMargin: '0px',
- threshold: 0.000001,
+ threshold: INTERSECTION_THRESHOLD_MIN,
};
Awful.ghostLottieObserver = new IntersectionObserver(function(posts) {
@@ -507,7 +515,7 @@ Awful.embedTweets = function() {
const lazyLoadConfig = {
root: null,
rootMargin: `${LAZY_LOAD_LOOKAHEAD_DISTANCE} 0px`,
- threshold: 0.000001,
+ threshold: INTERSECTION_THRESHOLD_MIN,
};
Awful.tweetLazyLoadObserver = new IntersectionObserver(function(entries) {
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/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift
index 25098c6cb..e29dc6971 100644
--- a/App/View Controllers/Posts/PostsPageViewController.swift
+++ b/App/View Controllers/Posts/PostsPageViewController.swift
@@ -670,7 +670,7 @@ final class PostsPageViewController: ViewController {
private func showLoadingView() {
guard postsView.loadingView == nil else { return }
- let loadingView = LoadingView.loadingViewWithTheme(theme)
+ let loadingView = LoadingView.loadingViewWithTheme(theme, configuration: .showStatusElements)
loadingView.updateStatus("Loading...")
loadingView.onDismiss = { [weak self] in
self?.clearLoadingMessage()
@@ -1507,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()
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 f299af48f..4efc3d2e0 100644
--- a/App/Views/LoadingView.swift
+++ b/App/Views/LoadingView.swift
@@ -7,6 +7,15 @@ 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 {
@@ -19,30 +28,32 @@ class LoadingView: UIView {
// MARK: - Properties
fileprivate let theme: Theme?
+ let configuration: LoadingViewConfiguration
- fileprivate init(theme: Theme?) {
+ 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)
}
}
@@ -71,7 +82,7 @@ private class DefaultLoadingView: LoadingView {
private let showNowButton: UIButton
private var visibilityTimer: Timer?
- override init(theme: Theme?) {
+ override init(theme: Theme?, configuration: LoadingViewConfiguration) {
animationView = LottieAnimationView(
animation: LottieAnimation.named("mainthrobber60"),
configuration: LottieConfiguration(renderingEngine: .mainThread))
@@ -79,7 +90,7 @@ private class DefaultLoadingView: LoadingView {
statusLabel = UILabel()
showNowButton = UIButton(type: .system)
- super.init(theme: theme)
+ super.init(theme: theme, configuration: configuration)
// Setup animation view
animationView.currentFrame = 0
@@ -176,6 +187,9 @@ private class DefaultLoadingView: LoadingView {
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
@@ -205,9 +219,9 @@ private class DefaultLoadingView: LoadingView {
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
@@ -271,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)
@@ -302,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)