Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Pin post thread view scroll

Grace Kind (Jul 15, 2026, 1:13 AM -0500) 64399182 e85a1e46

+302 -47
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.157", 3 + "version": "0.17.158", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+56
src/js/utils.js
··· 606 606 } 607 607 } 608 608 609 + export function pinScrollPosition({ 610 + targetY, 611 + durationMs = 1000, 612 + scroller = null, 613 + shouldStop = null, 614 + } = {}) { 615 + const listenTarget = scroller ?? window; 616 + const readScrollY = () => (scroller ? scroller.scrollTop : window.scrollY); 617 + const writeScrollY = (y) => { 618 + if (scroller) { 619 + scroller.scrollTop = y; 620 + } else { 621 + window.scrollTo(0, y); 622 + } 623 + }; 624 + let stopped = false; 625 + let lastPinnedY = null; 626 + const stop = () => { 627 + stopped = true; 628 + listenTarget.removeEventListener("touchmove", stop); 629 + listenTarget.removeEventListener("wheel", stop); 630 + window.removeEventListener("keydown", stop); 631 + window.removeEventListener("page-transition", stop); 632 + }; 633 + const startTime = performance.now(); 634 + const step = () => { 635 + if (stopped) { 636 + return; 637 + } 638 + if (shouldStop && shouldStop(readScrollY(), lastPinnedY)) { 639 + stop(); 640 + return; 641 + } 642 + const currentTargetY = typeof targetY === "function" ? targetY() : targetY; 643 + if (currentTargetY === null) { 644 + stop(); 645 + return; 646 + } 647 + if (Math.abs(readScrollY() - currentTargetY) >= 1) { 648 + writeScrollY(currentTargetY); 649 + } 650 + lastPinnedY = readScrollY(); 651 + if (performance.now() - startTime < durationMs) { 652 + requestAnimationFrame(step); 653 + } else { 654 + stop(); 655 + } 656 + }; 657 + step(); 658 + listenTarget.addEventListener("touchmove", stop, { passive: true }); 659 + listenTarget.addEventListener("wheel", stop, { passive: true }); 660 + window.addEventListener("keydown", stop); 661 + window.addEventListener("page-transition", stop); 662 + return stop; 663 + } 664 + 609 665 const LONG_PRESS_TIMEOUT_MS = 500; 610 666 const LONG_PRESS_MOVE_CANCEL_THRESHOLD_PX = 10; 611 667 const LONG_PRESS_GHOST_CLICK_WINDOW_MS = 400;
+9 -28
src/js/views/chatDetail.view.js
··· 31 31 differenceInMinutes, 32 32 isMobileViewport, 33 33 canHover, 34 + pinScrollPosition, 34 35 } from "/js/utils.js"; 35 36 import { Signal, ReactiveStore } from "/js/signals.js"; 36 37 import { getPermalinkForConvo } from "/js/navigation.js"; ··· 306 307 if (!scroller) { 307 308 return; 308 309 } 309 - let stopped = false; 310 - const stop = () => { 311 - stopped = true; 312 - }; 313 - const startTime = performance.now(); 314 - let lastPinnedScrollTop = null; 315 - const step = () => { 316 - if (stopped) { 317 - return; 318 - } 310 + pinScrollPosition({ 311 + targetY: () => scroller.scrollHeight - scroller.clientHeight, 312 + durationMs, 313 + scroller, 319 314 // If the position moved above where we last pinned it, the user 320 - // (or other code) scrolled up - stop fighting them 321 - if ( 322 - lastPinnedScrollTop !== null && 323 - scroller.scrollTop < lastPinnedScrollTop - 1 324 - ) { 325 - return; 326 - } 327 - scroller.scrollTop = scroller.scrollHeight; 328 - lastPinnedScrollTop = scroller.scrollTop; 329 - if (performance.now() - startTime < durationMs) { 330 - requestAnimationFrame(step); 331 - } 332 - }; 333 - step(); 334 - scroller.addEventListener("touchmove", stop, { 335 - once: true, 336 - passive: true, 315 + // (or other code, e.g. a reply-jump scrollIntoView) scrolled up - 316 + // stop fighting them 317 + shouldStop: (currentY, lastPinnedY) => 318 + lastPinnedY !== null && currentY < lastPinnedY - 1, 337 319 }); 338 - scroller.addEventListener("wheel", stop, { once: true, passive: true }); 339 320 } 340 321 341 322 function isScrolledToBottom() {
+32 -17
src/js/views/postThread.view.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { avatarTemplate } from "/js/templates/avatar.template.js"; 3 - import { sortBy } from "/js/utils.js"; 3 + import { sortBy, pinScrollPosition } from "/js/utils.js"; 4 4 import { pageEffect } from "/js/router.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 6 import { smallPostTemplate } from "/js/templates/smallPost.template.js"; ··· 560 560 } 561 561 }); 562 562 563 - function scrollToLargePost(largePost, header) { 563 + function getLargePostPinOffset(largePost, header) { 564 564 const headerHeight = header.getBoundingClientRect().height; 565 - const largePostTop = largePost.getBoundingClientRect().top; 566 - const offset = largePostTop - headerHeight; 567 - // The browser clamps scrolling at the document height, so when there 568 - // isn't enough content below the post to pin it under the header, 569 - // stretch the main section to provide the missing scroll runway. 565 + return largePost.getBoundingClientRect().top - headerHeight; 566 + } 567 + 568 + // The browser clamps scrolling at the document height, so when there 569 + // isn't enough content below the post to pin it under the header, 570 + // stretch the main section to provide the missing scroll runway. 571 + function ensureScrollRunway(largePost, header) { 570 572 const mainSection = root.querySelector(".post-thread-main-section"); 571 - if (mainSection) { 572 - const targetScrollY = window.scrollY + offset; 573 - const shortfall = 574 - targetScrollY + 575 - window.innerHeight - 576 - document.documentElement.scrollHeight; 577 - if (shortfall > 0) { 578 - mainSection.style.minHeight = `${mainSection.offsetHeight + shortfall}px`; 579 - } 573 + if (!mainSection) { 574 + return; 575 + } 576 + mainSection.style.minHeight = ""; 577 + const targetScrollY = 578 + window.scrollY + getLargePostPinOffset(largePost, header); 579 + const shortfall = 580 + targetScrollY + 581 + window.innerHeight - 582 + document.documentElement.scrollHeight; 583 + if (shortfall > -1) { 584 + const sectionHeight = mainSection.getBoundingClientRect().height; 585 + mainSection.style.minHeight = `${Math.ceil(sectionHeight + shortfall) + 1}px`; 580 586 } 581 - window.scrollBy(0, offset); 587 + } 588 + 589 + function scrollToLargePost(largePost, header) { 590 + ensureScrollRunway(largePost, header); 591 + pinScrollPosition({ 592 + targetY: () => { 593 + const offset = getLargePostPinOffset(largePost, header); 594 + return window.scrollY + offset; 595 + }, 596 + }); 582 597 } 583 598 584 599 root.addEventListener("page-enter", async () => {
+204 -1
tests/unit/specs/utils.test.js
··· 1 - import { describe, it, beforeEach, afterEach } from "node:test"; 1 + import { describe, it, beforeEach, afterEach, mock } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 3 import { 4 4 unique, ··· 25 25 TimeoutError, 26 26 debounce, 27 27 resetScrollOnBlur, 28 + pinScrollPosition, 28 29 } from "/js/utils.js"; 29 30 30 31 describe("sortBy", () => { ··· 1150 1151 assert.deepEqual(scrollArea.scrollTop, 42); 1151 1152 }); 1152 1153 }); 1154 + 1155 + describe("pinScrollPosition", () => { 1156 + let activeStops; 1157 + let scroller; 1158 + 1159 + beforeEach(() => { 1160 + activeStops = []; 1161 + scroller = document.createElement("div"); 1162 + }); 1163 + 1164 + afterEach(() => { 1165 + for (const stop of activeStops) { 1166 + stop(); 1167 + } 1168 + }); 1169 + 1170 + const startPin = (options) => { 1171 + const stop = pinScrollPosition(options); 1172 + activeStops.push(stop); 1173 + return stop; 1174 + }; 1175 + 1176 + it("evaluates the target synchronously and again on later frames", async () => { 1177 + const getTargetY = mock.fn(() => 0); 1178 + startPin({ 1179 + targetY: getTargetY, 1180 + durationMs: 30, 1181 + scroller, 1182 + }); 1183 + assert.equal(getTargetY.mock.callCount(), 1); 1184 + await wait(60); 1185 + assert(getTargetY.mock.callCount() > 1); 1186 + }); 1187 + 1188 + it("scrolls the scroller to the target", () => { 1189 + startPin({ 1190 + targetY: () => 100, 1191 + durationMs: 30, 1192 + scroller, 1193 + }); 1194 + assert.equal(scroller.scrollTop, 100); 1195 + }); 1196 + 1197 + it("accepts a plain number target", async () => { 1198 + startPin({ targetY: 100, durationMs: 60, scroller }); 1199 + assert.equal(scroller.scrollTop, 100); 1200 + scroller.scrollTop = 130; 1201 + await wait(30); 1202 + assert.equal(scroller.scrollTop, 100); 1203 + }); 1204 + 1205 + it("re-pins when the position deviates from the target", async () => { 1206 + startPin({ 1207 + targetY: () => 100, 1208 + durationMs: 60, 1209 + scroller, 1210 + }); 1211 + scroller.scrollTop = 130; 1212 + await wait(30); 1213 + assert.equal(scroller.scrollTop, 100); 1214 + }); 1215 + 1216 + it("follows a target that moves between frames", async () => { 1217 + let target = 100; 1218 + startPin({ 1219 + targetY: () => target, 1220 + durationMs: 60, 1221 + scroller, 1222 + }); 1223 + assert.equal(scroller.scrollTop, 100); 1224 + target = 200; 1225 + await wait(30); 1226 + assert.equal(scroller.scrollTop, 200); 1227 + }); 1228 + 1229 + it("stops re-evaluating once the duration elapses", async () => { 1230 + const getTargetY = mock.fn(() => 0); 1231 + startPin({ 1232 + targetY: getTargetY, 1233 + durationMs: 20, 1234 + scroller, 1235 + }); 1236 + await wait(60); 1237 + const countAfterExpiry = getTargetY.mock.callCount(); 1238 + await wait(30); 1239 + assert.equal(getTargetY.mock.callCount(), countAfterExpiry); 1240 + }); 1241 + 1242 + it("stops when getTargetY returns null", async () => { 1243 + const getTargetY = mock.fn(() => null); 1244 + startPin({ 1245 + targetY: getTargetY, 1246 + durationMs: 1000, 1247 + scroller, 1248 + }); 1249 + await wait(30); 1250 + assert.equal(getTargetY.mock.callCount(), 1); 1251 + }); 1252 + 1253 + it("stops when shouldStop returns true", async () => { 1254 + startPin({ 1255 + targetY: () => 100, 1256 + durationMs: 1000, 1257 + scroller, 1258 + shouldStop: (currentY, lastPinnedY) => 1259 + lastPinnedY !== null && currentY < lastPinnedY - 1, 1260 + }); 1261 + assert.equal(scroller.scrollTop, 100); 1262 + scroller.scrollTop = 50; 1263 + await wait(30); 1264 + assert.equal(scroller.scrollTop, 50); 1265 + }); 1266 + 1267 + it("passes the current and last pinned positions to shouldStop", async () => { 1268 + const shouldStop = mock.fn(() => false); 1269 + startPin({ 1270 + targetY: () => 100, 1271 + durationMs: 30, 1272 + scroller, 1273 + shouldStop, 1274 + }); 1275 + assert.deepEqual(shouldStop.mock.calls[0].arguments, [0, null]); 1276 + await wait(60); 1277 + const laterCall = shouldStop.mock.calls.at(-1); 1278 + assert.deepEqual(laterCall.arguments, [100, 100]); 1279 + }); 1280 + 1281 + it("keeps pinning after an upward deviation without shouldStop", async () => { 1282 + startPin({ 1283 + targetY: () => 100, 1284 + durationMs: 60, 1285 + scroller, 1286 + }); 1287 + scroller.scrollTop = 50; 1288 + await wait(30); 1289 + assert.equal(scroller.scrollTop, 100); 1290 + }); 1291 + 1292 + it("stops on touchmove on the scroller", async () => { 1293 + const getTargetY = mock.fn(() => 0); 1294 + startPin({ 1295 + targetY: getTargetY, 1296 + durationMs: 1000, 1297 + scroller, 1298 + }); 1299 + scroller.dispatchEvent(new window.Event("touchmove")); 1300 + const countAtStop = getTargetY.mock.callCount(); 1301 + await wait(30); 1302 + assert.equal(getTargetY.mock.callCount(), countAtStop); 1303 + }); 1304 + 1305 + it("stops on wheel on the scroller", async () => { 1306 + const getTargetY = mock.fn(() => 0); 1307 + startPin({ 1308 + targetY: getTargetY, 1309 + durationMs: 1000, 1310 + scroller, 1311 + }); 1312 + scroller.dispatchEvent(new window.Event("wheel")); 1313 + const countAtStop = getTargetY.mock.callCount(); 1314 + await wait(30); 1315 + assert.equal(getTargetY.mock.callCount(), countAtStop); 1316 + }); 1317 + 1318 + it("stops on page-transition", async () => { 1319 + const getTargetY = mock.fn(() => 0); 1320 + startPin({ 1321 + targetY: getTargetY, 1322 + durationMs: 1000, 1323 + scroller, 1324 + }); 1325 + window.dispatchEvent(new window.CustomEvent("page-transition")); 1326 + const countAtStop = getTargetY.mock.callCount(); 1327 + await wait(30); 1328 + assert.equal(getTargetY.mock.callCount(), countAtStop); 1329 + }); 1330 + 1331 + it("stops on keydown", async () => { 1332 + const getTargetY = mock.fn(() => 0); 1333 + startPin({ 1334 + targetY: getTargetY, 1335 + durationMs: 1000, 1336 + scroller, 1337 + }); 1338 + window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "a" })); 1339 + const countAtStop = getTargetY.mock.callCount(); 1340 + await wait(30); 1341 + assert.equal(getTargetY.mock.callCount(), countAtStop); 1342 + }); 1343 + 1344 + it("stops when the returned stop function is called", async () => { 1345 + const getTargetY = mock.fn(() => 0); 1346 + const stop = startPin({ 1347 + targetY: getTargetY, 1348 + durationMs: 1000, 1349 + scroller, 1350 + }); 1351 + stop(); 1352 + await wait(30); 1353 + assert.equal(getTargetY.mock.callCount(), 1); 1354 + }); 1355 + });