diff --git a/package.json b/package.json index 5636b4c8..1c2df5f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.202", + "version": "0.18.203", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index 51810e91..c9724958 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -45,10 +45,34 @@ export default async function homeView({ const CURRENT_FEED_URI_STORAGE_KEY = "home-view-currentFeedUri"; const WELCOME_MODAL_SEEN_STORAGE_KEY = "welcome-modal-seen"; - const storedFeedUri = isAuthenticated - ? localStorage.getItem(CURRENT_FEED_URI_STORAGE_KEY) + // Keyed per account + const currentFeedUriStorageKey = isAuthenticated + ? `${CURRENT_FEED_URI_STORAGE_KEY}:${api.session.did}` : null; + function readStoredFeedUri() { + if (!currentFeedUriStorageKey) { + return null; + } + const stored = localStorage.getItem(currentFeedUriStorageKey); + if (stored !== null) { + return stored; + } + // Carry over the selection saved before the key was per-account + const legacyStored = localStorage.getItem(CURRENT_FEED_URI_STORAGE_KEY); + localStorage.removeItem(CURRENT_FEED_URI_STORAGE_KEY); + return legacyStored; + } + + function saveFeedUri(feedUri) { + if (!currentFeedUriStorageKey) { + return; + } + localStorage.setItem(currentFeedUriStorageKey, JSON.stringify(feedUri)); + } + + const storedFeedUri = readStoredFeedUri(); + const state = new ReactiveStore("homeView"); state.$currentFeedUri = new Signal.State( storedFeedUri ? JSON.parse(storedFeedUri) : null, @@ -75,36 +99,31 @@ export default async function homeView({ WelcomeModal.open(); } - if (isAuthenticated) { - pageEffect(root, () => { - const currentFeedUri = state.$currentFeedUri.get(); - if (currentFeedUri) { - localStorage.setItem( - CURRENT_FEED_URI_STORAGE_KEY, - JSON.stringify(currentFeedUri), - ); - } - }); - } - const postSeenObservers = new Map(); - // Initialize post seen observers for feeds with proxy URLs - function initializePostSeenObservers(pinnedItems) { + function syncPostSeenObservers(pinnedItems) { if (!isAuthenticated) { return; } - const interactableItems = pinnedItems.filter( - (item) => item.acceptsInteractions || item.uri === LOGGED_OUT_FEED_URI, - ); - for (const observer of postSeenObservers.values()) { - observer.disconnect(); - } - postSeenObservers.clear(); - for (const item of interactableItems) { + const proxyUrls = new Map(); + for (const item of pinnedItems) { + if (!item.acceptsInteractions && item.uri !== LOGGED_OUT_FEED_URI) { + continue; + } const proxyUrl = getFeedGeneratorProxyUrl(item); if (proxyUrl) { - postSeenObservers.set(item.uri, new PostSeenObserver(api, proxyUrl)); + proxyUrls.set(item.uri, proxyUrl); + } + } + for (const [feedUri, observer] of postSeenObservers) { + if (proxyUrls.get(feedUri) !== observer.feedProxyUrl) { + observer.disconnect(); + postSeenObservers.delete(feedUri); + } + } + for (const [feedUri, proxyUrl] of proxyUrls) { + if (!postSeenObservers.has(feedUri)) { + postSeenObservers.set(feedUri, new PostSeenObserver(api, proxyUrl)); } } } @@ -176,6 +195,7 @@ export default async function homeView({ state.$materializedFeedUris.add(currentFeedUri); // Switch feed state.$currentFeedUri.set(feedUri); + saveFeedUri(feedUri); // Scroll to saved position for new feed const savedScrollY = feedScrollState.get(feedUri) ?? 0; requestAnimationFrame(() => { @@ -345,6 +365,7 @@ export default async function homeView({ `, root, ); + syncPostSeenObservers(pinnedItems); if (postSeenObservers.size > 0) { const feedContextsByFeedUri = $feedContextsByFeedUri.get(); root.querySelectorAll(".feed-item").forEach((feedItem) => { @@ -404,7 +425,11 @@ export default async function homeView({ resetToDefaultFeed(); } preloadHiddenFeeds(pinnedItems); - initializePostSeenObservers(pinnedItems); + // Ensure current user before loading the feed to prevent a flash of + // unfiltered posts + if (isAuthenticated) { + await dataLayer.declarative.ensureCurrentUser(); + } await loadCurrentFeed({ reload: true }); } diff --git a/tests/e2e/specs/flows/accountSwitch.test.js b/tests/e2e/specs/flows/accountSwitch.test.js index b6e7b75d..e5be31be 100644 --- a/tests/e2e/specs/flows/accountSwitch.test.js +++ b/tests/e2e/specs/flows/accountSwitch.test.js @@ -2,7 +2,10 @@ import { test, expect } from "../../base.js"; import { loginWithAccounts, longPress } from "../../helpers.js"; import { MockServer } from "../../mockServer.js"; import { userProfile } from "../../testData.js"; -import { createProfile } from "../../../shared/factories.js"; +import { + createFeedGenerator, + createProfile, +} from "../../../shared/factories.js"; // The footer (the long-press trigger) only renders on small viewports. test.use({ viewport: { width: 375, height: 667 } }); @@ -14,6 +17,19 @@ async function openSwitcherDialog(page) { return dialog; } +async function switchToAccount(page, did) { + const dialog = await openSwitcherDialog(page); + await dialog + .locator(`[data-testid="account-switcher-item"][data-did="${did}"]`) + .click(); + await expect + .poll( + () => page.evaluate(() => localStorage.getItem("oauth_current_did")), + { timeout: 10000 }, + ) + .toBe(did); +} + test.describe("Account switch flow", () => { let mockServer; let otherProfile; @@ -247,6 +263,40 @@ test.describe("Account switch flow", () => { await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); }); + + test("each account keeps its own home feed selection across switches", async ({ + page, + }) => { + 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 page.reload(); + + const view = page.locator("#home-view"); + const tabs = view.locator(".tab-bar-button"); + await expect(tabs).toHaveCount(2, { timeout: 10000 }); + await tabs.nth(1).click(); + await expect(tabs.nth(1)).toHaveClass(/active/); + + // The other account doesn't have that feed pinned + mockServer.setPinnedFeeds([]); + await switchToAccount(page, otherProfile.did); + + await expect(tabs).toHaveCount(1, { timeout: 10000 }); + await expect(tabs.nth(0)).toHaveClass(/active/); + + // Falling back to Following for the other account must not have discarded + // the first account's selection + mockServer.setPinnedFeeds([feed.uri]); + await switchToAccount(page, userProfile.did); + + await expect(tabs).toHaveCount(2, { timeout: 10000 }); + await expect(tabs.nth(1)).toHaveClass(/active/); + }); }); test.describe("Account switch flow with an account needing re-auth", () => { diff --git a/tests/e2e/specs/views/home.view.test.js b/tests/e2e/specs/views/home.view.test.js index 5b557c56..ba5da6e9 100644 --- a/tests/e2e/specs/views/home.view.test.js +++ b/tests/e2e/specs/views/home.view.test.js @@ -218,6 +218,49 @@ test.describe("Home view", () => { await expect(visibleFeed).toContainText("Trending feed post"); }); + test("should report seen posts for a feed that accepts interactions", 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", + acceptsInteractions: true, + }); + const feedPost = createPost({ + uri: "at://did:plc:author2/app.bsky.feed.post/post2", + text: "Trending feed post", + authorHandle: "author2.bsky.social", + }); + mockServer.addFeedGenerators([feed]); + mockServer.setPinnedFeeds([feed.uri]); + mockServer.addFeedItems(feed.uri, [feedPost]); + await mockServer.setup(page); + + await login(page); + await page.goto("/"); + + const view = page.locator("#home-view"); + await expect( + view.locator(".tab-bar-button", { hasText: "Trending" }), + ).toBeVisible({ timeout: 10000 }); + + const sendInteractionsRequest = page.waitForRequest( + (req) => req.url().includes("app.bsky.feed.sendInteractions"), + { timeout: 15000 }, + ); + await view.locator(".tab-bar-button", { hasText: "Trending" }).click(); + const request = await sendInteractionsRequest; + + expect(request.postDataJSON().interactions).toContainEqual( + expect.objectContaining({ + item: feedPost.uri, + event: "app.bsky.feed.defs#interactionSeen", + }), + ); + }); + test("should build inactive feed tabs in the background after the active feed renders", async ({ page, }) => { diff --git a/tests/shared/factories.js b/tests/shared/factories.js index 509377eb..c4a9e948 100644 --- a/tests/shared/factories.js +++ b/tests/shared/factories.js @@ -196,6 +196,7 @@ export function createFeedGenerator({ displayName, creatorHandle, description, + acceptsInteractions = false, }) { const creatorDid = uri.split("/")[2]; return { @@ -214,6 +215,7 @@ export function createFeedGenerator({ displayName, description: description || "", avatar: "", + acceptsInteractions, likeCount: 10, indexedAt: "2025-01-01T00:00:00.000Z", labels: [],