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 ? ( + + {sortLabel} + + + + + } + > + {SORT_OPTIONS.map(o => ( + { + onSortChange(o.value); + setSortMenuOpen(false); + }} + > + {o.label} + + ))} + + ) : null} + {showTimeFilter ? ( + + {timeLabel} + + + + + } + > + {TIME_OPTIONS.map(o => ( + { + onTimeChange(o.value); + setTimeMenuOpen(false); + }} + > + {o.label} + + ))} + + ) : null} + {showSpaceFilter ? ( +
+ + {spaceLabel} + + + + + } + > + { + onSpaceChange('all'); + setSpaceMenuOpen(false); + }} + > + Any space + + {initialSpaceOptions.map(o => ( + { + onSpaceChange(o.value); + setSpaceMenuOpen(false); + }} + > + {o.label} + + ))} + +
+ ) : 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 ? ( - - {sortLabel} - - - - - } - > - {SORT_OPTIONS.map(o => ( - { - setSort(o.value); - setSortMenuOpen(false); - }} - > - {o.label} - - ))} - - ) : null} - {showTimeFilter ? ( - - {timeLabel} - - - - - } - > - {TIME_OPTIONS.map(o => ( - { - setTime(o.value); - setTimeMenuOpen(false); - }} - > - {o.label} - - ))} - - ) : null} - {lockedSpaceId == null ? ( -
- - {spaceLabel} - - - - - } - > - { - setSelectedSpaceId('all'); - setSpaceMenuOpen(false); - }} - > - Any space - - {initialSpaceOptions.map(o => ( - { - setSelectedSpaceId(o.value); - setSpaceMenuOpen(false); - }} - > - {o.label} - - ))} - -
- ) : 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 ( +
+
+
+

News stories

+ +
+ +
+
+ +
+ +
+ ); +} 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} -