From c4b86d3369467f49c3f41d56547a812f02682501 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Wed, 21 Oct 2026 13:01:24 -0500 Subject: [PATCH] Update pinned items when pinning feeds and lists --- package.json | 2 +- src/js/dataLayer/mutations.js | 37 +++++- tests/e2e/specs/flows/pinFeed.test.js | 80 +++++++++++ tests/unit/specs/dataLayer/mutations.test.js | 131 ++++++++++++++++++- 4 files changed, 242 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 9dff0811..835f60fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.114", + "version": "0.20.115", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 84b23ebc..2cc96966 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -509,6 +509,7 @@ export class Mutations { } finally { this.patchStore.removePreferencePatch(patchId); } + this._addPinnedItem("feed", feedUri); } async pinList(listUri) { @@ -527,6 +528,7 @@ export class Mutations { } finally { this.patchStore.removePreferencePatch(patchId); } + this._addPinnedItem("list", listUri); } async unpinFeed(feedUri) { @@ -544,6 +546,7 @@ export class Mutations { } finally { this.patchStore.removePreferencePatch(patchId); } + this._removePinnedItem(feedUri); } async unpinList(listUri) { @@ -561,6 +564,33 @@ export class Mutations { } finally { this.patchStore.removePreferencePatch(patchId); } + this._removePinnedItem(listUri); + } + + _addPinnedItem(type, uri) { + const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); + if (!pinnedItems || pinnedItems.some((item) => item.data.uri === uri)) { + return; + } + const data = untrack(() => + type === "list" + ? this.dataStore.$lists.get(uri) + : this.dataStore.$feedGenerators.get(uri), + ); + if (!data) { + return; + } + this.dataStore.setPinnedItems([...pinnedItems, { type, data }]); + } + + _removePinnedItem(uri) { + const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); + if (!pinnedItems?.some((item) => item.data.uri === uri)) { + return; + } + this.dataStore.setPinnedItems( + pinnedItems.filter((item) => item.data.uri !== uri), + ); } async setPinnedItems(values) { @@ -1379,12 +1409,7 @@ export class Mutations { }); } } - const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); - if (pinnedItems?.some((item) => item.data?.uri === list.uri)) { - this.dataStore.setPinnedItems( - pinnedItems.filter((item) => item.data?.uri !== list.uri), - ); - } + this._removePinnedItem(list.uri); const preferences = await this.preferencesProvider.requirePreferences(); if (preferences.isFeedPinned(list.uri)) { const newPreferences = preferences.unpinFeed(list.uri); diff --git a/tests/e2e/specs/flows/pinFeed.test.js b/tests/e2e/specs/flows/pinFeed.test.js index 4fe62b27..c8c59d8c 100644 --- a/tests/e2e/specs/flows/pinFeed.test.js +++ b/tests/e2e/specs/flows/pinFeed.test.js @@ -260,4 +260,84 @@ test.describe("Pin feed flow", () => { await expect(tabs).toHaveCount(1, { timeout: 10000 }); await expect(tabs.nth(0)).toHaveAttribute("data-testid", "tab-following"); }); + + test("should update the sidebar and home tabs after pinning from search results without a reload", async ({ + page, + }) => { + const mockServer = new MockServer(); + const feed = createFeedGenerator({ + uri: "at://did:plc:creator1/app.bsky.feed.generator/trending", + displayName: "Trending", + creatorHandle: "creator1.bsky.social", + }); + mockServer.addFeedGenerators([feed]); + mockServer.addSearchFeedGenerators([feed]); + mockServer.setSavedFeeds([feed.uri]); + await mockServer.setup(page); + await login(page); + + await page.goto("/search?q=trending&tab=feeds"); + const view = page.locator("#search-view"); + const paneItems = page.locator( + '[data-testid="pinned-feeds-pane"] [data-testid="pinned-feeds-item"]', + ); + await expect(paneItems).toHaveCount(1, { timeout: 10000 }); + await expect(view.locator(".feeds-list-item")).toHaveCount(1, { + timeout: 10000, + }); + + await view.locator(".pin-feed-button").click(); + await expect(view.locator(".pin-feed-button.pinned")).toBeVisible({ + timeout: 10000, + }); + + await expect(paneItems).toHaveCount(2); + await expect(paneItems.nth(1)).toHaveAttribute( + "data-pinned-value", + feed.uri, + ); + + await page.locator('[data-testid="sidebar-nav-home"]').click(); + const homeView = page.locator("#home-view"); + await expect(homeView.locator(".tab-bar-button")).toHaveCount(2, { + timeout: 10000, + }); + }); + + test("should update the sidebar and home tabs after unpinning from feed detail without a reload", async ({ + page, + }) => { + const mockServer = new MockServer(); + const feed = createFeedGenerator({ + uri: "at://did:plc:creator1/app.bsky.feed.generator/trending", + displayName: "Trending", + creatorHandle: "creator1.bsky.social", + }); + mockServer.addFeedGenerators([feed]); + mockServer.setPinnedFeeds([feed.uri]); + await mockServer.setup(page); + await login(page); + + await page.goto("/profile/creator1.bsky.social/feed/trending"); + const detailView = page.locator("#feed-detail-view"); + const paneItems = page.locator( + '[data-testid="pinned-feeds-pane"] [data-testid="pinned-feeds-item"]', + ); + await expect(paneItems).toHaveCount(2, { timeout: 10000 }); + + await detailView.locator(".pin-feed-button.pinned").click(); + await expect(detailView.locator(".pin-feed-button.pinned")).toHaveCount(0); + + await expect(paneItems).toHaveCount(1); + await expect(paneItems.nth(0)).toHaveAttribute( + "data-pinned-value", + "following", + ); + + await page.locator('[data-testid="sidebar-nav-home"]').click(); + const homeView = page.locator("#home-view"); + await expect(homeView.locator(".tab-bar-button")).toHaveCount(1, { + timeout: 10000, + }); + }); }); diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index c41c9d09..5fa046e4 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -17,7 +17,11 @@ import { CDN_URL } from "/js/config.js"; import { ApiError } from "/js/api.js"; import { trackDisposable } from "../../testHelpers.js"; import { isBlockedPost, MAX_HIDDEN_REPLIES } from "/js/dataHelpers.js"; -import { createPost } from "../../../shared/factories.js"; +import { + createFeedGenerator, + createList, + createPost, +} from "../../../shared/factories.js"; function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -2464,11 +2468,81 @@ describe("pinFeed", () => { ); return { mutations, + dataStore, patchStore, getUpdatedPreferences: () => updatedPreferences, }; } + const timelineItem = { + type: "timeline", + data: { uri: "following", displayName: "Following" }, + }; + + it("should append the feed to loaded pinned items", async () => { + const { mutations, dataStore } = setupWithPreferences([ + { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [] }, + ]); + const feedGenerator = createFeedGenerator({ + uri: feedUri, + displayName: "Cool", + creatorHandle: "feed.test", + }); + dataStore.setFeedGenerators([feedGenerator]); + dataStore.setPinnedItems([timelineItem]); + + await mutations.pinFeed(feedUri); + + assert.deepEqual(dataStore.$pinnedItems.get(), [ + timelineItem, + { type: "feed", data: feedGenerator }, + ]); + }); + + it("should leave pinned items unloaded when they haven't been loaded", async () => { + const { mutations, dataStore } = setupWithPreferences([ + { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [] }, + ]); + dataStore.setFeedGenerators([ + createFeedGenerator({ + uri: feedUri, + displayName: "Cool", + creatorHandle: "feed.test", + }), + ]); + + await mutations.pinFeed(feedUri); + + assert.deepEqual(dataStore.$pinnedItems.get(), null); + }); + + it("should not update pinned items when saving fails", async (t) => { + t.mock.method(console, "error", () => {}); + const preferences = new Preferences( + [{ $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [] }], + [], + ); + const dataStore = new DataStore(createSessionState(null)); + const mutations = makeMutations({}, dataStore, new PatchStore(), { + requirePreferences: () => preferences, + updatePreferences: async () => { + throw new Error("network"); + }, + }); + dataStore.setFeedGenerators([ + createFeedGenerator({ + uri: feedUri, + displayName: "Cool", + creatorHandle: "feed.test", + }), + ]); + dataStore.setPinnedItems([timelineItem]); + + await assert.rejects(mutations.pinFeed(feedUri), /network/); + + assert.deepEqual(dataStore.$pinnedItems.get(), [timelineItem]); + }); + it("should append a pinned saved-feed entry when not previously saved", async () => { const { mutations, getUpdatedPreferences } = setupWithPreferences([ { @@ -2633,6 +2707,42 @@ describe("unpinFeed", () => { assert.deepEqual(updatedPreferences.obj[0].items.length, 1); }); + it("should remove the feed from loaded pinned items", async () => { + const preferences = new Preferences( + [ + { + $type: "app.bsky.actor.defs#savedFeedsPrefV2", + items: [{ id: "1", value: feedUri, type: "feed", pinned: true }], + }, + ], + [], + ); + const dataStore = new DataStore(createSessionState(null)); + const mutations = makeMutations({}, dataStore, new PatchStore(), { + requirePreferences: () => preferences, + updatePreferences: async () => {}, + }); + const timelineItem = { + type: "timeline", + data: { uri: "following", displayName: "Following" }, + }; + dataStore.setPinnedItems([ + timelineItem, + { + type: "feed", + data: createFeedGenerator({ + uri: feedUri, + displayName: "Cool", + creatorHandle: "feed.test", + }), + }, + ]); + + await mutations.unpinFeed(feedUri); + + assert.deepEqual(dataStore.$pinnedItems.get(), [timelineItem]); + }); + it("should add an optimistic patch and remove it on success", async () => { let updateResolve; const updatePromise = new Promise((resolve) => { @@ -4996,6 +5106,25 @@ describe("pinList", () => { assert.deepEqual(pinFeedCalls[0].type, "list"); }); + it("should append the list to loaded pinned items", async () => { + const { provider } = makeMockProvider(); + const dataStore = new DataStore(createSessionState(null)); + const mutations = makeMutations({}, dataStore, new PatchStore(), provider); + const list = createList({ + uri: listUri, + name: "Cool list", + creatorHandle: "list.test", + }); + dataStore.setLists([list]); + dataStore.setPinnedItems([]); + + await mutations.pinList(listUri); + + assert.deepEqual(dataStore.$pinnedItems.get(), [ + { type: "list", data: list }, + ]); + }); + it("should remove patch after successful update", async () => { const { provider } = makeMockProvider(); const dataStore = new DataStore(createSessionState(null)); -- knotmirror 0.0.1