diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 49f772e..7058518 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -77,15 +77,27 @@ export async function apiVerify(input: { ); } -export async function apiFeed(input?: { +export type FeedQueryInput = { stage?: string; + excludeStages?: string[]; + excludeEntityTypes?: string[]; cursor?: string; actor?: string; chambers?: string[]; limit?: number; -}): Promise { +}; + +export async function apiFeed( + input?: FeedQueryInput, +): Promise { const params = new URLSearchParams(); if (input?.stage) params.set("stage", input.stage); + if (input?.excludeStages && input.excludeStages.length > 0) { + params.set("excludeStages", input.excludeStages.join(",")); + } + if (input?.excludeEntityTypes && input.excludeEntityTypes.length > 0) { + params.set("excludeEntityTypes", input.excludeEntityTypes.join(",")); + } if (input?.cursor) params.set("cursor", input.cursor); if (input?.actor) params.set("actor", input.actor); if (input?.chambers && input.chambers.length > 0) { diff --git a/src/lib/feedScopeRouting.ts b/src/lib/feedScopeRouting.ts new file mode 100644 index 0000000..0d54a0f --- /dev/null +++ b/src/lib/feedScopeRouting.ts @@ -0,0 +1,110 @@ +import type { FeedQueryInput } from "@/lib/apiClient"; + +export type FeedScope = "urgent" | "my" | "chambers" | "system" | "all"; + +export const FEED_SCOPES: { value: FeedScope; label: string }[] = [ + { value: "urgent", label: "Urgent" }, + { value: "my", label: "My activity" }, + { value: "chambers", label: "Chambers and factions" }, + { value: "system", label: "System" }, + { value: "all", label: "All activity" }, +]; + +export const SYSTEM_FEED_STAGE = "system"; +export const PRIVATE_FEED_ENTITY_TYPES = ["faction_invite"] as const; + +const ACTIVITY_EXCLUDED_STAGES = [SYSTEM_FEED_STAGE]; + +export function feedScopeRequiresWallet(scope: FeedScope) { + return scope === "urgent" || scope === "my" || scope === "chambers"; +} + +export function feedScopeRequiresChambers(scope: FeedScope) { + return scope === "urgent" || scope === "chambers"; +} + +export function buildFeedRequestForScope(input: { + scope: Exclude; + address?: string; + chamberFilters?: string[] | null; + cursor?: string | null; + limit: number; +}): FeedQueryInput { + const base: FeedQueryInput = { + cursor: input.cursor ?? undefined, + limit: input.limit, + }; + + if (input.scope === "my") { + return { + ...base, + actor: input.address, + excludeStages: ACTIVITY_EXCLUDED_STAGES, + }; + } + + if (input.scope === "chambers") { + return { + ...base, + chambers: input.chamberFilters ?? [], + excludeStages: ACTIVITY_EXCLUDED_STAGES, + excludeEntityTypes: [...PRIVATE_FEED_ENTITY_TYPES], + }; + } + + if (input.scope === "system") { + return { + ...base, + stage: SYSTEM_FEED_STAGE, + }; + } + + return { + ...base, + excludeEntityTypes: [...PRIVATE_FEED_ENTITY_TYPES], + }; +} + +export function buildUrgentFeedRequests(input: { + address?: string; + chamberFilters: string[]; + baseLimit: number; + stageLimit: number; + factionLimit: number; +}): FeedQueryInput[] { + const requests: FeedQueryInput[] = [ + { + chambers: input.chamberFilters, + excludeStages: ACTIVITY_EXCLUDED_STAGES, + excludeEntityTypes: [...PRIVATE_FEED_ENTITY_TYPES], + limit: input.baseLimit, + }, + { + stage: "pool", + chambers: input.chamberFilters, + limit: input.stageLimit, + }, + { + stage: "vote", + chambers: input.chamberFilters, + limit: input.stageLimit, + }, + ]; + + if (input.address) { + requests.push( + { + stage: "build", + actor: input.address, + limit: input.stageLimit, + }, + { + stage: "faction", + actor: input.address, + limit: input.factionLimit, + }, + ); + } + + return requests; +} diff --git a/src/lib/feedUi.ts b/src/lib/feedUi.ts index fbacc34..7fede5a 100644 --- a/src/lib/feedUi.ts +++ b/src/lib/feedUi.ts @@ -107,3 +107,17 @@ export const toUrgentItems = ( } return Array.from(deduped.values()); }; + +export const toLimitedUrgentItems = ( + items: FeedItemDto[], + isGovernorActive: boolean, + viewerAddress: string | undefined, + limit: number, +): FeedItemDto[] => { + const safeLimit = Math.max(0, Math.floor(limit)); + if (safeLimit === 0) return []; + return toUrgentItems(items, isGovernorActive, viewerAddress).slice( + 0, + safeLimit, + ); +}; diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index a53a391..b7a487f 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -3,12 +3,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "@/app/auth/AuthContext"; import { PageHint } from "@/components/PageHint"; import { factionIdFromHref, feedItemKey } from "@/lib/feedUi"; +import type { FeedScope } from "@/lib/feedScopeRouting"; import { apiFactionCofounderInviteAccept, apiFactionCofounderInviteDecline, } from "@/lib/apiClient"; import type { FeedItemDto } from "@/types/api"; -import { FeedControls, type FeedScope } from "./components/FeedControls"; +import { FeedControls } from "./components/FeedControls"; import { FeedListSection } from "./components/FeedListSection"; import { FeedStatusMessages } from "./components/FeedStatusMessages"; import { useFeedChamberFilters } from "./hooks/useFeedChamberFilters"; diff --git a/src/pages/feed/components/FeedControls.tsx b/src/pages/feed/components/FeedControls.tsx index d02a5c3..6108c93 100644 --- a/src/pages/feed/components/FeedControls.tsx +++ b/src/pages/feed/components/FeedControls.tsx @@ -1,13 +1,5 @@ import { ToggleGroup } from "@/components/ToggleGroup"; - -export type FeedScope = "urgent" | "my" | "chambers" | "all"; - -const FEED_SCOPES: { value: FeedScope; label: string }[] = [ - { value: "urgent", label: "Urgent" }, - { value: "my", label: "My activity" }, - { value: "chambers", label: "Chambers and factions" }, - { value: "all", label: "All activity" }, -]; +import { FEED_SCOPES, type FeedScope } from "@/lib/feedScopeRouting"; type FeedControlsProps = { feedScope: FeedScope; diff --git a/src/pages/feed/hooks/useFeedChamberFilters.ts b/src/pages/feed/hooks/useFeedChamberFilters.ts index 6125e72..4a1e7b6 100644 --- a/src/pages/feed/hooks/useFeedChamberFilters.ts +++ b/src/pages/feed/hooks/useFeedChamberFilters.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { apiClock, apiHuman, apiMyGovernance } from "@/lib/apiClient"; -import type { FeedScope } from "../components/FeedControls"; +import type { FeedScope } from "@/lib/feedScopeRouting"; export function useFeedChamberFilters(input: { address?: string | null; diff --git a/src/pages/feed/hooks/useFeedItems.ts b/src/pages/feed/hooks/useFeedItems.ts index 36ea728..52aaabe 100644 --- a/src/pages/feed/hooks/useFeedItems.ts +++ b/src/pages/feed/hooks/useFeedItems.ts @@ -1,10 +1,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { toTimestampMs } from "@/lib/dateTime"; -import { feedItemKey, toUrgentItems } from "@/lib/feedUi"; +import { feedItemKey, toLimitedUrgentItems } from "@/lib/feedUi"; +import { + buildFeedRequestForScope, + buildUrgentFeedRequests, + feedScopeRequiresChambers, + feedScopeRequiresWallet, +} from "@/lib/feedScopeRouting"; +import type { FeedScope } from "@/lib/feedScopeRouting"; import { apiFeed } from "@/lib/apiClient"; import type { FeedItemDto } from "@/types/api"; -import type { FeedScope } from "../components/FeedControls"; import { FEED_MAX_PAGE_SIZE, FEED_MIN_PAGE_SIZE } from "./useFeedPageSize"; const URGENT_STAGE_LIMIT = FEED_MAX_PAGE_SIZE * 2; @@ -15,52 +21,21 @@ async function loadUrgentFeedItems(input: { limit: number; isGovernorActive: boolean; }): Promise { - const [base, pool, vote, build, invites, system] = await Promise.all([ - apiFeed({ chambers: input.chambers, limit: input.limit }), - apiFeed({ - stage: "pool", - chambers: input.chambers, - limit: URGENT_STAGE_LIMIT, - }), - apiFeed({ - stage: "vote", - chambers: input.chambers, - limit: URGENT_STAGE_LIMIT, - }), - input.address - ? apiFeed({ - stage: "build", - actor: input.address, - limit: URGENT_STAGE_LIMIT, - }) - : Promise.resolve({ items: [] as FeedItemDto[] }), - input.address - ? apiFeed({ - actor: input.address, - stage: "faction", - limit: FEED_MIN_PAGE_SIZE, - }) - : Promise.resolve({ items: [] as FeedItemDto[] }), - input.address - ? apiFeed({ - actor: input.address, - stage: "system", - limit: URGENT_STAGE_LIMIT, - }) - : Promise.resolve({ items: [] as FeedItemDto[] }), - ]); + const responses = await Promise.all( + buildUrgentFeedRequests({ + address: input.address, + chamberFilters: input.chambers, + baseLimit: input.limit, + stageLimit: URGENT_STAGE_LIMIT, + factionLimit: FEED_MIN_PAGE_SIZE, + }).map((request) => apiFeed(request)), + ); - return toUrgentItems( - [ - ...base.items, - ...pool.items, - ...vote.items, - ...build.items, - ...invites.items, - ...system.items, - ], + return toLimitedUrgentItems( + responses.flatMap((response) => response.items), input.isGovernorActive, input.address, + input.limit, ); } @@ -90,19 +65,15 @@ export function useFeedItems({ useEffect(() => { let active = true; const loadFeed = async () => { - if (feedScope !== "all" && !address) { + if (feedScopeRequiresWallet(feedScope) && !address) { setFeedItems([]); onLoadError("Connect a wallet to view your feed."); setNextCursor(null); return; } + if (feedScopeRequiresChambers(feedScope) && chambersLoading) return; if ( - (feedScope === "chambers" || feedScope === "urgent") && - chambersLoading - ) - return; - if ( - (feedScope === "chambers" || feedScope === "urgent") && + feedScopeRequiresChambers(feedScope) && chamberFilters && chamberFilters.length === 0 ) { @@ -126,12 +97,14 @@ export function useFeedItems({ onLoadError(null); return; } - const res = await apiFeed({ - actor: feedScope === "my" ? (address ?? undefined) : undefined, - chambers: - feedScope === "chambers" ? (chamberFilters ?? []) : undefined, - limit: pageSize, - }); + const res = await apiFeed( + buildFeedRequestForScope({ + scope: feedScope, + address: address ?? undefined, + chamberFilters, + limit: pageSize, + }), + ); if (!active) return; setFeedItems(res.items); setNextCursor(res.nextCursor ?? null); @@ -167,27 +140,17 @@ export function useFeedItems({ if (!nextCursor || loadingMore) return; setLoadingMore(true); try { - const res = await apiFeed({ - cursor: nextCursor, - actor: feedScope === "my" ? (address ?? undefined) : undefined, - chambers: - feedScope === "chambers" || feedScope === "urgent" - ? (chamberFilters ?? []) - : undefined, - limit: pageSize, - }); - const items = - feedScope === "urgent" - ? toUrgentItems(res.items, viewerGovernorActive, address ?? undefined) - : res.items; + const res = await apiFeed( + buildFeedRequestForScope({ + scope: feedScope === "urgent" ? "all" : feedScope, + address: address ?? undefined, + chamberFilters, + cursor: nextCursor, + limit: pageSize, + }), + ); + const items = res.items; setFeedItems((curr) => { - if (feedScope === "urgent") { - return toUrgentItems( - [...(curr ?? []), ...items], - viewerGovernorActive, - address ?? undefined, - ); - } const existing = new Set((curr ?? []).map(feedItemKey)); const nextItems = items.filter( (item) => !existing.has(feedItemKey(item)), @@ -209,7 +172,6 @@ export function useFeedItems({ nextCursor, onLoadError, pageSize, - viewerGovernorActive, ]); const dismissItem = useCallback((key: string) => { diff --git a/tests/unit/feed-scope-routing.test.ts b/tests/unit/feed-scope-routing.test.ts new file mode 100644 index 0000000..bf9e729 --- /dev/null +++ b/tests/unit/feed-scope-routing.test.ts @@ -0,0 +1,93 @@ +import { test, expect } from "@rstest/core"; + +import { + buildFeedRequestForScope, + buildUrgentFeedRequests, + feedScopeRequiresChambers, + feedScopeRequiresWallet, + PRIVATE_FEED_ENTITY_TYPES, + SYSTEM_FEED_STAGE, +} from "../../src/lib/feedScopeRouting"; + +test("feed scope wallet and chamber requirements match page semantics", () => { + expect(feedScopeRequiresWallet("urgent")).toBe(true); + expect(feedScopeRequiresWallet("my")).toBe(true); + expect(feedScopeRequiresWallet("chambers")).toBe(true); + expect(feedScopeRequiresWallet("system")).toBe(false); + expect(feedScopeRequiresWallet("all")).toBe(false); + + expect(feedScopeRequiresChambers("urgent")).toBe(true); + expect(feedScopeRequiresChambers("chambers")).toBe(true); + expect(feedScopeRequiresChambers("my")).toBe(false); + expect(feedScopeRequiresChambers("system")).toBe(false); + expect(feedScopeRequiresChambers("all")).toBe(false); +}); + +test("feed scope requests isolate system and private personal notifications", () => { + expect( + buildFeedRequestForScope({ + scope: "system", + limit: 12, + }), + ).toEqual({ stage: SYSTEM_FEED_STAGE, limit: 12 }); + + expect( + buildFeedRequestForScope({ + scope: "my", + address: "hmptest-user", + limit: 12, + }), + ).toEqual({ + actor: "hmptest-user", + excludeStages: [SYSTEM_FEED_STAGE], + limit: 12, + }); + + expect( + buildFeedRequestForScope({ + scope: "all", + limit: 12, + }), + ).toEqual({ + excludeEntityTypes: [...PRIVATE_FEED_ENTITY_TYPES], + limit: 12, + }); +}); + +test("chamber and urgent feed requests keep faction invites personal", () => { + expect( + buildFeedRequestForScope({ + scope: "chambers", + chamberFilters: ["general", "media"], + limit: 6, + }), + ).toEqual({ + chambers: ["general", "media"], + excludeStages: [SYSTEM_FEED_STAGE], + excludeEntityTypes: [...PRIVATE_FEED_ENTITY_TYPES], + limit: 6, + }); + + const urgentRequests = buildUrgentFeedRequests({ + address: "hmptest-user", + chamberFilters: ["general"], + baseLimit: 6, + stageLimit: 60, + factionLimit: 6, + }); + + expect(urgentRequests).toEqual( + expect.arrayContaining([ + { + chambers: ["general"], + excludeStages: [SYSTEM_FEED_STAGE], + excludeEntityTypes: [...PRIVATE_FEED_ENTITY_TYPES], + limit: 6, + }, + { stage: "pool", chambers: ["general"], limit: 60 }, + { stage: "vote", chambers: ["general"], limit: 60 }, + { stage: "build", actor: "hmptest-user", limit: 60 }, + { stage: "faction", actor: "hmptest-user", limit: 6 }, + ]), + ); +}); diff --git a/tests/unit/feed-urgent.test.ts b/tests/unit/feed-urgent.test.ts index e6be093..65723da 100644 --- a/tests/unit/feed-urgent.test.ts +++ b/tests/unit/feed-urgent.test.ts @@ -5,6 +5,7 @@ import { isUrgentItemInteractable, normalizeAppHref, proposalIdFromHref, + toLimitedUrgentItems, toUrgentItems, urgentEntityKey, } from "../../src/lib/feedUi"; @@ -59,3 +60,16 @@ test("urgent feed dedupes by entity and keeps newest item", () => { expect(urgentEntityKey(older)).toBe("proposal:p1"); expect(toUrgentItems([older, newer], true)).toEqual([newer]); }); + +test("limited urgent feed caps the filtered deduped result", () => { + const items: FeedItemDto[] = Array.from({ length: 4 }, (_, index) => ({ + ...buildItem, + id: `item-${index}`, + href: `/app/proposals/p${index}/pp`, + stage: "pool", + timestamp: `2026-01-0${index + 1}T00:00:00.000Z`, + })); + + expect(toLimitedUrgentItems(items, true, undefined, 2)).toHaveLength(2); + expect(toLimitedUrgentItems(items, true, undefined, 0)).toEqual([]); +});