diff --git a/package.json b/package.json index 375e726c..160ae86b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.19.7", + "version": "0.19.8", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 91d0ff94..d56d80b0 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -469,6 +469,112 @@ trending-pane { display: block; } +pinned-feeds-pane { + display: none; +} + +@media (min-width: 1100px) { + pinned-feeds-pane { + display: block; + } +} + +.pinned-feeds-pane { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + margin: 16px; +} + +.pinned-feeds-item { + display: flex; + align-items: center; + gap: 8px; + max-width: 100%; + box-sizing: border-box; + padding: 6px 8px; + border: none; + border-radius: 8px; + background: none; + color: var(--text-color-muted); + font-family: inherit; + font-size: 15px; + text-align: left; + text-decoration: none; + cursor: pointer; +} + +.pinned-feeds-item .home-icon { + width: 15px; +} + +.pinned-feeds-item .plus-icon { + width: 15px; +} + +@media (hover: hover) { + .pinned-feeds-item:hover { + color: var(--text-color); + } +} + +.pinned-feeds-item.active { + background: var(--highlight-color-tint); + color: var(--text-color); + font-weight: 600; +} + +.pinned-feeds-item-label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.pinned-feeds-item-avatar { + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: var(--feed-avatar-border-radius); + object-fit: cover; +} + +.pinned-feeds-item-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: var(--feed-avatar-border-radius); +} + +.pinned-feeds-item-icon svg { + width: 14px; + height: 14px; + display: block; +} + +.pinned-feeds-timeline-icon { + background: var(--highlight-color); + color: var(--white); +} + +.pinned-feeds-more-icon { + background: var(--post-hover-color); +} + +.pinned-feeds-item-skeleton { + align-self: stretch; + max-width: none; +} + +.pinned-feeds-skeleton-bar { + display: inline-block; + height: 14px; + border-radius: 4px; +} + .trending-list { display: flex; flex-direction: column; diff --git a/src/js/components/pinned-feeds-pane.js b/src/js/components/pinned-feeds-pane.js new file mode 100644 index 00000000..dcf363a9 --- /dev/null +++ b/src/js/components/pinned-feeds-pane.js @@ -0,0 +1,165 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { Signal, effect } from "/js/signals.js"; +import { classnames } from "/js/utils.js"; +import { cdnImageUrl } from "/js/dataHelpers.js"; +import { homeIconTemplate } from "/js/templates/icons/homeIcon.template.js"; +import { plusIconTemplate } from "/js/templates/icons/plusIcon.template.js"; + +const SKELETON_COUNT = 5; + +function pinnedItemAvatarTemplate({ item }) { + if (item.type === "timeline") { + return html`${homeIconTemplate({ filled: true })}`; + } + const fallback = + item.type === "list" + ? "/img/list-avatar-fallback.svg" + : "/img/feed-avatar-fallback.svg"; + return html``; +} + +function pinnedItemTemplate({ item, isCurrent, onSelect }) { + return html``; +} + +function pinnedFeedsSkeletonTemplate() { + return html`${Array.from( + { length: SKELETON_COUNT }, + (_, index) => + html`
+ +
`, + )}`; +} + +function pinnedFeedsPaneTemplate({ + pinnedItems, + isLoading, + currentFeedUri, + moreFeedsActive, + onSelect, +}) { + return html``; +} + +class PinnedFeedsPane extends Component { + static get observedAttributes() { + return ["show-selected", "more-feeds-active"]; + } + + attributeChangedCallback() { + if (this.initialized) { + this.render(); + } + } + + connectedCallback() { + if (this.initialized) return; + this.initialized = true; + if (!this.dataLayer) { + throw new Error("pinned-feeds-pane requires a dataLayer property"); + } + this.$failed = new Signal.State(false); + this._disposers = [effect(() => this.render())]; + this.load(); + } + + disconnectedCallback() { + if (!this.initialized) return; + this._disposers?.forEach((dispose) => dispose()); + this._disposers = null; + this.initialized = false; + } + + render() { + const pinnedItems = this.dataLayer.derived.$hydratedPinnedItems.get(); + const failed = this.$failed.get(); + const selectedFeedUri = this.dataLayer.derived.$selectedFeedUri.get(); + const currentFeedUri = this.hasAttribute("show-selected") + ? selectedFeedUri + : null; + if (failed) { + render(html``, this); + return; + } + render( + pinnedFeedsPaneTemplate({ + pinnedItems: pinnedItems ?? [], + isLoading: pinnedItems === null, + currentFeedUri, + moreFeedsActive: this.hasAttribute("more-feeds-active"), + onSelect: (feedUri) => this.handleSelect(feedUri), + }), + this, + ); + } + + handleSelect(feedUri) { + // On the home page, a "home-feed-select" event lets the view run its + // animated tab switch. From elsewhere, set the selection first so the home + // view's first paint already shows the right feed, then navigate; the view + // picks up the changed selection on page-show. + if (window.location.pathname === "/") { + window.dispatchEvent( + new CustomEvent("home-feed-select", { detail: feedUri }), + ); + return; + } + this.dataLayer.mutations.setSelectedFeedUri(feedUri); + window.router.go("/"); + } + + async load() { + try { + await this.dataLayer.declarative.ensurePinnedItems(); + } catch (error) { + console.warn("Could not load pinned feeds", error); + this.$failed.set(true); + } + } +} + +PinnedFeedsPane.register(); diff --git a/src/js/dataLayer/dataLayer.js b/src/js/dataLayer/dataLayer.js index e9ea2fc9..72d2b458 100644 --- a/src/js/dataLayer/dataLayer.js +++ b/src/js/dataLayer/dataLayer.js @@ -1,4 +1,5 @@ import { DataStore } from "/js/dataLayer/dataStore.js"; +import { createSessionState } from "/js/dataLayer/sessionState.js"; import { PatchStore } from "/js/dataLayer/patchStore.js"; import { Mutations } from "/js/dataLayer/mutations.js"; import { Requests } from "/js/dataLayer/requests.js"; @@ -20,7 +21,8 @@ export class DataLayer extends EventEmitter { this.identityResolver = identityResolver; this.draftMediaStore = draftMediaStore; this.isAuthenticated = api.isAuthenticated; - this.dataStore = new DataStore(); + this.sessionState = createSessionState(api.session ?? null); + this.dataStore = new DataStore(this.sessionState); this.patchStore = new PatchStore(this.dataStore); this.preferencesProvider = preferencesProvider; this.hiddenFeedItemsStore = hiddenFeedItemsStore; diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index 92c2faf8..6eef7f54 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -3,8 +3,9 @@ import { getQuotedPost, embedViewRecordToPostView } from "/js/dataHelpers.js"; // The store saves canonical data from the server. Patches are layered on top of this. export class DataStore extends ReactiveStore { - constructor() { + constructor(sessionState) { super("dataStore"); + this.sessionState = sessionState; // Single-value signals this.$currentUser = new Signal.State(null); this.$profileSearchResults = new Signal.State(null); @@ -16,6 +17,7 @@ export class DataStore extends ReactiveStore { this.$mentionNotifications = new Signal.State(null); this.$notificationsLastSeenAt = new Signal.State(null); this.$pinnedItems = new Signal.State(null); + this.$selectedFeedUri = this.sessionState.$selectedFeedUri; this.$bookmarks = new Signal.State(null); this.$drafts = new Signal.State(null); this.$convoList = new Signal.State(null); @@ -137,4 +139,20 @@ export class DataStore extends ReactiveStore { } } } + + // All pinned item writes go through here so the selected feed can't dangle: + // a selection that's no longer pinned falls back to the first pinned item. + setPinnedItems(pinnedItems) { + this.$pinnedItems.set(pinnedItems); + const selectedFeedUri = this.$selectedFeedUri.get(); + if (!selectedFeedUri) { + return; + } + const isPinned = pinnedItems.some( + (item) => item.data.uri === selectedFeedUri, + ); + if (!isPinned) { + this.$selectedFeedUri.set(pinnedItems[0]?.data.uri ?? null); + } + } } diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index ebeaa5cf..5b3ec695 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -342,6 +342,9 @@ export class Derived extends ReactiveStore { () => this.dataStore.$feedSearchResults.get()?.cursor ?? null, ); this.$trends = new Signal.Computed(() => this.dataStore.$trends.get()); + this.$selectedFeedUri = new Signal.Computed(() => + this.dataStore.$selectedFeedUri.get(), + ); this.$postSearchResultsTop = new Signal.Computed(() => this.hydratePostSearchResults(this.dataStore.$postSearchResultsTop), ); diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 0348fe71..30baae20 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -497,10 +497,14 @@ export class Mutations { pinnedItems.map((item) => [valueForPinnedItem(item), item]), ); const next = values.map((value) => byValue.get(value)).filter(Boolean); - this.dataStore.$pinnedItems.set(next); + this.dataStore.setPinnedItems(next); } } + setSelectedFeedUri(feedUri) { + this.dataStore.$selectedFeedUri.set(feedUri); + } + async hidePost(post) { const patchId = this.patchStore.addPostPatch(post.uri, { type: "hidePost", @@ -1165,7 +1169,7 @@ export class Mutations { } const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); if (pinnedItems?.some((item) => item.data?.uri === list.uri)) { - this.dataStore.$pinnedItems.set( + this.dataStore.setPinnedItems( pinnedItems.filter((item) => item.data?.uri !== list.uri), ); } diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index e3ef5c26..4d2263b9 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -1285,7 +1285,7 @@ export class Requests { } } - this.dataStore.$pinnedItems.set(orderedItems); + this.dataStore.setPinnedItems(orderedItems); } async loadActorFeeds(did, { reload = false, limit = 50 } = {}) { diff --git a/src/js/dataLayer/sessionState.js b/src/js/dataLayer/sessionState.js new file mode 100644 index 00000000..df11475c --- /dev/null +++ b/src/js/dataLayer/sessionState.js @@ -0,0 +1,42 @@ +import { Signal, ReactiveStore, PersistedReactiveStore } from "/js/signals.js"; + +const LEGACY_SELECTED_FEED_URI_STORAGE_KEY = "home-view-currentFeedUri"; + +function migrateLegacySelectedFeedUri(storageKey) { + if (localStorage.getItem(storageKey) !== null) { + return; + } + const legacyStored = localStorage.getItem( + LEGACY_SELECTED_FEED_URI_STORAGE_KEY, + ); + localStorage.removeItem(LEGACY_SELECTED_FEED_URI_STORAGE_KEY); + if (legacyStored === null) { + return; + } + let selectedFeedUri = null; + try { + selectedFeedUri = JSON.parse(legacyStored); + } catch { + return; + } + if (!selectedFeedUri) { + return; + } + localStorage.setItem(storageKey, JSON.stringify({ selectedFeedUri })); +} + +// Local client state, persisted per account. Logged-out session +// state (a null session) doesn't persist. +export function createSessionState(session) { + let sessionState; + if (session) { + const storageKey = `session-state:${session.did}`; + migrateLegacySelectedFeedUri(storageKey); + sessionState = new PersistedReactiveStore(storageKey); + } else { + sessionState = new ReactiveStore("sessionState"); + } + // The home view's selected feed, shared with the pinned feeds pane + sessionState.$selectedFeedUri = new Signal.State(null); + return sessionState; +} diff --git a/src/js/displayPreferences.js b/src/js/displayPreferences.js index a7b6724e..60ff2cbe 100644 --- a/src/js/displayPreferences.js +++ b/src/js/displayPreferences.js @@ -1,85 +1,4 @@ -import { Signal, effect, untrack } from "/js/signals.js"; - -function readPersisted(storageKey) { - let parsed = null; - try { - parsed = JSON.parse(localStorage.getItem(storageKey)); - } catch { - return {}; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - return parsed; -} - -// A store whose `$signals` are backed by one JSON object in localStorage. -export class PersistedReactiveStore { - #storageKey; - #stored; - #defaults = new Map(); - #signals = new Map(); - - constructor(storageKey) { - this.#storageKey = storageKey; - this.#stored = readPersisted(storageKey); - return new Proxy(this, { - set(target, prop, value) { - if (prop.startsWith("$")) { - target.#register(prop, value); - } else { - target[prop] = value; - } - return true; - }, - }); - } - - #register(prop, $signal) { - $signal.__debugName = `${this.#storageKey}.${prop}`; - const key = prop.slice(1); - const defaultValue = untrack(() => $signal.get()); - this.#defaults.set(key, defaultValue); - this.#signals.set(key, $signal); - if (typeof this.#stored[key] === typeof defaultValue) { - $signal.set(this.#stored[key]); - } - // `this` is the target rather than the proxy, so this won't re-enter the trap - this[prop] = $signal; - // effect() runs on creation; skip that so declaring a signal doesn't - // immediately write its default back to storage - let isFirstRun = true; - effect(() => { - $signal.get(); - if (isFirstRun) { - isFirstRun = false; - return; - } - this.#save(); - }); - } - - // untracked so one signal's save effect doesn't subscribe to all the others - #save() { - const data = { ...this.#stored }; - untrack(() => { - for (const [key, $signal] of this.#signals) { - const value = $signal.get(); - if (value === this.#defaults.get(key)) { - delete data[key]; - } else { - data[key] = value; - } - } - }); - this.#stored = data; - if (Object.keys(data).length === 0) { - localStorage.removeItem(this.#storageKey); - } else { - localStorage.setItem(this.#storageKey, JSON.stringify(data)); - } - } -} +import { Signal, PersistedReactiveStore } from "/js/signals.js"; const displayPreferences = new PersistedReactiveStore("display-preferences"); diff --git a/src/js/mainLayout.js b/src/js/mainLayout.js index 5a9a288b..607c11f0 100644 --- a/src/js/mainLayout.js +++ b/src/js/mainLayout.js @@ -53,6 +53,7 @@ import { Layout } from "/js/router.js"; import "/js/components/animated-sidebar.js"; import "/js/components/trending-pane.js"; import "/js/components/sidebar-search.js"; +import "/js/components/pinned-feeds-pane.js"; export function mainLayoutTemplate({ isAuthenticated = true, @@ -104,6 +105,13 @@ export function mainLayoutTemplate({ .dataLayer=${dataLayer} .isAuthenticated=${isAuthenticated} >`} + ${isAuthenticated + ? html`` + : ""} diff --git a/src/js/signals.js b/src/js/signals.js index 26da5d66..cf04db87 100644 --- a/src/js/signals.js +++ b/src/js/signals.js @@ -437,3 +437,92 @@ export class ReactiveStore extends EventEmitter { }); } } + +function readPersisted(storageKey) { + let parsed = null; + try { + parsed = JSON.parse(localStorage.getItem(storageKey)); + } catch { + return {}; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return parsed; +} + +// A store whose `$signals` are backed by one JSON object in localStorage. +export class PersistedReactiveStore { + #storageKey; + #stored; + #defaults = new Map(); + #signals = new Map(); + + constructor(storageKey) { + if (!storageKey) { + throw new Error("PersistedReactiveStore requires a storage key"); + } + this.#storageKey = storageKey; + this.#stored = readPersisted(storageKey); + return new Proxy(this, { + set(target, prop, value) { + if (prop.startsWith("$")) { + target.#register(prop, value); + } else { + target[prop] = value; + } + return true; + }, + }); + } + + #register(prop, $signal) { + $signal.__debugName = `${this.#storageKey}.${prop}`; + const key = prop.slice(1); + const defaultValue = untrack(() => $signal.get()); + this.#defaults.set(key, defaultValue); + this.#signals.set(key, $signal); + const storedValue = this.#stored[key]; + const restores = + defaultValue === null + ? storedValue !== undefined + : typeof storedValue === typeof defaultValue; + if (restores) { + $signal.set(storedValue); + } + // `this` is the target rather than the proxy, so this won't re-enter the trap + this[prop] = $signal; + // effect() runs on creation; skip that so declaring a signal doesn't + // immediately write its default back to storage + let isFirstRun = true; + effect(() => { + $signal.get(); + if (isFirstRun) { + isFirstRun = false; + return; + } + this.#save(); + }); + } + + // untracked so one signal's save effect doesn't subscribe to all the others + #save() { + const data = { ...this.#stored }; + untrack(() => { + for (const [key, $signal] of this.#signals) { + const value = $signal.get(); + if (value === this.#defaults.get(key)) { + delete data[key]; + } else { + data[key] = value; + } + } + }); + this.#stored = data; + if (Object.keys(data).length === 0) { + localStorage.removeItem(this.#storageKey); + } else { + localStorage.setItem(this.#storageKey, JSON.stringify(data)); + } + } +} diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index c9724958..5a14fac7 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -42,54 +42,18 @@ export default async function homeView({ interactionHandlers, }, }) { - const CURRENT_FEED_URI_STORAGE_KEY = "home-view-currentFeedUri"; const WELCOME_MODAL_SEEN_STORAGE_KEY = "welcome-modal-seen"; - // 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, - ); state.$isReloadingFeed = new Signal.State(false); state.$materializedFeedUris = new SignalSet(); - function resetToDefaultFeed() { - state.$currentFeedUri.set( + if (!dataLayer.derived.$selectedFeedUri.get()) { + dataLayer.mutations.setSelectedFeedUri( isAuthenticated ? FOLLOWING_FEED_URI : LOGGED_OUT_FEED_URI, ); } - if (!state.$currentFeedUri.get()) { - resetToDefaultFeed(); - } - if ( !isAuthenticated && !pluginService.isPreviewMode && @@ -185,7 +149,7 @@ export default async function homeView({ } async function handleTabClick(feedUri) { - let currentFeedUri = state.$currentFeedUri.get(); + let currentFeedUri = dataLayer.derived.$selectedFeedUri.get(); if (feedUri === currentFeedUri) { scrollAndReloadFeed(); return; @@ -194,8 +158,7 @@ export default async function homeView({ feedScrollState.set(currentFeedUri, window.scrollY); state.$materializedFeedUris.add(currentFeedUri); // Switch feed - state.$currentFeedUri.set(feedUri); - saveFeedUri(feedUri); + dataLayer.mutations.setSelectedFeedUri(feedUri); // Scroll to saved position for new feed const savedScrollY = feedScrollState.get(feedUri) ?? 0; requestAnimationFrame(() => { @@ -233,9 +196,15 @@ export default async function homeView({ scrollAndReloadFeed(); }); + // Dispatched by the pinned feeds pane in the right column + bindToPage(root, window, "home-feed-select", (event) => { + const feedUri = event.detail; + handleTabClick(feedUri); + }); + const $currentPinnedItem = new Signal.Computed(() => { const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get() ?? []; - const currentFeedUri = state.$currentFeedUri.get(); + const currentFeedUri = dataLayer.derived.$selectedFeedUri.get(); return pinnedItems.find((item) => item.uri === currentFeedUri); }); @@ -306,7 +275,7 @@ export default async function homeView({ pageEffect(root, () => { const currentUser = dataLayer.derived.$currentUser.get(); const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get() ?? []; - const currentFeedUri = state.$currentFeedUri.get(); + const currentFeedUri = dataLayer.derived.$selectedFeedUri.get(); const currentFeedRequestStatus = dataLayer.requests.statusStore.$statuses.get( "loadNextFeedPage-" + currentFeedUri, @@ -390,7 +359,7 @@ export default async function homeView({ } async function loadCurrentFeed({ reload = false } = {}) { - const currentFeedUri = state.$currentFeedUri.get(); + const currentFeedUri = dataLayer.derived.$selectedFeedUri.get(); await dataLayer.requests.loadNextFeedPage( getFeedRequestDescriptor(currentFeedUri), { reload, limit: FEED_PAGE_SIZE + 1 }, @@ -398,10 +367,11 @@ export default async function homeView({ } async function preloadHiddenFeeds(pinnedItems) { - const currentFeedUri = state.$currentFeedUri.get(); + const currentFeedUri = dataLayer.derived.$selectedFeedUri.get(); const itemsToPreload = pinnedItems .filter((item) => item.uri !== currentFeedUri) - .slice(0, 5); + .slice(0, 5) + .filter((item) => !dataLayer.hasCachedFeed(item.uri)); for (const item of itemsToPreload) { await dataLayer.requests.loadNextFeedPage(item, { limit: FEED_PAGE_SIZE + 1, @@ -419,23 +389,20 @@ export default async function homeView({ } async function loadPageData() { - const currentFeedUri = state.$currentFeedUri.get(); const pinnedItems = await dataLayer.declarative.ensurePinnedItems(); - if (!pinnedItems.some((item) => item.uri === currentFeedUri)) { - resetToDefaultFeed(); - } preloadHiddenFeeds(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 }); + if (!dataLayer.hasCachedFeed(dataLayer.derived.$selectedFeedUri.get())) { + await loadCurrentFeed(); + } } - loadPageData().catch((error) => console.error(error)); - onPageShow(root, () => { + loadPageData().catch((error) => console.error(error)); for (const observer of postSeenObservers.values()) { observer.connect(); } diff --git a/tests/e2e/specs/concerns/pinnedFeedsPane.test.js b/tests/e2e/specs/concerns/pinnedFeedsPane.test.js new file mode 100644 index 00000000..7dd886bd --- /dev/null +++ b/tests/e2e/specs/concerns/pinnedFeedsPane.test.js @@ -0,0 +1,98 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { createFeedGenerator, createPost } from "../../../shared/factories.js"; + +test.describe("Pinned feeds pane", () => { + let mockServer; + const feed = createFeedGenerator({ + uri: "at://did:plc:creator1/app.bsky.feed.generator/trending", + displayName: "Trending", + creatorHandle: "creator1.bsky.social", + }); + + test.beforeEach(async ({ page }) => { + mockServer = new MockServer(); + mockServer.addFeedGenerators([feed]); + mockServer.setPinnedFeeds([feed.uri]); + mockServer.addFeedItems(feed.uri, [ + createPost({ + uri: "at://did:plc:author1/app.bsky.feed.post/feedpost1", + text: "Post from the pinned feed", + authorHandle: "author1.bsky.social", + }), + ]); + await mockServer.setup(page); + await login(page); + }); + + test("lists pinned feeds with a More feeds link in the right column", async ({ + page, + }) => { + await page.goto("/"); + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); + + const pane = page.locator('[data-testid="pinned-feeds-pane"]'); + await expect(pane).toBeVisible(); + const items = pane.locator('[data-testid="pinned-feeds-item"]'); + await expect(items).toHaveCount(2, { timeout: 10000 }); + await expect(items.nth(0)).toContainText("Following"); + await expect(items.nth(1)).toContainText("Trending"); + await expect( + pane.locator('[data-testid="pinned-feeds-more"]'), + ).toBeVisible(); + }); + + test("switches the home feed when a pinned feed is clicked", async ({ + page, + }) => { + await page.goto("/"); + const view = page.locator("#home-view"); + await expect(view.locator(".tab-bar-button.active")).toContainText( + "Following", + { timeout: 10000 }, + ); + + const items = page.locator('[data-testid="pinned-feeds-item"]'); + await expect(items).toHaveCount(2, { timeout: 10000 }); + await items.nth(1).click(); + + await expect(view.locator(".tab-bar-button.active")).toContainText( + "Trending", + ); + await expect(items.nth(1)).toHaveClass(/active/); + await expect(view).toContainText("Post from the pinned feed", { + timeout: 10000, + }); + }); + + test("navigates home and selects the feed when clicked from another page", async ({ + page, + }) => { + await page.goto("/notifications"); + await expect(page.locator("#notifications-view")).toBeVisible({ + timeout: 10000, + }); + + const items = page.locator('[data-testid="pinned-feeds-item"]'); + await expect(items).toHaveCount(2, { timeout: 10000 }); + await items.nth(1).click(); + + await expect(page).toHaveURL("/"); + await expect( + page.locator("#home-view .tab-bar-button.active"), + ).toContainText("Trending", { timeout: 10000 }); + }); + + test("navigates to the feeds view from the More feeds link", async ({ + page, + }) => { + await page.goto("/"); + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); + + await page.locator('[data-testid="pinned-feeds-more"]').click(); + + await expect(page).toHaveURL("/feeds"); + await expect(page.locator("#feeds-view")).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/tests/e2e/specs/flows/pinFeed.test.js b/tests/e2e/specs/flows/pinFeed.test.js index 8bc98168..1e2acd41 100644 --- a/tests/e2e/specs/flows/pinFeed.test.js +++ b/tests/e2e/specs/flows/pinFeed.test.js @@ -1,9 +1,66 @@ import { test, expect } from "../../base.js"; import { login } from "../../helpers.js"; import { MockServer } from "../../mockServer.js"; -import { createFeedGenerator } from "../../../shared/factories.js"; +import { createFeedGenerator, createPost } from "../../../shared/factories.js"; test.describe("Pin feed flow", () => { + test("should fall back to Following on home after unpinning the selected feed", 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]); + mockServer.addFeedItems(feed.uri, [ + createPost({ + uri: "at://did:plc:author1/app.bsky.feed.post/trendingpost", + text: "Post from the trending feed", + authorHandle: "author1.bsky.social", + }), + ]); + await mockServer.setup(page); + await login(page); + + // Select the pinned feed on home + await page.goto("/"); + const homeView = page.locator("#home-view"); + const tabs = homeView.locator(".tab-bar-button"); + await expect(tabs).toHaveCount(2, { timeout: 10000 }); + await tabs.nth(1).click(); + await expect(homeView.locator(".tab-bar-button.active")).toContainText( + "Trending", + ); + + // Unpin it from the feeds view (in-app navigation keeps home cached) + await page.locator('[data-testid="pinned-feeds-more"]').click(); + const feedsView = page.locator("#feeds-view"); + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(2, { + timeout: 10000, + }); + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); + await feedsView + .locator(".feeds-list-item", { hasText: "Trending" }) + .locator('[data-testid="feeds-list-item-unpin-button"]') + .click(); + await feedsView.locator('[data-testid="feeds-save-button"]').click(); + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(1, { + timeout: 10000, + }); + + // Back on home, the selection falls back to Following + await page.locator('[data-testid="sidebar-nav-home"]').click(); + await expect(homeView.locator(".tab-bar-button")).toHaveCount(1, { + timeout: 10000, + }); + await expect(homeView.locator(".tab-bar-button.active")).toContainText( + "Following", + ); + }); + test("should show feed in feeds index after pinning from feed detail", async ({ page, }) => { diff --git a/tests/unit/specs/components/pinned-feeds-pane.test.js b/tests/unit/specs/components/pinned-feeds-pane.test.js new file mode 100644 index 00000000..694733cf --- /dev/null +++ b/tests/unit/specs/components/pinned-feeds-pane.test.js @@ -0,0 +1,234 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import "/js/components/pinned-feeds-pane.js"; +import { makeTestDataLayer } from "../../testHelpers.js"; +import { createFeedGenerator, createList } from "../../../shared/factories.js"; + +describe("pinned-feeds-pane", () => { + const feedGenerator = createFeedGenerator({ + uri: "at://did:plc:creator1/app.bsky.feed.generator/trending", + displayName: "Trending", + creatorHandle: "creator1.bsky.social", + }); + const list = createList({ + uri: "at://did:plc:creator1/app.bsky.graph.list/mylist", + name: "My Curated List", + creatorHandle: "creator1.bsky.social", + }); + const pinnedItems = [ + { + type: "timeline", + data: { uri: "following", displayName: "Following" }, + }, + { type: "feed", data: feedGenerator }, + { type: "list", data: list }, + ]; + + let dataLayer; + let element; + let routerGo; + let previousRouter; + + function mount({ showSelected = true, moreFeedsActive = false } = {}) { + element = document.createElement("pinned-feeds-pane"); + element.dataLayer = dataLayer; + if (showSelected) { + element.setAttribute("show-selected", ""); + } + if (moreFeedsActive) { + element.setAttribute("more-feeds-active", ""); + } + document.body.appendChild(element); + return element; + } + + async function flushMicrotasks() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + function itemLabels() { + return [ + ...element.querySelectorAll("[data-testid='pinned-feeds-item']"), + ].map((item) => item.querySelector(".pinned-feeds-item-label").textContent); + } + + beforeEach(() => { + document.body.innerHTML = ""; + dataLayer = makeTestDataLayer(); + routerGo = mock.fn(async () => {}); + previousRouter = window.router; + window.router = { go: routerGo }; + }); + + afterEach(() => { + document.body.innerHTML = ""; + window.router = previousRouter; + window.history.replaceState(null, "", "/"); + }); + + it("renders skeleton rows while pinned items are loading", () => { + mount(); + assert.deepEqual( + element.querySelectorAll("[data-testid='pinned-feeds-skeleton']").length, + 5, + ); + }); + + it("renders a row per pinned item plus the More feeds link", async () => { + dataLayer.dataStore.$pinnedItems.set(pinnedItems); + mount(); + await flushMicrotasks(); + + assert.deepEqual(itemLabels(), [ + "Following", + "Trending", + "My Curated List", + ]); + const moreLink = element.querySelector("[data-testid='pinned-feeds-more']"); + assert.deepEqual(moreLink.getAttribute("href"), "/feeds"); + }); + + it("renders an icon for the timeline item and avatars for feeds and lists", async () => { + dataLayer.dataStore.$pinnedItems.set(pinnedItems); + mount(); + await flushMicrotasks(); + + const items = element.querySelectorAll("[data-testid='pinned-feeds-item']"); + assert(items[0].querySelector(".pinned-feeds-timeline-icon") !== null); + assert.deepEqual( + items[1].querySelector(".pinned-feeds-item-avatar").getAttribute("src"), + "/img/feed-avatar-fallback.svg", + ); + assert.deepEqual( + items[2].querySelector(".pinned-feeds-item-avatar").getAttribute("src"), + "/img/list-avatar-fallback.svg", + ); + }); + + it("highlights the selected feed while on the home route", async () => { + dataLayer.dataStore.$pinnedItems.set(pinnedItems); + dataLayer.dataStore.$selectedFeedUri.set(feedGenerator.uri); + mount(); + await flushMicrotasks(); + + const active = element.querySelectorAll(".pinned-feeds-item.active"); + assert.deepEqual(active.length, 1); + assert.deepEqual( + active[0].querySelector(".pinned-feeds-item-label").textContent, + "Trending", + ); + }); + + it("moves the highlight when the selected feed changes", async () => { + dataLayer.dataStore.$pinnedItems.set(pinnedItems); + dataLayer.dataStore.$selectedFeedUri.set(feedGenerator.uri); + mount(); + await flushMicrotasks(); + + dataLayer.dataStore.$selectedFeedUri.set(list.uri); + await flushMicrotasks(); + + const active = element.querySelectorAll(".pinned-feeds-item.active"); + assert.deepEqual(active.length, 1); + assert.deepEqual( + active[0].querySelector(".pinned-feeds-item-label").textContent, + "My Curated List", + ); + }); + + it("does not highlight any feed when show-selected is absent", async () => { + dataLayer.dataStore.$pinnedItems.set(pinnedItems); + dataLayer.dataStore.$selectedFeedUri.set(feedGenerator.uri); + mount({ showSelected: false }); + await flushMicrotasks(); + + assert.deepEqual( + element.querySelectorAll(".pinned-feeds-item.active").length, + 0, + ); + }); + + it("re-renders the highlight when the show-selected attribute changes", async () => { + dataLayer.dataStore.$pinnedItems.set(pinnedItems); + dataLayer.dataStore.$selectedFeedUri.set(feedGenerator.uri); + mount(); + await flushMicrotasks(); + assert.deepEqual( + element.querySelectorAll(".pinned-feeds-item.active").length, + 1, + ); + + element.removeAttribute("show-selected"); + assert.deepEqual( + element.querySelectorAll(".pinned-feeds-item.active").length, + 0, + ); + + element.setAttribute("show-selected", ""); + assert.deepEqual( + element.querySelectorAll(".pinned-feeds-item.active").length, + 1, + ); + }); + + it("highlights the More feeds link when more-feeds-active is set", async () => { + dataLayer.dataStore.$pinnedItems.set(pinnedItems); + mount({ showSelected: false, moreFeedsActive: true }); + await flushMicrotasks(); + + const moreLink = element.querySelector("[data-testid='pinned-feeds-more']"); + assert(moreLink.classList.contains("active")); + }); + + it("dispatches home-feed-select without navigating when already on home", async () => { + dataLayer.dataStore.$pinnedItems.set(pinnedItems); + mount(); + await flushMicrotasks(); + const selected = []; + const listener = (event) => selected.push(event.detail); + window.addEventListener("home-feed-select", listener); + + element.querySelectorAll("[data-testid='pinned-feeds-item']")[1].click(); + await flushMicrotasks(); + window.removeEventListener("home-feed-select", listener); + + assert.deepEqual(routerGo.mock.callCount(), 0); + assert.deepEqual(selected, [feedGenerator.uri]); + }); + + it("sets the selection before navigating home from another route", async () => { + window.history.replaceState(null, "", "/notifications"); + dataLayer.dataStore.$pinnedItems.set(pinnedItems); + mount({ showSelected: false }); + await flushMicrotasks(); + const selected = []; + const listener = (event) => selected.push(event.detail); + window.addEventListener("home-feed-select", listener); + let selectedFeedUriAtNavigation = null; + routerGo.mock.mockImplementation(async () => { + selectedFeedUriAtNavigation = dataLayer.dataStore.$selectedFeedUri.get(); + }); + + element.querySelectorAll("[data-testid='pinned-feeds-item']")[0].click(); + await flushMicrotasks(); + window.removeEventListener("home-feed-select", listener); + + assert.deepEqual(routerGo.mock.callCount(), 1); + assert.deepEqual(routerGo.mock.calls[0].arguments, ["/"]); + assert.deepEqual(selectedFeedUriAtNavigation, "following"); + // The home view applies the pre-set selection itself; no event is needed + assert.deepEqual(selected, []); + }); + + it("renders nothing when loading pinned items fails", async () => { + // makeTestDataLayer has no loaded preferences, so ensurePinnedItems rejects + mount(); + await flushMicrotasks(); + + assert.deepEqual( + element.querySelector("[data-testid='pinned-feeds-pane']"), + null, + ); + }); +}); diff --git a/tests/unit/specs/dataLayer/dataLayer.test.js b/tests/unit/specs/dataLayer/dataLayer.test.js index 7d6ca1ce..af741901 100644 --- a/tests/unit/specs/dataLayer/dataLayer.test.js +++ b/tests/unit/specs/dataLayer/dataLayer.test.js @@ -1,4 +1,4 @@ -import { describe, it } from "node:test"; +import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { DataLayer } from "/js/dataLayer/dataLayer.js"; import { DraftMediaStore } from "/js/drafts.js"; @@ -7,9 +7,11 @@ import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js"; import { Constellation } from "/js/constellation.js"; function createMockApi(options = {}) { + const isAuthenticated = options.isAuthenticated ?? false; return { getProfile: async (did) => options.profiles?.[did] ?? null, - isAuthenticated: options.isAuthenticated ?? false, + isAuthenticated, + session: isAuthenticated ? { did: "did:plc:testuser" } : null, getPreferences: async () => options.preferences ?? [], getLabelers: async () => options.labelers ?? [], updatePreferences: async () => {}, @@ -206,3 +208,87 @@ describe("component integration", () => { assert(profile !== null); }); }); + +describe("selected feed persistence", () => { + // createMockApi's authenticated session did + const sessionStateKey = "session-state:did:plc:testuser"; + const legacyKey = "home-view-currentFeedUri"; + + // PersistedReactiveStore saves via an effect, which flushes on a double + // requestAnimationFrame + function flushEffects() { + return new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + } + + function cleanup() { + localStorage.removeItem(sessionStateKey); + localStorage.removeItem(legacyKey); + } + + function createAuthedDataLayer() { + return createDataLayer(createMockApi({ isAuthenticated: true })); + } + + beforeEach(cleanup); + afterEach(cleanup); + + it("restores the stored selection for the account", () => { + localStorage.setItem( + sessionStateKey, + JSON.stringify({ selectedFeedUri: "following" }), + ); + const dataLayer = createAuthedDataLayer(); + assert.deepEqual(dataLayer.dataStore.$selectedFeedUri.get(), "following"); + }); + + it("migrates a selection stored under the legacy key", () => { + localStorage.setItem(legacyKey, JSON.stringify("following")); + const dataLayer = createAuthedDataLayer(); + assert.deepEqual(dataLayer.dataStore.$selectedFeedUri.get(), "following"); + assert.deepEqual(localStorage.getItem(legacyKey), null); + }); + + it("prefers the session-state key over the legacy key", () => { + localStorage.setItem( + sessionStateKey, + JSON.stringify({ selectedFeedUri: "following" }), + ); + localStorage.setItem(legacyKey, JSON.stringify("stale")); + const dataLayer = createAuthedDataLayer(); + assert.deepEqual(dataLayer.dataStore.$selectedFeedUri.get(), "following"); + }); + + it("persists selection changes", async () => { + const dataLayer = createAuthedDataLayer(); + dataLayer.mutations.setSelectedFeedUri("following"); + await flushEffects(); + assert.deepEqual(JSON.parse(localStorage.getItem(sessionStateKey)), { + selectedFeedUri: "following", + }); + }); + + it("persists a fallback applied by setPinnedItems", async () => { + const dataLayer = createAuthedDataLayer(); + dataLayer.mutations.setSelectedFeedUri( + "at://did:test/app.bsky.feed.generator/gone", + ); + dataLayer.dataStore.setPinnedItems([ + { type: "timeline", data: { uri: "following" } }, + ]); + await flushEffects(); + assert.deepEqual(JSON.parse(localStorage.getItem(sessionStateKey)), { + selectedFeedUri: "following", + }); + }); + + it("neither restores nor persists without a session", async () => { + localStorage.setItem(legacyKey, JSON.stringify("following")); + const dataLayer = createDataLayer(createMockApi()); + assert.deepEqual(dataLayer.dataStore.$selectedFeedUri.get(), null); + dataLayer.mutations.setSelectedFeedUri("following"); + await flushEffects(); + assert.deepEqual(localStorage.getItem(sessionStateKey), null); + }); +}); diff --git a/tests/unit/specs/dataLayer/dataStore.test.js b/tests/unit/specs/dataLayer/dataStore.test.js index bd30ab2b..f11ddb92 100644 --- a/tests/unit/specs/dataLayer/dataStore.test.js +++ b/tests/unit/specs/dataLayer/dataStore.test.js @@ -1,10 +1,11 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { DataStore } from "/js/dataLayer/dataStore.js"; +import { createSessionState } from "/js/dataLayer/sessionState.js"; describe("setPosts", () => { it("should insert multiple posts", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const posts = [ { uri: "at://did:test/app.bsky.feed.post/1", record: { text: "one" } }, { uri: "at://did:test/app.bsky.feed.post/2", record: { text: "two" } }, @@ -17,8 +18,8 @@ describe("setPosts", () => { }); it("should match $posts.set behavior when given a single post", () => { - const dataStoreA = new DataStore(); - const dataStoreB = new DataStore(); + const dataStoreA = new DataStore(createSessionState(null)); + const dataStoreB = new DataStore(createSessionState(null)); const post = { uri: "at://did:test/app.bsky.feed.post/solo", record: { text: "solo" }, @@ -32,7 +33,7 @@ describe("setPosts", () => { }); it("should store nested quoted posts as embedded previews", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const nestedQuotedPost = { $type: "app.bsky.embed.record#viewRecord", uri: "at://did:test/app.bsky.feed.post/nested", @@ -97,7 +98,7 @@ describe("setPosts", () => { }); it("should replace an embedded preview with a full post", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const quotedUri = "at://did:test/app.bsky.feed.post/quoted"; const quotedPost = { $type: "app.bsky.embed.record#viewRecord", @@ -135,7 +136,7 @@ describe("setPosts", () => { describe("setConvo", () => { it("should save the convo and prepend it to the loaded convo list", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "c1" }], cursor: "page2" }); const convo = { id: "c2", status: "accepted" }; @@ -150,7 +151,7 @@ describe("setConvo", () => { }); it("should replace an existing list entry instead of duplicating it", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "c1", unreadCount: 0 }], cursor: null, @@ -168,7 +169,7 @@ describe("setConvo", () => { }); it("should leave unloaded lists null", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.setConvo({ id: "c1", status: "accepted" }); @@ -178,7 +179,7 @@ describe("setConvo", () => { }); it("should route request convos to the request list", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [], cursor: null }); dataStore.$convoRequestList.set({ convos: [], cursor: null }); @@ -192,7 +193,7 @@ describe("setConvo", () => { }); it("should move an accepted convo out of the request list", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [], cursor: null }); dataStore.$convoRequestList.set({ convos: [{ id: "c1", status: "request" }], @@ -208,3 +209,47 @@ describe("setConvo", () => { assert.deepEqual(dataStore.$convoRequestList.get().convos, []); }); }); + +describe("setPinnedItems", () => { + const followingItem = { + type: "timeline", + data: { uri: "following", displayName: "Following" }, + }; + const feedItem = { + type: "feed", + data: { uri: "at://did:test/app.bsky.feed.generator/cats" }, + }; + + it("should save the pinned items", () => { + const dataStore = new DataStore(createSessionState(null)); + dataStore.setPinnedItems([followingItem, feedItem]); + assert.deepEqual(dataStore.$pinnedItems.get(), [followingItem, feedItem]); + }); + + it("should keep a selected feed that is still pinned", () => { + const dataStore = new DataStore(createSessionState(null)); + dataStore.$selectedFeedUri.set(feedItem.data.uri); + dataStore.setPinnedItems([followingItem, feedItem]); + assert.deepEqual(dataStore.$selectedFeedUri.get(), feedItem.data.uri); + }); + + it("should reset a no-longer-pinned selected feed to the first pinned item", () => { + const dataStore = new DataStore(createSessionState(null)); + dataStore.$selectedFeedUri.set(feedItem.data.uri); + dataStore.setPinnedItems([followingItem]); + assert.deepEqual(dataStore.$selectedFeedUri.get(), "following"); + }); + + it("should leave a null selected feed alone", () => { + const dataStore = new DataStore(createSessionState(null)); + dataStore.setPinnedItems([followingItem]); + assert.deepEqual(dataStore.$selectedFeedUri.get(), null); + }); + + it("should clear the selected feed when nothing is pinned", () => { + const dataStore = new DataStore(createSessionState(null)); + dataStore.$selectedFeedUri.set(feedItem.data.uri); + dataStore.setPinnedItems([]); + assert.deepEqual(dataStore.$selectedFeedUri.get(), null); + }); +}); diff --git a/tests/unit/specs/dataLayer/derived.test.js b/tests/unit/specs/dataLayer/derived.test.js index 35ff50aa..bdc8f936 100644 --- a/tests/unit/specs/dataLayer/derived.test.js +++ b/tests/unit/specs/dataLayer/derived.test.js @@ -2,6 +2,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { Derived } from "/js/dataLayer/derived.js"; import { DataStore } from "/js/dataLayer/dataStore.js"; +import { createSessionState } from "/js/dataLayer/sessionState.js"; import { DraftMediaStore, getDraftDeviceId } from "/js/drafts.js"; import { PatchStore } from "/js/dataLayer/patchStore.js"; import { Preferences } from "/js/preferences.js"; @@ -57,13 +58,13 @@ describe("$hydratedFeeds", () => { const feedURI = "at://did:test/app.bsky.feed.generator/test"; it("should return null when feed does not exist", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedFeeds.get(feedURI), null); }); it("should hydrate and return a feed with posts", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const rawFeed = { @@ -88,7 +89,7 @@ describe("$hydratedFeeds", () => { }); it("should apply patches to posts in feed", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore); const rawFeed = { @@ -114,7 +115,7 @@ describe("$hydratedFeeds", () => { describe("$hydratedEmbeddedPosts", () => { it("hydrates embedded posts without exposing them as full posts", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const post = { uri: "at://did:test/app.bsky.feed.post/quoted", @@ -135,13 +136,13 @@ describe("$hydratedHashtagFeeds", () => { const hashtagKey = "javascript-top"; it("should return null when feed does not exist", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedHashtagFeeds.get(hashtagKey), null); }); it("should hydrate and return a feed with posts", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const rawFeed = { @@ -163,7 +164,7 @@ describe("$hydratedHashtagFeeds", () => { }); it("should attach parentAuthor when post is a reply and parent is loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const parentAuthor = { did: "did:parent", handle: "parent.test" }; @@ -195,7 +196,7 @@ describe("$hydratedHashtagFeeds", () => { }); it("should apply patches to posts in feed", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore); const rawFeed = { @@ -222,13 +223,13 @@ describe("$hydratedProfiles", () => { const did = "did:plc:user"; it("should return null when profile does not exist", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedProfiles.get(did), null); }); it("should return the profile when it exists", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const profile = { did, handle: "user.test", followersCount: 10 }; dataStore.$profiles.set(did, profile); @@ -239,7 +240,7 @@ describe("$hydratedProfiles", () => { }); it("should apply profile patches", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore); const profile = { did, @@ -269,13 +270,13 @@ describe("$convoProfiles", () => { } it("should return an empty list for an unknown convo", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$convoProfiles.get(convoId), []); }); it("should return the members when no other profiles are referenced", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); setupConvo(dataStore); const { derived } = makeDerived(dataStore); const profiles = derived.$convoProfiles.get(convoId); @@ -284,7 +285,7 @@ describe("$convoProfiles", () => { }); it("should append hydrated profiles referenced by the last interaction", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); setupConvo(dataStore, { lastMessage: { id: "msg1", sender: { did: referencedDid } }, }); @@ -298,7 +299,7 @@ describe("$convoProfiles", () => { }); it("should append hydrated profiles referenced by loaded messages", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); setupConvo(dataStore); dataStore.$convoMessages.set(convoId, { messages: [ @@ -319,7 +320,7 @@ describe("$convoProfiles", () => { }); it("should not duplicate referenced profiles that are also members", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); setupConvo(dataStore, { lastMessage: { id: "msg1", sender: { did: memberDid } }, }); @@ -330,7 +331,7 @@ describe("$convoProfiles", () => { }); it("should skip referenced dids whose profiles are not hydrated", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); setupConvo(dataStore, { lastMessage: { id: "msg1", sender: { did: referencedDid } }, }); @@ -346,13 +347,13 @@ describe("$hydratedAuthorFeeds", () => { const feedURI = `${did}-posts`; it("should return null when author feed does not exist", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedAuthorFeeds.get(feedURI), null); }); it("should hydrate and return an author feed", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const post1 = { uri: "post1", likeCount: 1 }; const post2 = { uri: "post2", likeCount: 2 }; @@ -370,7 +371,7 @@ describe("$hydratedAuthorFeeds", () => { }); it("should filter to replies-only for replies feed type", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const repliesFeedURI = `${did}-replies`; const post1 = { uri: "post1" }; @@ -394,7 +395,7 @@ describe("$hydratedAuthorFeeds", () => { }); it("should pass through tombstone reply roots and parents unchanged", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const repliesFeedURI = `${did}-replies`; const notFoundRoot = { @@ -424,7 +425,7 @@ describe("$hydratedAuthorFeeds", () => { }); it("should hydrate postView reply roots and parents from the post store", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const repliesFeedURI = `${did}-replies`; const rootPost = { uri: "root", likeCount: 3 }; @@ -448,7 +449,7 @@ describe("$hydratedAuthorFeeds", () => { }); it("should apply author feed patches", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore); const pinnedPost = { uri: "pinned", likeCount: 0 }; const otherPost = { uri: "other", likeCount: 0 }; @@ -471,13 +472,13 @@ describe("$actorFeeds", () => { const did = "did:plc:author"; it("should return null when actor feeds do not exist", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$actorFeeds.get(did), null); }); it("should return the stored actor feeds", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const actorFeeds = { feeds: [{ uri: "feed-1" }], cursor: "c" }; dataStore.$actorFeeds.set(did, actorFeeds); @@ -489,13 +490,13 @@ describe("$profileChatStatus", () => { const did = "did:plc:user"; it("should return null when chat status does not exist", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$profileChatStatus.get(did), null); }); it("should return the stored chat status", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const status = { canChat: true, convo: null }; dataStore.$profileChatStatus.set(did, status); @@ -507,13 +508,13 @@ describe("$labelerInfo", () => { const did = "did:plc:labeler"; it("should return null when labeler info does not exist", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$labelerInfo.get(did), null); }); it("should return the stored labeler info", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const info = { policies: { labelValues: ["spam"] } }; dataStore.$labelerInfo.set(did, info); @@ -523,7 +524,7 @@ describe("$labelerInfo", () => { describe("$labelerSettings", () => { it("should return labeler settings from preferences", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const labelerDid = "did:plc:labeler"; const result = derived.$labelerSettings.get(labelerDid); @@ -534,13 +535,13 @@ describe("$labelerSettings", () => { describe("$hydratedBookmarks", () => { it("should return null when bookmarks do not exist", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedBookmarks.get(), null); }); it("should hydrate and return bookmarks", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const post1 = { uri: "post1", likeCount: 5 }; const post2 = { uri: "post2", likeCount: 10 }; @@ -558,7 +559,7 @@ describe("$hydratedBookmarks", () => { }); it("should attach parentAuthor when bookmarked post is a reply", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const parentAuthor = { did: "did:parent", handle: "parent.test" }; dataStore.$posts.set("post-parent", { @@ -586,13 +587,13 @@ describe("$hydratedBookmarks", () => { describe("$hydratedPinnedItems", () => { it("should return null when pinned items are not set", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedPinnedItems.get(), null); }); it("should hydrate pinned feed generators from the store", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const fg1 = { uri: "feed-1", displayName: "Feed One" }; const fg2 = { uri: "feed-2", displayName: "Feed Two" }; @@ -612,7 +613,7 @@ describe("$hydratedPinnedItems", () => { }); it("should hydrate list and timeline entries", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const list = { uri: "list-1", name: "My List" }; dataStore.$pinnedItems.set([ @@ -632,13 +633,13 @@ describe("$hydratedPosts (post hydration)", () => { const postURI = "at://did:test/app.bsky.feed.post/x"; it("should return null when the post does not exist", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedPosts.get(postURI), null); }); it("should mark the post when it contains a muted word", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ postHasMutedWord: () => true }), }); @@ -648,7 +649,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should not mark the post when there is no muted word match", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -658,7 +659,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should mark the post hidden when preferences say so", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ isPostHidden: () => true }), }); @@ -668,7 +669,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should attach badge, content, and media labels from preferences", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ getBadgeLabelsForPost: () => ["badge"], @@ -684,7 +685,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should leave the post untouched when no labels apply", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -696,7 +697,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should compose muted/hidden/label marks on a single post", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ postHasMutedWord: () => true, @@ -712,7 +713,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should synthesize a #blockedPost when the viewer blocks the author", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -737,7 +738,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should not re-synthesize a post that is already a #blockedPost", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -770,7 +771,7 @@ describe("$hydratedPosts (post hydration)", () => { } it("should keep a viewer-blocked quote as blocked when the quoted post is not loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -786,7 +787,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should mark a viewer-blocked quote as deleted when the post is confirmed unavailable", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -807,7 +808,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should mark a blocked-by quote as deleted when the post is confirmed unavailable", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -825,7 +826,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should keep a viewer-blocked quote blocked even when the quoted post is loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -848,7 +849,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should resolve a third-party-blocked quote when the quoted post is loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -869,7 +870,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should keep the quote blocked when the quoted author blocks the viewer", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -889,7 +890,7 @@ describe("$hydratedPosts (post hydration)", () => { }); it("should return the post unchanged when there is no blocked quote to resolve", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -907,7 +908,7 @@ describe("$convoForProfile", () => { const members = [{ did: "did:plc:me" }, { did: profileDid }]; it("should return the direct convo containing the profile", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convos.set("direct1", { id: "direct1", members, @@ -919,7 +920,7 @@ describe("$convoForProfile", () => { }); it("should ignore group convos even with two members", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convos.set("group1", { id: "group1", members, @@ -942,7 +943,7 @@ describe("$hydratedConvoMessages", () => { const convoId = "convo-1"; it("should return null when the convo has no messages", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedConvoMessages.get(convoId), null); }); @@ -958,7 +959,7 @@ describe("$hydratedConvoMessages", () => { } it("should pass through raw messages and cursor", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); seedMessages( dataStore, [ @@ -976,7 +977,7 @@ describe("$hydratedConvoMessages", () => { }); it("should preserve replyTo when present on a message", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const replyTo = { $type: "chat.bsky.convo.defs#messageView", id: "m1", @@ -1009,7 +1010,7 @@ describe("$hydratedConvoMessages", () => { }); it("should drop reactions from senders the viewer is blocking or blocked by", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); seedConvoMembers(dataStore, [ { did: "did:plc:blocked", @@ -1037,7 +1038,7 @@ describe("$hydratedConvoMessages", () => { }); it("should keep reactions from senders who are not convo members", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); seedConvoMembers(dataStore, [{ did: "did:plc:alice", viewer: {} }]); seedMessages(dataStore, [ { @@ -1053,7 +1054,7 @@ describe("$hydratedConvoMessages", () => { }); it("should leave messages without reactions untouched", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); seedMessages(dataStore, [ { id: "m1", sender: { did: "did:plc:alice" }, text: "hello" }, ]); @@ -1063,7 +1064,7 @@ describe("$hydratedConvoMessages", () => { }); it("should not recompute when the convo changes but members stay the same", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const members = [{ did: "did:plc:alice", viewer: {} }]; dataStore.$convos.set(convoId, { id: convoId, members, unreadCount: 3 }); seedMessages(dataStore, [ @@ -1115,7 +1116,7 @@ describe("$hydratedDrafts", () => { // `localMedia` seeds the store's $media: path -> { url } | null function hydrateDraftPosts(draftViews, { localMedia = {} } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const draftMediaStore = new DraftMediaStore("test-media"); draftMediaStore.$media.set(localMedia); const { derived } = makeDerived(dataStore, { draftMediaStore }); @@ -1124,7 +1125,7 @@ describe("$hydratedDrafts", () => { } it("is null before loading and carries the view fields and cursor through", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedDrafts.get(), null); dataStore.$drafts.set({ drafts: [makeDraftView()], cursor: "next" }); @@ -1281,13 +1282,13 @@ describe("$groupConvoMemberList", () => { const convoId = "convo1"; it("should return null when nothing is loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$groupConvoMemberList.get(convoId), null); }); it("should pass through the stored members and cursor", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoMemberLists.set(convoId, { members: [{ did: "did:plc:alice" }, { did: "did:plc:bob" }], cursor: "2", @@ -1323,7 +1324,7 @@ describe("$hydratedPosts (nested quotes and author labels)", () => { } it("should mark a quoted post that contains a muted word", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ quotedPostHasMutedWord: (quotedPost) => quotedPost.uri === quotedUri, @@ -1336,7 +1337,7 @@ describe("$hydratedPosts (nested quotes and author labels)", () => { }); it("should mark a quoted post that is hidden", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ isPostHidden: (uri) => uri === quotedUri, @@ -1349,7 +1350,7 @@ describe("$hydratedPosts (nested quotes and author labels)", () => { }); it("should attach a blur label to the post author", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ getProfileBlurLabel: (author) => @@ -1366,7 +1367,7 @@ describe("$hydratedPosts (nested quotes and author labels)", () => { }); it("should keep a third-party-blocked quote blocked when the quoted post is not loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -1406,7 +1407,7 @@ describe("$hydratedPosts (join link previews)", () => { } it("should attach a loaded join link preview to an invite link embed", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const preview = { $type: "chat.bsky.group.defs#joinLinkPreviewView", @@ -1421,7 +1422,7 @@ describe("$hydratedPosts (join link previews)", () => { }); it("should leave the embed unchanged when no preview is loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); dataStore.$posts.set(postURI, makeInviteLinkPost()); const result = derived.$hydratedPosts.get(postURI); @@ -1433,7 +1434,7 @@ describe("$hydratedFeeds (reason, replies, following feed)", () => { const feedURI = "at://did:test/app.bsky.feed.generator/test"; it("should preserve the feed item reason", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const reason = { $type: "app.bsky.feed.defs#reasonRepost", @@ -1449,7 +1450,7 @@ describe("$hydratedFeeds (reason, replies, following feed)", () => { }); it("should hydrate reply root and parent post views and pass through other reply nodes", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const rootPost = { uri: "root1", record: { text: "r" }, likeCount: 7 }; const parentPost = { uri: "parent1", record: { text: "p" }, likeCount: 3 }; @@ -1482,7 +1483,7 @@ describe("$hydratedFeeds (reason, replies, following feed)", () => { }); it("should run the following feed through the following filter pipeline", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const post = createPost({ uri: "at://did:plc:author/app.bsky.feed.post/1", @@ -1512,13 +1513,13 @@ describe("$notifications", () => { } it("should return null when notifications are not loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$notifications.get(), null); }); it("should attach the liked post as subject when it is loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); dataStore.$posts.set("post1", { uri: "post1", record: { text: "hi" } }); seedNotifications(dataStore, [ @@ -1530,7 +1531,7 @@ describe("$notifications", () => { }); it("should attach an unavailable subject when the liked post is missing", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); seedNotifications(dataStore, [ createNotification({ reason: "repost", author, reasonSubject: "gone" }), @@ -1540,7 +1541,7 @@ describe("$notifications", () => { }); it("should resolve via-repost notifications from the record subject", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); dataStore.$posts.set("post1", { uri: "post1", record: { text: "hi" } }); seedNotifications(dataStore, [ @@ -1555,7 +1556,7 @@ describe("$notifications", () => { }); it("should attach post and parent for reply notifications", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); dataStore.$posts.set("reply1", { uri: "reply1", record: { text: "r" } }); dataStore.$posts.set("parent1", { uri: "parent1", record: { text: "p" } }); @@ -1573,7 +1574,7 @@ describe("$notifications", () => { }); it("should leave parentPost null for mentions without a reply parent", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); dataStore.$posts.set("m1", { uri: "m1", record: { text: "m" } }); seedNotifications(dataStore, [ @@ -1585,7 +1586,7 @@ describe("$notifications", () => { }); it("should attach the post as reasonSubject for subscribed-post notifications", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); dataStore.$posts.set("s1", { uri: "s1", record: { text: "s" } }); seedNotifications(dataStore, [ @@ -1596,7 +1597,7 @@ describe("$notifications", () => { }); it("should pass through notifications with other reasons unchanged", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const notification = createNotification({ reason: "follow", author }); seedNotifications(dataStore, [notification]); @@ -1605,7 +1606,7 @@ describe("$notifications", () => { }); it("should hydrate the author from the profile store", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore, { preferences: fakePreferences({ getBadgeLabelsForProfile: () => ["verified"], @@ -1621,7 +1622,7 @@ describe("$notifications", () => { }); it("should override isRead using the captured seenAt", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); seedNotifications(dataStore, [ createNotification({ @@ -1646,7 +1647,7 @@ describe("$notifications", () => { }); it("should treat a notification indexed exactly at seenAt as read", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); seedNotifications(dataStore, [ createNotification({ @@ -1661,7 +1662,7 @@ describe("$notifications", () => { }); it("should trust the server isRead when seenAt is null", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); seedNotifications(dataStore, [ createNotification({ reason: "follow", author, uri: "r", isRead: true }), @@ -1673,7 +1674,7 @@ describe("$notifications", () => { }); it("should filter out notifications from authors the viewer is blocking", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const blocked = createProfile({ did: "did:plc:blocked", @@ -1690,7 +1691,7 @@ describe("$notifications", () => { }); it("should filter out notifications from muted authors the viewer does not follow", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const muted = createProfile({ did: "did:plc:muted", @@ -1707,7 +1708,7 @@ describe("$notifications", () => { }); it("should keep notifications from muted authors when the viewer follows them", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const mutedFollow = createProfile({ did: "did:plc:mutedfollow", @@ -1726,7 +1727,7 @@ describe("$notifications", () => { }); it("should recompute isRead when seenAt is captured later", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); seedNotifications(dataStore, [ createNotification({ @@ -1744,13 +1745,13 @@ describe("$notifications", () => { describe("$mentionNotifications", () => { it("should return null when mention notifications are not loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$mentionNotifications.get(), null); }); it("should hydrate mention notifications", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const author = createProfile({ did: "did:plc:a", handle: "a.test" }); dataStore.setProfiles([author]); @@ -1772,7 +1773,7 @@ describe("$mentionNotifications", () => { }); it("should override isRead using the shared seenAt from the main feed", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const author = createProfile({ did: "did:plc:a", handle: "a.test" }); dataStore.setProfiles([author]); @@ -1796,7 +1797,7 @@ describe("$hydratedPostThreads", () => { const threadUri = "post1"; it("should return null when the thread or its hidden replies are not loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedPostThreads.get(threadUri), null); dataStore.$postThreads.set(threadUri, { @@ -1808,7 +1809,7 @@ describe("$hydratedPostThreads", () => { }); it("should pass through an empty (not found) thread", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const notFound = { $type: "app.bsky.feed.defs#notFoundPost", @@ -1820,7 +1821,7 @@ describe("$hydratedPostThreads", () => { }); it("should return null when the thread root post is not loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); dataStore.$postThreads.set(threadUri, { $type: "app.bsky.feed.defs#threadViewPost", @@ -1831,7 +1832,7 @@ describe("$hydratedPostThreads", () => { }); it("should hydrate replies, marking hidden ones and passing through non-post replies", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const blockedReply = { $type: "app.bsky.feed.defs#blockedPost", @@ -1876,7 +1877,7 @@ describe("$hydratedPostThreads", () => { } it("should hydrate a parent chain recursively", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const grandparent = { $type: "app.bsky.feed.defs#notFoundPost", uri: "gp" }; dataStore.$posts.set("pp", { uri: "pp", record: { text: "parent" } }); @@ -1892,7 +1893,7 @@ describe("$hydratedPostThreads", () => { }); it("should replace a confirmed-unavailable parent with an unavailable post", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); dataStore.$unavailablePosts.set("pp", createUnavailablePost("pp")); seedThreadWithParent(dataStore, { @@ -1906,7 +1907,7 @@ describe("$hydratedPostThreads", () => { }); it("should keep a blocked parent as-is when the parent author blocks the viewer", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const blockedParent = { $type: "app.bsky.feed.defs#blockedPost", @@ -1928,7 +1929,7 @@ describe("actor search results", () => { } it("$profileSearchResults should be null before a search and hydrate after", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$profileSearchResults.get(), null); assert.deepEqual(derived.$profileSearchCursor.get(), null); @@ -1944,7 +1945,7 @@ describe("actor search results", () => { }); it("$chatRecipientSearchResults should be null before a search and hydrate after", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$chatRecipientSearchResults.get(), null); seedProfile(dataStore); @@ -1954,7 +1955,7 @@ describe("actor search results", () => { }); it("$searchTypeaheadResults should be null before a search and hydrate after", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$searchTypeaheadResults.get(), null); seedProfile(dataStore); @@ -1966,7 +1967,7 @@ describe("actor search results", () => { describe("$feedSearchResults", () => { it("should be null before a search and pass feeds through after", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$feedSearchResults.get(), null); assert.deepEqual(derived.$feedSearchCursor.get(), null); @@ -1979,7 +1980,7 @@ describe("$feedSearchResults", () => { describe("post search results", () => { it("should be null before a search and hydrate loaded posts after", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$postSearchResultsTop.get(), null); assert.deepEqual(derived.$postSearchResultsLatest.get(), null); @@ -2008,13 +2009,13 @@ describe("$hydratedPostQuotes", () => { const postUri = "post1"; it("should return null when quotes are not loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedPostQuotes.get(postUri), null); }); it("should hydrate loaded quote posts and skip missing ones", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); dataStore.$posts.set("quote1", { uri: "quote1", record: { text: "q" } }); dataStore.$postQuotes.set(postUri, { @@ -2032,13 +2033,13 @@ describe("$listMembers", () => { const listUri = "at://did:plc:owner/app.bsky.graph.list/1"; it("should return null when list members are not loaded", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$listMembers.get(listUri), null); }); it("should hydrate member profiles and carry the cursor", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); const did = "did:plc:member"; dataStore.setProfiles([createProfile({ did, handle: "member.test" })]); @@ -2057,7 +2058,7 @@ describe("$hydratedProfiles (labels)", () => { const did = "did:plc:user"; it("should attach a blur label from preferences", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ getProfileBlurLabel: () => "adult" }), }); @@ -2067,7 +2068,7 @@ describe("$hydratedProfiles (labels)", () => { }); it("should attach badge labels from preferences", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ getBadgeLabelsForProfile: () => ["verified"], @@ -2079,7 +2080,7 @@ describe("$hydratedProfiles (labels)", () => { }); it("should return the profile unchanged when no labels apply", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -2100,7 +2101,7 @@ describe("$mutedProfiles and $blockedProfiles", () => { } it("should attach badge labels to muted profiles", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ getBadgeLabelsForProfile: () => ["verified"], @@ -2113,7 +2114,7 @@ describe("$mutedProfiles and $blockedProfiles", () => { }); it("should attach badge labels to blocked profiles", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ getBadgeLabelsForProfile: () => ["verified"], @@ -2127,7 +2128,7 @@ describe("$mutedProfiles and $blockedProfiles", () => { }); it("should return the lists unchanged when no labels apply", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -2138,7 +2139,7 @@ describe("$mutedProfiles and $blockedProfiles", () => { }); it("should reflect profile patches in the list", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -2153,13 +2154,13 @@ describe("$hydratedDetailedProfiles", () => { const did = "did:plc:user"; it("should return null when the detailed profile does not exist", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$hydratedDetailedProfiles.get(did), null); }); it("should return the profile unchanged when no labels apply", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), }); @@ -2174,7 +2175,7 @@ describe("$hydratedDetailedProfiles", () => { }); it("should attach blur and badge labels from preferences", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ getProfileBlurLabel: () => "adult", @@ -2216,14 +2217,14 @@ describe("$convoList and $convoRequestList", () => { } it("$convoList should be null before loading", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$convoList.get(), null); assert.deepEqual(derived.$convoListCursor.get(), null); }); it("$convoList should sort convos by last interaction, newest first", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const older = makeConvoWithMessage("older", "2026-01-01T00:00:00.000Z"); const newer = makeConvoWithMessage("newer", "2026-01-05T00:00:00.000Z"); seedConvos(dataStore, [older, newer]); @@ -2241,14 +2242,14 @@ describe("$convoList and $convoRequestList", () => { }); it("$convoRequestList should be null before loading", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$convoRequestList.get(), null); assert.deepEqual(derived.$convoRequestListCursor.get(), null); }); it("$convoRequestList should drop unknown convos and sort the rest", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const older = makeConvoWithMessage( "older", "2026-01-01T00:00:00.000Z", @@ -2283,7 +2284,7 @@ describe("$convoProfiles (labels)", () => { } it("should attach badge labels to convo members", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); seedConvo(dataStore); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ @@ -2295,7 +2296,7 @@ describe("$convoProfiles (labels)", () => { }); it("should attach a blur label to convo members", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); seedConvo(dataStore); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ getProfileBlurLabel: () => "adult" }), @@ -2305,7 +2306,7 @@ describe("$convoProfiles (labels)", () => { }); it("should return members unchanged when no labels apply", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); seedConvo(dataStore); const { derived } = makeDerived(dataStore, { preferences: fakePreferences(), @@ -2326,7 +2327,7 @@ describe("interaction and graph list hydration", () => { } it("$postLikes should be null before loading and hydrate actors after", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$postLikes.get("post1"), null); seedActor(dataStore); @@ -2341,7 +2342,7 @@ describe("interaction and graph list hydration", () => { }); it("$postReposts should be null before loading and hydrate actors after", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$postReposts.get("post1"), null); seedActor(dataStore); @@ -2355,7 +2356,7 @@ describe("interaction and graph list hydration", () => { }); it("$profileFollows should be null before loading and hydrate actors after", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$profileFollows.get("did:plc:subject"), null); seedActor(dataStore); @@ -2369,7 +2370,7 @@ describe("interaction and graph list hydration", () => { }); it("$profileFollowers should be null before loading and hydrate actors after", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$profileFollowers.get("did:plc:subject"), null); seedActor(dataStore); @@ -2383,7 +2384,7 @@ describe("interaction and graph list hydration", () => { }); it("$knownFollowers should be null before loading and hydrate actors after", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$knownFollowers.get("did:plc:subject"), null); seedActor(dataStore); @@ -2401,13 +2402,13 @@ describe("$isFollowPending / $isBlockPending / $isMutePending", () => { const did = "did:test:pending"; it("$isFollowPending is false when no follow/unfollow patch is pending", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived } = makeDerived(dataStore); assert.deepEqual(derived.$isFollowPending.get(did), false); }); it("$isFollowPending flips true while a followProfile patch is pending", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore); const patchId = patchStore.addProfilePatch(did, { type: "followProfile" }); assert.deepEqual(derived.$isFollowPending.get(did), true); @@ -2416,14 +2417,14 @@ describe("$isFollowPending / $isBlockPending / $isMutePending", () => { }); it("$isFollowPending flips true while an unfollowProfile patch is pending", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore); patchStore.addProfilePatch(did, { type: "unfollowProfile" }); assert.deepEqual(derived.$isFollowPending.get(did), true); }); it("$isFollowPending ignores unrelated profile patches", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore); patchStore.addProfilePatch(did, { type: "muteProfile" }); patchStore.addProfilePatch(did, { type: "blockProfile" }); @@ -2431,7 +2432,7 @@ describe("$isFollowPending / $isBlockPending / $isMutePending", () => { }); it("$isBlockPending covers blockProfile and unblockProfile", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore); const blockId = patchStore.addProfilePatch(did, { type: "blockProfile" }); assert.deepEqual(derived.$isBlockPending.get(did), true); @@ -2441,7 +2442,7 @@ describe("$isFollowPending / $isBlockPending / $isMutePending", () => { }); it("$isMutePending covers muteProfile and unmuteProfile", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore); const muteId = patchStore.addProfilePatch(did, { type: "muteProfile" }); assert.deepEqual(derived.$isMutePending.get(did), true); @@ -2451,7 +2452,7 @@ describe("$isFollowPending / $isBlockPending / $isMutePending", () => { }); it("pending signals are keyed per profile", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const { derived, patchStore } = makeDerived(dataStore); patchStore.addProfilePatch(did, { type: "followProfile" }); assert.deepEqual(derived.$isFollowPending.get(did), true); diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 1d2be3a8..532cdd8f 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -2,6 +2,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { Mutations } from "/js/dataLayer/mutations.js"; import { DataStore } from "/js/dataLayer/dataStore.js"; +import { createSessionState } from "/js/dataLayer/sessionState.js"; import { DraftMediaStore } from "/js/drafts.js"; import { PatchStore } from "/js/dataLayer/patchStore.js"; import { Derived } from "/js/dataLayer/derived.js"; @@ -75,7 +76,7 @@ describe("addLike", () => { const mockApi = { createLikeRecord: async () => ({ uri: "like-uri" }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -99,7 +100,7 @@ describe("addLike", () => { const mockApi = { createLikeRecord: async () => mockLike, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -128,7 +129,7 @@ describe("addLike", () => { setTimeout(() => resolve({ uri: "like-uri" }), 50), ), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -152,7 +153,7 @@ describe("addLike", () => { }); it("should not double-count when a refresh lands while the like is in flight", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockApi = { createLikeRecord: async () => { @@ -199,7 +200,7 @@ describe("removeLike", () => { setTimeout(resolve, 100); }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -222,7 +223,7 @@ describe("removeLike", () => { const mockApi = { deleteLikeRecord: async () => {}, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -261,7 +262,7 @@ describe("followProfile", () => { setTimeout(() => resolve({ uri: "follow-uri" }), 100); }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -285,7 +286,7 @@ describe("followProfile", () => { const mockApi = { createFollowRecord: async () => mockFollow, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$detailedProfiles.set(testProfile.did, testProfile); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { @@ -328,7 +329,7 @@ describe("unfollowProfile", () => { setTimeout(resolve, 100); }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -351,7 +352,7 @@ describe("unfollowProfile", () => { const mockApi = { deleteFollowRecord: async () => {}, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$detailedProfiles.set(testProfile.did, testProfile); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { @@ -397,7 +398,7 @@ describe("subscribeLabeler", () => { await new Promise((resolve) => setTimeout(resolve, 100)); }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -421,7 +422,7 @@ describe("subscribeLabeler", () => { }), updatePreferences: async () => {}, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -445,7 +446,7 @@ describe("subscribeLabeler", () => { throw new Error("API error"); }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -482,7 +483,7 @@ describe("unsubscribeLabeler", () => { await new Promise((resolve) => setTimeout(resolve, 100)); }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -506,7 +507,7 @@ describe("unsubscribeLabeler", () => { }), updatePreferences: async () => {}, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -530,7 +531,7 @@ describe("unsubscribeLabeler", () => { throw new Error("API error"); }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -566,7 +567,7 @@ describe("updateLabelerSetting", () => { await new Promise((resolve) => setTimeout(resolve, 100)); }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -592,7 +593,7 @@ describe("updateLabelerSetting", () => { }), updatePreferences: async () => {}, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -616,7 +617,7 @@ describe("updateLabelerSetting", () => { throw new Error("API error"); }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -648,7 +649,7 @@ describe("updateLabelerSetting", () => { }), updatePreferences: async () => {}, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -681,7 +682,7 @@ describe("Error Handling and Edge Cases", () => { deleteLikeRecord: async () => new Promise((resolve) => setTimeout(resolve, 75)), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -714,7 +715,7 @@ describe("Error Handling and Edge Cases", () => { const mockApi = { deleteLikeRecord: async () => undefined, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -742,7 +743,7 @@ describe("addMutedWord", () => { updatedPreferences = prefs; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -772,7 +773,7 @@ describe("addMutedWord", () => { updatedPreferences = prefs; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -826,7 +827,7 @@ describe("removeMutedWord", () => { updatedPreferences = prefs; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -854,7 +855,7 @@ describe("updateProfile", () => { }; function createMutationsWithMockApi(mockApi) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1084,7 +1085,7 @@ describe("pinPost", () => { }; function setup(mockApi, { pinnedPost = null, authorFeed = null } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1258,7 +1259,7 @@ describe("unpinPost", () => { }; function setup(mockApi, { pinnedPost, authorFeed = null } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1339,7 +1340,7 @@ describe("muteProfile", () => { }; function setup(mockApi = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1400,7 +1401,7 @@ describe("unmuteProfile", () => { }; function setup(mockApi = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1456,7 +1457,7 @@ describe("blockProfile", () => { const blockUri = "at://did:plc:me/app.bsky.graph.block/123"; function setup(mockApi = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1548,7 +1549,7 @@ describe("unblockProfile", () => { }; function setup(mockApi = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1628,7 +1629,7 @@ describe("addBookmark", () => { }; function setup(mockApi = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1692,7 +1693,7 @@ describe("removeBookmark", () => { }; function setup(mockApi = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1759,7 +1760,7 @@ describe("createRepost", () => { }; function setup(mockApi = {}, { authorFeed } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1865,7 +1866,7 @@ describe("deleteRepost", () => { }; function setup(mockApi = {}, { authorFeed } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -1941,7 +1942,7 @@ describe("pinFeed", () => { updatedPreferences = prefs; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -2005,7 +2006,7 @@ describe("pinFeed", () => { requirePreferences: () => preferences, updatePreferences: () => updatePromise, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -2046,7 +2047,7 @@ describe("unpinFeed", () => { updatedPreferences = prefs; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -2078,7 +2079,7 @@ describe("unpinFeed", () => { requirePreferences: () => preferences, updatePreferences: () => updatePromise, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -2136,7 +2137,7 @@ describe("setPinnedItems", () => { await updatePromise; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); if (preloadPinnedItems) { dataStore.$pinnedItems.set([ { type: "timeline", data: { uri: "following" } }, @@ -2210,7 +2211,7 @@ describe("setPinnedItems", () => { throw error; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$pinnedItems.set([ { type: "feed", data: { uri: feedA } }, { type: "list", data: { uri: listA } }, @@ -2249,7 +2250,7 @@ describe("hidePost", () => { updatedPreferences = prefs; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -2273,7 +2274,7 @@ describe("hidePost", () => { requirePreferences: () => preferences, updatePreferences: () => updatePromise, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -2321,7 +2322,7 @@ describe("updateMutedWord", () => { updatedPreferences = prefs; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -2352,7 +2353,7 @@ describe("updatePostNotificationSubscription", () => { it("should set viewer.activitySubscription on the profile", async () => { const subscription = { post: true, reply: false }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2380,7 +2381,7 @@ describe("updatePostNotificationSubscription", () => { }); it("should remove the patch on failure and rethrow", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2414,7 +2415,7 @@ describe("createThread", () => { const newPostUri = `at://${currentUserDid}/app.bsky.feed.post/new`; function setup({ replyPostThread, authorFeed, replyAuthorFeed } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2537,7 +2538,7 @@ describe("deletePost", () => { cid: "cid-abc", }; let apiCalledWith = null; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2572,7 +2573,7 @@ describe("createMessage", () => { }; function setup({ convoMessages, convo } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2626,7 +2627,7 @@ describe("createMessage", () => { it("should pass replyTo to the api", async () => { let apiCalledWith = null; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2651,7 +2652,7 @@ describe("createMessage", () => { it("should pass embed to the api", async () => { let apiCalledWith = null; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2676,7 +2677,7 @@ describe("createMessage", () => { }); it("should propagate the raw error on send failure", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2705,7 +2706,7 @@ describe("acceptConvo", () => { const convo = { id: "convo-1", status: "request" }; function setup({ convoList, convoRequestList } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2793,7 +2794,7 @@ describe("rejectConvo", () => { it("should clear the convo, call api.leaveConvo, and remove it from the request list only", async () => { const otherAccepted = { id: "convo-2", status: "accepted" }; const otherRequest = { id: "convo-3", status: "request" }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2841,7 +2842,7 @@ describe("leaveConvo", () => { it("should clear the convo, call api.leaveConvo, and remove it from the accepted list only", async () => { const otherAccepted = { id: "convo-2", status: "accepted" }; const otherRequest = { id: "convo-3", status: "request" }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2884,7 +2885,7 @@ describe("leaveConvo", () => { it("should leave the store unchanged when api.leaveConvo throws", async () => { const otherAccepted = { id: "convo-2", status: "accepted" }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2918,7 +2919,7 @@ describe("setConvoMuted", () => { const convo = { id: "convo-1", muted: false }; it("should optimistically patch, then write to dataStore and clear the patch on success", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2950,7 +2951,7 @@ describe("setConvoMuted", () => { it("should call api.unmuteConvo when muted=false", async () => { const mutedConvo = { id: "convo-1", muted: true }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -2975,7 +2976,7 @@ describe("setConvoMuted", () => { }); it("should revert the optimistic patch and leave the dataStore unchanged when the api throws", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3000,7 +3001,7 @@ describe("setConvoMuted", () => { }); it("should not write to dataStore if the underlying convo was cleared during the api call", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3027,7 +3028,7 @@ describe("setConvoMuted", () => { describe("markConvoAsRead", () => { it("should call api.markConvoAsRead and zero the unread count", async () => { const convoId = "convo-1"; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3052,7 +3053,7 @@ describe("markConvoAsRead", () => { }); it("should not throw when the convo is not cached", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3070,7 +3071,7 @@ describe("markConvoAsRead", () => { it("should not call the api when the convo has no unread messages", async () => { const convoId = "convo-read"; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3105,7 +3106,7 @@ describe("addMessageReaction", () => { }; function setup({ convo } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3172,7 +3173,7 @@ describe("removeMessageReaction", () => { const updatedMessage = { id: messageId, reactions: [] }; function setup({ convo } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3235,7 +3236,7 @@ describe("sendShowLessInteraction", () => { const feedProxyUrl = "https://feed.example/xrpc"; it("should append the interaction to the dataStore (empty list branch)", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3270,7 +3271,7 @@ describe("sendShowLessInteraction", () => { }); it("should append to an existing list (non-empty branch)", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3298,7 +3299,7 @@ describe("sendShowLessInteraction", () => { }); it("should key stored interactions by feed", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3323,7 +3324,7 @@ describe("sendShowLessInteraction", () => { }); it("should omit feedContext when null but keep an empty string", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3359,7 +3360,7 @@ describe("sendShowLessInteraction", () => { }); it("should store but not send when there is no feed proxy url", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3390,7 +3391,7 @@ describe("sendShowMoreInteraction", () => { const feedProxyUrl = "https://feed.example/xrpc"; it("should append the interaction to the dataStore (empty list branch)", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3424,7 +3425,7 @@ describe("sendShowMoreInteraction", () => { }); it("should append to an existing list (non-empty branch)", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3476,7 +3477,7 @@ describe("pinList", () => { updatePreferences: async () => new Promise((resolve) => setTimeout(resolve, 100)), }); - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations({}, dataStore, patchStore, provider); @@ -3491,7 +3492,7 @@ describe("pinList", () => { it("should call preferences.pinFeed with type 'list'", async () => { const { provider, pinFeedCalls } = makeMockProvider(); - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations({}, dataStore, patchStore, provider); @@ -3504,7 +3505,7 @@ describe("pinList", () => { it("should remove patch after successful update", async () => { const { provider } = makeMockProvider(); - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations({}, dataStore, patchStore, provider); @@ -3519,7 +3520,7 @@ describe("pinList", () => { throw new Error("API error"); }, }); - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations({}, dataStore, patchStore, provider); @@ -3547,7 +3548,7 @@ describe("pinFeed entryType", () => { updatePreferences: () => new Promise((resolve) => setTimeout(resolve, 100)), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations( {}, @@ -3578,7 +3579,7 @@ describe("unpinList", () => { }), updatePreferences: async () => {}, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations({}, dataStore, patchStore, provider); @@ -3599,7 +3600,7 @@ describe("unpinList", () => { }), updatePreferences: () => updatePromise, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mutations = makeMutations({}, dataStore, patchStore, provider); @@ -3629,7 +3630,7 @@ describe("addProfileToList", () => { const mockApi = { createListItemRecord: async () => ({ uri: "listitem-real-uri" }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3664,7 +3665,7 @@ describe("addProfileToList", () => { const mockApi = { createListItemRecord: async () => ({ uri: "listitem-real-uri" }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3688,7 +3689,7 @@ describe("addProfileToList", () => { const mockApi = { createListItemRecord: async () => ({ uri: "listitem-real-uri" }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$listMembers.set(testList.uri, { items: [ { @@ -3723,7 +3724,7 @@ describe("addProfileToList", () => { throw new Error("nope"); }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3764,7 +3765,7 @@ describe("removeProfileFromList", () => { const mockApi = { deleteListItemRecord: async () => {}, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$listsWithMembershipByActor.set(testProfile.did, { listsWithMembership: [ { @@ -3795,7 +3796,7 @@ describe("removeProfileFromList", () => { const mockApi = { deleteListItemRecord: async () => {}, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3819,7 +3820,7 @@ describe("removeProfileFromList", () => { const mockApi = { deleteListItemRecord: async () => {}, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$listsWithMembershipByActor.set(testProfile.did, { listsWithMembership: [ { @@ -3866,7 +3867,7 @@ describe("removeProfileFromList", () => { throw new Error("boom"); }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const initialListItem = { uri: membershipUri, subject: testProfile.did }; dataStore.$listsWithMembershipByActor.set(testProfile.did, { listsWithMembership: [{ list: testList, listItem: initialListItem }], @@ -3916,7 +3917,7 @@ describe("$detailedProfiles mirroring", () => { }; function setup(mockApi, { seedDetailed = true } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -3981,7 +3982,7 @@ describe("$detailedProfiles mirroring", () => { it("unfollowProfile mirrors viewer.following=null and decremented count", async () => { const seedFollowed = { ...detailedSeed, viewer: { following: "at://x" } }; const mockApi = { deleteFollowRecord: async () => {} }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -4017,7 +4018,7 @@ describe("$detailedProfiles mirroring", () => { }); it("unmuteProfile mirrors viewer.muted=false into $detailedProfiles", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -4058,7 +4059,7 @@ describe("$detailedProfiles mirroring", () => { }); it("unblockProfile mirrors viewer.blocking=null into $detailedProfiles", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -4116,7 +4117,7 @@ describe("updateList", () => { }; function setup(overrides = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -4344,7 +4345,7 @@ describe("deleteList", () => { }; function setup({ listItems = [], overrides = {} } = {}) { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const preferences = Preferences.createLoggedOutPreferences(); const preferencesProvider = { @@ -4531,7 +4532,7 @@ describe("deleteList", () => { }); it("unpins the list if it was pinned", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); let preferences = Preferences.createLoggedOutPreferences().pinFeed( listUri, @@ -4564,3 +4565,14 @@ describe("deleteList", () => { assert.equal(preferences.isFeedPinned(listUri), false); }); }); + +describe("setSelectedFeedUri", () => { + it("should set the selection", () => { + const dataStore = new DataStore(createSessionState(null)); + const mutations = makeMutations({}, dataStore, new PatchStore(), {}); + mutations.setSelectedFeedUri("following"); + assert.deepEqual(dataStore.$selectedFeedUri.get(), "following"); + mutations.setSelectedFeedUri(null); + assert.deepEqual(dataStore.$selectedFeedUri.get(), null); + }); +}); diff --git a/tests/unit/specs/dataLayer/patchStore.test.js b/tests/unit/specs/dataLayer/patchStore.test.js index 435efaa2..ab8ce895 100644 --- a/tests/unit/specs/dataLayer/patchStore.test.js +++ b/tests/unit/specs/dataLayer/patchStore.test.js @@ -2,6 +2,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { PatchStore } from "/js/dataLayer/patchStore.js"; import { DataStore } from "/js/dataLayer/dataStore.js"; +import { createSessionState } from "/js/dataLayer/sessionState.js"; // applyPostPatches now requires the patches array explicitly. This helper // fetches the current patches for a post URI and applies them. @@ -1055,7 +1056,7 @@ describe("Message Patches", () => { }); it("should expose the overlay via $patchedMessages", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); dataStore.$messages.set(messageId, baseMessage); @@ -1073,7 +1074,7 @@ describe("Message Patches", () => { }); it("should return null from $patchedMessages when the underlying message is absent", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); assert.deepEqual(patchStore.$patchedMessages.get("missing"), null); }); @@ -1130,7 +1131,7 @@ describe("Author Feed Patches - pinPost apply", () => { describe("Post Patches - $patchedPosts overlay", () => { it("should overlay post + author profile patches", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const postURI = "at://did:plc:author/app.bsky.feed.post/1"; const post = { @@ -1159,7 +1160,7 @@ describe("Post Patches - $patchedPosts overlay", () => { }); it("should return null when the underlying post is absent", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); assert.deepEqual(patchStore.$patchedPosts.get("missing"), null); }); @@ -1169,7 +1170,7 @@ describe("Profile Patches - $patchedProfiles / $patchedDetailedProfiles overlays const did = "did:plc:x"; it("should overlay profile patches on $profiles reads", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); dataStore.$profiles.set(did, { did, @@ -1185,7 +1186,7 @@ describe("Profile Patches - $patchedProfiles / $patchedDetailedProfiles overlays }); it("should overlay profile patches on $detailedProfiles reads", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); dataStore.$detailedProfiles.set(did, { did, @@ -1200,7 +1201,7 @@ describe("Profile Patches - $patchedProfiles / $patchedDetailedProfiles overlays }); it("should return null when the underlying profile is absent", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); assert.deepEqual(patchStore.$patchedProfiles.get("missing"), null); assert.deepEqual(patchStore.$patchedDetailedProfiles.get("missing"), null); @@ -1276,7 +1277,7 @@ describe("Post Patches - convergence", () => { }); it("should not double-apply when a canonical refresh lands while the patch is installed", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); dataStore.$posts.set(postURI, { uri: postURI, @@ -1387,7 +1388,7 @@ describe("Message Patches - convergence", () => { describe("Convo Patches - $patchedConvos", () => { it("should overlay the patch on top of the dataStore convo", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); const convo = { id: "convo-1", muted: false, rev: "rev-1" }; dataStore.$convos.set("convo-1", convo); @@ -1406,13 +1407,13 @@ describe("Convo Patches - $patchedConvos", () => { }); it("should return null when the underlying convo is absent", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); assert.deepEqual(patchStore.$patchedConvos.get("nope"), null); }); it("should isolate patches between different convos", () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(dataStore); dataStore.$convos.set("convo-1", { id: "convo-1", muted: false }); dataStore.$convos.set("convo-2", { id: "convo-2", muted: false }); diff --git a/tests/unit/specs/dataLayer/requests.test.js b/tests/unit/specs/dataLayer/requests.test.js index 524153e6..bf67e4a0 100644 --- a/tests/unit/specs/dataLayer/requests.test.js +++ b/tests/unit/specs/dataLayer/requests.test.js @@ -2,6 +2,7 @@ import { describe, it, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { Requests } from "/js/dataLayer/requests.js"; import { DataStore } from "/js/dataLayer/dataStore.js"; +import { createSessionState } from "/js/dataLayer/sessionState.js"; import { DraftMediaStore } from "/js/drafts.js"; import { Preferences } from "/js/preferences.js"; import { ApiError } from "/js/api.js"; @@ -46,7 +47,7 @@ describe("loadPostThread", () => { getPostThreadOther: async () => mockPostThreadOther, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -85,7 +86,7 @@ describe("loadPostThread", () => { getPostThreadOther: async () => [], }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -118,7 +119,7 @@ describe("loadNextFeedPage", () => { getFeed: async () => mockFeed, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -139,7 +140,7 @@ describe("loadNextFeedPage", () => { }); it("should append to existing feed", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); // Set up existing feed const existingFeed = { @@ -185,7 +186,7 @@ describe("loadNextFeedPage", () => { }); it("should discard a stale page when a reload lands mid-flight", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$feeds.set(feedURI, { feed: [{ post: { uri: "post1" } }], cursor: "cursor1", @@ -218,7 +219,7 @@ describe("loadNextFeedPage", () => { }); it("should emit feedLoaded with the reload flag", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$feeds.set(feedURI, { feed: [{ post: { uri: "post1" } }], cursor: "cursor1", @@ -256,7 +257,7 @@ describe("loadNextFeedPage", () => { getFeed: async () => emptyFeed, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -295,7 +296,7 @@ describe("loadNextFeedPage", () => { getFeed: async () => feedWithReplies, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -332,7 +333,7 @@ describe("loadDetailedProfile", () => { getProfile: async () => mockProfile, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -350,7 +351,7 @@ describe("loadDetailedProfile", () => { }); it("should handle profile updates", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); // Load initial profile const initialProfile = { @@ -404,7 +405,7 @@ describe("loadPosts", () => { }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -430,7 +431,7 @@ describe("loadPosts", () => { }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -464,7 +465,7 @@ describe("loadLabelerInfo", () => { getLabeler: async () => mockLabelerInfo, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -488,7 +489,7 @@ describe("loadLabelerInfo", () => { }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -515,7 +516,7 @@ describe("loadLabelerInfo", () => { }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -547,7 +548,7 @@ describe("loadMutedProfiles", () => { cursor: "next", }; const mockApi = { getMutes: async () => res }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -569,7 +570,7 @@ describe("loadMutedProfiles", () => { }); it("should append paginated muted profiles when cursor is provided", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$mutedProfiles.set({ mutes: [{ did: "did:plc:a" }], cursor: "page2", @@ -606,7 +607,7 @@ describe("loadMutedProfiles", () => { return { mutes: [], cursor: undefined }; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$mutedProfiles.set({ mutes: [], cursor: "abc" }); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), @@ -622,7 +623,7 @@ describe("loadMutedProfiles", () => { }); it("should discard the response when the cursor no longer matches", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const existing = { mutes: [{ did: "did:plc:a" }], cursor: "page3", @@ -650,7 +651,11 @@ describe("loadMutedProfiles", () => { }); }); -function makeRequests(api, dataStore = new DataStore(), preferences) { +function makeRequests( + api, + dataStore = new DataStore(createSessionState(null)), + preferences, +) { const provider = { requirePreferences: () => preferences ?? Preferences.createLoggedOutPreferences(), @@ -665,7 +670,7 @@ describe("loadBlockedProfiles", () => { cursor: "next", }; const mockApi = { getBlocks: async () => res }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadBlockedProfiles(); @@ -680,7 +685,7 @@ describe("loadBlockedProfiles", () => { }); it("should append paginated blocked profiles when cursor is provided", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$blockedProfiles.set({ blocks: [{ did: "did:plc:a" }], cursor: "page2", @@ -710,7 +715,7 @@ describe("loadBlockedProfiles", () => { return { blocks: [], cursor: undefined }; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$blockedProfiles.set({ blocks: [], cursor: "abc" }); const requests = makeRequests(mockApi, dataStore); @@ -730,7 +735,7 @@ describe("loadNextAuthorFeedPage", () => { return { feed: [{ post: { uri: "p1" } }], cursor: "c1" }; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadNextAuthorFeedPage(did, "posts"); @@ -797,7 +802,7 @@ describe("loadNextAuthorFeedPage", () => { it("should append to existing feed", async () => { const feedURI = `${did}-posts`; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$authorFeeds.set(feedURI, { feed: [{ post: { uri: "old1" } }], cursor: "c1", @@ -822,7 +827,7 @@ describe("loadNextAuthorFeedPage", () => { it("should reset cursor and replace feed on reload", async () => { const feedURI = `${did}-posts`; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$authorFeeds.set(feedURI, { feed: [{ post: { uri: "old1" } }], cursor: "c1", @@ -861,7 +866,7 @@ describe("loadNextAuthorFeedPage", () => { describe("loadPostSearchTop / loadPostSearchLatest", () => { it("should clear results for both sorts when query is empty", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$postSearchResultsTop.set({ posts: [{ uri: "p1" }], cursor: "c1", @@ -887,7 +892,7 @@ describe("loadPostSearchTop / loadPostSearchLatest", () => { cursor: "next", }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadPostSearchTop("hello"); @@ -904,7 +909,7 @@ describe("loadPostSearchTop / loadPostSearchLatest", () => { cursor: null, }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadPostSearchTop("hello"); @@ -921,7 +926,7 @@ describe("loadPostSearchTop / loadPostSearchLatest", () => { }); it("should not discard an in-flight sort when the other sort loads", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let resolveTop; const topPromise = new Promise((resolve) => { resolveTop = resolve; @@ -954,7 +959,7 @@ describe("loadPostSearchTop / loadPostSearchLatest", () => { }); it("should discard stale responses based on requestTime guard", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let resolveFirst; const firstPromise = new Promise((resolve) => { resolveFirst = resolve; @@ -984,7 +989,7 @@ describe("loadPostSearchTop / loadPostSearchLatest", () => { }); it("should discard a stale cursored response that finishes dependency loading after a re-search", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const replyPost = (uri) => ({ uri, record: { reply: { parent: { uri: `${uri}-parent` } } }, @@ -1028,7 +1033,7 @@ describe("loadPostSearchTop / loadPostSearchLatest", () => { }); it("should append when cursor is provided and existing results present", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$postSearchResultsTop.set({ posts: [{ uri: "p1", record: {} }], cursor: "c1", @@ -1052,7 +1057,7 @@ describe("loadPostSearchTop / loadPostSearchLatest", () => { describe("loadProfileSearch", () => { it("should clear results when query is empty", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$profileSearchResults.set({ actors: [{ did: "x" }], cursor: "c", @@ -1074,7 +1079,7 @@ describe("loadProfileSearch", () => { cursor: "next", }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadProfileSearch("alice"); @@ -1085,7 +1090,7 @@ describe("loadProfileSearch", () => { }); it("should discard stale responses", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let resolveFirst; const firstPromise = new Promise((resolve) => { resolveFirst = resolve; @@ -1114,7 +1119,7 @@ describe("loadProfileSearch", () => { }); it("should append when cursor is provided", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$profileSearchResults.set({ actors: [{ did: "did:plc:a" }], cursor: "c1", @@ -1135,7 +1140,7 @@ describe("loadProfileSearch", () => { }); it("should discard in-flight responses after the query is cleared", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let resolveSearch; const searchPromise = new Promise((resolve) => { resolveSearch = resolve; @@ -1159,7 +1164,7 @@ describe("loadProfileSearch", () => { describe("loadChatRecipientSearch", () => { it("should store the search results", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [{ did: "did:plc:a" }], @@ -1175,7 +1180,7 @@ describe("loadChatRecipientSearch", () => { }); it("should clear results when query is empty", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$chatRecipientSearchResults.set({ actors: [{ did: "x" }] }); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [] }), @@ -1188,7 +1193,7 @@ describe("loadChatRecipientSearch", () => { }); it("should discard in-flight responses after the query is cleared", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let resolveSearch; const searchPromise = new Promise((resolve) => { resolveSearch = resolve; @@ -1212,7 +1217,7 @@ describe("loadChatRecipientSearch", () => { describe("loadSearchTypeahead", () => { it("should store the search results and hydrate profiles", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [{ did: "did:plc:a" }], @@ -1231,7 +1236,7 @@ describe("loadSearchTypeahead", () => { }); it("should clear results when query is empty", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$searchTypeaheadResults.set({ actors: [{ did: "x" }] }); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [] }), @@ -1244,7 +1249,7 @@ describe("loadSearchTypeahead", () => { }); it("should discard in-flight responses after the query is cleared", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let resolveSearch; const searchPromise = new Promise((resolve) => { resolveSearch = resolve; @@ -1268,7 +1273,7 @@ describe("loadSearchTypeahead", () => { describe("loadSidebarSearchTypeahead", () => { it("should store the search results and hydrate profiles", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [{ did: "did:plc:a" }], @@ -1287,7 +1292,7 @@ describe("loadSidebarSearchTypeahead", () => { }); it("should clear results when query is empty", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$sidebarSearchTypeaheadResults.set({ actors: [{ did: "x" }] }); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [] }), @@ -1300,7 +1305,7 @@ describe("loadSidebarSearchTypeahead", () => { }); it("should discard in-flight responses after the query is cleared", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let resolveSearch; const searchPromise = new Promise((resolve) => { resolveSearch = resolve; @@ -1322,7 +1327,7 @@ describe("loadSidebarSearchTypeahead", () => { }); it("should not disturb the search view's typeahead results", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [{ did: "did:plc:sidebar" }], @@ -1344,7 +1349,7 @@ describe("loadSidebarSearchTypeahead", () => { describe("loadFeedSearch", () => { it("should clear results when query is empty", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$feedSearchResults.set({ feeds: [{ uri: "f1" }], cursor: "c" }); const mockApi = { searchFeedGenerators: async () => ({ feeds: [], cursor: null }), @@ -1357,7 +1362,7 @@ describe("loadFeedSearch", () => { }); it("should store feeds and cache feed generators", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchFeedGenerators: async () => ({ feeds: [{ uri: "f1", displayName: "Feed One" }], @@ -1377,7 +1382,7 @@ describe("loadFeedSearch", () => { }); it("should discard stale responses", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let resolveFirst; const firstPromise = new Promise((resolve) => { resolveFirst = resolve; @@ -1406,7 +1411,7 @@ describe("loadFeedSearch", () => { }); it("should append when cursor is provided", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$feedSearchResults.set({ feeds: [{ uri: "f1" }], cursor: "c1", @@ -1429,7 +1434,7 @@ describe("loadFeedSearch", () => { describe("loadNotifications", () => { it("should set notifications and cursor on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getNotifications: async () => ({ notifications: [ @@ -1451,7 +1456,7 @@ describe("loadNotifications", () => { }); it("should append when cursor matches previous", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ reason: "like", uri: "n1" }], cursor: "page2", @@ -1480,7 +1485,7 @@ describe("loadNotifications", () => { }); it("should reset on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ reason: "like", uri: "n1" }], cursor: "page2", @@ -1511,7 +1516,7 @@ describe("loadNotifications", () => { }); it("should capture seenAt on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getNotifications: async () => ({ notifications: [ @@ -1533,7 +1538,7 @@ describe("loadNotifications", () => { }); it("should overwrite the captured seenAt on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ reason: "like", uri: "n1" }], cursor: "page2", @@ -1561,7 +1566,7 @@ describe("loadNotifications", () => { }); it("should not capture seenAt on subsequent pages", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ reason: "like", uri: "n1" }], cursor: "page2", @@ -1589,7 +1594,7 @@ describe("loadNotifications", () => { }); it("should set seenAt to null when the response omits it", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$notificationsLastSeenAt.set("2025-01-14T10:00:00.000Z"); const mockApi = { getNotifications: async () => ({ @@ -1608,7 +1613,7 @@ describe("loadNotifications", () => { }); it("should discard a stale response when a reload lands mid-flight", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ uri: "n1", reason: "follow" }], cursor: "c1", @@ -1638,7 +1643,7 @@ describe("loadNotifications", () => { }); it("should discard a stale page when the list reaches its end mid-flight", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ uri: "n1", reason: "follow" }], cursor: "c1", @@ -1673,7 +1678,7 @@ describe("loadNotifications", () => { describe("loadMentionNotifications", () => { it("should request only mention reasons and store results", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let capturedReasons; const mockApi = { getNotifications: async ({ reasons }) => { @@ -1700,7 +1705,7 @@ describe("loadMentionNotifications", () => { }); it("should not capture seenAt", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getNotifications: async () => ({ notifications: [ @@ -1719,7 +1724,7 @@ describe("loadMentionNotifications", () => { }); it("should append when cursor matches previous", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$mentionNotifications.set({ notifications: [{ reason: "mention", uri: "n1" }], cursor: "page2", @@ -1746,7 +1751,7 @@ describe("loadMentionNotifications", () => { }); it("should reset on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$mentionNotifications.set({ notifications: [{ reason: "mention", uri: "n1" }], cursor: "page2", @@ -1773,7 +1778,7 @@ describe("loadMentionNotifications", () => { describe("loadBookmarks", () => { it("should set bookmarks on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getBookmarks: async () => ({ bookmarks: [{ item: { uri: "post1", record: {} } }], @@ -1792,7 +1797,7 @@ describe("loadBookmarks", () => { }); it("should append on subsequent loads", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$bookmarks.set({ bookmarks: [{ item: { uri: "post1" } }], cursor: "c1", @@ -1815,7 +1820,7 @@ describe("loadBookmarks", () => { }); it("should reset on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$bookmarks.set({ bookmarks: [{ item: { uri: "post1" } }], cursor: "c1", @@ -1847,7 +1852,7 @@ describe("loadProfileFollowers", () => { const profileDid = "did:plc:profile"; it("should set followers on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const res = { followers: [{ did: "did:plc:a" }], cursor: "next", @@ -1861,7 +1866,7 @@ describe("loadProfileFollowers", () => { }); it("should append followers when cursor is provided", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$profileFollowers.set(profileDid, { followers: [{ did: "did:plc:a" }], cursor: "c1", @@ -1886,7 +1891,7 @@ describe("loadProfileFollows", () => { const profileDid = "did:plc:profile"; it("should set follows on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const res = { follows: [{ did: "did:plc:a" }], cursor: "next" }; const mockApi = { getFollows: async () => res }; const requests = makeRequests(mockApi, dataStore); @@ -1897,7 +1902,7 @@ describe("loadProfileFollows", () => { }); it("should append follows when cursor is provided", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$profileFollows.set(profileDid, { follows: [{ did: "did:plc:a" }], cursor: "c1", @@ -1920,7 +1925,7 @@ describe("loadProfileFollows", () => { describe("loadConvoList", () => { it("should set convo list and cache individual convos on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { listConvos: async () => ({ convos: [ @@ -1941,7 +1946,7 @@ describe("loadConvoList", () => { }); it("should append when previous cursor matches", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "c1" }], cursor: "page2" }); const mockApi = { @@ -1959,7 +1964,7 @@ describe("loadConvoList", () => { }); it("should drop convos already in the list when appending a page", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "c2", unreadCount: 1 }, { id: "c1" }], cursor: "page2", @@ -1984,7 +1989,7 @@ describe("loadConvoList", () => { }); it("should reset cursor and replace on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "c1" }], cursor: "page2" }); let capturedCursor; @@ -2007,7 +2012,7 @@ describe("loadConvoList", () => { describe("loadConvoRequestList", () => { it("should request only request convos and cache them on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let capturedStatus; const mockApi = { listConvos: async ({ status }) => { @@ -2033,7 +2038,7 @@ describe("loadConvoRequestList", () => { }); it("should append when previous cursor matches", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoRequestList.set({ convos: [{ id: "r1" }], cursor: "page2", @@ -2054,7 +2059,7 @@ describe("loadConvoRequestList", () => { }); it("should drop convos already in the list when appending a page", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoRequestList.set({ convos: [{ id: "r2" }, { id: "r1" }], cursor: "page2", @@ -2077,7 +2082,7 @@ describe("loadConvoRequestList", () => { }); it("should reset cursor and replace on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoRequestList.set({ convos: [{ id: "r1" }], cursor: "page2", @@ -2105,7 +2110,7 @@ describe("loadConvo", () => { const convoId = "convo1"; it("should store the convo and track status under a namespaced key", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getConvo: async () => ({ convo: { id: convoId } }), }; @@ -2120,7 +2125,7 @@ describe("loadConvo", () => { }); it("should add the convo to the loaded convo list", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "other" }], cursor: null }); const mockApi = { getConvo: async () => ({ convo: { id: convoId, status: "accepted" } }), @@ -2143,7 +2148,7 @@ describe("loadConvo", () => { headers: {}, url: "/x", }); - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getConvo: async () => { throw apiError; @@ -2165,7 +2170,7 @@ describe("loadConvo", () => { describe("loadConvoForProfile", () => { it("should store the convo and add it to the loaded convo list", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "other" }], cursor: null }); const mockApi = { getConvoForMembers: async () => ({ @@ -2188,7 +2193,7 @@ describe("loadConvoMembers", () => { const convoId = "convo1"; it("should store the first page with its cursor", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getConvoMembers: async () => ({ members: [{ did: "did:plc:alice" }, { did: "did:plc:bob" }], @@ -2208,7 +2213,7 @@ describe("loadConvoMembers", () => { }); it("should append the next page using the stored cursor", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const capturedCursors = []; const pages = [ { members: [{ did: "did:plc:alice" }], cursor: "1" }, @@ -2235,7 +2240,7 @@ describe("loadConvoMembers", () => { }); it("should overwrite the stored list on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoMemberLists.set(convoId, { members: [{ did: "did:plc:stale" }], cursor: "5", @@ -2267,7 +2272,7 @@ describe("loadConvoMembers", () => { headers: {}, url: "/x", }); - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getConvoMembers: async () => { throw apiError; @@ -2291,7 +2296,7 @@ describe("loadConvoMessages", () => { const convoId = "convo1"; it("should set messages on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getMessages: async () => ({ messages: [{ id: "m1" }, { id: "m2" }], @@ -2308,7 +2313,7 @@ describe("loadConvoMessages", () => { }); it("should append messages when prior cursor exists", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoMessages.set(convoId, { messages: [{ id: "m1" }], cursor: "page2", @@ -2335,7 +2340,7 @@ describe("loadConvoMessages", () => { }); it("should reset on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$convoMessages.set(convoId, { messages: [{ id: "old" }], cursor: "page2", @@ -2359,7 +2364,7 @@ describe("loadConvoMessages", () => { }); it("should store related profiles", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getMessages: async () => ({ messages: [{ id: "m1" }], @@ -2398,7 +2403,7 @@ describe("pollConvoMessages", () => { let dataStore; beforeEach(() => { - dataStore = new DataStore(); + dataStore = new DataStore(createSessionState(null)); dataStore.$currentUser.set({ did: currentUserDid }); dataStore.$convos.set(convoId, { id: convoId, @@ -2691,7 +2696,7 @@ describe("loadPostLikes", () => { const postUri = "at://did/post/1"; it("should set likes on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const res = { likes: [{ actor: { did: "did:plc:a" } }], cursor: "next" }; const mockApi = { getLikes: async () => res }; const requests = makeRequests(mockApi, dataStore); @@ -2702,7 +2707,7 @@ describe("loadPostLikes", () => { }); it("should append likes when cursor is provided", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$postLikes.set(postUri, { likes: [{ actor: { did: "did:plc:a" } }], cursor: "c1", @@ -2727,7 +2732,7 @@ describe("loadPostQuotes", () => { const postUri = "at://did/post/1"; it("should set quotes on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getQuotes: async () => ({ posts: [{ uri: "q1", record: {} }], @@ -2745,7 +2750,7 @@ describe("loadPostQuotes", () => { }); it("should append quotes when cursor is provided", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$postQuotes.set(postUri, { posts: [{ uri: "q1", record: {} }], cursor: "c1", @@ -2771,7 +2776,7 @@ describe("loadPostReposts", () => { const postUri = "at://did/post/1"; it("should set reposts on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getRepostedBy: async () => ({ repostedBy: [{ did: "did:plc:a" }], @@ -2788,7 +2793,7 @@ describe("loadPostReposts", () => { }); it("should append reposts when cursor is provided", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$postReposts.set(postUri, { repostedBy: [{ did: "did:plc:a" }], cursor: "c1", @@ -2813,7 +2818,7 @@ describe("loadActorFeeds", () => { const did = "did:plc:author"; it("should set actor feeds and cache feed generators on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getActorFeeds: async () => ({ feeds: [{ uri: "f1", displayName: "F1" }], @@ -2831,7 +2836,7 @@ describe("loadActorFeeds", () => { }); it("should append on subsequent calls when cursor remains", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$actorFeeds.set(did, { feeds: [{ uri: "f1" }], cursor: "c1", @@ -2852,7 +2857,7 @@ describe("loadActorFeeds", () => { }); it("should short-circuit when there is no remaining cursor", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$actorFeeds.set(did, { feeds: [{ uri: "f1" }], cursor: null, @@ -2872,7 +2877,7 @@ describe("loadActorFeeds", () => { }); it("should reset on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$actorFeeds.set(did, { feeds: [{ uri: "f1" }], cursor: null, @@ -2902,7 +2907,7 @@ describe("loadListsWithMembershipForActor", () => { const list2 = { uri: "at://owner/app.bsky.graph.list/2", name: "L2" }; it("should store the first page keyed by actor", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getListsWithMembership: async () => ({ listsWithMembership: [ @@ -2923,7 +2928,7 @@ describe("loadListsWithMembershipForActor", () => { }); it("should append the next page when called again with a cached cursor", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$listsWithMembershipByActor.set(actorDid, { listsWithMembership: [{ list: list1 }], cursor: "c1", @@ -2949,7 +2954,7 @@ describe("loadListsWithMembershipForActor", () => { }); it("should short-circuit when fully loaded", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$listsWithMembershipByActor.set(actorDid, { listsWithMembership: [{ list: list1 }], cursor: null, @@ -2969,7 +2974,7 @@ describe("loadListsWithMembershipForActor", () => { }); it("should refetch from scratch on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$listsWithMembershipByActor.set(actorDid, { listsWithMembership: [{ list: list1 }], cursor: "c1", @@ -2997,7 +3002,7 @@ describe("loadListsWithMembershipForActor", () => { describe("loadHashtagFeed", () => { it("should store hashtag feed posts on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchPosts: async () => ({ posts: [{ uri: "p1", record: {} }], @@ -3016,7 +3021,7 @@ describe("loadHashtagFeed", () => { }); it("should append on subsequent loads", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$hashtagFeeds.set("foo-top", { posts: [{ uri: "p1" }], cursor: "c1", @@ -3038,7 +3043,7 @@ describe("loadHashtagFeed", () => { }); it("should store an empty page when the response has no posts array", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchPosts: async () => ({ cursor: null }), getPosts: async () => [], @@ -3053,7 +3058,7 @@ describe("loadHashtagFeed", () => { }); it("should reset on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$hashtagFeeds.set("foo-top", { posts: [{ uri: "p1" }], cursor: "c1", @@ -3101,7 +3106,7 @@ describe("loadPinnedItems", () => { return { list: { uri, name: `list-${uri}` }, items: [], cursor: "" }; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const provider = { requirePreferences: () => preferences }; const requests = createRequests(mockApi, dataStore, provider); @@ -3139,7 +3144,7 @@ describe("loadPinnedItems", () => { return null; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const provider = { requirePreferences: () => preferences }; const requests = createRequests(mockApi, dataStore, provider); @@ -3156,7 +3161,7 @@ describe("loadPinnedItems", () => { describe("enableStatus / getStatus", () => { it("should track loading start, end, and clear errors on success", async () => { const mockApi = { getMutes: async () => ({ mutes: [], cursor: null }) }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); const initialStatus = requests.getStatus("loadMutedProfiles"); @@ -3185,7 +3190,7 @@ describe("enableStatus / getStatus", () => { throw apiError; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadMutedProfiles(); @@ -3205,7 +3210,7 @@ describe("enableStatus / getStatus", () => { throw otherError; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); let caught = null; @@ -3233,7 +3238,7 @@ describe("enableStatus / getStatus", () => { return { mutes: [], cursor: null }; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadMutedProfiles().catch(() => {}); @@ -3245,7 +3250,7 @@ describe("enableStatus / getStatus", () => { }); it("should namespace status by request id derived from arguments", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getProfile: async (did) => ({ did, handle: "x" }), }; @@ -3275,7 +3280,7 @@ describe("_loadBlockedPosts", () => { function setup({ getPosts = async () => [], getRecord }) { const mockApi = { getPosts, getRecord }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3359,7 +3364,10 @@ describe("statusStore.$statuses", () => { throw apiError; }, }; - const requests = makeRequests(mockApi, new DataStore()); + const requests = makeRequests( + mockApi, + new DataStore(createSessionState(null)), + ); assert.deepEqual(requests.statusStore.$statuses.get("loadMutedProfiles"), { loading: false, @@ -3390,7 +3398,7 @@ describe("loadCurrentUser", () => { return profile; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadCurrentUser(); @@ -3410,7 +3418,7 @@ describe("loadCurrentUser", () => { value: { displayName: "Me" }, }), }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadCurrentUser(); @@ -3436,7 +3444,7 @@ describe("loadCurrentUser", () => { }); }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadCurrentUser(); @@ -3458,7 +3466,7 @@ describe("loadCurrentUser", () => { throw recordError; }, }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await assert.rejects(() => requests.loadCurrentUser(), recordError); @@ -3470,7 +3478,7 @@ describe("loadPost", () => { it("should load and store a single post", async () => { const post = { uri: "at://did:plc:a/app.bsky.feed.post/1", record: {} }; const mockApi = { getPost: async () => post }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadPost(post.uri); @@ -3532,7 +3540,7 @@ describe("loadPostThread with a blocked parent", () => { } it("should rebuild the parent chain from backlinks across blocked authors", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => [ { @@ -3579,7 +3587,7 @@ describe("loadPostThread with a blocked parent", () => { [grandparentUri, grandparentPost], [rootUri, rootPost], ]); - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => [ { @@ -3619,7 +3627,7 @@ describe("loadPostThread with a blocked parent", () => { }); it("should rethrow non-abort backlink failures", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => { throw new TypeError("network down"); @@ -3643,7 +3651,7 @@ describe("loadPostThread with a blocked parent", () => { }); it("should fall back to loading the blocked parent thread when the viewer is involved in the block", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let constellationCalled = false; const constellation = { getLinks: async () => { @@ -3680,7 +3688,7 @@ describe("loadPostThread with a blocked parent", () => { }); it("should fall back to loading the blocked parent thread when backlinks time out", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => { throw Object.assign(new Error("timed out"), { name: "AbortError" }); @@ -3714,7 +3722,7 @@ describe("loadPostThread with a blocked parent", () => { }); it("should fall back to loading the blocked parent thread when backlinks yield no posts", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); // No backlinks by the blocked author — only the root gets appended const constellation = { getLinks: async () => [] }; const parentThread = { @@ -3751,7 +3759,7 @@ describe("_loadBlockedReplies", () => { const blockedReplyUri = "at://did:plc:blocker/app.bsky.feed.post/r2"; it("should return an empty list when the thread has no post", async () => { - const requests = makeRequests({}, new DataStore()); + const requests = makeRequests({}, new DataStore(createSessionState(null))); const replies = await requests._loadBlockedReplies({}); @@ -3759,7 +3767,7 @@ describe("_loadBlockedReplies", () => { }); it("should keep the loaded replies when backlinks time out", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => { throw Object.assign(new Error("timed out"), { name: "AbortError" }); @@ -3784,7 +3792,7 @@ describe("_loadBlockedReplies", () => { }); it("should rethrow non-abort backlink failures", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => { throw new TypeError("network down"); @@ -3807,7 +3815,7 @@ describe("_loadBlockedReplies", () => { }); it("should load missing replies from backlinks and mark them as blocked replies", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => [ { @@ -3864,7 +3872,7 @@ describe("_loadBlockedReplies", () => { describe("loadNextFeedPage feed types", () => { it("should use getFollowingFeed for the timeline type", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let called = false; const mockApi = { getFollowingFeed: async () => { @@ -3881,7 +3889,7 @@ describe("loadNextFeedPage feed types", () => { }); it("should use getListFeed for the list type", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const listUri = "at://did/app.bsky.graph.list/1"; let requestedUri = null; const mockApi = { @@ -3899,7 +3907,7 @@ describe("loadNextFeedPage feed types", () => { }); it("should reject on an unknown feed type", async () => { - const requests = makeRequests({}, new DataStore()); + const requests = makeRequests({}, new DataStore(createSessionState(null))); await assert.rejects( requests.loadNextFeedPage({ type: "bogus", uri: "x" }), @@ -3915,7 +3923,7 @@ describe("loadDetailedProfiles", () => { { did: "did:plc:b", handle: "b.test" }, ]; const mockApi = { getProfiles: async () => profiles }; - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore); await requests.loadDetailedProfiles(["did:plc:a", "did:plc:b"]); @@ -3932,7 +3940,10 @@ describe("loadDetailedProfiles", () => { return []; }, }; - const requests = makeRequests(mockApi, new DataStore()); + const requests = makeRequests( + mockApi, + new DataStore(createSessionState(null)), + ); await requests.loadDetailedProfiles([]); @@ -3942,7 +3953,7 @@ describe("loadDetailedProfiles", () => { describe("_loadJoinLinkPreviews", () => { it("should fetch distinct codes and store previews by code", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let requestedCodes = null; const mockApi = { isAuthenticated: true, @@ -3977,7 +3988,10 @@ describe("_loadJoinLinkPreviews", () => { return { joinLinkPreviews: [] }; }, }; - const requests = makeRequests(mockApi, new DataStore()); + const requests = makeRequests( + mockApi, + new DataStore(createSessionState(null)), + ); await requests._loadJoinLinkPreviews(["abc"]); assert.deepEqual(called, false); @@ -3994,7 +4008,10 @@ describe("_loadJoinLinkPreviews", () => { throw new Error("boom"); }, }; - const requests = makeRequests(mockApi, new DataStore()); + const requests = makeRequests( + mockApi, + new DataStore(createSessionState(null)), + ); await requests._loadJoinLinkPreviews(["abc"]); }); @@ -4007,7 +4024,10 @@ describe("_loadPostDependencies", () => { throw new Error("network down"); }, }; - const requests = makeRequests(mockApi, new DataStore()); + const requests = makeRequests( + mockApi, + new DataStore(createSessionState(null)), + ); const blockedPost = { $type: "app.bsky.feed.defs#blockedPost", uri: "at://did:plc:x/app.bsky.feed.post/1", @@ -4019,7 +4039,7 @@ describe("_loadPostDependencies", () => { describe("loadFeedGenerator / loadList / loadStarterPack", () => { it("should store the feed generator by uri", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const feedGenerator = { uri: "at://did/feed/1", displayName: "Feed" }; const mockApi = { getFeedGenerator: async () => feedGenerator }; const requests = makeRequests(mockApi, dataStore); @@ -4033,7 +4053,7 @@ describe("loadFeedGenerator / loadList / loadStarterPack", () => { }); it("should store the list view by uri", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const listUri = "at://did/app.bsky.graph.list/1"; let capturedOptions = null; const mockApi = { @@ -4051,7 +4071,7 @@ describe("loadFeedGenerator / loadList / loadStarterPack", () => { }); it("should store the starter pack by uri", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const starterPack = { uri: "at://did/starterpack/1", record: {} }; const mockApi = { getStarterPack: async () => starterPack }; const requests = makeRequests(mockApi, dataStore); @@ -4066,7 +4086,7 @@ describe("loadListMembers", () => { const listUri = "at://did/app.bsky.graph.list/1"; it("should store the first page and hydrate member profiles", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getList: async () => ({ list: { uri: listUri }, @@ -4085,7 +4105,7 @@ describe("loadListMembers", () => { }); it("should short-circuit when fully loaded", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$listMembers.set(listUri, { items: [], cursor: null }); let called = false; const mockApi = { @@ -4102,7 +4122,7 @@ describe("loadListMembers", () => { }); it("should refetch from scratch on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$listMembers.set(listUri, { items: [{ uri: "li1", subject: { did: "did:plc:a" } }], cursor: null, @@ -4132,7 +4152,7 @@ describe("loadActorLists", () => { const did = "did:plc:author"; it("should store actor lists and cache each list view", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getActorLists: async () => ({ lists: [{ uri: "at://did/app.bsky.graph.list/1", name: "L1" }], @@ -4153,7 +4173,7 @@ describe("loadActorLists", () => { }); it("should short-circuit when there is no remaining cursor", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$actorLists.set(did, { lists: [], cursor: null }); let called = false; const mockApi = { @@ -4170,7 +4190,7 @@ describe("loadActorLists", () => { }); it("should refetch from scratch on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$actorLists.set(did, { lists: [{ uri: "old" }], cursor: null, @@ -4202,7 +4222,10 @@ describe("loadCurrentUserLists", () => { return { lists: [], cursor: null }; }, }; - const requests = makeRequests(mockApi, new DataStore()); + const requests = makeRequests( + mockApi, + new DataStore(createSessionState(null)), + ); await requests.loadCurrentUserLists(); @@ -4210,7 +4233,7 @@ describe("loadCurrentUserLists", () => { }); it("should load the current user's lists", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$currentUser.set({ did: "did:plc:me" }); let requestedDid = null; const mockApi = { @@ -4241,7 +4264,7 @@ describe("loadDrafts", () => { } it("should store the drafts page and load local media refs", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const draftView = { draft: { posts: [ @@ -4276,7 +4299,7 @@ describe("loadDrafts", () => { }); it("should refetch from scratch on reload", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$drafts.set({ drafts: [{ draft: {} }], cursor: "c1" }); let capturedCursor; const mockApi = { @@ -4302,7 +4325,7 @@ describe("loadKnownFollowers", () => { const profileDid = "did:plc:target"; it("should store known followers and hydrate profiles on first load", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const mockApi = { getKnownFollowers: async () => ({ followers: [{ did: "did:plc:a", handle: "a" }], @@ -4320,7 +4343,7 @@ describe("loadKnownFollowers", () => { }); it("should append when cursor is provided", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); dataStore.$knownFollowers.set(profileDid, { followers: [{ did: "did:plc:a" }], cursor: "c1", @@ -4343,7 +4366,7 @@ describe("loadKnownFollowers", () => { describe("loadProfileChatStatus", () => { it("should store the availability response keyed by profile did", async () => { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); let requestedDids = null; const availability = { canChat: true }; const mockApi = { diff --git a/tests/unit/specs/identityPrecaching.test.js b/tests/unit/specs/identityPrecaching.test.js index 2b163938..d47f1879 100644 --- a/tests/unit/specs/identityPrecaching.test.js +++ b/tests/unit/specs/identityPrecaching.test.js @@ -2,9 +2,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { setUpIdentityPrecaching } from "/js/identityPrecaching.js"; import { DataStore } from "/js/dataLayer/dataStore.js"; +import { createSessionState } from "/js/dataLayer/sessionState.js"; function setup() { - const dataStore = new DataStore(); + const dataStore = new DataStore(createSessionState(null)); const dataLayer = { dataStore, preferencesProvider: { $preferences: { get: () => null } }, diff --git a/tests/unit/specs/mainLayout.test.js b/tests/unit/specs/mainLayout.test.js index 3027fa76..f9b9b7b8 100644 --- a/tests/unit/specs/mainLayout.test.js +++ b/tests/unit/specs/mainLayout.test.js @@ -32,11 +32,20 @@ describe("MainLayout", () => { const composePost = mock.fn(); const $trends = new Signal.State(null); const loadTrends = mock.fn(async () => {}); + const $hydratedPinnedItems = new Signal.State(null); + const $selectedFeedUri = new Signal.State(null); + const ensurePinnedItems = mock.fn(async () => []); const context = { isAuthenticated: true, dataLayer: { - derived: { $currentUser, $trends }, + derived: { + $currentUser, + $trends, + $hydratedPinnedItems, + $selectedFeedUri, + }, requests: { loadTrends }, + declarative: { ensurePinnedItems }, }, notificationService: { $numNotifications }, chatNotificationService: { $numNotifications: $numChatNotifications }, @@ -133,7 +142,16 @@ describe("MainLayout", () => { const elements = [...rightColumn.children].map((child) => child.tagName.toLowerCase(), ); - assert.deepEqual(elements, ["sidebar-search", "trending-pane"]); + assert.deepEqual(elements, [ + "sidebar-search", + "pinned-feeds-pane", + "trending-pane", + ]); + assert( + rightColumn + .querySelector("pinned-feeds-pane") + .hasAttribute("show-selected"), + ); setRoute({ layoutOptions: { activeNavItem: "search" } }); await flushRender(); @@ -289,6 +307,37 @@ describe("mainLayoutTemplate", () => { }); }); +describe("mainLayoutTemplate - pinned feeds pane", () => { + it("renders the pinned feeds pane only when authenticated", () => { + const container = document.createElement("div"); + render( + mainLayoutTemplate({ + pluginService: mockPluginService, + isAuthenticated: true, + currentUser: mockUser, + children: html`
Content
`, + }), + container, + ); + assert(container.querySelector("pinned-feeds-pane") !== null); + + const loggedOutContainer = document.createElement("div"); + render( + mainLayoutTemplate({ + pluginService: mockPluginService, + isAuthenticated: false, + currentUser: null, + children: html`
Content
`, + }), + loggedOutContainer, + ); + assert.deepEqual( + loggedOutContainer.querySelector("pinned-feeds-pane"), + null, + ); + }); +}); + describe("mainLayoutTemplate - footer", () => { it("should render footer", () => { const result = mainLayoutTemplate({ diff --git a/tests/unit/specs/signals.test.js b/tests/unit/specs/signals.test.js index 87eec186..170ffe88 100644 --- a/tests/unit/specs/signals.test.js +++ b/tests/unit/specs/signals.test.js @@ -1,4 +1,4 @@ -import { describe, it } from "node:test"; +import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { Signal, @@ -6,6 +6,7 @@ import { SignalMap, SignalArray, effect, + PersistedReactiveStore, } from "/js/signals.js"; // effect() batches reactions via a double requestAnimationFrame (see @@ -609,3 +610,67 @@ describe("effect", () => { assert.deepEqual(seen, [0]); }); }); + +describe("PersistedReactiveStore", () => { + const storageKey = "persisted-store-test"; + + beforeEach(() => localStorage.removeItem(storageKey)); + afterEach(() => localStorage.removeItem(storageKey)); + + it("restores a stored value matching the default's type", () => { + localStorage.setItem(storageKey, JSON.stringify({ hidden: true })); + const store = new PersistedReactiveStore(storageKey); + store.$hidden = new Signal.State(false); + assert.deepEqual(store.$hidden.get(), true); + }); + + it("ignores a stored value whose type differs from the default", () => { + localStorage.setItem(storageKey, JSON.stringify({ hidden: "yes" })); + const store = new PersistedReactiveStore(storageKey); + store.$hidden = new Signal.State(false); + assert.deepEqual(store.$hidden.get(), false); + }); + + it("restores any stored value when the default is null", () => { + localStorage.setItem(storageKey, JSON.stringify({ selected: "following" })); + const store = new PersistedReactiveStore(storageKey); + store.$selected = new Signal.State(null); + assert.deepEqual(store.$selected.get(), "following"); + }); + + it("keeps a null default when nothing is stored", () => { + const store = new PersistedReactiveStore(storageKey); + store.$selected = new Signal.State(null); + assert.deepEqual(store.$selected.get(), null); + }); + + it("saves changed values and drops values back at their default", async () => { + const store = new PersistedReactiveStore(storageKey); + store.$hidden = new Signal.State(false); + store.$selected = new Signal.State(null); + + store.$hidden.set(true); + store.$selected.set("following"); + await flushEffects(); + assert.deepEqual(JSON.parse(localStorage.getItem(storageKey)), { + hidden: true, + selected: "following", + }); + + store.$hidden.set(false); + store.$selected.set(null); + await flushEffects(); + assert.deepEqual(localStorage.getItem(storageKey), null); + }); + + it("does not write the defaults back on registration", async () => { + const store = new PersistedReactiveStore(storageKey); + store.$hidden = new Signal.State(false); + await flushEffects(); + assert.deepEqual(localStorage.getItem(storageKey), null); + }); + + it("throws without a storage key", () => { + assert.throws(() => new PersistedReactiveStore(), /storage key/); + }); +});