Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions apps/web/src/app/waves/_components/waves-list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
import { useWavesTagFilter } from "@/app/waves/_context";
import { useBottomPagination } from "@/core/hooks";
import { Button } from "@ui/button";
import { DEFAULT_OBSERVER } from "@/consts/observer";
import i18next from "i18next";
import { sentry } from "@/core/sentry/lazy-sentry";

Expand All @@ -40,11 +39,15 @@ export function WavesListView({ feedType, username }: Props) {
// server-side and each page still arrives full (client-side filtering would
// shrink pages). Note esync actually drops muted authors here, unlike the
// Hive bridge, which only flags them `stats.gray`.
// Ecency's own moderation mutes are not this parameter's job: esync applies
// that list to every waves request on top of the observer's, so it holds for
// signed-in viewers too. The fallback below only keeps anonymous requests
// shaped like the rest.
const observer = username || DEFAULT_OBSERVER;
//
// Logged out this stays undefined rather than falling back to Ecency's
// moderation account. That fallback used to be what filtered the anonymous
// feed; esync now applies the moderation mute list to every waves request on
// its own, so sending it changed nothing except the cache tier: any observer
// marks a request personalised, and personalised requests skip the 60s shared
// response cache. Every anonymous visitor was paying for a per-viewer cache
// entry that no other viewer could ever reuse.
const observer = username;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Missing observer regression coverage 📘 Rule violation ▣ Testability

The changed waves and shorts request behavior is functional code, but this PR adds or updates no
automated test that verifies anonymous requests omit observer while authenticated requests still
pass username. This leaves the cache-tier change and logged-in behavior unprotected against
regression.
Agent Prompt
## Issue description
The waves views now pass `undefined` for anonymous observers, but no test in this change exercises the new request behavior or verifies that authenticated usernames remain observers.

## Issue Context
Add tests for both `WavesListView` and `WavesReelsView` (or their query option integration) that assert anonymous requests omit `observer` and authenticated requests include the username. Cover the relevant feed and shorts paths.

## Fix Focus Areas
- apps/web/src/app/waves/_components/waves-list-view.tsx[50-53]
- apps/web/src/app/waves/_components/waves-reels-view.tsx[22-26]
- packages/sdk/src/modules/posts/queries/get-waves-feed-query-options.ts[120-128]
- packages/sdk/src/modules/posts/queries/get-shorts-feed-query-options.ts[131-139]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

const queryOptions = useMemo(() => {
if (selectedSource) {
return getWavesFeedQueryOptions({ containers: [selectedSource], observer });
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/app/waves/_components/waves-reels-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { Button } from "@ui/button";
import { useInfiniteDataFlow } from "@/utils";
import { WavesReelItem } from "@/app/waves/_components/waves-reel-item";
import { WavesFastReplyDialog } from "@/app/waves/_components/waves-fast-reply-dialog";
import { DEFAULT_OBSERVER } from "@/consts/observer";

interface Props {
username?: string;
Expand All @@ -20,7 +19,10 @@ interface Props {
* Selected via the "Shorts" source tab; renders in place of the wave card list.
*/
export function WavesReelsView({ username }: Props) {
const observer = username || DEFAULT_OBSERVER;
// Undefined when logged out, like the waves list: esync applies the
// moderation mute list to the shorts feed itself, and an observer would only
// push the request off the shared 60s response cache (see waves-list-view).
const observer = username;
const queryOptions = useMemo(() => getShortsFeedQueryOptions({ observer }), [observer]);

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, refetch } =
Expand Down
28 changes: 18 additions & 10 deletions apps/web/src/consts/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,30 @@
*
* Four things worth knowing before using this:
*
* 1. On the Hive bridge an observer only *marks* content. Muted authors' posts
* are still returned, so swapping the observer never shortens a feed or a
* comment thread. The waves feed (esync) is the exception: it drops them.
* 1. On the Hive bridge an observer marks ranked-feed content rather than
* removing it: muted authors' posts come back flagged `stats.gray`, so
* swapping the observer does not shorten a feed. Comment threads are NOT
* like that, despite what this comment used to claim. `bridge.get_discussion`
* drops the observer's muted authors outright — the same thread measured
* 2026-08-23 returns 303 nodes under a neutral observer and 60 under this
* one. (An observer that is not a real account returns nothing at all, so
* never pass an unvalidated name here.) The waves feed (esync) also drops
* them, and applies Ecency's moderation list besides — see 3.
* 2. Only personalise the observer where the whole list is fetched under it.
* Profile and community routes server-render their first page as this
* default and their infinite lists then discard their own first page, so
* swapping to the logged-in user client-side would filter every page except
* the one people actually look at. Feeds personalise instead at the server,
* where cache-policy already marks those tiers user-specific.
* 3. This is NOT how Ecency's moderation mutes reach the waves feed. An
* observer only ever carries the mute list of whoever is viewing, so before
* esync applied the moderation list itself, signed-in users (who send their
* own name) got none of it. esync now ANDs `@ecency`'s on-chain mutes into
* every waves query on top of the observer's, so muting an account there is
* what takes it out of the feed for everyone. Bridge reads still behave as
* described above.
* 3. This is NOT how Ecency's moderation mutes reach the waves feed, and the
* waves feed no longer sends this default at all. An observer only ever
* carries the mute list of whoever is viewing, so before esync applied the
* moderation list itself, signed-in users (who send their own name) got none
* of it. esync now ANDs `@ecency`'s on-chain mutes into every waves query on
* top of the observer's, so muting an account there is what takes it out of
* the feed for everyone, and the waves views pass `undefined` when logged
* out so the request stays on the shared response cache. Bridge reads still
* behave as described above.
* 4. Kept as a plain literal on purpose. This mirrors `CONFIG.defaultObserver`
* in @ecency/sdk, but reading it from the SDK would make every module that
* touches an observer depend on the SDK being mocked in specs, and several
Expand Down
160 changes: 160 additions & 0 deletions apps/web/src/specs/app/waves/waves-observer.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import React from "react";
import { render } from "@testing-library/react";
import "@testing-library/jest-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryKeys } from "@ecency/sdk";

/**
* What the waves views send as `observer`.
*
* The parameter carries the *viewer's* mute list, and esync treats any request
* that has one as personalised, skipping its shared response cache. Logged out
* there is no viewer, so it must be absent: a fallback there costs every
* anonymous visitor a cache entry nobody else can reuse, and filters nothing
* that esync does not already filter server-side.
*
* Logged in it must still be the viewer's own name, or their personal mutes
* stop being applied at all.
*/

const getWavesFeedQueryOptions = vi.hoisted(() => vi.fn());
const getShortsFeedQueryOptions = vi.hoisted(() => vi.fn());

/**
* Keys come from `QueryKeys` rather than a literal, so a mocked feed lands on
* the same cache entry production would give it. That matters here: the key
* carries the observer, so anonymous and logged-in renders are distinct
* entries, exactly as they are in the app. A literal would collapse them onto
* one and quietly hide a cache-sharing bug from any later test that seeds or
* asserts on cache state.
*/
const stubInfiniteQuery = (queryKey: readonly unknown[]) => ({
queryKey,
initialPageParam: undefined,
queryFn: async () => [],
getNextPageParam: () => undefined,
enabled: false
});

vi.mock("@/utils", async () => ({
...(await vi.importActual<typeof import("@/utils")>("@/utils")),
random: vi.fn(),
getAccessToken: vi.fn(() => "mock-token")
}));

vi.mock("@ecency/sdk", async () => ({
...(await vi.importActual<Record<string, unknown>>("@ecency/sdk")),
getWavesFeedQueryOptions: getWavesFeedQueryOptions.mockImplementation(
(params: Parameters<typeof QueryKeys.posts.wavesFeed>[0] = {}) =>
stubInfiniteQuery(QueryKeys.posts.wavesFeed(params))
),
getShortsFeedQueryOptions: getShortsFeedQueryOptions.mockImplementation(
(params: Parameters<typeof QueryKeys.posts.shortsFeed>[0] = {}) =>
stubInfiniteQuery(QueryKeys.posts.shortsFeed(params))
),
getPromotedPostsQuery: vi.fn((type: string = "feed") => ({
queryKey: QueryKeys.posts.promoted(type),
queryFn: async () => [],
enabled: false
}))
}));

vi.mock("@/app/waves/_context", () => ({
useWavesTagFilter: () => ({ selectedTag: null, selectedSource: null })
}));
vi.mock("@/app/waves/_hooks", () => ({
useWavesAutoRefresh: () => ({ newWaves: [], clear: vi.fn(), now: 0 })
}));
vi.mock("@/app/waves/_components", () => ({ WavesRefreshPopup: () => null }));
vi.mock("@/app/waves/_components/waves-list-item", () => ({ WavesListItem: () => null }));
vi.mock("@/app/waves/_components/waves-list-loader", () => ({ WavesListLoader: () => null }));
vi.mock("@/app/waves/_components/waves-reel-item", () => ({ WavesReelItem: () => null }));
vi.mock("@/app/waves/_components/waves-fast-reply-dialog", () => ({
WavesFastReplyDialog: () => null
}));
vi.mock("@/features/shared", () => ({ DetectBottom: () => null }));
vi.mock("@/core/hooks", () => ({ useBottomPagination: () => [vi.fn(), vi.fn()] }));

import { WavesListView } from "@/app/waves/_components/waves-list-view";
import { WavesReelsView } from "@/app/waves/_components/waves-reels-view";

function renderView(node: React.ReactElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
}

describe("waves observer", () => {
beforeEach(() => {
getWavesFeedQueryOptions.mockClear();
getShortsFeedQueryOptions.mockClear();
});

it("omits the observer on the anonymous waves feed", () => {
renderView(<WavesListView feedType="for-you" />);

expect(getWavesFeedQueryOptions).toHaveBeenCalled();
for (const [params] of getWavesFeedQueryOptions.mock.calls) {
expect(params.observer).toBeUndefined();
}
});

it("sends the logged-in viewer as the observer on the waves feed", () => {
renderView(<WavesListView feedType="for-you" username="viewer" />);

expect(getWavesFeedQueryOptions).toHaveBeenCalled();
for (const [params] of getWavesFeedQueryOptions.mock.calls) {
expect(params.observer).toBe("viewer");
}
});

it("omits the observer on the anonymous shorts feed", () => {
renderView(<WavesReelsView />);

expect(getShortsFeedQueryOptions).toHaveBeenCalled();
for (const [params] of getShortsFeedQueryOptions.mock.calls) {
expect(params.observer).toBeUndefined();
}
});

it("sends the logged-in viewer as the observer on the shorts feed", () => {
renderView(<WavesReelsView username="viewer" />);

expect(getShortsFeedQueryOptions).toHaveBeenCalled();
for (const [params] of getShortsFeedQueryOptions.mock.calls) {
expect(params.observer).toBe("viewer");
}
});

it("puts the anonymous and logged-in feeds on different cache keys", () => {
// The whole point of dropping the observer is the cache tier, so the key
// has to actually differ. Also guards the mocks above: if they went back to
// a literal key, the two renders would share one entry and every
// cache-sensitive test written after this would be testing a fiction.
renderView(<WavesListView feedType="for-you" />);
const anonymous = getWavesFeedQueryOptions.mock.results[0].value.queryKey;

getWavesFeedQueryOptions.mockClear();
renderView(<WavesListView feedType="for-you" username="viewer" />);
const signedIn = getWavesFeedQueryOptions.mock.results[0].value.queryKey;

expect(anonymous).not.toEqual(signedIn);
expect(signedIn).toContain("viewer");
});

it("never substitutes Ecency's moderation account for a missing viewer", () => {
// The regression this guards: `username || DEFAULT_OBSERVER` reads as a
// harmless default, and the response is identical either way, so nothing
// visible breaks when it comes back -- only the cache tier changes.
renderView(<WavesListView feedType="for-you" />);
renderView(<WavesReelsView />);

const observers = [
...getWavesFeedQueryOptions.mock.calls,
...getShortsFeedQueryOptions.mock.calls
].map(([params]) => params.observer);

expect(observers.length).toBeGreaterThan(0);
expect(observers).not.toContain("ecency");
});
});
Loading