infiniteScroll.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
  2. // SPDX-License-Identifier: AGPL-3.0-only
  3. function insertBeforeLast(node, elem) {
  4. node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
  5. }
  6. function getLoadMore(doc) {
  7. return doc.querySelector(".show-more:not(.timeline-item)");
  8. }
  9. function getHrefs(selector) {
  10. return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
  11. }
  12. function getTweetId(item) {
  13. const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
  14. return m ? m[1] : "";
  15. }
  16. function isDuplicate(item, hrefs) {
  17. return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
  18. }
  19. const GAP = 10;
  20. class Masonry {
  21. constructor(container) {
  22. this.container = container;
  23. const colSizes = {
  24. small: w => Math.max(130, w * 0.11),
  25. medium: w => Math.max(190, Math.min(350, w * 0.22)),
  26. large: w => Math.max(350, Math.min(480, w * 0.22)),
  27. };
  28. const size = container.dataset.colSize || "medium";
  29. this._targetWidth = colSizes[size] || colSizes.medium;
  30. this.colHeights = [];
  31. this.colCounts = [];
  32. this.colCount = 0;
  33. this._lastWidth = 0;
  34. this._colWidthCache = 0;
  35. this._items = [];
  36. this._revealTimer = null;
  37. this.container.classList.add("masonry-active");
  38. let resizeTimer;
  39. window.addEventListener("resize", () => {
  40. clearTimeout(resizeTimer);
  41. resizeTimer = setTimeout(() => this._rebuild(), 50);
  42. });
  43. // Re-sync positions whenever images finish loading and items grow taller.
  44. // Must be set up before _rebuild() so initial items get observed on first pass.
  45. let syncTimer;
  46. this._observer = window.ResizeObserver ? new ResizeObserver(() => {
  47. clearTimeout(syncTimer);
  48. syncTimer = setTimeout(() => this.syncHeights(), 100);
  49. }) : null;
  50. this._rebuild();
  51. }
  52. // Reveal all items and gallery siblings (show-more, top-ref). Idempotent.
  53. _revealAll() {
  54. clearTimeout(this._revealTimer);
  55. for (const item of this._items) item.classList.add("masonry-visible");
  56. for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer"))
  57. el.classList.add("masonry-visible");
  58. }
  59. // Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images.
  60. _pickCol() {
  61. return this.colHeights.reduce((min, h, i) => {
  62. const m = this.colHeights[min];
  63. return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min;
  64. }, 0);
  65. }
  66. // Position items using current column state. Updates colHeights, colCounts, container height.
  67. _position(items, heights, colWidth) {
  68. for (let i = 0; i < items.length; i++) {
  69. const col = this._pickCol();
  70. items[i].style.left = `${col * (colWidth + GAP)}px`;
  71. items[i].style.top = `${this.colHeights[col]}px`;
  72. this.colHeights[col] += heights[i] + GAP;
  73. this.colCounts[col]++;
  74. }
  75. this.container.style.height = `${Math.max(0, ...this.colHeights)}px`;
  76. }
  77. // Full reset and re-place all items.
  78. _place(items, heights, n, colWidth) {
  79. this.colHeights = new Array(n).fill(0);
  80. this.colCounts = new Array(n).fill(0);
  81. this.colCount = n;
  82. this._position(items, heights, colWidth);
  83. }
  84. _rebuild() {
  85. const w = this.container.clientWidth;
  86. const n = Math.max(1, Math.floor(w / this._targetWidth(w)));
  87. if (n === this.colCount && w === this._lastWidth) return;
  88. const isFirst = this.colCount === 0;
  89. if (isFirst) {
  90. this._items = [...this.container.querySelectorAll(".timeline-item")];
  91. }
  92. // Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings).
  93. this._items.sort((a, b) => {
  94. const idA = getTweetId(a), idB = getTweetId(b);
  95. if (idA.length !== idB.length) return idB.length - idA.length;
  96. return idB < idA ? -1 : idB > idA ? 1 : 0;
  97. });
  98. // Pre-set widths BEFORE reading heights so measurements reflect the new column width.
  99. const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n);
  100. for (const item of this._items) item.style.width = `${colWidth}px`;
  101. this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth);
  102. this._lastWidth = w;
  103. if (isFirst) {
  104. if (this._observer) this._items.forEach(item => this._observer.observe(item));
  105. // Reveal immediately if all images are cached, else wait for syncHeights.
  106. const hasUnloaded = this._items.some(item =>
  107. [...item.querySelectorAll("img")].some(img => !img.complete));
  108. if (hasUnloaded) {
  109. this._revealTimer = setTimeout(() => this._revealAll(), 1000);
  110. } else {
  111. this._revealAll();
  112. }
  113. }
  114. }
  115. // Re-read actual heights and re-place all items. Fixes drift after images load.
  116. syncHeights() {
  117. this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache);
  118. this._revealAll();
  119. }
  120. // Batch-add items in three phases to avoid O(N) reflows:
  121. // 1. writes: set widths, append all — no reads, no reflows
  122. // 2. one read: batch offsetHeight
  123. // 3. writes: assign columns, set left/top
  124. addAll(newItems) {
  125. if (!newItems.length) return;
  126. const colWidth = this._colWidthCache;
  127. for (const item of newItems) {
  128. item.style.width = `${colWidth}px`;
  129. this.container.appendChild(item);
  130. }
  131. this._position(newItems, newItems.map(item => item.offsetHeight), colWidth);
  132. this._items.push(...newItems);
  133. if (this._observer) newItems.forEach(item => this._observer.observe(item));
  134. }
  135. }
  136. document.addEventListener("DOMContentLoaded", function () {
  137. const isTweet = location.pathname.includes("/status/");
  138. const containerClass = isTweet ? ".replies" : ".timeline";
  139. const itemClass = containerClass + " > div:not(.top-ref)";
  140. const html = document.documentElement;
  141. const container = document.querySelector(containerClass);
  142. const masonryEl = container?.querySelector(".gallery-masonry");
  143. const masonry = masonryEl ? new Masonry(masonryEl) : null;
  144. let loading = false;
  145. function handleScroll(failed) {
  146. if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return;
  147. const loadMore = getLoadMore(document);
  148. if (!loadMore) return;
  149. loading = true;
  150. loadMore.children[0].text = "Loading...";
  151. const url = new URL(loadMore.children[0].href);
  152. url.searchParams.append("scroll", "true");
  153. fetch(url)
  154. .then(r => {
  155. if (r.status > 299) throw new Error("error");
  156. return r.text();
  157. })
  158. .then(responseText => {
  159. const doc = new DOMParser().parseFromString(responseText, "text/html");
  160. loadMore.remove();
  161. if (masonry) {
  162. masonry.syncHeights();
  163. const newMasonry = doc.querySelector(".gallery-masonry");
  164. if (newMasonry) {
  165. const knownHrefs = getHrefs(".gallery-masonry .tweet-link");
  166. masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs)));
  167. }
  168. } else {
  169. const knownHrefs = getHrefs(`${itemClass} .tweet-link`);
  170. for (const item of doc.querySelectorAll(itemClass)) {
  171. if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue;
  172. isTweet ? container.appendChild(item) : insertBeforeLast(container, item);
  173. }
  174. }
  175. loading = false;
  176. const newLoadMore = getLoadMore(doc);
  177. if (newLoadMore) {
  178. isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore);
  179. if (masonry) newLoadMore.classList.add("masonry-visible");
  180. }
  181. })
  182. .catch(err => {
  183. console.warn("Something went wrong.", err);
  184. if (failed > 3) { loadMore.children[0].text = "Error"; return; }
  185. loading = false;
  186. handleScroll((failed || 0) + 1);
  187. });
  188. }
  189. window.addEventListener("scroll", () => handleScroll());
  190. });
  191. // @license-end