From af5e19dd244ecbc5406564859ab6c2bd837a83c8 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 19 Sep 2026 14:22:12 -0700 Subject: [PATCH 01/29] feat: show claim media in custom view Render configured cover and avatar media on the custom claim surface so route and side-panel views preserve the entity's visual identity. --- .../entity-page-body.side-panel.test.tsx | 44 +++++++++++++++++-- .../partials/entity-page/entity-page-body.tsx | 27 +++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx b/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx index 87998c4d62..99c8a22462 100644 --- a/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx +++ b/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx @@ -5,16 +5,20 @@ import * as React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; + import { EntityPageBody } from './entity-page-body'; const mocks = vi.hoisted(() => ({ actions: null as Record | null, + cover: null as Record | null, + entity: { id: 'entity-1', types: [] as { id: string }[] }, heading: null as Record | null, })); vi.mock('~/core/hooks/use-user-is-editing', () => ({ useUserIsEditing: () => false })); vi.mock('~/core/sync/use-store', () => ({ - useQueryEntity: () => ({ entity: { id: 'entity-1', types: [] }, isLoading: false }), + useQueryEntity: () => ({ entity: mocks.entity, isLoading: false }), })); // `useCustomBrowseView` asks for the space to tell a person's profile from an // ordinary entity. This file renders without a QueryClient on purpose — it is @@ -47,7 +51,12 @@ vi.mock('~/partials/entity-page/entity-page-inline-description', () => ({ // Everything below the header row. Each reaches for the sync engine, the editor or geo-chat, and // none of it is what this file asserts. -vi.mock('~/partials/entity-page/entity-page-cover', () => ({ EntityPageCover: () => null })); +vi.mock('~/partials/entity-page/entity-page-cover', () => ({ + EntityPageCover: (props: Record) => { + mocks.cover = props; + return
; + }, +})); vi.mock('~/partials/entity-page/entity-page-content-container', () => ({ EntityPageContentContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, })); @@ -59,8 +68,11 @@ vi.mock('~/partials/entity-page/type-schema-inline', () => ({ TypeSchemaInline: vi.mock('~/partials/entity-page/entity-page-header', () => ({ EntityPageHeader: () => null })); vi.mock('~/partials/editor/editor', () => ({ Editor: () => null })); vi.mock('~/partials/comments/comments-section', () => ({ CommentSection: () => null })); -vi.mock('~/core/claims/browse/claim-page-view', () => ({ ClaimPageView: () => null })); +vi.mock('~/core/claims/browse/claim-page-view', () => ({ + ClaimPageView: () =>
, +})); vi.mock('~/core/topics/browse/topic-page-view', () => ({ TopicPageView: () => null })); +vi.mock('~/partials/profile/person-profile-view', () => ({ PersonProfileView: () => null })); const SHARED = { entityId: 'entity-1', @@ -77,6 +89,8 @@ function renderPanel(overrides?: { isRelationPage?: boolean; previewName?: strin beforeEach(() => { mocks.actions = null; + mocks.cover = null; + mocks.entity = { id: 'entity-1', types: [] }; mocks.heading = null; }); @@ -131,3 +145,27 @@ describe('EntityPageBody relation side panel', () => { expect(mocks.heading).toMatchObject({ entityId: 'entity-1', spaceId: 'space-1', fallbackName: 'Preview name' }); }); }); + +describe('EntityPageBody claim side panel', () => { + it('shows both configured claim images above the custom view', () => { + mocks.entity = { id: 'entity-1', types: [{ id: CLAIM_TYPE_ID }] }; + + render( + + ); + + expect(screen.getByTestId('cover')).toBeInTheDocument(); + expect(screen.getByTestId('claim-page')).toBeInTheDocument(); + expect(mocks.cover).toMatchObject({ + avatarUrl: 'https://example.com/avatar.png', + coverUrl: 'https://example.com/cover.png', + fitImage: true, + withAvatar: true, + }); + }); +}); diff --git a/apps/web/partials/entity-page/entity-page-body.tsx b/apps/web/partials/entity-page/entity-page-body.tsx index cd255b5955..28a6ebd84a 100644 --- a/apps/web/partials/entity-page/entity-page-body.tsx +++ b/apps/web/partials/entity-page/entity-page-body.tsx @@ -294,7 +294,32 @@ export function EntityPageBody(props: EntityPageBodyProps) { if (customView === 'pending') return null; if (customView === 'claim') { - return ; + const claimAvatarUrl = + props.variant === 'sidePanel' + ? (props.avatarUrl ?? entityMediaUrl ?? previewImageUrlResolved ?? null) + : props.avatarUrl; + const showClaimMedia = props.variant === 'sidePanel' || props.showCover !== false; + + return ( + <> + {showClaimMedia ? ( + props.variant === 'route' && props.coverSlot ? ( + props.coverSlot + ) : ( + + ) + ) : null} + + + ); } if (customView === 'topic') { From 855f102cc6d85f240f3419a9676e12c546af4791 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 19 Sep 2026 14:38:00 -0700 Subject: [PATCH 02/29] feat: add claim record tabs and activity --- .../[id]/[entityId]/claim-record-page.tsx | 30 +++ .../(entity)/[id]/[entityId]/claims/page.tsx | 5 + .../(entity)/[id]/[entityId]/debates/page.tsx | 5 + .../(entity)/[id]/[entityId]/sources/page.tsx | 5 + .../claims/browse/claim-page-view.test.tsx | 74 ++++++- .../core/claims/browse/claim-page-view.tsx | 181 ++++++++++++++++-- .../core/claims/browse/claim-sources-tab.tsx | 65 +++++++ .../claims/browse/use-claim-record.test.ts | 28 +++ .../core/claims/browse/use-claim-record.ts | 141 ++++++++++++++ .../state/entity-side-panel-active-tab.tsx | 19 +- apps/web/design-system/tab-group.tsx | 35 +++- .../entity-page/editable-tab-group.tsx | 39 +++- .../partials/entity-page/entity-page-body.tsx | 7 +- .../partials/entity-page/entity-tabs.test.tsx | 90 +++++++++ apps/web/partials/entity-page/entity-tabs.tsx | 57 +++--- 15 files changed, 718 insertions(+), 63 deletions(-) create mode 100644 apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx create mode 100644 apps/web/app/space/(entity)/[id]/[entityId]/claims/page.tsx create mode 100644 apps/web/app/space/(entity)/[id]/[entityId]/debates/page.tsx create mode 100644 apps/web/app/space/(entity)/[id]/[entityId]/sources/page.tsx create mode 100644 apps/web/core/claims/browse/claim-sources-tab.tsx create mode 100644 apps/web/core/claims/browse/use-claim-record.test.ts create mode 100644 apps/web/core/claims/browse/use-claim-record.ts create mode 100644 apps/web/partials/entity-page/entity-tabs.test.tsx diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx new file mode 100644 index 0000000000..869a5707b8 --- /dev/null +++ b/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx @@ -0,0 +1,30 @@ +import { IdUtils } from '@geoprotocol/geo-sdk/lite'; + +import { notFound } from 'next/navigation'; + +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; +import { ID } from '~/core/id'; +import { isHiddenEntity } from '~/core/moderation/hidden'; + +import { cachedFetchEntityPage } from './cached-fetch-entity'; +import DefaultEntityPage from './default-entity-page'; + +export type ClaimRecordPageProps = { + params: Promise<{ id: string; entityId: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}; + +/** The shared server guard and page shell behind the claim's three record routes. */ +export async function ClaimRecordPage(props: ClaimRecordPageProps) { + const params = await props.params; + const searchParams = await props.searchParams; + + if (!IdUtils.isValid(params.id) || !IdUtils.isValid(params.entityId)) notFound(); + + const result = await cachedFetchEntityPage(params.entityId, params.id); + if (isHiddenEntity(result?.entity) || !result?.entity?.types.some(type => ID.equals(type.id, CLAIM_TYPE_ID))) { + notFound(); + } + + return ; +} diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/claims/page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/claims/page.tsx new file mode 100644 index 0000000000..57aaff8f7f --- /dev/null +++ b/apps/web/app/space/(entity)/[id]/[entityId]/claims/page.tsx @@ -0,0 +1,5 @@ +import { ClaimRecordPage, type ClaimRecordPageProps } from '../claim-record-page'; + +export default function ClaimClaimsPage(props: ClaimRecordPageProps) { + return ; +} diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/debates/page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/debates/page.tsx new file mode 100644 index 0000000000..99a34a0fa1 --- /dev/null +++ b/apps/web/app/space/(entity)/[id]/[entityId]/debates/page.tsx @@ -0,0 +1,5 @@ +import { ClaimRecordPage, type ClaimRecordPageProps } from '../claim-record-page'; + +export default function ClaimDebatesPage(props: ClaimRecordPageProps) { + return ; +} diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/sources/page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/sources/page.tsx new file mode 100644 index 0000000000..35ee6dfdd2 --- /dev/null +++ b/apps/web/app/space/(entity)/[id]/[entityId]/sources/page.tsx @@ -0,0 +1,5 @@ +import { ClaimRecordPage, type ClaimRecordPageProps } from '../claim-record-page'; + +export default function ClaimSourcesPage(props: ClaimRecordPageProps) { + return ; +} diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index 9fcb1702eb..95470a76f5 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ clamp: null as Record | null, /** Props the chip section received, or null if the page rendered none. */ chipSection: null as Record | null, + tabs: null as Record | null, /** * Deliberately not 3. * @@ -27,6 +28,11 @@ const mocks = vi.hoisted(() => ({ maxLines: 5, })); +vi.mock('next/navigation', () => ({ + usePathname: () => '/space/space-1/claim-1', + useSearchParams: () => ({ get: () => null }), +})); + vi.mock('~/partials/entity-page/entity-page-inline-description', () => ({ ENTITY_DESCRIPTION_MAX_LINES: mocks.maxLines, })); @@ -74,7 +80,7 @@ vi.mock('./use-claim-response-state', () => ({ // The page's modules each reach for the sync engine, geo-chat or Privy. None of them is what this // file is asserting, and the hero renders above all of them. vi.mock('~/core/debates/matchmaking/matchmaking-claim-card', () => ({ - PositionRow: () => null, + PositionRow: () =>
, useClaimPositionControl: () => ({ optimisticPositions: [], viewerPosition: null, @@ -88,13 +94,38 @@ vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => () => { vi.mock('~/core/debates/backfill-readiness-for-held-position', () => ({ useBackfillReadinessForHeldPosition: () => {}, })); -vi.mock('./claim-verdict', () => ({ ClaimVerdict: () => null })); -vi.mock('./claim-debates', () => ({ ClaimDebates: () => null })); -vi.mock('./claim-provenance', () => ({ ClaimProvenance: () => null })); -vi.mock('./claim-related-claims', () => ({ ClaimRelatedClaims: () => null })); +vi.mock('./claim-verdict', () => ({ ClaimVerdict: () =>
})); +vi.mock('./claim-sources-tab', () => ({ ClaimSourcesTab: () =>
})); vi.mock('./claim-end-slot', () => ({ ClaimEndSlot: () => null })); vi.mock('./claim-summary', () => ({ ControversialTag: () => null })); -vi.mock('~/partials/comments/comments-section', () => ({ CommentSection: () => null })); +vi.mock('./use-claim-record', () => ({ + useClaimRecord: () => ({ + claimIds: ['claim-1'], + claimRows: [], + debateRows: [], + claimsTotal: 1, + debatesTotal: 0, + claimsLoading: false, + debatesLoading: false, + claimsError: false, + debatesError: false, + }), +})); +vi.mock('~/core/state/entity-side-panel-active-tab', () => ({ useEntitySidePanelActiveTab: () => null })); +vi.mock('~/partials/entity-page/entity-tabs', () => ({ + EntityTabs: (props: Record) => { + mocks.tabs = props; + return
; + }, +})); +vi.mock('~/partials/profile/profile-activity-section', () => ({ + ProfileActivitySection: () =>
, +})); +vi.mock('~/partials/profile/person-record-feed', () => ({ PersonRecordFeed: () =>
})); +vi.mock('~/partials/editor/editor', () => ({ Editor: () =>
})); +vi.mock('~/partials/comments/comments-section', () => ({ + CommentSection: () =>
, +})); function claimEntity(description: string | null) { return { @@ -110,6 +141,37 @@ beforeEach(() => { mocks.entity = claimEntity('A description long enough that the page has something to collapse.'); mocks.clamp = null; mocks.chipSection = null; + mocks.tabs = null; +}); + +describe('ClaimPageView record', () => { + it('offers product tabs before authored claim tabs', () => { + render(); + + expect(mocks.tabs).toMatchObject({ + reservedSystemLabels: ['Overview', 'Debates', 'Claims', 'Sources'], + divideBeforeAuthored: true, + }); + expect(mocks.tabs?.systemTabsBefore).toEqual([ + expect.objectContaining({ label: 'Overview', sidePanelKey: 'overview' }), + expect.objectContaining({ label: 'Debates', sidePanelKey: 'debates' }), + expect.objectContaining({ label: 'Claims', sidePanelKey: 'claims' }), + expect.objectContaining({ label: 'Sources', sidePanelKey: 'sources' }), + ]); + }); + + it('orders Overview as position, response summary, activity, then comments', () => { + render(); + + const position = screen.getByTestId('position'); + const verdict = screen.getByTestId('verdict'); + const activity = screen.getByTestId('activity'); + const comments = screen.getByTestId('comments'); + + expect(position.compareDocumentPosition(verdict) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(verdict.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(activity.compareDocumentPosition(comments) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); }); afterEach(cleanup); diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 308db0ed66..c2e8c27ca5 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; + import { TOPICS_PROPERTY_ID } from '~/core/claims/ontology'; import { TAG_PROPERTY_ID } from '~/core/constants'; import type { DebateClaim } from '~/core/debates/api'; @@ -10,26 +12,35 @@ import { useDebateClaims } from '~/core/debates/hooks'; import { PositionRow, useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { ID } from '~/core/id'; +import { useEntitySidePanelActiveTab } from '~/core/state/entity-side-panel-active-tab'; import { useQueryEntity } from '~/core/sync/use-store'; -import type { Relation } from '~/core/types'; +import type { Relation, TabEntity } from '~/core/types'; +import { NavUtils } from '~/core/utils/utils'; import { ClampedText } from '~/design-system/clamped-text'; import { Skeleton } from '~/design-system/skeleton'; import { Text } from '~/design-system/text'; import { CommentSection } from '~/partials/comments/comments-section'; +import { Editor } from '~/partials/editor/editor'; import { ENTITY_DESCRIPTION_MAX_LINES } from '~/partials/entity-page/entity-page-inline-description'; +import { EntityTabs } from '~/partials/entity-page/entity-tabs'; import { META_CHIP_CLASS, RelationChipSection } from '~/partials/entity-page/relation-chip-section'; import { SectionTitle } from '~/partials/entity-page/section-title'; +import { PersonRecordFeed } from '~/partials/profile/person-record-feed'; +import { type ActivityKind, ProfileActivitySection } from '~/partials/profile/profile-activity-section'; -import { ClaimDebates } from './claim-debates'; import { ClaimEndSlot } from './claim-end-slot'; -import { ClaimProvenance } from './claim-provenance'; -import { ClaimRelatedClaims } from './claim-related-claims'; +import { ClaimSourcesTab } from './claim-sources-tab'; import { ControversialTag } from './claim-summary'; import { ClaimVerdict } from './claim-verdict'; +import { useClaimRecord } from './use-claim-record'; import { type ClaimResponseState, useClaimResponseState } from './use-claim-response-state'; +type ClaimTab = 'overview' | 'debates' | 'claims' | 'sources' | 'custom'; + +const SYSTEM_TAB_LABELS = ['Overview', 'Debates', 'Claims', 'Sources']; + /** * The browse-mode read view for a Claim. * @@ -46,8 +57,21 @@ import { type ClaimResponseState, useClaimResponseState } from './use-claim-resp * never been debated, that carries no topics and was authored by hand shows its text, its space, * and the controls to act on it — and nothing else. */ -export function ClaimPageView({ entityId, spaceId }: { entityId: string; spaceId: string }) { +export function ClaimPageView({ + entityId, + spaceId, + initialTabRelations = [], + tabEntities = [], +}: { + entityId: string; + spaceId: string; + initialTabRelations?: Relation[]; + tabEntities?: TabEntity[]; +}) { const { entity, isLoading } = useQueryEntity({ id: entityId, spaceId }); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const sidePanelTab = useEntitySidePanelActiveTab(); // Hoisted so one lookup answers for the whole page. geo-chat's row and the graph's `Is factual` // are two copies of the same fact and can disagree — while an edit to the flag indexes, most @@ -67,6 +91,26 @@ export function ClaimPageView({ entityId, spaceId }: { entityId: string; spaceId const topicIds = React.useMemo(() => topics.map(topic => topic.toEntity.id), [topics]); // Named types only: an unnamed one would render as a raw id, which says less than no chip. const typeName = entity?.types.find(type => type.name)?.name ?? null; + const record = useClaimRecord({ claimId: entityId, spaceId, topicIds }); + + const activeTab: ClaimTab = + sidePanelTab?.activeTabId || searchParams.get('tabId') + ? 'custom' + : sidePanelTab?.activeSystemTab === 'debates' || pathname.endsWith('/debates') + ? 'debates' + : sidePanelTab?.activeSystemTab === 'claims' || pathname.endsWith('/claims') + ? 'claims' + : sidePanelTab?.activeSystemTab === 'sources' || pathname.endsWith('/sources') + ? 'sources' + : 'overview'; + + const overviewHref = NavUtils.toEntity(spaceId, entityId); + const systemTabs = [ + { label: 'Overview', href: overviewHref, sidePanelKey: 'overview' }, + { label: 'Debates', href: `${overviewHref}/debates`, sidePanelKey: 'debates' }, + { label: 'Claims', href: `${overviewHref}/claims`, sidePanelKey: 'claims' }, + { label: 'Sources', href: `${overviewHref}/sources`, sidePanelKey: 'sources' }, + ]; if (isLoading && !entity) { return ( @@ -134,23 +178,126 @@ export function ClaimPageView({ entityId, spaceId }: { entityId: string; spaceId offering before the argument itself is somewhere else to take it. */} - + - + +
+
+ ); +} - +function ClaimTabPanel({ + activeTab, + entityId, + spaceId, + entityRelations, + responseKind, + summary, + state, + row, + record, + hrefs, +}: { + activeTab: ClaimTab; + entityId: string; + spaceId: string; + entityRelations: Relation[]; + responseKind: ClaimResponseState['responseKind']; + summary: ClaimResponseState['summary']; + state: ClaimResponseState; + row: DebateClaim | null; + record: ReturnType; + hrefs: { debates: string; claims: string }; +}) { + if (activeTab === 'custom') return ; - + if (activeTab === 'debates') { + return ( + + ); + } - + if (activeTab === 'claims') { + return ( + + ); + } - {/* Last, and in the same `page` variant a regular entity uses — the entity body renders it - this way for both the route and the side panel, and only the dedicated comments panel - asks for the `panel` variant. Unlike the modules above, this one always renders: an - empty thread is an invitation to start it, not an absence to hide. */} - -
-
+ if (activeTab === 'sources') { + return ; + } + + const kinds: ActivityKind[] = [ + { + key: 'debates', + label: 'Debates', + rows: record.debateRows, + total: record.debatesTotal, + isLoading: record.debatesLoading, + isError: record.debatesError, + href: hrefs.debates, + seeAllLabel: 'See all debates', + }, + { + key: 'claims', + label: 'Claims', + rows: record.claimRows, + total: record.claimsTotal, + isLoading: record.claimsLoading, + isError: record.claimsError, + href: hrefs.claims, + seeAllLabel: 'See all claims', + }, + ]; + + return ( + <> + {/* The response control is the first Overview section. Its summary is grouped with it because + it is the result of the same choice, rather than an activity module between the position + options and Activity. */} +
+ + +
+ + {/* Last, like the ordinary entity page. An empty thread is an invitation, not absence. */} + + ); } diff --git a/apps/web/core/claims/browse/claim-sources-tab.tsx b/apps/web/core/claims/browse/claim-sources-tab.tsx new file mode 100644 index 0000000000..e56d7ac3d9 --- /dev/null +++ b/apps/web/core/claims/browse/claim-sources-tab.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; +import { ID } from '~/core/id'; +import { fetchExploreRowsByIds } from '~/core/profile/explore-rows-by-ids'; +import type { Relation } from '~/core/types'; +import { dedupeRelationsByToEntityId } from '~/core/utils/dedupe-relations'; + +import { PersonRecordFeed } from '~/partials/profile/person-record-feed'; + +import { ClaimProvenance } from './claim-provenance'; + +/** Provenance plus the source entities themselves, rendered as the explore feed cards they use elsewhere. */ +export function ClaimSourcesTab({ + claimId, + claimRelations, + spaceId, +}: { + claimId: string; + claimRelations: Relation[]; + spaceId: string; +}) { + const sourceIds = React.useMemo( + () => + dedupeRelationsByToEntityId( + claimRelations.filter( + relation => relation.isDeleted !== true && ID.equals(relation.type.id, SOURCES_PROPERTY_ID) + ) + ).map(relation => relation.toEntity.id), + [claimRelations] + ); + + const sources = useQuery({ + queryKey: ['claim', ID.uuidToHex(claimId), 'sources', sourceIds.map(ID.uuidToHex)], + queryFn: ({ signal }) => { + const preferredSpaces = new Map(sourceIds.map(id => [ID.uuidToHex(id), [spaceId]])); + return fetchExploreRowsByIds(sourceIds, signal, preferredSpaces); + }, + enabled: sourceIds.length > 0, + staleTime: 30_000, + }); + + if (sourceIds.length === 0) { + return

No sources have been linked to this claim yet.

; + } + + return ( +
+ + +
+ ); +} diff --git a/apps/web/core/claims/browse/use-claim-record.test.ts b/apps/web/core/claims/browse/use-claim-record.test.ts new file mode 100644 index 0000000000..20fadcd422 --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-record.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { claimRecordIds } from './use-claim-record'; + +describe('claimRecordIds', () => { + it('drops the source before counting its related claims', () => { + const ids = claimRecordIds('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', [ + { id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', name: 'The source claim' }, + ]); + + expect(ids).toEqual(['aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa']); + expect(ids.length - 1).toBe(0); + }); + + it('keeps named neighbours after the source in query order', () => { + const ids = claimRecordIds('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', [ + { id: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', name: 'First neighbour' }, + { id: 'cccccccccccccccccccccccccccccccc', name: null }, + { id: 'dddddddddddddddddddddddddddddddd', name: 'Second neighbour' }, + ]); + + expect(ids).toEqual([ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'dddddddddddddddddddddddddddddddd', + ]); + }); +}); diff --git a/apps/web/core/claims/browse/use-claim-record.ts b/apps/web/core/claims/browse/use-claim-record.ts new file mode 100644 index 0000000000..bef5c63b9a --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-record.ts @@ -0,0 +1,141 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { relatedClaimsWhere } from '~/core/claims/related-claims'; +import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_TAG_ID, DEBATE_TYPE_ID } from '~/core/debates/ontology'; +import type { ExploreFeedRow } from '~/core/explore/explore-card-item'; +import { EntitiesOrderBy } from '~/core/gql/graphql'; +import { ID } from '~/core/id'; +import { fetchExploreRowsByIds } from '~/core/profile/explore-rows-by-ids'; +import { sortRows } from '~/core/profile/record-client-filter'; +import { useEntityScores } from '~/core/profile/use-entity-scores'; +import { useQueryEntities } from '~/core/sync/use-store'; +import type { Entity } from '~/core/types'; + +/** + * One bounded window is enough for the summary and covers the full record for ordinary claims. + * The GEO-2975 claims feed will own paging when it lands; keeping the scope in this hook means that + * feed can replace the transport without changing what this page considers related. + */ +const CLAIM_RECORD_LIMIT = 100; + +/** Stable empty rows keep the query result from changing identity while it is disabled. */ +const NO_ROWS: ExploreFeedRow[] = []; + +/** + * The current claim followed by its drawable neighbours. + * + * The source matches its own `relatedClaimsWhere` clause, so it must be removed before the related + * count is observed. This helper owns that ordering and is exported for the regression test: doing + * the subtraction after counting is the GEO-2758 bug the ticket explicitly calls out. + */ +export function claimRecordIds(claimId: string, related: Pick[]): string[] { + return [ + claimId, + ...related.filter(entity => Boolean(entity.name) && !ID.equals(entity.id, claimId)).map(entity => entity.id), + ]; +} + +function useExploreRows(ids: string[], spaceId: string, enabled: boolean) { + const normalizedIds = React.useMemo(() => ids.map(ID.uuidToHex), [ids]); + + return useQuery({ + queryKey: ['claim-record', 'explore-rows', spaceId, normalizedIds], + queryFn: ({ signal }) => { + const preferredSpaces = new Map(ids.map(id => [ID.uuidToHex(id), [spaceId]])); + return fetchExploreRowsByIds(ids, signal, preferredSpaces); + }, + enabled: enabled && ids.length > 0, + staleTime: 30_000, + }); +} + +/** + * The Debates and Claims record shared by the claim Overview summary and its two full tabs. + * + * Related claims use the canonical clause and require the debate tag. Debates then point at any + * claim in that exact scope, while both row sets are hydrated through the explore card projection + * so the summary and tabs render the same cards as the rest of the product. + */ +export function useClaimRecord({ + claimId, + spaceId, + topicIds, +}: { + claimId: string; + spaceId: string; + topicIds: string[]; +}) { + const related = useQueryEntities({ + where: relatedClaimsWhere({ spaceId, topicIds, requireTagId: DEBATE_TAG_ID }), + first: CLAIM_RECORD_LIMIT, + orderBy: [EntitiesOrderBy.UpdatedAtDesc], + enabled: topicIds.length > 0, + deferUntilFetched: true, + prefetchNextPage: false, + }); + + const claimIds = React.useMemo(() => claimRecordIds(claimId, related.entities), [claimId, related.entities]); + + const debates = useQueryEntities({ + where: { + types: [{ id: { equals: DEBATE_TYPE_ID } }], + spaces: [{ equals: spaceId }], + relations: [ + { + typeOf: { id: { equals: DEBATE_CLAIMS_PROPERTY_ID } }, + toEntity: { id: { in: claimIds } }, + }, + ], + }, + first: CLAIM_RECORD_LIMIT, + orderBy: [EntitiesOrderBy.UpdatedAtDesc], + enabled: claimIds.length > 0, + deferUntilFetched: true, + prefetchNextPage: false, + }); + + const debateIds = React.useMemo(() => debates.entities.map(entity => entity.id), [debates.entities]); + const claimsRowsQuery = useExploreRows(claimIds, spaceId, !related.isLoading); + const debatesRowsQuery = useExploreRows(debateIds, spaceId, !debates.isLoading); + + const claimScores = useEntityScores({ ids: claimIds }); + const debateScores = useEntityScores({ ids: debateIds }); + + const claimRanks = React.useMemo( + () => ({ scores: claimScores.scores, rankings: claimScores.rankings }), + [claimScores.rankings, claimScores.scores] + ); + const debateRanks = React.useMemo( + () => ({ scores: debateScores.scores, rankings: debateScores.rankings }), + [debateScores.rankings, debateScores.scores] + ); + + const claimRows = React.useMemo( + () => sortRows(claimsRowsQuery.data ?? NO_ROWS, 'best', claimRanks), + [claimRanks, claimsRowsQuery.data] + ); + const debateRows = React.useMemo( + () => sortRows(debatesRowsQuery.data ?? NO_ROWS, 'best', debateRanks), + [debateRanks, debatesRowsQuery.data] + ); + + return { + claimIds, + claimRows, + debateRows, + claimsTotal: claimIds.length, + debatesTotal: debateIds.length, + claimsLoading: related.isLoading || claimsRowsQuery.isLoading || (claimScores.isLoading && !claimScores.isError), + debatesLoading: + related.isLoading || + debates.isLoading || + debatesRowsQuery.isLoading || + (debateScores.isLoading && !debateScores.isError), + claimsError: Boolean(related.error ?? claimsRowsQuery.error), + debatesError: Boolean(related.error ?? debates.error ?? debatesRowsQuery.error), + }; +} diff --git a/apps/web/core/state/entity-side-panel-active-tab.tsx b/apps/web/core/state/entity-side-panel-active-tab.tsx index 64b7a35220..dba132c187 100644 --- a/apps/web/core/state/entity-side-panel-active-tab.tsx +++ b/apps/web/core/state/entity-side-panel-active-tab.tsx @@ -6,7 +6,9 @@ import { validateEntityId } from '~/core/utils/utils'; export type EntitySidePanelActiveTabContextValue = { activeTabId: string | null; + activeSystemTab: string | null; setActiveTabId: (tabId: string | null) => void; + setActiveSystemTab: (tab: string) => void; }; export const EntitySidePanelActiveTabContext = React.createContext(null); @@ -19,19 +21,32 @@ export function EntitySidePanelActiveTabProvider({ children: React.ReactNode; }) { const [activeTabId, setActiveTabId] = React.useState(null); + const [activeSystemTab, setActiveSystemTab] = React.useState(null); React.useEffect(() => { setActiveTabId(null); + setActiveSystemTab(null); }, [entityId]); const setActiveTabIdValidated = React.useCallback((tabId: string | null) => { if (tabId !== null && !validateEntityId(tabId)) return; setActiveTabId(tabId); + setActiveSystemTab(null); + }, []); + + const selectSystemTab = React.useCallback((tab: string) => { + setActiveTabId(null); + setActiveSystemTab(tab); }, []); const value = React.useMemo( - () => ({ activeTabId, setActiveTabId: setActiveTabIdValidated }), - [activeTabId, setActiveTabIdValidated] + () => ({ + activeTabId, + activeSystemTab, + setActiveTabId: setActiveTabIdValidated, + setActiveSystemTab: selectSystemTab, + }), + [activeSystemTab, activeTabId, selectSystemTab, setActiveTabIdValidated] ); return {children}; diff --git a/apps/web/design-system/tab-group.tsx b/apps/web/design-system/tab-group.tsx index d226c41ee6..e4d7618fa1 100644 --- a/apps/web/design-system/tab-group.tsx +++ b/apps/web/design-system/tab-group.tsx @@ -21,6 +21,8 @@ interface TabGroupProps { badge?: string; disabled?: boolean; hidden?: boolean; + /** In-place product tab key used when this tab group renders in an entity side panel. */ + sidePanelKey?: string; /** Draws a rule before this tab, marking where one group of tabs ends and another begins. */ dividerBefore?: boolean; /** @@ -136,10 +138,24 @@ export function TabGroup({ tabs, className = '' }: TabGroupProps) { {t.dividerBefore && } {t.onlyWhenNarrow ? ( - ) : ( -