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 index 869a5707b8..31eabac1a3 100644 --- a/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx +++ b/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx @@ -14,7 +14,7 @@ export type ClaimRecordPageProps = { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; -/** The shared server guard and page shell behind the claim's three record routes. */ +/** The shared server guard and page shell behind the claim's record routes. */ export async function ClaimRecordPage(props: ClaimRecordPageProps) { const params = await props.params; const searchParams = await props.searchParams; diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx new file mode 100644 index 0000000000..07ef5185e3 --- /dev/null +++ b/apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx @@ -0,0 +1,5 @@ +import { ClaimRecordPage, type ClaimRecordPageProps } from '../claim-record-page'; + +export default function ClaimTopicsPage(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 71efb965b3..66e273f50a 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -13,10 +13,20 @@ import { ClaimPageView, resolveClaimTab } from './claim-page-view'; const mocks = vi.hoisted(() => ({ entity: null as Record | null, + /** How many responses the claim has; zero means the hero draws no verdict column. */ + responseTotal: 11, + /** Whether the response counts are still out, which is what the hero reserves its column for. */ + summaryLoading: false, + /** Whether the counts actually answered. False after a terminal failure, not just while loading. */ + hasCounts: true, + /** Drives the one strip that can occupy the hero's first grid row. */ + isControversial: false, /** Props the description's clamp received, or null if it rendered no clamp at all. */ clamp: null as Record | null, /** Props the chip section received, or null if the page rendered none. */ chipSection: null as Record | null, + /** Props the Topics tab received, or null if the page rendered none. */ + topicsTab: null as Record | null, tabs: null as Record | null, activity: null as Record | null, recordTab: null as Record | null, @@ -80,7 +90,8 @@ vi.mock('~/design-system/clamped-text', () => ({ }, })); -// Its own suite covers the chips and the expander; here we only need to see what it was handed. +// Only `META_CHIP_CLASS` is still read from here — the hero's switched-off topics row borrows the +// chips' shape. The section itself no longer renders on this page at all. vi.mock('~/partials/entity-page/relation-chip-section', () => ({ META_CHIP_CLASS: 'meta-chip', RelationChipSection: (props: Record) => { @@ -89,6 +100,15 @@ vi.mock('~/partials/entity-page/relation-chip-section', () => ({ }, })); +// The Topics tab is a feed of its own, with its own counts and ordering covered in its own suite. +// Here we only need to see which topics reached it. +vi.mock('./claim-topics-tab', () => ({ + ClaimTopicsTab: (props: Record) => { + mocks.topicsTab = props; + return
; + }, +})); + vi.mock('~/core/sync/use-store', () => ({ useQueryEntity: () => ({ entity: mocks.entity, isLoading: false }), })); @@ -101,7 +121,10 @@ vi.mock('./use-claim-response-state', () => ({ useClaimResponseState: () => ({ responseKind: 'stance', summary: { - isControversial: false, + isLoading: mocks.summaryLoading, + hasCounts: mocks.hasCounts, + total: mocks.responseTotal, + isControversial: mocks.isControversial, viewerDirection: 'positive', viewerSpaceId: 'viewer-space', isViewerResponseLoading: true, @@ -138,10 +161,20 @@ vi.mock('./claim-comment-position', () => ({ }, })); vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => () => {} })); +// The prefetching link reaches for the sync engine; the topic chips only need to be links. +vi.mock('~/design-system/prefetch-link', () => ({ + PrefetchLink: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => ( + + {children} + + ), +})); vi.mock('~/core/debates/backfill-readiness-for-held-position', () => ({ useBackfillReadinessForHeldPosition: () => {}, })); -vi.mock('./claim-verdict', () => ({ ClaimVerdict: () =>
})); +vi.mock('~/partials/explore/claim-explore-feed-card', () => ({ + ClaimVerdictColumn: () =>
, +})); vi.mock('./claim-sources-tab', () => ({ ClaimSourcesTab: () =>
})); vi.mock('./claim-end-slot', () => ({ ClaimEndSlot: () => null })); vi.mock('./claim-record-tab', () => ({ @@ -185,9 +218,14 @@ function claimEntity(description: string | null) { } beforeEach(() => { + mocks.responseTotal = 11; + mocks.summaryLoading = false; + mocks.hasCounts = true; + mocks.isControversial = false; mocks.entity = claimEntity('A description long enough that the page has something to collapse.'); mocks.clamp = null; mocks.chipSection = null; + mocks.topicsTab = null; mocks.tabs = null; mocks.activity = null; mocks.recordTab = null; @@ -261,16 +299,114 @@ describe('ClaimPageView record', () => { ]); }); - it('orders Overview as response summary, position, activity, then comments', () => { + it('heads the page like an Explore claim card: the claim, the pills, then the verdict column', () => { + mocks.entity = { + ...mocks.entity, + types: [{ id: 'claim-type', name: 'Claim' }], + relations: [{ id: 'relation-1', type: { id: TOPICS_PROPERTY_ID }, toEntity: { id: 'topic-1', name: 'Ethics' } }], + }; render(); + const heading = screen.getByRole('heading', { level: 1 }); const position = screen.getByTestId('position'); const verdict = screen.getByTestId('verdict'); + const tabs = screen.getByTestId('tabs'); + + // Topics are on their own tab for now, not above the claim. + expect(screen.queryByRole('navigation', { name: 'Topics' })).toBeNull(); + expect(heading.compareDocumentPosition(position) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(position.compareDocumentPosition(verdict) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(verdict.compareDocumentPosition(tabs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // No type chip: every claim on this page is a Claim. + expect(screen.queryByText('Claim')).toBeNull(); + }); + + it('draws no verdict column on a claim nobody has answered, as Explore does', () => { + mocks.responseTotal = 0; + render(); + + expect(screen.queryByTestId('verdict')).toBeNull(); + expect(screen.getByTestId('position')).toBeInTheDocument(); + }); + + it('keeps both hero tracks while the counts are still out', () => { + // `hasVerdict` cannot be true until they answer, so a template derived from it alone painted + // one column and then re-wrapped the claim when the second appeared — a shift at the top of + // the page on every load. + mocks.summaryLoading = true; + const { container } = render(); + + const grid = container.querySelector('header > div'); + expect(grid?.className).toContain('grid-cols-[minmax(0,1fr)_220px]'); + // Reserved, not filled: nothing has said what the verdict is yet. + expect(screen.queryByTestId('verdict')).toBeNull(); + }); + + it('keeps the track when the counts fail rather than reading the failure as a zero', () => { + // A counts query that exhausts its retries leaves `total` at zero with nothing loading any + // more — the shape of an unanswered claim, which is exactly what it is not. Keying the track + // off `isLoading` gave it back on that failure and re-wrapped the title anyway. + mocks.summaryLoading = false; + mocks.hasCounts = false; + mocks.responseTotal = 0; + const { container } = render(); + + expect(container.querySelector('header > div')?.className).toContain('grid-cols-[minmax(0,1fr)_220px]'); + // Reserved, not filled: there is still no verdict to draw. + expect(screen.queryByTestId('verdict')).toBeNull(); + }); + + it('gives the column back once the counts settle on nobody having answered', () => { + mocks.responseTotal = 0; + const { container } = render(); + + expect(container.querySelector('header > div')?.className).toContain('grid-cols-1'); + }); + + it('leaves no empty row above the claim when nothing is drawn there', () => { + // The hero pins its parts to explicit rows so the verdict can start on the title's. With the + // chips row empty, row 1 is a `gap-y-4` above the claim belonging to a row nothing occupies — + // visible in the side panel and at phone widths, where that gap is set. + const { container } = render(); + + expect(screen.getByRole('heading', { level: 1 }).closest('div')?.className).toContain('row-start-1'); + expect(container.querySelector('[data-testid="position"]')?.closest('.col-start-1')?.className).toContain( + 'row-start-2' + ); + }); + + it('moves the rows down again when the claim is controversial', () => { + mocks.isControversial = true; + render(); + + expect(screen.getByRole('heading', { level: 1 }).closest('div')?.className).toContain('row-start-2'); + }); + + it('announces no Topics landmark over a row that is only the Controversial chip', () => { + // `SHOW_HERO_TOPICS` is off, so being controversial is the only thing that puts this row on + // the page — and it holds one status chip and no links. A navigation landmark named "Topics" + // over that is both empty and misnamed to anyone moving through the page by landmark. + mocks.isControversial = true; + // With topics on the claim, so this is about the row having no *links* rather than the claim + // having no topics. + mocks.entity = { + ...claimEntity('Anything'), + relations: [{ id: 'relation-1', type: { id: TOPICS_PROPERTY_ID }, toEntity: { id: 'topic-1', name: 'Ethics' } }], + }; + render(); + + expect(screen.getByText('Controversial')).toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'Topics' })).toBeNull(); + }); + + it('orders Overview as activity, then comments', () => { + render(); + + const tabs = screen.getByTestId('tabs'); const activity = screen.getByTestId('activity'); const comments = screen.getByTestId('comments'); - expect(verdict.compareDocumentPosition(position) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(position.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(tabs.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(activity.compareDocumentPosition(comments) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); @@ -388,38 +524,46 @@ describe('ClaimPageView comments', () => { }); }); -// GEO-2781. Topics used to be a run of chips crammed into the header's meta row, capped at three -// and with a `+N` that only counted. It is now the topic view's Subtopics section, which is the -// same question asked of the reader and so should not be a second thing that merely looks like it. +// Topics live on their own tab for now; the hero's row is switched off (`SHOW_HERO_TOPICS`). The +// Overview no longer repeats them either. describe('ClaimPageView topics', () => { - const topicRelation = { - id: 'relation-1', + const topic = (n: number) => ({ + id: `relation-${n}`, type: { id: TOPICS_PROPERTY_ID }, - toEntity: { id: 'topic-1', name: 'Ethics' }, - }; + toEntity: { id: `topic-${n}`, name: `Topic ${n}` }, + }); + const tagRelation = { id: 'relation-tag', type: { id: TAG_PROPERTY_ID }, toEntity: { id: 'tag-1', name: 'Draft' } }; + + it('draws every topic, and only topics, on the Topics tab', () => { + mocks.sidePanel = { activeTabId: null, activeSystemTab: 'topics', setActiveSystemTab: vi.fn() }; + mocks.entity = { ...claimEntity('Anything'), relations: [topic(1), topic(2), tagRelation] }; + render(); + + expect(screen.getByTestId('topics-tab')).toBeInTheDocument(); + expect(mocks.topicsTab?.topics).toEqual([topic(1), topic(2)]); + expect(mocks.topicsTab?.spaceId).toBe('space-1'); + }); - it('draws them with the shared chip section, under the label Topics', () => { - mocks.entity = { ...claimEntity('Anything'), relations: [topicRelation] }; + it('no longer draws them as chips anywhere on the page', () => { + mocks.sidePanel = { activeTabId: null, activeSystemTab: 'topics', setActiveSystemTab: vi.fn() }; + mocks.entity = { ...claimEntity('Anything'), relations: [topic(1)] }; render(); - expect(screen.getByTestId('chip-section')).toHaveAttribute('data-label', 'Topics'); - expect(screen.getByTestId('activity').nextElementSibling).toBe(screen.getByTestId('chip-section')); + expect(screen.queryByTestId('chip-section')).toBeNull(); }); - it('hands the section the topic relations, scoped to the viewing space', () => { - mocks.entity = { ...claimEntity('Anything'), relations: [topicRelation] }; + it('no longer repeats them on the Overview', () => { + mocks.entity = { ...claimEntity('Anything'), relations: [topic(1)] }; render(); - expect(mocks.chipSection?.relations).toEqual([topicRelation]); - expect(mocks.chipSection?.spaceId).toBe('space-1'); + expect(screen.queryByTestId('chip-section')).toBeNull(); + expect(screen.queryByTestId('topics-tab')).toBeNull(); }); - // Tags share the header row with the type and are a different relation; only Topics moved. - it('passes only topic relations, not the tags beside the type', () => { - const tagRelation = { id: 'relation-2', type: { id: TAG_PROPERTY_ID }, toEntity: { id: 'tag-1', name: 'Draft' } }; - mocks.entity = { ...claimEntity('Anything'), relations: [topicRelation, tagRelation] }; + it('offers a Topics tab only when the claim has topics', () => { + mocks.entity = { ...claimEntity('Anything'), relations: [topic(1)] }; render(); - expect(mocks.chipSection?.relations).toEqual([topicRelation]); + expect((mocks.tabs?.systemTabsBefore as Array<{ label: string }>).map(tab => tab.label)).toContain('Topics'); }); }); diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 8d73469d58..c6f113cb5c 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -2,12 +2,12 @@ import * as React from 'react'; +import cx from 'classnames'; import { usePathname } from 'next/navigation'; import { ClaimCommentPositionProvider } from '~/core/claims/browse/claim-comment-position'; import { ClaimPositionCommentControl } from '~/core/claims/browse/claim-position-comment'; import { TOPICS_PROPERTY_ID } from '~/core/claims/ontology'; -import { TAG_PROPERTY_ID } from '~/core/constants'; import type { DebateClaim } from '~/core/debates/api'; import { useBackfillReadinessForHeldPosition } from '~/core/debates/backfill-readiness-for-held-position'; import { useDebateClaims } from '~/core/debates/hooks'; @@ -22,6 +22,8 @@ import type { Relation, TabEntity } from '~/core/types'; import { NavUtils } from '~/core/utils/utils'; import { ClampedText } from '~/design-system/clamped-text'; +import { Fire } from '~/design-system/icons/fire'; +import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Skeleton } from '~/design-system/skeleton'; import { Text } from '~/design-system/text'; @@ -33,8 +35,8 @@ import { EntityPageInlineDescription, } 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 { META_CHIP_CLASS } from '~/partials/entity-page/relation-chip-section'; +import { ClaimVerdictColumn } from '~/partials/explore/claim-explore-feed-card'; import { type ActivityKind, ProfileActivitySection } from '~/partials/profile/profile-activity-section'; import { SPACE_TABS_ANCHOR } from '~/partials/space-page/space-tabs-anchor'; @@ -42,16 +44,19 @@ import { ClaimEndSlot } from './claim-end-slot'; import { ClaimRecordTab } from './claim-record-tab'; import { getClaimSources } from './claim-sources'; import { ClaimSourcesTab } from './claim-sources-tab'; -import { ControversialTag } from './claim-summary'; -import { ClaimVerdict } from './claim-verdict'; +import { ClaimTopicsTab } from './claim-topics-tab'; import { useClaimRecord } from './use-claim-record'; import { type ClaimResponseState, useClaimResponseState } from './use-claim-response-state'; -type ClaimTab = 'overview' | 'debates' | 'claims' | 'sources' | 'custom'; +type ClaimTab = 'overview' | 'debates' | 'claims' | 'topics' | 'sources' | 'custom'; type ClaimSystemTab = Exclude; -/** Shared with the cover/avatar header so its left edge stays aligned with the claim column. */ -export const CLAIM_PAGE_CONTENT_MAX_WIDTH = 720; +/** + * Shared with the cover/avatar header so its left edge stays aligned with the claim column. + * + * 840 for 800px of content: the inset below sits inside this width, at 20px a side. + */ +export const CLAIM_PAGE_CONTENT_MAX_WIDTH = 840; export const CLAIM_PAGE_CONTENT_INSET_CLASS = 'px-4 @[560px]:px-5'; export function resolveClaimTab({ @@ -69,6 +74,7 @@ export function resolveClaimTab({ if (panel.activeTabId) return 'custom'; if (panel.activeSystemTab === 'debates') return 'debates'; if (panel.activeSystemTab === 'claims') return 'claims'; + if (panel.activeSystemTab === 'topics') return 'topics'; if (panel.activeSystemTab === 'sources') return 'sources'; return 'overview'; } @@ -76,6 +82,7 @@ export function resolveClaimTab({ if (authoredTabId) return 'custom'; if (pathname.endsWith('/debates')) return 'debates'; if (pathname.endsWith('/claims')) return 'claims'; + if (pathname.endsWith('/topics')) return 'topics'; if (pathname.endsWith('/sources')) return 'sources'; return 'overview'; } @@ -128,13 +135,33 @@ export function ClaimPageView({ const row: DebateClaim | null = rowQuery.data?.claims.find(claim => claim.claim_entity_id === entityId) ?? null; const state = useClaimResponseState({ claimId: entityId, spaceId, row, entity: entity ?? null }); const { responseKind, summary } = state; + // Explore's rule for drawing the verdict column at all: counts that answered, and at least one. + const hasVerdict = !summary.isLoading && summary.hasCounts && summary.total > 0; + // Whether the hero keeps a second track, which is deliberately not the same question. + // + // `hasVerdict` cannot be true until the counts answer, so a grid template derived from it alone + // painted every claim one-column and then re-wrapped the title the moment the column appeared — + // a layout shift at the very top of the page, on every load, that the old below-header verdict + // never had. An answered claim is the common case on this page, so the unknown state reserves + // the column and only a settled zero takes it away. + // + // `hasCounts`, not `isLoading`, is what "settled" means. A counts query that exhausts its + // retries leaves `total` at zero with nothing loading any more — the shape of an unanswered + // claim, which is exactly what it is not, and the distinction `hasCounts` exists to draw. Keying + // off `isLoading` gave the track back on that failure and re-wrapped the title anyway, and would + // have taken it away again if a later refetch succeeded. Unknown reserves; only a measured zero + // releases. + const reserveVerdictColumn = !summary.hasCounts || summary.total > 0; const topics = React.useMemo(() => relationsOfType(entity?.relations, TOPICS_PROPERTY_ID), [entity?.relations]); - const tags = React.useMemo(() => relationsOfType(entity?.relations, TAG_PROPERTY_ID), [entity?.relations]); const topicIds = React.useMemo(() => topics.map(topic => topic.toEntity.id), [topics]); const sources = React.useMemo(() => getClaimSources(entity?.relations ?? []), [entity?.relations]); - // 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; + + // Whether anything is drawn above the claim. The hero pins its parts to explicit rows so the + // verdict can start on the title's, and row 1 belongs to this strip — so when nothing fills it, + // the rows have to move up rather than leave a `gap-y-4` above the claim that belongs to a row + // nothing occupies. Visible in the side panel and at phone widths, where that gap is set. + const hasChipsRow = (SHOW_HERO_TOPICS && topics.length > 0) || summary.isControversial; const requestedTab = resolveClaimTab({ pathname, @@ -151,6 +178,7 @@ export function ClaimPageView({ const hrefs = { debates: `${overviewHref}/debates`, claims: `${overviewHref}/claims`, + topics: `${overviewHref}/topics`, sources: `${overviewHref}/sources`, }; // Matches profile record tabs: unknown/error stays reachable, while a settled zero disappears. @@ -160,11 +188,13 @@ export function ClaimPageView({ const hasClaims = hasRecordToShow( record.claimsLoading || record.claimsError || record.claimsCountUnavailable ? undefined : record.claimsTotal ); + const hasTopics = topics.length > 0; const hasSources = sources.length > 0; const systemTabs = [ { label: 'Overview', href: overviewHref, sidePanelKey: 'overview' }, ...(hasDebates ? [{ label: 'Debates', href: hrefs.debates, sidePanelKey: 'debates' }] : []), ...(hasClaims ? [{ label: 'Related claims', href: hrefs.claims, sidePanelKey: 'claims' }] : []), + ...(hasTopics ? [{ label: 'Topics', href: hrefs.topics, sidePanelKey: 'topics' }] : []), ...(hasSources ? [{ label: 'Sources', href: hrefs.sources, sidePanelKey: 'sources' }] : []), ]; @@ -186,73 +216,117 @@ export function ClaimPageView({ className={`mx-auto flex w-full flex-col gap-6 py-6 @[560px]:gap-8 @[560px]:py-8 ${CLAIM_PAGE_CONTENT_INSET_CLASS}`} style={{ maxWidth: CLAIM_PAGE_CONTENT_MAX_WIDTH }} > - {/* Hero */} -
- {/* `text-pretty`, not `text-balance`. Balancing evens every line to the same length, - which on a claim — a full sentence running to three or four lines — leaves each one - breaking well short of the measure and reads as wrapping early. Pretty only avoids a - stranded last word, so the lines fill. */} - {isEditing ? ( - - ) : ( -

- {entity.name ?? entity.id} -

- )} - - {/* Clamped, like entity pages and the side panel (GEO-2772). What is shared is the line - budget, not the cut: wrapping differs with width, so the route, the side panel and a - phone — three widths of one layout, per the note above — break at different words. - They give up the same three lines of vertical space, which a character count could - not do; the same count spends a different number of lines at each width, which is the - measurement the reader actually feels. - - `ClampedText` measures an unclamped clone, so the toggle appears only when something - is genuinely hidden, and it is unaffected by the naive-overflow bug GEO-2756 fixed in - the feed's own title. */} - {isEditing ? ( - + {/* Hero: the Explore claim card's layout at page scale. Everything you can *do* to the + claim on the left — what it is, the claim, the pills — and where opinion stands on the + right, behind a rule that runs the full height. Under `claim-card-narrow` (a 520px + container) it stacks: the verdict drops below the pills and the rule goes, exactly as + the card does. `@container` here so that decision is the hero's width, not the page's. + + No verdict, no column: a claim nobody has answered takes the full width, as on Explore. */} +
+
+ {/* Above the claim and across both columns: what it is about. Capped, with the rest a + tab away rather than a wall of chips over the title. No type or tag chips — every + claim on this page is a Claim. Drawn only when there is something to draw, so a claim + with neither leaves no empty track behind. */} + {hasChipsRow ? ( + sidePanelTab.setActiveSystemTab('topics') : undefined} + isControversial={summary.isControversial} + className="col-span-full row-start-1 mb-3 claim-card-narrow:mb-0" + /> + ) : null} + +
+ {/* `text-pretty`, not `text-balance`. Balancing evens every line to the same length, + which on a claim — a full sentence running to three or four lines — leaves each one + breaking well short of the measure and reads as wrapping early. Pretty only avoids a + stranded last word, so the lines fill. */} + {isEditing ? ( + + ) : ( +

+ {entity.name ?? entity.id} +

+ )} + + {/* Clamped, like entity pages and the side panel (GEO-2772). What is shared is the line + budget, not the cut: wrapping differs with width, so the route, the side panel and a + phone break at different words but give up the same three lines of vertical space. + `ClampedText` measures an unclamped clone, so the toggle appears only when something + is genuinely hidden (GEO-2756). */} + {isEditing ? ( + + ) : ( + entity.description && ( + + ) + )} +
+ +
+ +
+ + {/* From the title's row down, so the share lines up with the claim's first line rather + than the chips above it; the rule runs beside the claim and the pills. Explore's own + column, not a copy of it. */} + {hasVerdict ? ( +
+ +
+ ) : null} +
+
+ +
+ tab.label)} + divideBeforeAuthored /> - ) : ( - entity.description && ( - - ) - )} - - {/* What this is. Topics — what it is *about* — used to sit opposite these, pushed to the - right of the same row; they are their own section below now (GEO-2781), so this row - has one job and no longer has to survive being squeezed from both ends in the side - panel. */} -
- {typeName && {typeName}} - {tags.map(tag => ( - {tag.toEntity.name ?? tag.toEntity.id} - ))} - {/* Among the chips that say what this is, which is what "contested" is — and the same - component the cards use, so all three surfaces move together. Not a chip itself: - the flame and red are what make it findable among neutral ones. */} - {summary.isControversial ? : null}
-
- -
- tab.label)} - divideBeforeAuthored - />
; topics: Relation[]; availableSpaceIds: string[]; @@ -331,6 +399,12 @@ function ClaimTabPanel({ ); } + if (activeTab === 'topics') { + // Every topic, uncapped, as explore cards: this tab is where the hero's "See all" leads, and a + // list somebody was sent to is a list worth ordering and describing rather than a row of chips. + return ; + } + if (activeTab === 'sources') { return ; } @@ -364,17 +438,7 @@ function ClaimTabPanel({ return ( <> - - {/* The response control follows the aggregate result, so a reader understands the current - split before being asked to add their own position. */} -
- -
- {/* The topic view's Subtopics, drawing a claim's Topics (GEO-2781) — same question for the - reader, so the same shared section rather than two implementations. On a claim, Topics - belong to the Overview's supporting context and follow its Activity record. */} - {/* Last, like the ordinary entity page. An empty thread is an invitation, not absence. */} - {/* No readiness switch — the Debate toggle is gone from the product. Master left the header - row that used to hold it; with nothing on its right there is no row, just a label. */} - Your position + // No card of its own: it renders in the hero's left column, under the claim. +
{control.responseError ? (
@@ -481,19 +549,86 @@ function ClaimPositionSection({ } /** - * The chip a space homepage uses for its types, reused here for the claim's type and its tags — - * the same shape in both places, since they are the same kind of label. + * Whether the hero shows the claim's topics above it. Off for now: the Topics tab holds them, and the + * hero keeps only Controversial. Turning this back on restores the capped row and its "See all". + */ +const SHOW_HERO_TOPICS = false; + +/** How many topics the hero shows before "See all" hands over to the Topics tab. */ +const HERO_TOPICS_LIMIT = 5; + +/** + * The claim's topics above it, across the hero: the first few as links, and "See all" to the Topics + * tab when there are more. Controversial leads the row when it applies. * - * A plain span, and not a component wrapping the class: topics used to be drawn here too and - * needed to be links with their own hover state, which is why {@link META_CHIP_CLASS} is a string - * that callers compose rather than an element. Topics now come from `RelationChipSection`, which - * composes it the same way. + * "See all" is a link to the tab's route on the page, and switches the panel's own tab in the side + * panel — the panel owns its navigation, so a link there would move the page behind it instead. */ -function MetaChip({ children }: { children: React.ReactNode }) { +function ClaimTopicsRow({ + topics, + spaceId, + seeAllHref, + onSeeAll, + isControversial, + className, +}: { + topics: Relation[]; + spaceId: string; + seeAllHref: string; + onSeeAll?: () => void; + isControversial: boolean; + className?: string; +}) { + const seeAllClass = `${META_CHIP_CLASS} text-grey-04 transition-colors hover:border-text hover:text-text`; + const rowClass = cx('flex min-w-0 flex-wrap items-center gap-1.5', className); + + const chips = ( + <> + {/* First in the row: "contested" is a fact about the claim rather than one of its topics. The + chips' own shape and type — `META_CHIP_CLASS`, spelled out because its border and fill are + the parts that change — in the tag's red, so it reads as one of the row's labels and still + stands out from them. */} + {isControversial ? ( + + + Controversial + + ) : null} + {topics.slice(0, HERO_TOPICS_LIMIT).map(topic => ( + + {topic.toEntity.name ?? topic.toEntity.id} + + ))} + {topics.length > HERO_TOPICS_LIMIT ? ( + onSeeAll ? ( + + ) : ( + + See all + + ) + ) : null} + + ); + + // A navigation landmark has to contain navigation. With `SHOW_HERO_TOPICS` off, the only thing + // that puts this row on the page is a claim being controversial — and that row holds one status + // chip and no links, so naming it "Topics" announced an empty region under a heading that + // describes something else entirely to anyone moving through the page by landmark. + if (topics.length === 0) { + return
{chips}
; + } + return ( - - {children} - + ); } diff --git a/apps/web/core/claims/browse/claim-record-tab.test.tsx b/apps/web/core/claims/browse/claim-record-tab.test.tsx index 54dcc0a6bb..57930d01ba 100644 --- a/apps/web/core/claims/browse/claim-record-tab.test.tsx +++ b/apps/web/core/claims/browse/claim-record-tab.test.tsx @@ -186,13 +186,7 @@ describe('ClaimRecordTab', () => { ); view.rerender( - + ); expect(mocks.hookCalls.at(-1)).toMatchObject({ @@ -203,6 +197,19 @@ describe('ClaimRecordTab', () => { }); }); + it('lets a debate fill the claim page column, on both tabs that can draw one', () => { + // The claim page's column is already the reading width, so a debate capped at the card's 560px + // sat narrower than everything around it. The flag has to reach the feed for that to happen — + // `DebateExploreFeedCard`'s own suite covers what it then does with it. + render(); + expect(mocks.feedProps?.fullWidthDebates).toBe(true); + + cleanup(); + + render(); + expect(mocks.feedProps?.fullWidthDebates).toBe(true); + }); + it('gives Debates the same sort order and only its Spaces filter', () => { render(); diff --git a/apps/web/core/claims/browse/claim-record-tab.tsx b/apps/web/core/claims/browse/claim-record-tab.tsx index 8fc500444e..92c1155948 100644 --- a/apps/web/core/claims/browse/claim-record-tab.tsx +++ b/apps/web/core/claims/browse/claim-record-tab.tsx @@ -39,13 +39,7 @@ type ClaimRecordTabProps = { * difference is the initial space: a claim page opens scoped to the space in its URL, while * clearing Spaces broadens the tab to every space carrying that same claim. */ -export function ClaimRecordTab({ - kind, - claimId, - spaceId, - availableSpaceIds, - sourceTopics, -}: ClaimRecordTabProps) { +export function ClaimRecordTab({ kind, claimId, spaceId, availableSpaceIds, sourceTopics }: ClaimRecordTabProps) { return ( ('best'); const spaces = useRecordSelection([spaceId]); const topics = useRecordSelection(); @@ -196,6 +184,9 @@ function ClaimRecordTabContent({ } errorLabel={isClaims ? 'Couldn’t load claims.' : 'Couldn’t load debates.'} noun={isClaims ? 'claims' : 'debates'} + // The claim page's column is already the reading width; a capped debate sat narrower than + // everything else on the tab. + fullWidthDebates />
); diff --git a/apps/web/core/claims/browse/claim-side-responders.tsx b/apps/web/core/claims/browse/claim-side-responders.tsx index 2c0ba352b4..b54954a80e 100644 --- a/apps/web/core/claims/browse/claim-side-responders.tsx +++ b/apps/web/core/claims/browse/claim-side-responders.tsx @@ -44,7 +44,7 @@ export function ClaimSideResponders({ spaceId: string; responseKind: ResponseKind; direction: ActiveResponseDirection; - /** Names the side in the claim's own vocabulary, for the panel's footer and the trigger's label. */ + /** Names the side in the claim's own vocabulary, for the panel's title and the trigger's label. */ label: string; /** The authoritative count for this side, which can exceed the faces the query returns. */ totalResponders: number; @@ -115,7 +115,7 @@ export function ClaimSideResponders({ /** * The list itself: the space editors and members popover pattern — same scroll cap, same divided * rows, same counted footer — so a reader meets one list shape in the app rather than two that do - * the same job differently. + * the same job differently — except that the count is a title above the rows, not a footer. * * Narrower than those, though. They hang off a page header with the width to spare; this hangs off a * count inside a card, and at 356px it arrived as a slab wider than the column that opened it. A row @@ -128,22 +128,34 @@ function ResponderList({ spaceIds, label, totalCount }: { spaceIds: string[]; la staleTime: 30_000, }); + // People with a profile picture first, so the top of the list is faces rather than generated + // placeholders. Stable within each group, so the order the responders came in otherwise holds. + const sortedProfiles = React.useMemo( + () => (profiles ? [...profiles].sort((a, b) => Number(Boolean(b.avatarUrl)) - Number(Boolean(a.avatarUrl))) : []), + [profiles] + ); + return (
+ {/* A title over the list rather than a counted footer under it: it says what the list is + before the reader scrolls it. The verb agrees with the count — "1 person agrees", + "6 people agree". */} +

+ {totalCount} {pluralize('person', totalCount)}{' '} + {totalCount === 1 ? pluralize(label.toLowerCase()) : label.toLowerCase()} +

{/* Contained, so a wheel past the end of the list does not chain through to the page behind. */} -
+ {/* Inset, so each row's hover sits inside the box as a rounded highlight — the browse sidebar's + own rows, in shape and colour. */} +
{isLoading || !profiles ? ( ) : ( - profiles.map(profile => ) + sortedProfiles.map(profile => ( + + )) )}
-
-

- {totalCount} {pluralize('person', totalCount)} -

-

{label}

-
); } diff --git a/apps/web/core/claims/browse/claim-topics-tab.test.tsx b/apps/web/core/claims/browse/claim-topics-tab.test.tsx new file mode 100644 index 0000000000..af8f2deb17 --- /dev/null +++ b/apps/web/core/claims/browse/claim-topics-tab.test.tsx @@ -0,0 +1,212 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExploreFeedRow } from '~/core/explore/explore-card-item'; +import type { TopicConnectionCounts } from '~/core/topics/browse/use-topic-connection-counts'; +import type { Relation } from '~/core/types'; +import { normId } from '~/core/utils/norm-id'; + +import { ClaimTopicsTab } from './claim-topics-tab'; + +const mocks = vi.hoisted(() => ({ + rows: [] as unknown[], + rowsLoading: false, + rowsError: false, + refetch: vi.fn(), + /** Ids the hydration was asked for, so the tab's dedupe and its order are both observable. */ + rowIdCalls: [] as string[][], + counts: null as Record | null, + countsLoading: false, + countsError: false, + countIdCalls: [] as string[][], +})); + +vi.mock('./use-claim-explore-rows', () => ({ + useClaimExploreRows: (ids: string[]) => { + mocks.rowIdCalls.push(ids); + return { + data: mocks.rows, + isLoading: mocks.rowsLoading, + isError: mocks.rowsError, + isFetching: false, + refetch: mocks.refetch, + }; + }, +})); + +vi.mock('~/core/topics/browse/use-topic-connection-counts', () => ({ + useTopicConnectionCounts: (ids: string[]) => { + mocks.countIdCalls.push(ids); + return { countsByTopicId: mocks.counts, isLoading: mocks.countsLoading, isError: mocks.countsError }; + }, +})); + +vi.mock('~/core/hooks/use-space-labels', () => ({ + useSpaceLabels: () => ({ labelsById: {}, isLoading: false }), + spaceLabel: () => ({ name: 'Space One', image: null }), +})); + +// The card has its own suite. Here it only has to say which topic it drew and with what counts. +vi.mock('~/partials/explore/topic-explore-feed-card', () => ({ + TopicExploreFeedCard: (props: { item: { entityId: string; title: string }; counts: unknown }) => ( +
+ {props.item.title} +
+ ), +})); + +const row = (id: string, title: string): ExploreFeedRow => ({ + entityId: id, + spaceId: 'space-1', + types: [], + createdAtSec: 0, + title, + description: null, + imageUrl: null, + recordingUrls: [], + debateVideoUrls: [], + debateClaim: null, + commentCount: 0, + isMemberOrEditor: false, +}); + +const counts = (claims: number, news: number, debates: number): TopicConnectionCounts => ({ + claims, + news, + debates, + total: claims + news + debates, +}); + +const topicRelation = (id: string) => ({ id: `relation-${id}`, toEntity: { id, name: id } }) as unknown as Relation; + +const drawnTopics = () => screen.getAllByTestId('topic-card').map(card => card.getAttribute('data-entity')); + +beforeEach(() => { + mocks.rows = []; + mocks.rowsLoading = false; + mocks.rowsError = false; + mocks.rowIdCalls = []; + mocks.counts = null; + mocks.countsLoading = false; + mocks.countsError = false; + mocks.countIdCalls = []; +}); + +afterEach(cleanup); + +describe('ClaimTopicsTab', () => { + const topics = [topicRelation('topic-a'), topicRelation('topic-b'), topicRelation('topic-c')]; + + it('orders by everything the metadata line adds up, largest first', () => { + mocks.rows = [row('topic-a', 'A'), row('topic-b', 'B'), row('topic-c', 'C')]; + mocks.counts = { + [normId('topic-a')]: counts(1, 0, 0), + [normId('topic-b')]: counts(100, 5, 2), + [normId('topic-c')]: counts(10, 0, 1), + }; + + render(); + + expect(drawnTopics()).toEqual(['topic-b', 'topic-c', 'topic-a']); + }); + + it('breaks a tie on claims, then on name, so the order is stable', () => { + mocks.rows = [row('topic-a', 'Zebra'), row('topic-b', 'Apple'), row('topic-c', 'Middle')]; + mocks.counts = { + // Same total; different shapes. + [normId('topic-a')]: counts(5, 5, 0), + [normId('topic-b')]: counts(5, 5, 0), + [normId('topic-c')]: counts(9, 1, 0), + }; + + render(); + + expect(drawnTopics()).toEqual(['topic-c', 'topic-b', 'topic-a']); + }); + + it('hands each card its own counts', () => { + mocks.rows = [row('topic-a', 'A')]; + mocks.counts = { [normId('topic-a')]: counts(7, 1, 2) }; + + render(); + + expect(screen.getByTestId('topic-card')).toHaveAttribute( + 'data-counts', + JSON.stringify({ claims: 7, news: 1, debates: 2, total: 10 }) + ); + }); + + it('keeps the claim’s order when only some count batches answered', () => { + // The counts arrive in batches, so one can fail while another succeeds. Sorting the partial + // map ranks every unmeasured topic as zero and then alphabetises it — neither the claim's + // order nor largest-first — and nothing on screen says which topics were measured. + mocks.rows = [row('topic-a', 'A'), row('topic-b', 'B'), row('topic-c', 'C')]; + mocks.countsError = true; + mocks.counts = { [normId('topic-c')]: counts(1, 0, 0) }; + + render(); + + expect(drawnTopics()).toEqual(['topic-a', 'topic-b', 'topic-c']); + }); + + it('still gives the cards whose batch answered their counts', () => { + mocks.rows = [row('topic-a', 'A'), row('topic-b', 'B')]; + mocks.countsError = true; + mocks.counts = { [normId('topic-b')]: counts(4, 1, 2) }; + + render(); + + const [first, second] = screen.getAllByTestId('topic-card'); + expect(first).toHaveAttribute('data-counts', 'null'); + expect(second).toHaveAttribute('data-counts', JSON.stringify({ claims: 4, news: 1, debates: 2, total: 7 })); + }); + + it('keeps the claim’s own order, and no counts, when the count could not be read', () => { + mocks.rows = [row('topic-a', 'A'), row('topic-b', 'B'), row('topic-c', 'C')]; + mocks.countsError = true; + mocks.counts = null; + + render(); + + expect(drawnTopics()).toEqual(['topic-a', 'topic-b', 'topic-c']); + expect(screen.getAllByTestId('topic-card')[0]).toHaveAttribute('data-counts', 'null'); + }); + + it('draws nothing until the counts are in, so the list cannot resequence under the reader', () => { + mocks.rows = [row('topic-a', 'A')]; + mocks.countsLoading = true; + + render(); + + expect(screen.queryByTestId('topic-card')).toBeNull(); + expect(screen.getByText('Loading topics…')).toBeInTheDocument(); + }); + + it('asks for a topic related twice only once', () => { + render( + + ); + + expect(mocks.rowIdCalls.at(-1)).toEqual(['topic-a', 'topic-b']); + expect(mocks.countIdCalls.at(-1)).toEqual(['topic-a', 'topic-b']); + }); + + it('is honest about a claim with no topics, which is a route somebody bookmarked', () => { + render(); + + expect(screen.getByText('No topics have been linked to this claim yet.')).toBeInTheDocument(); + }); + + it('offers a retry when the topics themselves could not be loaded', () => { + mocks.rowsError = true; + + render(); + + expect(screen.getByText('Couldn’t load topics.')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/core/claims/browse/claim-topics-tab.tsx b/apps/web/core/claims/browse/claim-topics-tab.tsx new file mode 100644 index 0000000000..b34fde8583 --- /dev/null +++ b/apps/web/core/claims/browse/claim-topics-tab.tsx @@ -0,0 +1,113 @@ +'use client'; + +import * as React from 'react'; + +import { useTopicConnectionCounts } from '~/core/topics/browse/use-topic-connection-counts'; +import type { Relation } from '~/core/types'; +import { normId } from '~/core/utils/norm-id'; + +import { TopicExploreFeedCard } from '~/partials/explore/topic-explore-feed-card'; +import { PersonRecordFeed } from '~/partials/profile/person-record-feed'; + +import { useClaimExploreRows } from './use-claim-explore-rows'; + +/** + * The claim's topics, as an explore feed. + * + * It was a row of chips, which is the right answer for a header strip and the wrong one for a tab: + * a chip says a topic's name and nothing else, so a tab of them is a list with no way to tell which + * entry is worth opening. As cards they carry what the graph already knows about each topic — its + * description, its picture, and how much is attached to it — which is also what this list is + * ordered by. + * + * Ordered by the metadata it shows — debates + claims + news stories totalled, largest first. That + * is the ask's first choice and the one worth having, since "Best" over a handful of topics + * resolves to an order a reader cannot see. Ties break on claims, then on name, so the list is + * stable rather than merely sorted. (The card names those three in a different order than they are + * summed in; the sum is over all three either way.) + * + * Nothing renders until the counts are in. The rows and the counts are two requests, and painting + * the first would show the claim's own topic order and then resequence it under the reader — the + * same trade `useTopicLinkedEntities` makes, for the same reason. + * + * And nothing is ordered unless *every* count arrived. A partial answer falls back to the claim's + * order rather than ranking the topics it could not measure as zero. + * + * The feed itself is `PersonRecordFeed`, as the Sources tab does with the same shape of question — + * a bounded id list hydrated into explore cards. It draws a topic card instead of the shared one; + * everything around the rows is the same and is not worth a second copy. + */ +export function ClaimTopicsTab({ topics, spaceId }: { topics: Relation[]; spaceId: string }) { + // The claim's order, deduped: a topic related twice in different spaces is one card. + const topicIds = React.useMemo(() => { + const seen = new Set(); + const ids: string[] = []; + for (const topic of topics) { + const key = normId(topic.toEntity.id); + if (seen.has(key)) continue; + seen.add(key); + ids.push(topic.toEntity.id); + } + return ids; + }, [topics]); + + const counts = useTopicConnectionCounts(topicIds); + // Hydrated in the claim's order and sorted afterwards. Sorting the ids first would work and would + // also change the query key the moment the counts landed, paying for the whole list twice. + const rows = useClaimExploreRows(topicIds, spaceId, topicIds.length > 0); + + const countsByTopicId = counts.countsByTopicId; + const countsFailed = counts.isError; + const ordered = React.useMemo(() => { + // Ordering is a promise about *all* the numbers, so a partial answer cannot keep it. The + // counts arrive in batches, and one batch can fail while another succeeds — which leaves a map + // covering some topics and not others. Sorting on that ranks every unmeasured topic as zero + // and then alphabetises it: neither the claim's order nor largest-first, and nothing on screen + // says which. So any failure falls back to the claim's order, exactly as a total failure + // already did. The counts that did arrive still reach their own cards. + if (!countsByTopicId || countsFailed) return rows.data; + + const totalOf = (entityId: string) => countsByTopicId[normId(entityId)]; + + return [...rows.data].sort( + (a, b) => + (totalOf(b.entityId)?.total ?? 0) - (totalOf(a.entityId)?.total ?? 0) || + (totalOf(b.entityId)?.claims ?? 0) - (totalOf(a.entityId)?.claims ?? 0) || + a.title.localeCompare(b.title) + ); + }, [countsByTopicId, countsFailed, rows.data]); + + // The parent only offers this tab when the claim has topics. The route stays addressable + // directly, matching the Sources tab, so a stale bookmark still gets an honest empty state. + if (topicIds.length === 0) { + return

No topics have been linked to this claim yet.

; + } + + return ( + ( + + )} + /> + ); +} diff --git a/apps/web/core/claims/browse/claim-verdict.test.tsx b/apps/web/core/claims/browse/claim-verdict.test.tsx deleted file mode 100644 index ed2a472cd4..0000000000 --- a/apps/web/core/claims/browse/claim-verdict.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import '@testing-library/jest-dom/vitest'; -import { cleanup, render, screen } from '@testing-library/react'; - -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { ClaimResponseSummary } from './claim-response-summary'; -import { summarizeClaimResponses } from './claim-response-summary'; -import { ClaimVerdict } from './claim-verdict'; - -// The sides render their own responder query and popover, neither of which this module decides. -vi.mock('./claim-summary', () => ({ - ClaimSides: () =>
, - ClaimSplitBar: () =>
, -})); - -const ENTITY = 'claim-1'; -const SPACE = 'space-1'; - -/** The hook's shape, so a test says only what it is about. */ -function summary(overrides: Partial = {}): ClaimResponseSummary { - return { - ...summarizeClaimResponses(0, 0), - isLoading: false, - isViewerResponseLoading: false, - hasCounts: true, - viewerDirection: null, - indexedViewerDirection: null, - viewerSpaceId: null, - ...overrides, - }; -} - -function renderVerdict(value: ClaimResponseSummary) { - return render(); -} - -afterEach(cleanup); - -describe('ClaimVerdict', () => { - it('invites a first response on a claim the server says nobody has answered', () => { - renderVerdict(summary()); - - expect(screen.getByText('No responses yet')).toBeInTheDocument(); - }); - - it('reports the share once there are responses', () => { - renderVerdict(summary({ ...summarizeClaimResponses(17, 3) })); - - expect(screen.getByText('85%')).toBeInTheDocument(); - expect(screen.getByText('20 responses')).toBeInTheDocument(); - expect(screen.queryByText('No responses yet')).toBeNull(); - }); - - // `total: 0` has three causes and only one of them is "nobody has answered". This module is the - // one place that turns that zero into a statement about the claim, so it is the one place the - // difference matters: a reader told "No responses yet" about a claim with two hundred of them has - // been handed a wrong verdict rather than a missing one, with nothing on screen to say so. - it('says nothing at all when the counts never answered', () => { - // What a failed count query leaves behind: nothing loading any more, and no data. - renderVerdict(summary({ hasCounts: false })); - - expect(screen.queryByText('No responses yet')).toBeNull(); - expect(screen.queryByTestId('claim-split-bar')).toBeNull(); - }); - - it('says nothing while the summary is still being held back', () => { - // The claim page reaches this on every load: the summary waits for the vocabulary, so until the - // entity lands the hook has asked nothing and reports a zero that stands for nothing. Loading - // is already false by then, so the skeleton above does not cover it. - renderVerdict(summary({ hasCounts: false, isLoading: false })); - - expect(screen.queryByText('No responses yet')).toBeNull(); - }); - - it('draws a skeleton while the counts are still on their way', () => { - const { container } = renderVerdict(summary({ isLoading: true, hasCounts: false })); - - expect(screen.queryByText('No responses yet')).toBeNull(); - expect(container.querySelector('.animate-pulse')).not.toBeNull(); - }); -}); diff --git a/apps/web/core/claims/browse/claim-verdict.tsx b/apps/web/core/claims/browse/claim-verdict.tsx deleted file mode 100644 index 3b75e3aec6..0000000000 --- a/apps/web/core/claims/browse/claim-verdict.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; - -import * as React from 'react'; - -import { ENTITY_RESPONSE_COPY, type ResponseKind } from '~/core/responses/entity-response'; - -import { Skeleton } from '~/design-system/skeleton'; -import { Text } from '~/design-system/text'; - -import { type ClaimResponseSummary, claimSummaryTier } from './claim-response-summary'; -import { ClaimSides, ClaimSplitBar } from './claim-summary'; - -/** - * Where opinion sits on a claim: one number, the split, and who is on each side. - * - * Reads the same `claimSummaryTier` every card reads, so the page and a card describing the same - * claim cannot say different things about it. - * - * At zero it invites a first response rather than rendering nothing at all, which is what it used - * to do on the state most claims are in — but only at a zero the server actually reported. A zero - * standing in for a failed or unasked question renders nothing, because the invitation is an - * assertion about the claim and those two are not. From the first response the share is shown, however small - * the sample: 93% of answered claims are unanimous and the median has two responses, so a "100%" - * here is usually standing on very little — and what keeps that honest is the responder counts - * directly beneath it, not withholding the number. - */ -export function ClaimVerdict({ - entityId, - spaceId, - responseKind, - summary, -}: { - entityId: string; - spaceId: string; - responseKind: ResponseKind; - summary: ClaimResponseSummary; -}) { - if (summary.isLoading) { - return ; - } - - // Nothing, where the counts never answered. - // - // `total: 0` is also what a failed count query and a held-back hook produce, and this module is - // the one place that turns a zero into a *claim about the world* — "No responses yet", followed - // by an invitation to be the first. Said over a claim with two hundred responses that is not a - // missing verdict but a wrong one, and the reader has no way to tell. On the claim page the - // held-back case is reached on every load: the summary waits for the vocabulary, so until the - // entity lands there is a window where nothing is loading and nothing has been asked. - // - // Rendering nothing is what this did before it learned to invite, and it is the honest answer to - // a question that was never put. - if (!summary.hasCounts) return null; - - const copy = ENTITY_RESPONSE_COPY[responseKind]; - const tier = claimSummaryTier(summary.total); - - // An invitation, where before there was nothing at all — and nothing is what the great majority - // of claims render, so this is the state most readers meet. - if (tier === 'invite') { - return ( -
- - No responses yet - - - {copy.firstResponsePrompt} - -
- ); - } - - const percent = summary.percent ?? 0; - - return ( -
-
- {/* The share and what it is a share *of*, on one baseline. They were stacked, which gave the - verb a line of its own for one small word and pushed everything under it down. Sharing a - line reads as one statement — "68% agree" — which is what it is, and it matches the - explore card exactly. */} - - {percent}% - - {/* "Agreements" → "agree", "Verifications" → "verify" reads wrong; use the action verb. */} - {copy.positiveAction.toLowerCase()} - - - {/* No Controversial tag here: it moved up to the hero chips, beside the type and tags, where - it says what kind of claim this is. Flagging it in both places said it twice. */} - - {summary.total} {summary.total === 1 ? 'response' : 'responses'} - -
- - - - -
- ); -} diff --git a/apps/web/core/topics/browse/use-topic-connection-counts.test.ts b/apps/web/core/topics/browse/use-topic-connection-counts.test.ts new file mode 100644 index 0000000000..db67e5aa9d --- /dev/null +++ b/apps/web/core/topics/browse/use-topic-connection-counts.test.ts @@ -0,0 +1,112 @@ +import { print } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { normId } from '~/core/utils/norm-id'; + +import { + TOPIC_COUNT_BATCH_SIZE, + decodeTopicConnectionCounts, + topicConnectionCountsDocument, + topicCountBatches, +} from './use-topic-connection-counts'; + +describe('topicConnectionCountsDocument', () => { + it('declares one variable and three buckets per topic', () => { + const printed = print(topicConnectionCountsDocument(3)); + + for (const index of [0, 1, 2]) { + expect(printed).toContain(`$topic${index}: UUID!`); + expect(printed).toContain(`claims${index}:`); + expect(printed).toContain(`news${index}:`); + expect(printed).toContain(`debates${index}:`); + } + expect(printed).not.toContain('$topic3'); + }); + + it('reuses the parsed document for a given topic count', () => { + expect(topicConnectionCountsDocument(2)).toBe(topicConnectionCountsDocument(2)); + expect(topicConnectionCountsDocument(2)).not.toBe(topicConnectionCountsDocument(4)); + }); + + it('counts entities, not relations', () => { + // The distinction the topic page's composition strip gets wrong by design: a claim carrying + // `Topics` in two spaces is two relations and one claim, and this list is *ordered* by the + // number, so it has to be a count of things. + const printed = print(topicConnectionCountsDocument(1)); + + expect(printed).toContain('entitiesConnection'); + expect(printed).not.toContain('relationsConnection'); + }); + + it('reaches debates through the claims that name the topic, not through the topic', () => { + const printed = print(topicConnectionCountsDocument(1)).replace(/\s+/g, ' '); + + expect(printed).toContain( + 'debates0: entitiesConnection( filter: {typeIds: {overlaps: $debateTypeIds}, relations: {some: {typeId: {is: $debateClaimsPropertyId}, toEntity: {relations: {some: {typeId: {is: $topicsPropertyId}, toEntityId: {is: $topic0}}}}}}} )' + ); + + // And a claim reaches its topic in one hop, so the two buckets cannot silently become the same + // query. + expect(printed).toContain( + 'claims0: entitiesConnection( filter: {typeIds: {overlaps: $claimTypeIds}, relations: {some: {typeId: {is: $topicsPropertyId}, toEntityId: {is: $topic0}}}} )' + ); + }); +}); + +describe('decodeTopicConnectionCounts', () => { + const ids = ['AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb']; + + it('puts each aliased bucket back on the id that asked for it, and totals them', () => { + const decoded = decodeTopicConnectionCounts( + { + claims0: { totalCount: 117 }, + news0: { totalCount: 2 }, + debates0: { totalCount: 3 }, + claims1: { totalCount: 5 }, + news1: { totalCount: 0 }, + debates1: { totalCount: 0 }, + }, + ids + ); + + expect(decoded[normId(ids[0])]).toEqual({ claims: 117, news: 2, debates: 3, total: 122 }); + expect(decoded[normId(ids[1])]).toEqual({ claims: 5, news: 0, debates: 0, total: 5 }); + }); + + it('reads a missing bucket as zero rather than as undefined', () => { + const decoded = decodeTopicConnectionCounts({ claims0: { totalCount: 4 } }, [ids[0]]); + + expect(decoded[normId(ids[0])]).toEqual({ claims: 4, news: 0, debates: 0, total: 4 }); + }); +}); + +describe('topicCountBatches', () => { + // Dash-free: `normId` strips dashes, so an id written with them would not come back as it went in. + const id = (n: number) => `topic${String(n).padStart(3, '0')}`; + + it('keeps a claim-sized list to one request', () => { + // Claims on testnet carry at most 7 topics, so this is the case that actually happens. + expect(topicCountBatches([id(3), id(1), id(2)])).toEqual([[id(1), id(2), id(3)]]); + }); + + it('bounds the request rather than growing it without limit', () => { + const ids = Array.from({ length: TOPIC_COUNT_BATCH_SIZE * 2 + 1 }, (_, index) => id(index)); + + expect(topicCountBatches(ids).map(batch => batch.length)).toEqual([ + TOPIC_COUNT_BATCH_SIZE, + TOPIC_COUNT_BATCH_SIZE, + 1, + ]); + expect(topicCountBatches(ids).flat()).toEqual(ids); + }); + + it('asks once for a topic named twice, however it was spelled', () => { + expect(topicCountBatches(['AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'])).toEqual([ + ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], + ]); + }); + + it('asks for nothing when there are no topics', () => { + expect(topicCountBatches([])).toEqual([]); + }); +}); diff --git a/apps/web/core/topics/browse/use-topic-connection-counts.ts b/apps/web/core/topics/browse/use-topic-connection-counts.ts new file mode 100644 index 0000000000..f511e74d8c --- /dev/null +++ b/apps/web/core/topics/browse/use-topic-connection-counts.ts @@ -0,0 +1,213 @@ +'use client'; + +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { useQueries } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { Effect } from 'effect'; +import { parse } from 'graphql'; + +import { CLAIM_TYPE_ID, TOPICS_PROPERTY_ID } from '~/core/claims/ontology'; +import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_TYPE_ID } from '~/core/debates/ontology'; +import { ID } from '~/core/id'; +import { graphql } from '~/core/io/graphql-client'; +import { normId } from '~/core/utils/norm-id'; + +import { NEWS_STORY_TYPE_ID } from '../ontology'; + +/** How much is attached to a topic, as a card reports it. */ +export type TopicConnectionCounts = { + claims: number; + news: number; + /** Debates on the topic's claims — two hops, not one. See the query. */ + debates: number; + /** What the list is ordered by: everything the card names, added up. */ + total: number; +}; + +export const EMPTY_TOPIC_CONNECTION_COUNTS: TopicConnectionCounts = { claims: 0, news: 0, debates: 0, total: 0 }; + +/** + * Asked of entities rather than of relations, for all three buckets. + * + * `relationsConnection` answers faster and is what the topic page's composition strip uses, but it + * counts *links*: the same claim carrying `Topics` in two spaces is two relations, and on + * `AI governance` that is 552 relations against 505 claims and 209 against 195 news stories. A card + * saying "552 claims" beside a topic page saying 505 is a number nobody can reconcile, and the + * count is also what orders the list — so it has to be a count of things. + * + * Debates are the bucket that was never a `Topics` relation at all. A Debate carries `Claims` and + * never `Topics`, so its link to a topic is two hops — debate to claim, claim to topic — expressed + * as a nested filter on the far end of the relation. That is also exactly what the ask describes: + * debates connected to the *attached claims*, not to the topic. + * + * Unscoped to spaces, unlike the composition strip. A topic gathers across the whole graph, and + * this count is asked on a claim page that is scoped to one space — narrowing to the spaces a + * viewer happens to be in would make a topic's size depend on who is reading it, and would reorder + * the tab under anyone whose space allowlist resolved a beat after the counts did. + * + * One request per batch of topics, and in practice one batch: each topic contributes three aliased + * counts over the same connections, and the list is split at {@link TOPIC_COUNT_BATCH_SIZE}. Claims + * on testnet carry at most 7 topics, so a claim's whole Topics tab is normally a single request. + * Five topics measured ~0.75s against testnet, against five round trips for the same work. + */ +function buildCountsSource(topicCount: number): string { + const declarations = [ + '$topicsPropertyId: UUID!', + '$claimTypeIds: [UUID!]', + '$newsTypeIds: [UUID!]', + '$debateTypeIds: [UUID!]', + '$debateClaimsPropertyId: UUID!', + ...Array.from({ length: topicCount }, (_, index) => `$topic${index}: UUID!`), + ].join(', '); + + const buckets = Array.from({ length: topicCount }, (_, index) => { + const topic = `$topic${index}`; + + const namesTopic = `relations: { some: { typeId: { is: $topicsPropertyId }, toEntityId: { is: ${topic} } } }`; + + return /* GraphQL */ ` + claims${index}: entitiesConnection(filter: { typeIds: { overlaps: $claimTypeIds }, ${namesTopic} }) { + totalCount + } + news${index}: entitiesConnection(filter: { typeIds: { overlaps: $newsTypeIds }, ${namesTopic} }) { + totalCount + } + debates${index}: entitiesConnection( + filter: { + typeIds: { overlaps: $debateTypeIds } + relations: { some: { typeId: { is: $debateClaimsPropertyId }, toEntity: { ${namesTopic} } } } + } + ) { + totalCount + } + `; + }).join('\n'); + + return /* GraphQL */ `query TopicConnectionCounts(${declarations}) {\n${buckets}\n}`; +} + +/** + * Parsed documents by topic count. + * + * The document is shaped by *how many* topics are being counted and by nothing else — the ids + * themselves are variables — so a claim page with three topics reuses the same parsed document as + * every other three-topic claim, rather than re-parsing a string per render. + */ +const documentsByTopicCount = new Map>(); + +export function topicConnectionCountsDocument(topicCount: number): TypedDocumentNode { + const cached = documentsByTopicCount.get(topicCount); + if (cached) return cached; + + const document = parse(buildCountsSource(topicCount)) as TypedDocumentNode; + documentsByTopicCount.set(topicCount, document); + return document; +} + +type CountsResponse = Record; + +/** Decodes the aliased buckets back onto the ids that produced them, keyed by normalized id. */ +export function decodeTopicConnectionCounts( + response: CountsResponse, + topicIds: readonly string[] +): Record { + const out: Record = {}; + + topicIds.forEach((id, index) => { + const claims = response[`claims${index}`]?.totalCount ?? 0; + const news = response[`news${index}`]?.totalCount ?? 0; + const debates = response[`debates${index}`]?.totalCount ?? 0; + out[normId(id)] = { claims, news, debates, total: claims + news + debates }; + }); + + return out; +} + +/** + * Topics per request. + * + * Each one contributes three aliased counts, one of them a two-hop nested filter, so the request + * grows with the list. Claims measured on testnet carry at most 7 topics and three at the 95th + * percentile, which means this is one request in practice — the chunking is what stops a + * topic-heavy claim from becoming one unbounded query, and what keeps a chunk that fails from + * costing every other topic its numbers. + * + * The same shape `useClaimExploreRows` and `fetchExploreRowsByIds` use, for the same reason. + */ +export const TOPIC_COUNT_BATCH_SIZE = 10; + +/** + * Topic ids as request-sized batches: deduped, normalized, and sorted. + * + * Sorted so the same set of topics asked in a different order is the same cache entry rather than + * a second request — which matters here, because the tab re-asks in count order once the counts + * have landed. + */ +export function topicCountBatches(topicIds: readonly string[]): string[][] { + const ids = [...new Set(topicIds.map(normId))].sort(); + const batches: string[][] = []; + for (let start = 0; start < ids.length; start += TOPIC_COUNT_BATCH_SIZE) { + batches.push(ids.slice(start, start + TOPIC_COUNT_BATCH_SIZE)); + } + return batches; +} + +type CountsQueryResult = { data?: Record; isLoading: boolean; isError: boolean }; + +function combineTopicConnectionCounts(queries: CountsQueryResult[]) { + // `null`, not `{}`, while nothing has answered: an empty map is indistinguishable from a topic + // the counts came back empty for, and the card draws no metadata line for either. + const answered = queries.filter(query => query.data); + + return { + countsByTopicId: answered.length > 0 ? Object.assign({}, ...answered.map(query => query.data)) : null, + isLoading: queries.some(query => query.isLoading), + /** + * A failed count is not an empty one. The tab still lists its topics, in the order the claim + * carries them in, rather than printing zeros for numbers nobody measured. + * + * True of a *partial* failure too, which is why this is `some` rather than `every`: with more + * than one batch, one can fail while another answers, and half the numbers cannot order the + * list. The cards whose batch did answer still get their own counts; the rest draw no metadata + * line. + */ + isError: queries.some(query => query.isError), + }; +} + +/** + * Claims, news stories and debates attached to each of these topics. + * + * Returns a map rather than an array so a caller can look a topic up without depending on the + * order it asked in — which is the point, since the order it renders in is derived from these + * numbers. + */ +export function useTopicConnectionCounts(topicIds: string[]) { + const batches = React.useMemo(() => topicCountBatches(topicIds), [topicIds]); + + return useQueries({ + queries: batches.map(ids => ({ + queryKey: ['topic', 'connection-counts', ids], + queryFn: ({ signal }: { signal: AbortSignal }) => + Effect.runPromise( + graphql({ + query: topicConnectionCountsDocument(ids.length), + decoder: (response: CountsResponse) => decodeTopicConnectionCounts(response, ids), + variables: { + topicsPropertyId: ID.uuidToHex(TOPICS_PROPERTY_ID), + claimTypeIds: [ID.uuidToHex(CLAIM_TYPE_ID)], + newsTypeIds: [ID.uuidToHex(NEWS_STORY_TYPE_ID)], + debateTypeIds: [ID.uuidToHex(DEBATE_TYPE_ID)], + debateClaimsPropertyId: ID.uuidToHex(DEBATE_CLAIMS_PROPERTY_ID), + ...Object.fromEntries(ids.map((id, index) => [`topic${index}`, ID.uuidToHex(id)])), + }, + signal, + }) + ), + staleTime: 30_000, + })), + combine: combineTopicConnectionCounts, + }); +} diff --git a/apps/web/partials/explore/claim-explore-feed-card.tsx b/apps/web/partials/explore/claim-explore-feed-card.tsx index 97df8f9247..270f7539df 100644 --- a/apps/web/partials/explore/claim-explore-feed-card.tsx +++ b/apps/web/partials/explore/claim-explore-feed-card.tsx @@ -372,8 +372,12 @@ export function ClaimExploreFeedCard({ * `ClaimSummary`'s responder cluster read the *same* `entityRespondersQueryKey`, so react-query * serves both from one cache entry and one request. That is worth knowing before either side is * repointed at a query of its own. + * + * Exported for the claim page's hero, which is this card's layout at page scale: the same column, + * so the page and the feed cannot describe one claim in two shapes. The caller supplies the + * `@container` the `claim-card-narrow` rules measure. */ -function ClaimVerdictColumn({ +export function ClaimVerdictColumn({ entityId, spaceId, responseKind, diff --git a/apps/web/partials/explore/debate-explore-feed-card.test.tsx b/apps/web/partials/explore/debate-explore-feed-card.test.tsx index bf49b49a14..95d3525725 100644 --- a/apps/web/partials/explore/debate-explore-feed-card.test.tsx +++ b/apps/web/partials/explore/debate-explore-feed-card.test.tsx @@ -411,16 +411,10 @@ describe('DebateExploreFeedCard', () => { expect(onPlaybackAvailabilityChange).not.toHaveBeenCalled(); intersectAll(0.1); - expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith( - 'fd51f935-2063-4617-8039-7b672b23364c', - true - ); + expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith('fd51f935-2063-4617-8039-7b672b23364c', true); intersectAll(0); - expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith( - 'fd51f935-2063-4617-8039-7b672b23364c', - false - ); + expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith('fd51f935-2063-4617-8039-7b672b23364c', false); }); it('unregisters stale playable data when a refetch replaces the player with fallback', () => { @@ -429,20 +423,14 @@ describe('DebateExploreFeedCard', () => { const onPlaybackAvailabilityChange = vi.fn(); const view = renderCard({ onPlaybackAvailabilityChange }); intersectAll(0.7); - expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith( - 'fd51f935-2063-4617-8039-7b672b23364c', - true - ); + expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith('fd51f935-2063-4617-8039-7b672b23364c', true); // TanStack Query retains the previous data when a background refetch fails. mocks.debateQuery = { data: watchableDebate(), isError: true }; view.rerenderCard(); expect(screen.getByTestId('fallback')).toBeInTheDocument(); - expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith( - 'fd51f935-2063-4617-8039-7b672b23364c', - false - ); + expect(onPlaybackAvailabilityChange).toHaveBeenLastCalledWith('fd51f935-2063-4617-8039-7b672b23364c', false); }); it('does not request playback from a loading skeleton that may resolve to the fallback', () => { @@ -587,6 +575,33 @@ describe('DebateExploreFeedCard', () => { * itself — the open question in that ticket's notes. This control is the answer, and it has to * stay a real link: the Debate entity's page *is* the full-screen feed anchored to that debate. */ + describe('column width', () => { + /** The one element carrying the column budget: the card's single inner column. */ + const column = (container: HTMLElement) => container.querySelector('article > div') as HTMLElement; + + it('caps itself at the card width, fitted to the viewport, by default', () => { + const { container } = renderCard(); + + expect(column(container).className).toContain('max-w-[var(--debate-card-column-width)]'); + expect(column(container).style.getPropertyValue('--debate-card-column-width')).toBe( + 'clamp(320px, calc(83dvh - 178px), 560px)' + ); + }); + + it('raises the ceiling to the container under fullWidth without dropping the viewport budget', () => { + // `fullWidth` means "this column is already the reading width, don't cap me at 560px". It + // used to drop the `max-w` and the budget together, which on the claim page's 840px column + // made the card ~1140px tall — so on a 900px viewport the second debater and the interaction + // bar could not be seen together, which is the one thing the budget guarantees. + const { container } = renderCard({ fullWidth: true }); + + expect(column(container).className).toContain('max-w-[var(--debate-card-column-width)]'); + expect(column(container).style.getPropertyValue('--debate-card-column-width')).toBe( + 'max(320px, min(calc(83dvh - 178px), 100%))' + ); + }); + }); + describe('full-screen control', () => { it('links to the Debate entity, which is the anchored full-screen feed', () => { renderCard(); diff --git a/apps/web/partials/explore/debate-explore-feed-card.tsx b/apps/web/partials/explore/debate-explore-feed-card.tsx index 5a3c478827..ab352860eb 100644 --- a/apps/web/partials/explore/debate-explore-feed-card.tsx +++ b/apps/web/partials/explore/debate-explore-feed-card.tsx @@ -65,6 +65,22 @@ const DEBATE_CARD_COLUMN_STYLE = { '--debate-card-column-width': 'clamp(320px, calc(83dvh - 178px), 560px)', } as React.CSSProperties; +/** + * The same budget with the column, rather than 560px, as its ceiling. + * + * `fullWidth` means "this column is already the reading width, don't cap it at the card's 560px" — + * it does not mean "stop fitting the viewport". Dropping the budget outright made a debate on the + * claim page's 840px column about 1140px tall (two `aspect-480/289` tiles at ~800px), so on a + * 900px viewport the second debater and the interaction bar could not be seen together — which is + * the single thing the budget above exists to guarantee. + * + * `min` with `100%` rather than a larger constant: the ceiling is whatever column the card was + * given, so this is right at 840px and at any other width a host passes. + */ +const DEBATE_CARD_FULL_WIDTH_STYLE = { + '--debate-card-column-width': 'max(320px, min(calc(83dvh - 178px), 100%))', +} as React.CSSProperties; + type DebateExploreFeedCardProps = { item: ExploreFeedItem; /** Hide the space thumbnail + space-name link in the meta row (same semantics as ExploreFeedCard). */ @@ -75,6 +91,15 @@ type DebateExploreFeedCardProps = { titleOpensSidePanel?: boolean; /** Compact title and metadata treatment used by the narrow profile Activity rail. */ compactChrome?: boolean; + /** + * Raise the column's ceiling from the card's 560px to the container's own width. For a surface + * whose column is already the reading width, where the capped card sat narrower than everything + * around it — the claim page's Debates tab. + * + * Not "uncapped": the viewport-height budget still applies, so the whole card still fits the + * screen it is watched on. See {@link DEBATE_CARD_FULL_WIDTH_STYLE}. + */ + fullWidth?: boolean; /** * Called when a coordinated surface's player is clicked. An allowed active player transfers * before the click reaches it. A visible non-owner consumes that first click while ownership @@ -108,6 +133,7 @@ export function DebateExploreFeedCard({ hideJoinButton = false, titleOpensSidePanel = false, compactChrome = false, + fullWidth = false, onPlaybackRequest, onPlaybackAvailabilityChange, fallback, @@ -302,7 +328,7 @@ export function DebateExploreFeedCard({ and what keeps the bar beneath the videos the same width as them. */}
{/* The way out of the card and into the debate at full size. This corner used to hold "View all", a link to the space's whole debates list. Since GEO-2879 headed the card diff --git a/apps/web/partials/explore/explore-card-chrome.tsx b/apps/web/partials/explore/explore-card-chrome.tsx new file mode 100644 index 0000000000..d989169bd2 --- /dev/null +++ b/apps/web/partials/explore/explore-card-chrome.tsx @@ -0,0 +1,100 @@ +'use client'; + +import * as React from 'react'; + +import type { ExploreFeedItem } from '~/core/explore/explore-card-item'; +import { NavUtils } from '~/core/utils/utils'; + +import { FallbackImage } from '~/design-system/fallback-image'; +import { PrefetchLink as Link } from '~/design-system/prefetch-link'; + +import { EntityRowActions } from '~/partials/entity-page/entity-row-actions'; + +import { ExploreCardTitle } from './explore-card-title'; +import { ExploreFeedCommentLink } from './explore-feed-comment-link'; + +/** + * The parts of an explore card that every card type shares, as components rather than as a class + * list to copy. + * + * Its own module for the reason {@link ExploreCardTitle} and {@link MetaDot} are: a second card + * type needs the same chrome, and reaching into `explore-feed-card` for it would close a cycle the + * moment that dispatcher ever draws the second type. Copying it instead is what this file exists + * to prevent — the two existing copies of the meta row drifted on spacing and font weight without + * either diff showing it. + */ + +/** The card's own frame: its padding and the rule under it, cleared on the last card in a feed. */ +export const EXPLORE_CARD_CLASS = 'flex flex-col gap-2 border-b border-divider py-4 last:border-b-0'; + +/** The vote and comment row every card body ends on. */ +export function ExploreCardActions({ item }: { item: ExploreFeedItem }) { + return ( + + + + ); +} + +/** + * The default body: thumbnail on the left, title and description beside it. + * + * The thumbnail navigates even where the title opens the side panel — a picture is not a heading, + * and the panel is reached by the name. + */ +export function ExploreCardDefaultBody({ + item, + actions, + titleOpensSidePanel, + compactTitle = false, + meta, +}: { + item: ExploreFeedItem; + /** The vote / comment row, owned by the card so bodies render it identically. */ + actions: React.ReactNode; + /** Threaded to the title only. The thumbnail beside it still navigates. */ + titleOpensSidePanel: boolean; + compactTitle?: boolean; + /** + * A line of the card's own metadata, between the description and the actions. + * + * Only the topic card passes one — how much is attached to a topic is the thing that + * distinguishes one from the next, and nothing else the generic body draws can say it. A slot + * rather than a second copy of this body: the copy is what drifts. + */ + meta?: React.ReactNode; +}) { + return ( +
+ {item.imageUrl ? ( + + + + ) : null} +
+
+ + {item.description ? ( +

+ {item.description} +

+ ) : null} +
+ + {meta} + {actions} +
+
+ ); +} diff --git a/apps/web/partials/explore/explore-feed-card.tsx b/apps/web/partials/explore/explore-feed-card.tsx index 672ca33133..f6317611df 100644 --- a/apps/web/partials/explore/explore-feed-card.tsx +++ b/apps/web/partials/explore/explore-feed-card.tsx @@ -9,19 +9,14 @@ import { isDebateEntity } from '~/core/debates/is-debate-entity'; import type { ExploreFeedItem } from '~/core/explore/fetch-explore-feed'; import { RANKING_BLOCK_TYPE_ID } from '~/core/ranking-block-ids'; import { normId } from '~/core/utils/norm-id'; -import { NavUtils } from '~/core/utils/utils'; - -import { FallbackImage } from '~/design-system/fallback-image'; -import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { PublishedRecordingPlayer } from '~/partials/community-calls/published-recording-player'; -import { EntityRowActions } from '~/partials/entity-page/entity-row-actions'; import { type ClaimCardVariant, ClaimExploreFeedCard } from './claim-explore-feed-card'; import { DebateExploreFeedCard } from './debate-explore-feed-card'; import { DebateExploreMetaRow } from './debate-explore-meta-row'; +import { EXPLORE_CARD_CLASS, ExploreCardActions, ExploreCardDefaultBody } from './explore-card-chrome'; import { ExploreCardTitle } from './explore-card-title'; -import { ExploreCommentsIcon } from './explore-comments-icon'; import { ExploreMetaRow } from './explore-meta-row'; import { RankingCardBody } from './explore-ranking-card-body'; @@ -42,21 +37,14 @@ type ExploreFeedCardProps = { responseNote?: (responseKind: 'stance' | 'veracity', position: boolean) => React.ReactNode; /** Compact title and metadata treatment for a debate in profile Activity. */ compactDebateChrome?: boolean; + /** Let a debate fill the column rather than its viewport-fitted cap. See `DebateExploreFeedCard`. */ + fullWidthDebate?: boolean; /** Transfer playback ownership when this debate's player is clicked. Ignored by other row types. */ onDebatePlaybackRequest?: (debateId: string) => void; /** Register whether this debate currently has a mounted player. Ignored by other row types. */ onDebatePlaybackAvailabilityChange?: (debateId: string, available: boolean) => void; }; -function ExploreFeedCommentLink({ href, count }: { href: string; count: number }) { - return ( - - - {count} - - ); -} - const COMMUNITY_CALL_EVENT_TYPE = normId(EVENT_SCHEMA.COMMUNITY_CALL_EVENT_TYPE); const CLAIM_TYPE = normId(CLAIM_TYPE_ID); const RANKING_BLOCK_TYPE = normId(RANKING_BLOCK_TYPE_ID); @@ -65,48 +53,10 @@ type CardBodyProps = { item: ExploreFeedItem; /** The vote / comment row, owned by the shell so bodies render it identically. Not every body takes it. */ actions: React.ReactNode; - /** Threaded to the title only. The thumbnail beside it still navigates — see `BaseExploreFeedCard`. */ + /** Threaded to the title only. The thumbnail beside it still navigates — see `ExploreCardDefaultBody`. */ titleOpensSidePanel: boolean; }; -/** The default body: thumbnail on the left, title and description beside it. */ -function DefaultCardBody({ - item, - actions, - titleOpensSidePanel, - compactTitle = false, -}: CardBodyProps & { compactTitle?: boolean }) { - return ( -
- {item.imageUrl ? ( - - - - ) : null} -
-
- - {item.description ? ( -

- {item.description} -

- ) : null} -
- - {actions} -
-
- ); -} - /** A Community call event's body */ function CommunityCallCardBody({ item, actions, titleOpensSidePanel }: CardBodyProps) { const sources = useRecordingSources({ @@ -149,6 +99,7 @@ export function ExploreFeedCard(props: ExploreFeedCardProps) { hideJoinButton={props.hideJoinButton} titleOpensSidePanel={props.titleOpensSidePanel} compactChrome={props.compactDebateChrome} + fullWidth={props.fullWidthDebate} onPlaybackRequest={props.onDebatePlaybackRequest} onPlaybackAvailabilityChange={props.onDebatePlaybackAvailabilityChange} fallback={} @@ -185,22 +136,12 @@ function BaseExploreFeedCard({ }: ExploreFeedCardProps) { const isCommunityCall = item.types.some(type => normId(type.id) === COMMUNITY_CALL_EVENT_TYPE); const isRanking = item.types.some(type => normId(type.id) === RANKING_BLOCK_TYPE); - const entityHref = `${NavUtils.toEntity(item.spaceId, item.entityId)}#entity-comments`; - const cardActions = ( - - - - ); + const cardActions = ; return ( -
+
{compactDebateChrome ? ( - + ) : ( )} @@ -210,7 +151,7 @@ function BaseExploreFeedCard({ ) : isRanking ? ( ) : ( - + + {count} + + ); +} diff --git a/apps/web/partials/explore/explore-meta-row.tsx b/apps/web/partials/explore/explore-meta-row.tsx index a2211ec150..6f041896ca 100644 --- a/apps/web/partials/explore/explore-meta-row.tsx +++ b/apps/web/partials/explore/explore-meta-row.tsx @@ -18,7 +18,14 @@ import { SpaceThumb } from './space-thumb'; const RANKING_BLOCK_TYPE = normId(RANKING_BLOCK_TYPE_ID); -const SEGMENT_CLASS = 'text-[14px] leading-[13px] font-normal tracking-[-0.35px] text-grey-04'; +/** + * The type of one metadata segment. + * + * Exported because the topic card draws a second line of metadata under the description and it has + * to be the same type as this row. Spelling it out there instead is precisely how the claim card's + * copy of this row lost `font-normal` on three segments without any diff showing it. + */ +export const META_SEGMENT_CLASS = 'text-[14px] leading-[13px] font-normal tracking-[-0.35px] text-grey-04'; /** * The line above every explore card: the space, what the thing is, and when it appeared. @@ -103,7 +110,7 @@ export function ExploreMetaRow({ key: 'types', showOnCompactMobile: false, content: ( - + {types.map((type, index) => ( {index > 0 ? : null} @@ -123,7 +130,7 @@ export function ExploreMetaRow({ segments.push({ key: 'time', showOnCompactMobile: false, - content: {timeAgo}, + content: {timeAgo}, }); } diff --git a/apps/web/partials/explore/topic-explore-feed-card.test.tsx b/apps/web/partials/explore/topic-explore-feed-card.test.tsx new file mode 100644 index 0000000000..2e97e411f6 --- /dev/null +++ b/apps/web/partials/explore/topic-explore-feed-card.test.tsx @@ -0,0 +1,145 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +import type React from 'react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ExploreFeedItem } from '~/core/explore/explore-card-item'; + +import { TopicExploreFeedCard } from './topic-explore-feed-card'; + +// The card's job here is the metadata line and the layout around it. The meta row, the title link +// and the vote/comment row all have their own suites and all reach the network. +vi.mock('./explore-meta-row', () => ({ + META_SEGMENT_CLASS: 'meta-segment', + ExploreMetaRow: (props: Record) => ( +
+ ), +})); + +vi.mock('./explore-card-title', () => ({ + ExploreCardTitle: (props: { item: ExploreFeedItem; opensSidePanel: boolean }) => ( +

+ {props.item.title} +

+ ), +})); + +vi.mock('~/design-system/prefetch-link', () => ({ + PrefetchLink: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => ( + + {children} + + ), +})); + +vi.mock('~/partials/entity-page/entity-row-actions', () => ({ + EntityRowActions: (props: { children?: React.ReactNode }) =>
{props.children}
, +})); + +const item = (overrides: Partial = {}): ExploreFeedItem => ({ + entityId: 'topic-1', + spaceId: 'space-1', + spaceName: 'Space One', + spaceImage: null, + types: [{ id: 'topic-type', name: 'Topic' }], + createdAtSec: 0, + title: 'AI Safety', + description: 'Preventing harm from advanced AI systems.', + imageUrl: null, + recordingUrls: [], + debateVideoUrls: [], + debateClaim: null, + commentCount: 0, + isMemberOrEditor: false, + hasPendingMembershipRequest: false, + ...overrides, +}); + +afterEach(cleanup); + +describe('TopicExploreFeedCard', () => { + it('names every attached kind, with thousands separated', () => { + render(); + + expect(screen.getByText(/1,170/)).toBeInTheDocument(); + expect(screen.getByText(/claims/)).toBeInTheDocument(); + expect(screen.getByText(/news stories/)).toBeInTheDocument(); + expect(screen.getByText(/debates/)).toBeInTheDocument(); + }); + + it('leads with debates, then claims, then news stories', () => { + const { container } = render( + + ); + + // The only paragraph on a card with no description is the metadata line. The dots carry their + // spacing as margins rather than as whitespace, so they sit flush in `textContent`. + expect(container.querySelector('p')?.textContent).toBe('3 debates·117 claims·2 news stories'); + }); + + it('agrees with the count', () => { + render(); + + expect(screen.getByText(/claim$/)).toBeInTheDocument(); + expect(screen.getByText(/news story$/)).toBeInTheDocument(); + expect(screen.getByText(/debate$/)).toBeInTheDocument(); + }); + + it('leaves out the kinds a topic has none of', () => { + render(); + + expect(screen.getByText(/claims/)).toBeInTheDocument(); + expect(screen.queryByText(/news/)).toBeNull(); + expect(screen.queryByText(/debate/)).toBeNull(); + }); + + it('names the kinds it measured rather than claiming a topic is empty', () => { + // A topic also carries episodes, tweets and posts, which this card never counts — so "nothing + // attached" would be a claim about buckets it did not measure. + render(); + + expect(screen.getByText('No debates, claims or news stories yet')).toBeInTheDocument(); + expect(screen.queryByText('Nothing attached yet')).toBeNull(); + }); + + it('draws no metadata at all when the count could not be read', () => { + // A failed count is not a count of zero, and a card must not say it was. + render(); + + expect(screen.queryByText('Nothing attached yet')).toBeNull(); + expect(screen.queryByText(/claim/)).toBeNull(); + }); + + it('keeps the generic card around the new line', () => { + render( + + ); + + expect(screen.getByTestId('meta-row')).toHaveAttribute('data-hide-join', 'true'); + expect(screen.getByTestId('title')).toHaveAttribute('data-opens-panel', 'true'); + expect(screen.getByText('Preventing harm from advanced AI systems.')).toBeInTheDocument(); + expect(screen.getByTestId('actions')).toBeInTheDocument(); + }); + + it('draws the thumbnail well only for a topic with a picture', () => { + // The exact entity href, not a prefix: the comment link points at the same entity with a + // `#entity-comments` fragment and is always drawn. + const thumbnail = 'a[href="/space/space-1/topic-1"]'; + + const { container, rerender } = render(); + expect(container.querySelector(thumbnail)).toBeNull(); + + rerender(); + expect(container.querySelector(thumbnail)).not.toBeNull(); + }); +}); diff --git a/apps/web/partials/explore/topic-explore-feed-card.tsx b/apps/web/partials/explore/topic-explore-feed-card.tsx new file mode 100644 index 0000000000..d0a0847d13 --- /dev/null +++ b/apps/web/partials/explore/topic-explore-feed-card.tsx @@ -0,0 +1,105 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; + +import type { ExploreFeedItem } from '~/core/explore/explore-card-item'; +import type { TopicConnectionCounts } from '~/core/topics/browse/use-topic-connection-counts'; + +import { EXPLORE_CARD_CLASS, ExploreCardActions, ExploreCardDefaultBody } from './explore-card-chrome'; +import { ExploreMetaRow, META_SEGMENT_CLASS } from './explore-meta-row'; +import { MetaDot } from './meta-dot'; + +/** + * A Topic in an explore feed. + * + * The generic card, plus the one thing it cannot say. A topic has no verdict, no video and no + * position to take — what distinguishes one from the next is how much hangs off it — and the + * generic card renders it as a name and a description, which is the same card an empty topic gets. + * + * So this *is* the generic card: the shared frame, meta row, body and actions from + * `explore-card-chrome`, with the counts passed into the body's `meta` slot. Reproducing the body + * here instead would have been the third copy of that layout in this directory, and the first two + * drifted. + * + * Not wired into `ExploreFeedCard`'s type dispatch. The claim page's Topics tab is the surface that + * asked for this and the surface that can pay for it: the counts are a second request, and the main + * explore feed pre-mounts cards thousands of pixels below the fold. A Topic drawn anywhere else + * keeps exactly the card it has today. + */ +export function TopicExploreFeedCard({ + item, + counts, + hideSpaceLink = false, + hideJoinButton = false, + titleOpensSidePanel = false, +}: { + item: ExploreFeedItem; + /** + * What is attached to this topic. `null` when the count could not be read, which draws no + * metadata line at all rather than a row of zeros. + */ + counts: TopicConnectionCounts | null; + hideSpaceLink?: boolean; + hideJoinButton?: boolean; + titleOpensSidePanel?: boolean; +}) { + return ( +
+ + } + actions={} + /> +
+ ); +} + +/** "3 debates · 117 claims · 2 news stories", in the meta row's own type and separator. */ +export function TopicConnectionMeta({ + counts, + className, +}: { + counts: TopicConnectionCounts | null; + className?: string; +}) { + if (!counts) return null; + + // Debates first, then claims, then news stories: rarest and most specific to least. A topic with + // debates on it is the interesting case and the count that most often distinguishes two topics, + // and it would otherwise be the segment most likely to be pushed onto a second line. + const segments = [ + { key: 'debates', count: counts.debates, noun: ['debate', 'debates'] as const }, + { key: 'claims', count: counts.claims, noun: ['claim', 'claims'] as const }, + { key: 'news', count: counts.news, noun: ['news story', 'news stories'] as const }, + ].filter(segment => segment.count > 0); + + // Zeros are dropped rather than printed: "0 news stories" on a topic that is entirely claims is + // three words about something that isn't there. A topic with none of the three says so once. + // + // Named rather than summarised: a topic also carries episodes, tweets, posts and the rest — the + // topic page's composition strip counts six kinds and a remainder — so "nothing attached" would + // be a claim about buckets this card never measured. On a claim's Topics tab the line is close + // to unreachable anyway, since the claim being read is itself a claim attached to every topic + // listed; it can show while that relation is still indexing, and wherever else the card is used. + if (segments.length === 0) { + return

No debates, claims or news stories yet

; + } + + return ( +

+ {segments.map((segment, index) => ( + + {index > 0 ? : null} + + {segment.count.toLocaleString()}{' '} + {segment.count === 1 ? segment.noun[0] : segment.noun[1]} + + + ))} +

+ ); +} diff --git a/apps/web/partials/profile/person-record-feed.test.tsx b/apps/web/partials/profile/person-record-feed.test.tsx index 8f41d1c13b..147877f7c3 100644 --- a/apps/web/partials/profile/person-record-feed.test.tsx +++ b/apps/web/partials/profile/person-record-feed.test.tsx @@ -123,6 +123,24 @@ describe('PersonRecordFeed', () => { expect(screen.queryByText('Couldn’t load more positions.')).not.toBeInTheDocument(); }); + it('draws the shared card when the caller supplies none', () => { + renderFeed({ rows: [row('a'), row('b')] }); + + expect(screen.getAllByTestId('card').map(card => card.textContent)).toEqual(['a', 'b']); + }); + + it('lets a caller draw its own card, keyed for it', () => { + // The Topics tab's card carries counts this component cannot fetch; everything around the rows + // is the same, which is why it is a render function rather than a second copy of this file. + renderFeed({ + rows: [row('a'), row('b')], + renderCard: item =>
{item.entityId}
, + }); + + expect(screen.getAllByTestId('custom').map(card => card.textContent)).toEqual(['a', 'b']); + expect(screen.queryByTestId('card')).toBeNull(); + }); + it('offers no retry when nothing failed', () => { renderFeed({ rows: [row('claim-1')], fetchNextPage: () => {} }); diff --git a/apps/web/partials/profile/person-record-feed.tsx b/apps/web/partials/profile/person-record-feed.tsx index f0fdbfcecd..a63d5bd3f5 100644 --- a/apps/web/partials/profile/person-record-feed.tsx +++ b/apps/web/partials/profile/person-record-feed.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; -import { type ExploreFeedRow, toExploreFeedItem } from '~/core/explore/explore-card-item'; +import { type ExploreFeedItem, type ExploreFeedRow, toExploreFeedItem } from '~/core/explore/explore-card-item'; import { spaceLabel, useSpaceLabels } from '~/core/hooks/use-space-labels'; import type { ClaimResponse } from '~/core/profile/person-position-order'; import { useInfiniteSentinel } from '~/core/profile/use-infinite-sentinel'; @@ -41,6 +41,8 @@ export function PersonRecordFeed({ noun, responseByClaimId, personName, + fullWidthDebates = false, + renderCard, }: { rows: ExploreFeedRow[]; isLoading: boolean; @@ -82,6 +84,19 @@ export function PersonRecordFeed({ * beside them says nothing about which of the two people it describes. */ personName?: string | null; + /** Debates fill the column instead of their viewport-fitted cap. */ + fullWidthDebates?: boolean; + /** + * Draws a row with something other than the shared `ExploreFeedCard`. + * + * The claim page's Topics tab passes one: a topic gets a card of its own, carrying counts this + * component has no way to fetch. Everything around the rows — the space lookup, the item + * projection, and the loading, empty, error and partial-failure states — is identical whichever + * card is drawn, and is the reason this takes a render function rather than being copied. + * + * The key stays here, so a caller cannot forget it. + */ + renderCard?: (item: ExploreFeedItem) => React.ReactNode; }) { // Looked up once for the page. These are routinely spaces the viewer has never // opened, which the browse sidebar cannot name. @@ -128,30 +143,34 @@ export function PersonRecordFeed({ */}
{items.map(item => ( - ( - - ) - : undefined - } - hideJoinButton - // The claim opens in the side panel rather than navigating, as it - // does on Explore: this is a list somebody is reading down, and - // losing the page to read one row is a worse trade here than it is - // anywhere. - titleOpensSidePanel - /> + + {renderCard?.(item) ?? ( + ( + + ) + : undefined + } + hideJoinButton + // The claim opens in the side panel rather than navigating, as it + // does on Explore: this is a list somebody is reading down, and + // losing the page to read one row is a worse trade here than it is + // anywhere. + titleOpensSidePanel + /> + )} + ))}
diff --git a/apps/web/partials/space-page/space-member-row.tsx b/apps/web/partials/space-page/space-member-row.tsx index e2dc3d763f..25b01515d6 100644 --- a/apps/web/partials/space-page/space-member-row.tsx +++ b/apps/web/partials/space-page/space-member-row.tsx @@ -1,3 +1,5 @@ +import cx from 'classnames'; + import { OmitStrict, Profile } from '~/core/types'; import { formatShortAddress } from '~/core/utils/utils'; @@ -6,11 +8,12 @@ import { PrefetchLink as Link } from '~/design-system/prefetch-link'; interface EditorRowProps { user: OmitStrict; + className?: string; } -export function MemberRow({ user }: EditorRowProps) { +export function MemberRow({ user, className }: EditorRowProps) { return ( - +