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

Configure Feed

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

Fix report dialog double tap bug

Grace Kind (Jul 11, 2026, 12:53 PM -0500) 976cf23b a0dae171

+225 -12
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.136", 3 + "version": "0.17.137", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+16 -9
src/js/scrollLock.js
··· 88 88 return null; 89 89 } 90 90 91 - let __activeScrollLock = null; 91 + // Locks are tracked as a stack of holders: the first holder locks the 92 + // page and only the last holder to unlock restores it. 93 + let __scrollLockHolders = []; 94 + let __lockedContainer = null; 92 95 93 96 export class ScrollLock { 94 97 constructor(target) { ··· 100 103 } 101 104 102 105 lock() { 103 - if (__activeScrollLock) { 104 - // If scroll is already locked by another element, don't lock it again 105 - return; 106 - } 107 106 if (this.locked) { 108 107 return; 109 108 } ··· 113 112 ); 114 113 return; 115 114 } 116 - lockScroll(this.container); 115 + if (__scrollLockHolders.length === 0) { 116 + lockScroll(this.container); 117 + __lockedContainer = this.container; 118 + } 119 + __scrollLockHolders.push(this); 117 120 // If target is passed, lock the nearest scrollable ancestor of that target in addition to the outer page 118 121 const ancestor = this.target ? findScrollableAncestor(this.target) : null; 119 122 if (ancestor) { ··· 122 125 ancestor.style.overflow = "hidden"; 123 126 } 124 127 this.locked = true; 125 - __activeScrollLock = this; 126 128 } 127 129 128 130 unlock() { ··· 134 136 this._lockedAncestor = null; 135 137 this._previousAncestorOverflow = ""; 136 138 } 137 - unlockScroll(this.container); 139 + __scrollLockHolders = __scrollLockHolders.filter( 140 + (holder) => holder !== this, 141 + ); 142 + if (__scrollLockHolders.length === 0) { 143 + unlockScroll(__lockedContainer); 144 + __lockedContainer = null; 145 + } 138 146 this.locked = false; 139 - __activeScrollLock = null; 140 147 } 141 148 }
+7 -2
src/js/utils.js
··· 466 466 467 467 // iOS Safari: dismissing the keyboard via the "Done" button leaves the 468 468 // dialog's inner scroll area offset, which makes buttons unclickable 469 - // until the dialog is swiped or re-tapped. Reset scroll on blur. 469 + // until the dialog is swiped or re-tapped. Reset scroll on text input blur. 470 470 export function resetScrollOnBlur(dialog, scrollArea) { 471 471 dialog.addEventListener( 472 472 "blur", 473 - () => { 473 + (event) => { 474 + if ( 475 + !event.target.matches?.('input, textarea, [contenteditable="true"]') 476 + ) { 477 + return; 478 + } 474 479 if (scrollArea) scrollArea.scrollTop = 0; 475 480 window.scrollTo(0, 0); 476 481 },
+42
tests/e2e/specs/flows/reportPost.test.js
··· 74 74 cid: post.cid, 75 75 }); 76 76 }); 77 + 78 + test("should keep page scroll locked while the report dialog is open", async ({ 79 + page, 80 + }) => { 81 + const mockServer = new MockServer(); 82 + const post = createPost({ 83 + uri: "at://did:plc:author1/app.bsky.feed.post/post1", 84 + text: "Post to report", 85 + authorHandle: "author1.bsky.social", 86 + authorDisplayName: "Author One", 87 + }); 88 + mockServer.addTimelinePosts([post]); 89 + await mockServer.setup(page); 90 + 91 + await login(page); 92 + await page.goto("/"); 93 + 94 + const homeView = page.locator("#home-view"); 95 + const feedItem = homeView.locator('[data-testid="feed-item"]'); 96 + await expect(feedItem).toHaveCount(1, { timeout: 10000 }); 97 + 98 + await feedItem.locator(".text-button").click(); 99 + await page.locator('[data-testid="menu-action-post-report"]').click(); 100 + 101 + const reportDialog = page.locator("report-dialog"); 102 + await expect(reportDialog.locator(".report-dialog")).toBeVisible({ 103 + timeout: 5000, 104 + }); 105 + 106 + // The report dialog opens from the menu item's click handler before the 107 + // context menu itself closes, so the menu's scroll lock must hand off to 108 + // the dialog instead of unlocking the page underneath it. 109 + await expect 110 + .poll(() => page.evaluate(() => document.body.style.position)) 111 + .toBe("fixed"); 112 + 113 + await reportDialog.locator(".report-dialog-close").click(); 114 + await expect(reportDialog).toHaveCount(0, { timeout: 5000 }); 115 + await expect 116 + .poll(() => page.evaluate(() => document.body.style.position)) 117 + .toBe(""); 118 + }); 77 119 });
+107
tests/unit/specs/scrollLock.test.js
··· 1 + import { describe, it, beforeEach, afterEach } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + 4 + // scrollLock.js tracks lock holders in module-global state 5 + const { ScrollLock } = await import("/js/scrollLock.js?fresh-for-test"); 6 + 7 + describe("ScrollLock", () => { 8 + let container; 9 + let createdLocks; 10 + 11 + const createLock = (target) => { 12 + const lock = new ScrollLock(target); 13 + createdLocks.push(lock); 14 + return lock; 15 + }; 16 + 17 + beforeEach(() => { 18 + container = document.createElement("div"); 19 + container.className = "page-visible"; 20 + container.innerHTML = "<header></header><main></main>"; 21 + document.body.appendChild(container); 22 + createdLocks = []; 23 + document.body.style.position = ""; 24 + }); 25 + 26 + afterEach(() => { 27 + for (const lock of createdLocks) { 28 + lock.unlock(); 29 + } 30 + container.remove(); 31 + document.body.style.position = ""; 32 + document.body.style.overflow = ""; 33 + document.body.style.top = ""; 34 + document.body.style.width = ""; 35 + document.body.style.height = ""; 36 + }); 37 + 38 + it("locks and unlocks page scroll", () => { 39 + const lock = createLock(); 40 + lock.lock(); 41 + assert.deepEqual(document.body.style.position, "fixed"); 42 + lock.unlock(); 43 + assert.deepEqual(document.body.style.position, ""); 44 + }); 45 + 46 + it("keeps the page locked when the first holder unlocks before the second", () => { 47 + const menuLock = createLock(); 48 + const dialogLock = createLock(); 49 + menuLock.lock(); 50 + dialogLock.lock(); 51 + 52 + menuLock.unlock(); 53 + assert.deepEqual(document.body.style.position, "fixed"); 54 + 55 + dialogLock.unlock(); 56 + assert.deepEqual(document.body.style.position, ""); 57 + }); 58 + 59 + it("keeps the page locked when a stacked dialog unlocks first", () => { 60 + const composerLock = createLock(); 61 + const nestedDialogLock = createLock(); 62 + composerLock.lock(); 63 + nestedDialogLock.lock(); 64 + 65 + nestedDialogLock.unlock(); 66 + assert.deepEqual(document.body.style.position, "fixed"); 67 + 68 + composerLock.unlock(); 69 + assert.deepEqual(document.body.style.position, ""); 70 + }); 71 + 72 + it("ignores unlock without a prior lock", () => { 73 + const heldLock = createLock(); 74 + heldLock.lock(); 75 + 76 + const idleLock = createLock(); 77 + idleLock.unlock(); 78 + assert.deepEqual(document.body.style.position, "fixed"); 79 + 80 + heldLock.unlock(); 81 + assert.deepEqual(document.body.style.position, ""); 82 + }); 83 + 84 + it("ignores repeated lock calls from the same holder", () => { 85 + const lock = createLock(); 86 + lock.lock(); 87 + lock.lock(); 88 + lock.unlock(); 89 + assert.deepEqual(document.body.style.position, ""); 90 + }); 91 + 92 + it("restores a locked scrollable ancestor's overflow on unlock", () => { 93 + const scrollable = document.createElement("div"); 94 + scrollable.style.overflowY = "auto"; 95 + Object.defineProperty(scrollable, "scrollHeight", { value: 200 }); 96 + Object.defineProperty(scrollable, "clientHeight", { value: 100 }); 97 + const target = document.createElement("div"); 98 + scrollable.appendChild(target); 99 + container.appendChild(scrollable); 100 + 101 + const lock = createLock(target); 102 + lock.lock(); 103 + assert.deepEqual(scrollable.style.overflow, "hidden"); 104 + lock.unlock(); 105 + assert.deepEqual(scrollable.style.overflow, ""); 106 + }); 107 + });
+52
tests/unit/specs/utils.test.js
··· 22 22 enableLongPress, 23 23 TimeoutError, 24 24 debounce, 25 + resetScrollOnBlur, 25 26 } from "/js/utils.js"; 26 27 27 28 describe("unique", () => { ··· 1031 1032 assert.deepEqual(calls, ["kept"]); 1032 1033 }); 1033 1034 }); 1035 + 1036 + describe("resetScrollOnBlur", () => { 1037 + let dialog; 1038 + let scrollArea; 1039 + 1040 + const blurFrom = (element) => { 1041 + element.dispatchEvent(new window.FocusEvent("blur")); 1042 + }; 1043 + 1044 + beforeEach(() => { 1045 + dialog = document.createElement("dialog"); 1046 + scrollArea = document.createElement("div"); 1047 + dialog.appendChild(scrollArea); 1048 + document.body.appendChild(dialog); 1049 + resetScrollOnBlur(dialog, scrollArea); 1050 + scrollArea.scrollTop = 42; 1051 + }); 1052 + 1053 + afterEach(() => { 1054 + dialog.remove(); 1055 + }); 1056 + 1057 + it("resets the scroll area when a textarea blurs", () => { 1058 + const textarea = document.createElement("textarea"); 1059 + scrollArea.appendChild(textarea); 1060 + blurFrom(textarea); 1061 + assert.deepEqual(scrollArea.scrollTop, 0); 1062 + }); 1063 + 1064 + it("resets the scroll area when an input blurs", () => { 1065 + const input = document.createElement("input"); 1066 + scrollArea.appendChild(input); 1067 + blurFrom(input); 1068 + assert.deepEqual(scrollArea.scrollTop, 0); 1069 + }); 1070 + 1071 + it("resets the scroll area when a contenteditable element blurs", () => { 1072 + const editable = document.createElement("div"); 1073 + editable.setAttribute("contenteditable", "true"); 1074 + scrollArea.appendChild(editable); 1075 + blurFrom(editable); 1076 + assert.deepEqual(scrollArea.scrollTop, 0); 1077 + }); 1078 + 1079 + it("does not reset scroll when a button blurs", () => { 1080 + const button = document.createElement("button"); 1081 + scrollArea.appendChild(button); 1082 + blurFrom(button); 1083 + assert.deepEqual(scrollArea.scrollTop, 42); 1084 + }); 1085 + });