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.

Add home page loading spinner

Grace Kind (Jul 13, 2026, 11:09 PM -0500) bd85ca16 a07fbbdd

+109 -4
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.153", 3 + "version": "0.17.154", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+16 -1
src/js/views/home.view.js
··· 36 36 state.$currentFeedUri = new Signal.State( 37 37 storedFeedUri ? JSON.parse(storedFeedUri) : null, 38 38 ); 39 + state.$isReloadingFeed = new Signal.State(false); 39 40 40 41 function resetToDefaultFeed() { 41 42 state.$currentFeedUri.set( ··· 143 144 window.scrollTo({ top: -1, behavior: "smooth" }); 144 145 } 145 146 // TODO - add setting to prevent reload? 146 - await loadCurrentFeed({ reload: true }); 147 + state.$isReloadingFeed.set(true); 148 + try { 149 + await loadCurrentFeed({ reload: true }); 150 + } finally { 151 + state.$isReloadingFeed.set(false); 152 + } 147 153 } 148 154 149 155 async function handleTabClick(feedUri) { ··· 200 206 const currentUser = dataLayer.derived.$currentUser.get(); 201 207 const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get() ?? []; 202 208 const currentFeedUri = state.$currentFeedUri.get(); 209 + const currentFeedRequestStatus = 210 + dataLayer.requests.statusStore.$statuses.get( 211 + "loadNextFeedPage-" + currentFeedUri, 212 + ); 213 + const isLoading = 214 + currentFeedRequestStatus.loading && 215 + state.$isReloadingFeed.get() && 216 + !!dataLayer.derived.$hydratedFeeds.get(currentFeedUri); 203 217 render( 204 218 html`<div id="home-view"> 205 219 ${headerTemplate({ 220 + showLoadingSpinner: isLoading, 206 221 leftButton: "menu", 207 222 onClickMenuButton: () => handleMenuClick(), 208 223 bottomItemTemplate: () => html`
+24 -2
tests/e2e/mockServer.js
··· 37 37 this.contentLabelPrefs = []; 38 38 this.notifications = []; 39 39 this.notificationCursor = undefined; 40 + this.notificationsDelayMs = 0; 40 41 this.pinnedFeedUris = []; 41 42 this.pinnedListUris = []; 42 43 this.lists = []; ··· 63 64 this.searchPosts = []; 64 65 this.searchProfiles = []; 65 66 this.timelinePosts = []; 67 + this.timelineDelayMs = 0; 66 68 this.pluginSettings = new Map(); 67 69 this.installedPlugins = []; 68 70 // Override the source served for the local test plugin's main.js; defaults ··· 156 158 157 159 addTimelinePosts(posts) { 158 160 this.timelinePosts.push(...posts); 161 + } 162 + 163 + // Delays apply to requests made after the setter is called, so a test can 164 + // let the initial load complete instantly and only slow down a reload. 165 + setTimelineDelay(delayMs) { 166 + this.timelineDelayMs = delayMs; 167 + } 168 + 169 + setNotificationsDelay(delayMs) { 170 + this.notificationsDelayMs = delayMs; 159 171 } 160 172 161 173 addSearchPosts(posts) { ··· 613 625 614 626 await page.route( 615 627 "**/xrpc/app.bsky.notification.listNotifications*", 616 - (route) => { 628 + async (route) => { 629 + if (this.notificationsDelayMs > 0) { 630 + await new Promise((resolve) => 631 + setTimeout(resolve, this.notificationsDelayMs), 632 + ); 633 + } 617 634 const url = new URL(route.request().url()); 618 635 const cursor = url.searchParams.get("cursor") || ""; 619 636 const limit = parseInt(url.searchParams.get("limit") || "0", 10); ··· 1279 1296 }); 1280 1297 }); 1281 1298 1282 - await page.route("**/xrpc/app.bsky.feed.getTimeline*", (route) => { 1299 + await page.route("**/xrpc/app.bsky.feed.getTimeline*", async (route) => { 1300 + if (this.timelineDelayMs > 0) { 1301 + await new Promise((resolve) => 1302 + setTimeout(resolve, this.timelineDelayMs), 1303 + ); 1304 + } 1283 1305 const url = new URL(route.request().url()); 1284 1306 const cursor = url.searchParams.get("cursor") || ""; 1285 1307 const limit = parseInt(url.searchParams.get("limit") || "0", 10);
+37
tests/e2e/specs/views/home.view.test.js
··· 44 44 await expect(view).toContainText("Another timeline post"); 45 45 }); 46 46 47 + // The home header row (and its spinner) is only visible on mobile widths; 48 + // on desktop only the tab bar row renders. 49 + test.describe("Feed reload spinner (mobile viewport)", () => { 50 + test.use({ viewport: { width: 375, height: 667 } }); 51 + 52 + test("should show a header loading spinner while the feed reloads", async ({ 53 + page, 54 + }) => { 55 + const mockServer = new MockServer(); 56 + const post = createPost({ 57 + uri: "at://did:plc:author1/app.bsky.feed.post/post1", 58 + text: "Hello from the timeline", 59 + authorHandle: "author1.bsky.social", 60 + authorDisplayName: "Author One", 61 + }); 62 + mockServer.addTimelinePosts([post]); 63 + await mockServer.setup(page); 64 + 65 + await login(page); 66 + await page.goto("/"); 67 + 68 + const view = page.locator("#home-view"); 69 + const spinner = view.locator('[data-testid="loading-spinner"]'); 70 + await expect(view.locator('[data-testid="feed-item"]')).toHaveCount(1, { 71 + timeout: 10000, 72 + }); 73 + await expect(spinner).not.toBeVisible(); 74 + 75 + mockServer.setTimelineDelay(1500); 76 + await view.locator(".tab-bar-button.active").click(); 77 + 78 + await expect(spinner).toBeVisible(); 79 + await expect(spinner).not.toBeVisible({ timeout: 10000 }); 80 + await expect(view.locator('[data-testid="feed-item"]')).toHaveCount(1); 81 + }); 82 + }); 83 + 47 84 test("should display pinned feed tabs alongside Following", async ({ 48 85 page, 49 86 }) => {
+31
tests/e2e/specs/views/notifications.view.test.js
··· 64 64 await expect(item).toContainText("followed you"); 65 65 }); 66 66 67 + test("should show a header loading spinner while notifications reload", async ({ 68 + page, 69 + }) => { 70 + const mockServer = new MockServer(); 71 + mockServer.addNotifications([ 72 + createNotification({ 73 + reason: "follow", 74 + author: alice, 75 + indexedAt: new Date().toISOString(), 76 + }), 77 + ]); 78 + await mockServer.setup(page); 79 + 80 + await login(page); 81 + await page.goto("/notifications"); 82 + 83 + const view = page.locator("#notifications-view"); 84 + const spinner = view.locator('[data-testid="loading-spinner"]'); 85 + await expect(view.locator(".notification-item")).toHaveCount(1, { 86 + timeout: 10000, 87 + }); 88 + await expect(spinner).not.toBeVisible(); 89 + 90 + mockServer.setNotificationsDelay(1500); 91 + await view.locator(".tab-bar-button.active").click(); 92 + 93 + await expect(spinner).toBeVisible(); 94 + await expect(spinner).not.toBeVisible({ timeout: 10000 }); 95 + await expect(view.locator(".notification-item")).toHaveCount(1); 96 + }); 97 + 67 98 test("should display a like notification with post preview", async ({ 68 99 page, 69 100 }) => {