diff --git a/apps/web/app/api/space/feed/route.ts b/apps/web/app/api/space/feed/route.ts
new file mode 100644
index 0000000000..57300c9bb8
--- /dev/null
+++ b/apps/web/app/api/space/feed/route.ts
@@ -0,0 +1,94 @@
+import { cookies } from 'next/headers';
+import { NextResponse } from 'next/server';
+
+import type { BrowseSidebarData } from '~/core/browse/fetch-browse-sidebar-data';
+import { resolveMemberSpaceFromWalletSafe } from '~/core/browse/resolve-member-space-from-wallet';
+import { WALLET_ADDRESS } from '~/core/cookie';
+import { type ExploreSort, type ExploreTime, fetchExploreFeed } from '~/core/explore/fetch-explore-feed';
+
+import { getGovernanceHomeSpaceContext } from '~/app/home/governance-home-space-ids';
+
+const SORTS: ExploreSort[] = ['new', 'top'];
+const TIMES: ExploreTime[] = ['today', 'week', 'month', 'year', 'all'];
+
+function parseSort(raw: string | null): ExploreSort {
+ if (raw && (SORTS as string[]).includes(raw)) return raw as ExploreSort;
+ return 'new';
+}
+
+function parseTime(raw: string | null): ExploreTime {
+ if (raw && (TIMES as string[]).includes(raw)) return raw as ExploreTime;
+ return 'week';
+}
+
+/**
+ * Generic type-scoped feed for a single space. Reuses the explore fetcher pinned to one
+ * space (via a synthesized single-space browse, so it works for spaces the viewer is not a
+ * member of) and restricted to the requested entity types. Powers the space-home content
+ * sections (News stories, and later debates / claims) — each section passes its own `types`.
+ */
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const sort = parseSort(searchParams.get('sort'));
+ const time = parseTime(searchParams.get('time'));
+ const spaceId = searchParams.get('spaceId');
+ const cursor = searchParams.get('cursor');
+
+ const parsedTypes = (searchParams.get('types') ?? '')
+ .split(',')
+ .map(t => t.trim())
+ .filter(Boolean);
+ const typeIds = parsedTypes.length > 0 ? parsedTypes : undefined;
+
+ if (!spaceId) {
+ return NextResponse.json({ items: [], nextCursor: null });
+ }
+
+ const cookieWallet = (await cookies()).get(WALLET_ADDRESS)?.value;
+
+ let personalMemberSpaceId: string | null = null;
+ let memberOrEditorSpaceIds: string[] = [];
+
+ if (cookieWallet) {
+ personalMemberSpaceId = await resolveMemberSpaceFromWalletSafe(cookieWallet);
+ if (personalMemberSpaceId) {
+ try {
+ const ctx = await getGovernanceHomeSpaceContext(personalMemberSpaceId);
+ memberOrEditorSpaceIds = [...new Set([...ctx.editorIds, ...ctx.myProposalSpaceIds, personalMemberSpaceId])];
+ } catch {
+ memberOrEditorSpaceIds = [personalMemberSpaceId];
+ }
+ }
+ }
+
+ // Pinned to a single space passed in from the URL, so we don't need the real browse
+ // sidebar data to build the allowed-space set — synthesize a minimal one. This also lets
+ // the feed work for spaces the viewer is not a member of (a space home must render for
+ // anyone), unlike the explore route which matches against the viewer's own browse spaces.
+ const browse: BrowseSidebarData = {
+ featured: [{ id: spaceId, name: '', image: null }],
+ editorOf: [],
+ memberOf: [],
+ documentationImage: null,
+ personalSpaceId: null,
+ };
+
+ try {
+ const result = await fetchExploreFeed({
+ browse,
+ sort,
+ time,
+ spaceFilterId: spaceId,
+ cursor,
+ walletAddress: cookieWallet ?? null,
+ memberOrEditorSpaceIds,
+ typeIds,
+ requireName: true,
+ });
+ return NextResponse.json(result);
+ } catch (e) {
+ console.error('space feed', e);
+ /** Degraded response so the space home still mounts when GraphQL is down; client shows empty feed. */
+ return NextResponse.json({ items: [], nextCursor: null });
+ }
+}
diff --git a/apps/web/app/space/[id]/(space)/layout.tsx b/apps/web/app/space/[id]/(space)/layout.tsx
index 5355e80e59..16e52c9ba7 100644
--- a/apps/web/app/space/[id]/(space)/layout.tsx
+++ b/apps/web/app/space/[id]/(space)/layout.tsx
@@ -22,7 +22,6 @@ import { PersonalProfileBioStarterMerge } from '~/partials/entity-page/personal-
import { PersonalProfileSuggestedCard } from '~/partials/entity-page/personal-profile-suggested-card';
import { PersonalProfileSuggestedTaskSync } from '~/partials/entity-page/personal-profile-suggested-task-sync';
import { TypeSchemaInline } from '~/partials/entity-page/type-schema-inline';
-import { AddDataPanel } from '~/partials/space-page/add-data-panel';
import { SpaceEditors } from '~/partials/space-page/space-editors';
import { SpaceMembers } from '~/partials/space-page/space-members';
import { SpacePageMetadataHeader } from '~/partials/space-page/space-metadata-header';
@@ -82,8 +81,6 @@ export default async function Layout(props0: LayoutProps) {
-
-
{typeIds.includes(SystemIds.PERSON_TYPE) ? (
<>
diff --git a/apps/web/app/space/[id]/(space)/page.tsx b/apps/web/app/space/[id]/(space)/page.tsx
index 7c35a2bed5..92f10054e6 100644
--- a/apps/web/app/space/[id]/(space)/page.tsx
+++ b/apps/web/app/space/[id]/(space)/page.tsx
@@ -23,6 +23,7 @@ import { Spacer } from '~/design-system/spacer';
import { Editor } from '~/partials/editor/editor';
import { BacklinksServerContainer } from '~/partials/entity-page/backlinks-server-container';
import { ToggleEntityPage } from '~/partials/entity-page/toggle-entity-page';
+import { NewsStoriesSection } from '~/partials/space-page/news-stories/news-stories-section';
import { SubtopicGallery } from '~/partials/space-page/subtopic-gallery';
import { cachedFetchEntitiesBatch, cachedFetchEntityPage } from '../../(entity)/[id]/[entityId]/cached-fetch-entity';
@@ -77,6 +78,8 @@ export default async function SpacePage(props0: Props) {
return (
<>
+
+
}>
@@ -113,6 +116,8 @@ async function TopicEntityBody({ spaceId, topicEntityId }: { spaceId: string; to
initialTabs={topic.tabs}
initialCollectionItems={topic.initialCollectionItems}
>
+
+
}>
diff --git a/apps/web/partials/feed/entity-feed.tsx b/apps/web/partials/feed/entity-feed.tsx
index 3d7b7d18d5..9c6bb47d54 100644
--- a/apps/web/partials/feed/entity-feed.tsx
+++ b/apps/web/partials/feed/entity-feed.tsx
@@ -42,6 +42,161 @@ const TIME_OPTIONS: { value: ExploreTime; label: string }[] = [
export type SpaceOption = { value: string; label: string };
+export type EntityFeedFiltersProps = {
+ sort: ExploreSort;
+ onSortChange: (sort: ExploreSort) => void;
+ time: ExploreTime;
+ onTimeChange: (time: ExploreTime) => void;
+ showSortFilter?: boolean;
+ showTimeFilter?: boolean;
+ initialSpaceOptions?: SpaceOption[];
+ selectedSpaceId?: string;
+ onSpaceChange?: (spaceId: string) => void;
+ className?: string;
+};
+
+export function EntityFeedFilters({
+ sort,
+ onSortChange,
+ time,
+ onTimeChange,
+ showSortFilter = false,
+ showTimeFilter = true,
+ initialSpaceOptions = [],
+ selectedSpaceId = 'all',
+ onSpaceChange,
+ className,
+}: EntityFeedFiltersProps) {
+ const [sortMenuOpen, setSortMenuOpen] = React.useState(false);
+ const [timeMenuOpen, setTimeMenuOpen] = React.useState(false);
+ const [spaceMenuOpen, setSpaceMenuOpen] = React.useState(false);
+ const showSpaceFilter = onSpaceChange != null;
+
+ const timeLabel = TIME_OPTIONS.find(o => o.value === time)?.label ?? time;
+ const sortLabel = SORT_OPTIONS.find(o => o.value === sort)?.label ?? sort;
+ const spaceLabel =
+ selectedSpaceId === 'all'
+ ? 'Any space'
+ : (initialSpaceOptions.find(o => o.value === selectedSpaceId)?.label ?? 'Any space');
+
+ if (!showSortFilter && !showTimeFilter && !showSpaceFilter) return null;
+
+ return (
+
+ {showSortFilter ? (
+
+ ) : null}
+ {showTimeFilter ? (
+
+ ) : null}
+ {showSpaceFilter ? (
+
+
+
+ ) : null}
+
+ );
+}
+
type EntityFeedProps = {
/** REST endpoint this feed fetches from (e.g. `/api/explore/feed` or `/api/activity/feed`). */
apiEndpoint: string;
@@ -49,6 +204,8 @@ type EntityFeedProps = {
initialSpaceOptions?: SpaceOption[];
/** When set, the feed is pinned to this space. No space dropdown is rendered. */
lockedSpaceId?: string;
+ /** Restrict the feed to these entity type IDs. Forwarded to the endpoint as a `types` param. */
+ typeIds?: readonly string[];
/** Initial value for the time dropdown. Defaults to "week". */
initialTime?: ExploreTime;
/** Initial value for the sort dropdown. Defaults to "new". */
@@ -57,6 +214,14 @@ type EntityFeedProps = {
showTimeFilter?: boolean;
/** Whether to render the sort dropdown (New / Top). Defaults to false. */
showSortFilter?: boolean;
+ /** When false, hides the built-in filter row (use `EntityFeedFilters` externally). Defaults to true. */
+ showFilterRow?: boolean;
+ /** Controlled sort value. When set with `time`, the feed uses these instead of internal state. */
+ sort?: ExploreSort;
+ /** Controlled time value. When set with `sort`, the feed uses these instead of internal state. */
+ time?: ExploreTime;
+ /** When true, skips the outer max-width container so a parent can own layout. */
+ embedded?: boolean;
/** Override the spacing between the filter row and the feed. Defaults to `mt-8`. */
feedTopSpacingClassName?: string;
/** When true, renders a divider line between the filter row and the first feed card. */
@@ -70,12 +235,14 @@ async function fetchFeedPage(
time: ExploreTime;
spaceId: string;
cursor: string | undefined;
+ types: readonly string[] | undefined;
}
): Promise
{
const sp = new URLSearchParams();
sp.set('sort', params.sort);
sp.set('time', params.time);
sp.set('spaceId', params.spaceId);
+ if (params.types && params.types.length > 0) sp.set('types', params.types.join(','));
if (params.cursor) sp.set('cursor', params.cursor);
const res = await fetch(`${apiEndpoint}?${sp.toString()}`, { credentials: 'include' });
if (!res.ok) {
@@ -92,20 +259,25 @@ export function EntityFeed({
apiEndpoint,
initialSpaceOptions = [],
lockedSpaceId,
+ typeIds,
initialTime = 'week',
initialSort = 'new',
showTimeFilter = true,
showSortFilter = false,
+ showFilterRow = true,
+ sort: controlledSort,
+ time: controlledTime,
+ embedded = false,
feedTopSpacingClassName,
dividerBeforeFeed = false,
}: EntityFeedProps) {
- const [time, setTime] = React.useState(initialTime);
- const [sort, setSort] = React.useState(initialSort);
+ const [internalTime, setInternalTime] = React.useState(initialTime);
+ const [internalSort, setInternalSort] = React.useState(initialSort);
+ const time = controlledTime ?? internalTime;
+ const sort = controlledSort ?? internalSort;
const [selectedSpaceId, setSelectedSpaceId] = React.useState('all');
- const [sortMenuOpen, setSortMenuOpen] = React.useState(false);
- const [timeMenuOpen, setTimeMenuOpen] = React.useState(false);
- const [spaceMenuOpen, setSpaceMenuOpen] = React.useState(false);
const spaceId = lockedSpaceId ?? selectedSpaceId;
+ const hasBuiltInFilters = showFilterRow && (showSortFilter || showTimeFilter || lockedSpaceId == null);
// Key the query on the smart-account address because that hook is what writes the
// WALLET_ADDRESS cookie the server route reads. Privy's user.id updates earlier
@@ -115,9 +287,9 @@ export function EntityFeed({
const smartAccountAddress = smartAccount?.account.address ?? null;
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, error } = useInfiniteQuery({
- queryKey: [apiEndpoint, sort, time, spaceId, smartAccountAddress],
+ queryKey: [apiEndpoint, sort, time, spaceId, typeIds?.join(',') ?? '', smartAccountAddress],
queryFn: ({ pageParam }) =>
- fetchFeedPage(apiEndpoint, { sort, time, spaceId, cursor: pageParam as string | undefined }),
+ fetchFeedPage(apiEndpoint, { sort, time, spaceId, cursor: pageParam as string | undefined, types: typeIds }),
initialPageParam: undefined as string | undefined,
getNextPageParam: last => last.nextCursor ?? undefined,
retry: 2,
@@ -148,136 +320,26 @@ export function EntityFeed({
return flat;
}, [data?.pages]);
- const timeLabel = TIME_OPTIONS.find(o => o.value === time)?.label ?? time;
- const sortLabel = SORT_OPTIONS.find(o => o.value === sort)?.label ?? sort;
- const spaceLabel =
- selectedSpaceId === 'all'
- ? 'Any space'
- : (initialSpaceOptions.find(o => o.value === selectedSpaceId)?.label ?? 'Any space');
-
- return (
-
- {showSortFilter || showTimeFilter || lockedSpaceId == null ? (
-
- {showSortFilter ? (
-
- ) : null}
- {showTimeFilter ? (
-
- ) : null}
- {lockedSpaceId == null ? (
-
-
-
- ) : null}
-
+ const content = (
+ <>
+ {hasBuiltInFilters ? (
+
) : null}
{dividerBeforeFeed ?
: null}
{error ? (
@@ -309,6 +371,10 @@ export function EntityFeed({
) : null}
-
+ >
);
+
+ if (embedded) return content;
+
+ return {content}
;
}
diff --git a/apps/web/partials/space-page/news-stories/news-stories-section.tsx b/apps/web/partials/space-page/news-stories/news-stories-section.tsx
new file mode 100644
index 0000000000..e946598a34
--- /dev/null
+++ b/apps/web/partials/space-page/news-stories/news-stories-section.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { ContentIds } from '@geoprotocol/geo-sdk/lite';
+
+import * as React from 'react';
+
+import type { ExploreSort, ExploreTime } from '~/core/explore/fetch-explore-feed';
+
+import { EntityFeed, EntityFeedFilters } from '~/partials/feed/entity-feed';
+import { AddDataChip, AddDataPanel } from '~/partials/space-page/add-data-panel';
+
+const NEWS_STORY_TYPE_IDS = [ContentIds.NEWS_STORY_TYPE] as const;
+
+/**
+ * Space-home "News stories" section: an explore-style infinite feed scoped to this space and
+ * to News story entities, with the import ("inject") widget moved here. The collapsed import
+ * chip sits next to the header and the expanded paste-URL panel renders beneath it — both
+ * self-gate on edit permission and share `addDataPanelExpandedAtom`, so only one shows at a time.
+ */
+export function NewsStoriesSection({ spaceId }: { spaceId: string }) {
+ const [sort, setSort] = React.useState('top');
+ const [time, setTime] = React.useState('week');
+
+ return (
+
+ );
+}
diff --git a/apps/web/partials/space-page/space-metadata-header.tsx b/apps/web/partials/space-page/space-metadata-header.tsx
index 429b2b586f..5245f3932d 100644
--- a/apps/web/partials/space-page/space-metadata-header.tsx
+++ b/apps/web/partials/space-page/space-metadata-header.tsx
@@ -12,7 +12,6 @@ import { Create } from '~/design-system/icons/create';
import { RelationsGroup } from '../entity-page/editable-entity-page';
import { EntityVoteButtons } from '../entity-page/entity-vote-buttons';
-import { AddDataChip } from './add-data-panel';
interface SpacePageMetadataHeaderProps {
spaceId: string;
@@ -56,7 +55,6 @@ export function SpacePageMetadataHeader({ spaceId, membersComponent }: SpacePage
additionalTypeChips
)}
{membersComponent}
-