From 771ace1470622da9f90ed45b836808d05c33ff51 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 22 Sep 2026 17:49:05 -0700 Subject: [PATCH 01/12] feat(claims): answer every claim with Agree/Disagree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A claim carrying the "Is factual" flag was answered with Verify/Dispute, drawn with chevrons, and published as its own vote kind. Every claim now asks the same question, whatever the flag says. The flag itself stays. The extractor still writes it and the debate surfaces that read it are untouched — it just no longer decides how a claim is answered. What goes with the vocabulary: - the `veracity` response kind, its vote kind (2) and its verify/dispute/unverify SDK methods, so there is no longer a way to publish one - the chevron glyph, and the hand-written `#2A2B2E` a selected chevron needed because it had no filled form to switch to - `claimResponseKind`, and the branch in `resolveEntityResponseKind`. The latter no longer takes a space: "Is factual" was the only per-space value it read - the "Is factual" half of the unpublished-edit guard. A draft edit to the flag cannot change which vote kind gets published any more, so blocking on it would only disable the pills for no reason. The Claim *type* half still blocks, since that really does move an entity between curation and stance Existing veracity responses — 178 of them, on 145 claims — stop being read. That is deliberate: those claims lose that part of their count and those responders see them as unanswered. The votes are still on chain if we ever want to backfill them as stances. The one exception is the curator onboarding checklist, which still counts vote kind 2 when asking whether someone has *ever* taken a position on a claim: they did, and un-ticking a finished step has nothing to recommend it. Two things the wire forced, both of which would have been crashes: geo-chat still labels old claims `veracity`, and that value arrives typed as the narrowed kind it no longer matches — so `ENTITY_RESPONSE_COPY[kind]` and the glyph map both missed and threw during render, blanking the claims ticker and the matches list. Claim surfaces now read `CLAIM_RESPONSE_COPY` without indexing anything, and the glyph map falls back rather than throwing. geo-chat also sends `position_label` per side, and the pills preferred it over their own copy — which would have printed "Verify" on a control that can only publish an Agree. Our label wins now. --- .../[sessionId]/rematch-page-client.test.tsx | 13 ++- .../[sessionId]/rematch-page-client.tsx | 28 ++--- .../ranking/use-vote-tab-entities.test.tsx | 31 +++-- .../blocks/ranking/use-vote-tab-entities.ts | 2 +- .../browse/claim-comment-position.test.tsx | 12 +- .../claims/browse/claim-comment-position.tsx | 7 +- apps/web/core/claims/browse/claim-debates.tsx | 7 +- .../browse/claim-position-comment.test.tsx | 14 +-- .../claims/browse/claim-position-comment.tsx | 4 +- .../claims/browse/claim-position-summaries.ts | 22 ++-- .../core/claims/browse/claim-summary.test.tsx | 2 +- apps/web/core/claims/browse/claim-summary.tsx | 14 +-- .../browse/use-claim-response-state.test.tsx | 46 ++++---- .../claims/browse/use-claim-response-state.ts | 33 +++--- apps/web/core/claims/response-kind.ts | 29 ----- apps/web/core/debates/api.test.ts | 4 +- apps/web/core/debates/api.ts | 14 ++- .../browse/debate-claim-ticker.test.tsx | 14 ++- .../debates/browse/debate-claim-ticker.tsx | 8 +- .../debate-entity-response-controls.test.tsx | 6 +- apps/web/core/debates/debate-gateway.test.ts | 16 +-- apps/web/core/debates/debate-gateway.ts | 2 +- apps/web/core/debates/hooks.test.tsx | 8 +- .../core/debates/matchmaking/claims-tab.tsx | 14 +-- .../debates/matchmaking/matches-list.test.tsx | 12 +- .../matchmaking-claim-card.test.tsx | 39 +------ .../matchmaking/matchmaking-claim-card.tsx | 23 ++-- .../person-records-document.test.ts | 11 +- .../debates/matchmaking/position-row.test.tsx | 89 ++++++++++----- .../matchmaking/with-viewer-position.test.ts | 2 - .../debates/participant-positions.test.ts | 17 +-- .../web/core/debates/participant-positions.ts | 8 +- .../hooks/use-curator-onboarding-status.ts | 14 ++- apps/web/core/hooks/use-entity-vote.test.tsx | 10 +- apps/web/core/io/queries.ts | 12 +- .../claim-response-summaries.test.ts | 51 +++++---- .../core/responses/entity-response.test.ts | 102 +++++++++++------ apps/web/core/responses/entity-response.ts | 106 +++++++++--------- .../use-claim-response-summaries.test.tsx | 4 +- apps/web/core/topics/browse/topic-debates.tsx | 39 ------- .../icons/response-position-icon.test.tsx | 40 +++---- .../icons/response-position-icon.tsx | 38 +++---- .../entity-vote-buttons.batch.test.tsx | 15 ++- .../entity-vote-buttons.claim-space.test.tsx | 41 ++++--- ...ntity-vote-buttons.selected-state.test.tsx | 50 ++++----- .../entity-page/entity-vote-buttons.tsx | 26 ++--- .../entity-page/vote-button-styles.ts | 19 +--- .../explore/claim-explore-feed-card.test.tsx | 15 +-- .../explore/claim-explore-feed-card.tsx | 10 +- .../partials/explore/explore-feed-card.tsx | 3 +- .../profile/claim-response-tag.test.tsx | 58 +++------- .../partials/profile/claim-response-tag.tsx | 26 ++--- .../partials/profile/gallery-claim-card.tsx | 8 +- .../partials/profile/person-record-feed.tsx | 1 - 54 files changed, 567 insertions(+), 672 deletions(-) delete mode 100644 apps/web/core/claims/response-kind.ts diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index 72f07c5b08..d8e6c1bbf8 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -1127,11 +1127,11 @@ describe('DebateRematchPageClient', () => { expect(within(syntheticClaimCard!).getByRole('button', { name: /^Disagree/ })).toBeEnabled(); }); - it('uses Verify and Dispute for factual claims', async () => { - mocks.claims = [{ ...sharedClaim(), response_kind: 'veracity' }]; + it('uses Agree and Disagree for a claim geo-chat still calls factual', async () => { + mocks.claims = [{ ...sharedClaim(), response_kind: 'veracity' as 'stance' }]; mocks.positions = [ - { ...position('profile-local', CLAIM_SHARED, SPACE_1, true), responseKind: 'veracity' }, - { ...position('profile-remote', CLAIM_SHARED, SPACE_1, false), responseKind: 'veracity' }, + { ...position('profile-local', CLAIM_SHARED, SPACE_1, true), responseKind: 'veracity' as 'stance' }, + { ...position('profile-remote', CLAIM_SHARED, SPACE_1, false), responseKind: 'veracity' as 'stance' }, ]; render(); @@ -1139,8 +1139,9 @@ describe('DebateRematchPageClient', () => { const claimCard = screen.getByText('A claim both participants chose').closest('article'); expect(claimCard).not.toBeNull(); - expect(within(claimCard!).getByRole('button', { name: /^Verify/ })).toBeEnabled(); - expect(within(claimCard!).getByRole('button', { name: /^Dispute/ })).toBeEnabled(); + expect(within(claimCard!).getByRole('button', { name: /^Agree/ })).toBeEnabled(); + expect(within(claimCard!).getByRole('button', { name: /^Disagree/ })).toBeEnabled(); + expect(within(claimCard!).queryByRole('button', { name: /^Verify/ })).not.toBeInTheDocument(); }); // The header used to grow the claim and two position chips the moment a request went out, moving diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index cae87a5ae3..c25b31b09b 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -8,14 +8,13 @@ import cx from 'classnames'; import { useAtom } from 'jotai'; import { useRouter } from 'next/navigation'; -import { claimResponseKind } from '~/core/claims/response-kind'; +import { resolveClaimResponseKind } from '~/core/claims/browse/use-claim-response-state'; import { type DebateClaimPositionSummary, type DebateRematchClaim, type DebateRematchClaimPosition, type DebateRematchParticipant, type DebateRematchSession, - type DebateResponseKind, type MatchmakingReadiness, } from '~/core/debates/api'; import { type ClaimPickerEntity, useClaimEntitiesByIds } from '~/core/debates/claim-picker-page'; @@ -580,12 +579,11 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // A claim's sides, from the graph. The shape the rest of the page was already drawing. const sidesOf = React.useCallback( - (claimId: string, claimSpaceId: string, responseKind: DebateResponseKind | null): DebateRematchClaimPosition[] => + (claimId: string, claimSpaceId: string): DebateRematchClaimPosition[] => participantSidesOn(positions.byClaim, claimId, claimSpaceId, participants).map(side => ({ user_id: side.participant.user_id, position: side.position, - position_label: - side.position === null ? null : responsePositionLabel(side.responseKind ?? responseKind, side.position), + position_label: side.position === null ? null : responsePositionLabel(side.position), })), [participants, positions.byClaim] ); @@ -847,7 +845,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const recordedRow = sessionRowFor(entity.id); const sessionRow = preferred && recordedRow && !idEquals(recordedRow.claim.space_id, preferred) ? undefined : recordedRow; - const responseKind = sessionRow?.response_kind ?? claimResponseKind(entity, homeSpaceId); + const responseKind = resolveClaimResponseKind(); return { /** * Left in whichever spelling its source used, deliberately. @@ -867,7 +865,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { description: entity.description, }, response_kind: responseKind, - participants: sidesOf(entity.id, sessionRow?.claim.space_id ?? homeSpaceId, responseKind), + participants: sidesOf(entity.id, sessionRow?.claim.space_id ?? homeSpaceId), shared_preference: sessionRow?.shared_preference ?? false, recently_rejected: sessionRow?.recently_rejected ?? recentlyRejectedClaimIds.has(normId(entity.id)), previously_debated: sessionRow?.previously_debated ?? false, @@ -2095,7 +2093,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { return { ...participant, position, - position_label: positionLabel ?? responsePositionLabel(incomingRequest.response_kind ?? null, position), + position_label: positionLabel ?? responsePositionLabel(position), }; }) : []; @@ -2590,7 +2588,7 @@ function RematchClaimCard({ // A claim whose stored kind didn't parse still has to render; 'stance' is the fallback // `responsePositionLabel` already applies, so the labels agree either way. - const responseKind = claim.response_kind ?? 'stance'; + const responseKind = resolveClaimResponseKind(); // The client knows its own answer long before geo-chat echoes it back. Reading the optimistic // copy is what keeps the side you just picked highlighted, and Request debate appearing with it, @@ -2694,8 +2692,8 @@ function RematchClaimCard({ idEquals(request.claim.claim_entity_id, claim.claim.claim_entity_id); const positions = React.useMemo( - () => rematchPositionSummaries(claim, session, responseKind), - [claim, responseKind, session] + () => rematchPositionSummaries(claim, session), + [claim, session] ); // geo-chat's copy, deliberately — not the optimistic one. The card reads the viewer's own @@ -2716,7 +2714,7 @@ function RematchClaimCard({ viewer_response: chatPosition === null || chatPosition === undefined ? null - : { position: chatPosition, position_label: responsePositionLabel(responseKind, chatPosition) }, + : { position: chatPosition, position_label: responsePositionLabel(chatPosition) }, viewer_debate_ready: claimReadiness?.viewer_debate_ready ?? false, readiness_disabled_reason: claimReadiness?.readiness_disabled_reason ?? null, }; @@ -2831,8 +2829,7 @@ function RecommendedSection({ name, count, children }: { name: string; count: nu /** Both sides of a rematch claim, in the shape the shared card draws avatars from. */ function rematchPositionSummaries( claim: DebateRematchClaim, - session: DebateRematchSession | null, - responseKind: 'stance' | 'veracity' + session: DebateRematchSession | null ): DebateClaimPositionSummary[] { return [true, false].map(position => { const holders = claim.participants.filter(side => side.position === position); @@ -2843,8 +2840,7 @@ function rematchPositionSummaries( return { position, // A server-supplied label wins, so an authoritative Verify/Dispute survives. - position_label: - holders.find(holder => holder.position_label)?.position_label ?? responsePositionLabel(responseKind, position), + position_label: holders.find(holder => holder.position_label)?.position_label ?? responsePositionLabel(position), total_count: holders.length, // Only meaningful for the hub's "available now" counts; a rematch is already a fixed pair, // so there is nobody here the viewer would send a request to. diff --git a/apps/web/core/blocks/ranking/use-vote-tab-entities.test.tsx b/apps/web/core/blocks/ranking/use-vote-tab-entities.test.tsx index 2940281430..c032a1e9c4 100644 --- a/apps/web/core/blocks/ranking/use-vote-tab-entities.test.tsx +++ b/apps/web/core/blocks/ranking/use-vote-tab-entities.test.tsx @@ -25,7 +25,6 @@ const ENTITY_IDS = [ const [FIRST, SECOND, THIRD] = ENTITY_IDS; const SPACE_ID = '44444444-4444-4444-4444-444444444444'; /** A space other than the one the data block is scoped to. */ -const OTHER_SPACE_ID = '55555555-5555-5555-5555-555555555555'; const hex = (uuid: string) => ID.uuidToHex(uuid); const CURATION = 0; @@ -288,13 +287,15 @@ describe('useVoteTabEntities', () => { }); describe('response kinds', () => { + // The factual flag used to send this claim down a different branch. Both claims ask for a + // stance now, so both are kept on the one vote kind. it('keeps a claim answered with the action it still asks for', () => { mocks.entitiesById = new Map([ [hex(FIRST), claimEntity(FIRST, { isFactual: true })], [hex(SECOND), claimEntity(SECOND, { isFactual: false })], ]); mocks.voteKindById = new Map([ - [hex(FIRST), VERACITY], + [hex(FIRST), STANCE], [hex(SECOND), STANCE], ]); mocks.idPages = [[hex(FIRST), hex(SECOND)]]; @@ -304,26 +305,22 @@ describe('useVoteTabEntities', () => { expect(result.current.orderedIds).toEqual([FIRST, SECOND]); }); - // Votes span every space the viewer has voted in, but Is Factual is only - // readable in the claim's own space — resolving against the block's space - // downgrades a verified claim to a stance and drops it. - it('keeps a claim verified in a space other than the block’s', () => { - mocks.entitiesById = new Map([[hex(FIRST), claimEntity(FIRST, { isFactual: true, spaceId: OTHER_SPACE_ID })]]); - mocks.voteKindById = new Map([[hex(FIRST), VERACITY]]); - mocks.idPages = [[hex(FIRST)]]; - - const { result } = renderHook(() => useVoteTabEntities('up')); - - expect(result.current.orderedIds).toEqual([FIRST]); - }); - - it('drops a claim whose response kind changed since the vote', () => { + /** + * A case that used to sit here — "keeps a claim verified in a space other than the block's" — + * is gone. It existed because Is Factual is only readable in the claim's own space, so + * resolving it against the block's space downgraded a verified claim to a stance and dropped + * it. Nothing reads the flag to pick a kind any more, so the space it is readable in cannot + * change the answer. + */ + it('drops a claim answered with the retired veracity kind', () => { mocks.entitiesById = new Map([[hex(FIRST), claimEntity(FIRST, { isFactual: true })]]); - mocks.voteKindById = new Map([[hex(FIRST), STANCE]]); + mocks.voteKindById = new Map([[hex(FIRST), VERACITY]]); mocks.idPages = [[hex(FIRST)]]; const { result } = renderHook(() => useVoteTabEntities('up')); + // The claim asks for a stance now, so a vote recorded under the old kind is not an answer to + // the question it currently asks. Dropping it is the agreed cost of the clean break. expect(result.current.orderedIds).toEqual([]); }); diff --git a/apps/web/core/blocks/ranking/use-vote-tab-entities.ts b/apps/web/core/blocks/ranking/use-vote-tab-entities.ts index 2325954196..aed8e302a3 100644 --- a/apps/web/core/blocks/ranking/use-vote-tab-entities.ts +++ b/apps/web/core/blocks/ranking/use-vote-tab-entities.ts @@ -102,7 +102,7 @@ export function useVoteTabEntities(direction: EntityVoteDirectionFilter | null) // read in the entity's own space — resolving a claim verified elsewhere // against this block's space downgrades it and drops it from the tab. const entitySpaceId = resolveEntitySpaceId(entity, spaceId); - if (responseKindToVoteKind(resolveEntityResponseKind(entity, entitySpaceId)) !== votedKind) return []; + if (responseKindToVoteKind(resolveEntityResponseKind(entity)) !== votedKind) return []; return [toRankingEntryDisplay(entity, entitySpaceId)]; }), [entities, voteKindById, spaceId] diff --git a/apps/web/core/claims/browse/claim-comment-position.test.tsx b/apps/web/core/claims/browse/claim-comment-position.test.tsx index 1ac4e05f2a..20550376c9 100644 --- a/apps/web/core/claims/browse/claim-comment-position.test.tsx +++ b/apps/web/core/claims/browse/claim-comment-position.test.tsx @@ -1,5 +1,5 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SystemIds } from '@geoprotocol/geo-sdk/lite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; import { cleanup, render, screen } from '@testing-library/react'; @@ -98,7 +98,7 @@ describe('claim comment position badges', () => { { ) ); - expect(await screen.findByText('Verify')).toBeInTheDocument(); - expect(screen.queryByText('Dispute')).not.toBeInTheDocument(); + expect(await screen.findByText('Agree')).toBeInTheDocument(); + expect(screen.queryByText('Disagree')).not.toBeInTheDocument(); }); it('preserves the indexed viewer position while their own response query is unresolved', async () => { @@ -198,7 +198,7 @@ describe('claim comment position badges', () => { ) ); - expect(await screen.findByText('Dispute')).toBeInTheDocument(); - expect(mocks.summaryArgs).toEqual(['claim-1', 'space-1', 'veracity', true]); + expect(await screen.findByText('Disagree')).toBeInTheDocument(); + expect(mocks.summaryArgs).toEqual(['claim-1', 'space-1', 'stance', true]); }); }); diff --git a/apps/web/core/claims/browse/claim-comment-position.tsx b/apps/web/core/claims/browse/claim-comment-position.tsx index fae6fff86a..2438488f28 100644 --- a/apps/web/core/claims/browse/claim-comment-position.tsx +++ b/apps/web/core/claims/browse/claim-comment-position.tsx @@ -42,7 +42,7 @@ export function ClaimCommentPositionBoundary({ children: React.ReactNode; }) { const { entity } = useQueryEntity({ id: entityId, spaceId }); - const responseKind = resolveEntityResponseKind(entity, spaceId); + const responseKind = resolveEntityResponseKind(entity); const summary = useClaimResponseSummary(entityId, spaceId, responseKind, responseKind !== 'curation'); if (responseKind === 'curation') return children; @@ -90,8 +90,7 @@ export function ClaimCommentPositionProvider({ // the indexed viewer query is unresolved. Null needs the extra state: after a successful read it // means an explicit clear, but while loading (or after failure) it means "unknown" and must not // remove the viewer from the independently indexed responder list. - const viewerResponseOverlay = - viewerDirection ?? (isViewerResponseLoading ? undefined : null); + const viewerResponseOverlay = viewerDirection ?? (isViewerResponseLoading ? undefined : null); const { responders } = useEntityResponders({ entityId, spaceId, @@ -129,7 +128,7 @@ export function ClaimCommentPositionBadge({ authorSpaceId }: { authorSpaceId: st positive ? 'bg-successTertiary' : 'bg-errorTertiary' )} > - {responsePositionLabel(context.responseKind, positive)} + {responsePositionLabel(positive)} ); } diff --git a/apps/web/core/claims/browse/claim-debates.tsx b/apps/web/core/claims/browse/claim-debates.tsx index 47facc27e9..5c387bd486 100644 --- a/apps/web/core/claims/browse/claim-debates.tsx +++ b/apps/web/core/claims/browse/claim-debates.tsx @@ -66,12 +66,10 @@ const VOTE_FETCH_CAP = 500; export function ClaimDebates({ claimId, spaceId, - responseKind, }: { claimId: string; spaceId: string; /** Labels each debater's side in the claim's own vocabulary — Agree/Disagree or Verify/Dispute. */ - responseKind: 'stance' | 'veracity'; }) { // A page at a time rather than an accumulating list: appending pushes everything below the // section down the page as the reader loads more, where swapping keeps the layout where they @@ -144,7 +142,6 @@ export function ClaimDebates({ profilesBySpaceId={profilesBySpaceId} winnerShare={winnerShareByDebateId.get(debate.id) ?? null} keyframeUrl={keyframeByDebateId.get(debate.id) ?? null} - responseKind={responseKind} /> ))} @@ -295,7 +292,6 @@ export function DebateRow({ profilesBySpaceId, winnerShare, keyframeUrl, - responseKind, }: { debate: Entity; spaceId: string; @@ -303,7 +299,6 @@ export function DebateRow({ profilesBySpaceId: Map; winnerShare: WinnerShare | null; keyframeUrl: string | null; - responseKind: 'stance' | 'veracity'; }) { const nameFor = (participantSpaceId: string) => profilesBySpaceId.get(participantSpaceId)?.name ?? 'Unnamed debater'; @@ -344,7 +339,7 @@ export function DebateRow({ side.position ? 'bg-successTertiary text-text' : 'bg-errorTertiary text-text' )} > - {responsePositionLabel(responseKind, side.position)} + {responsePositionLabel(side.position)} diff --git a/apps/web/core/claims/browse/claim-position-comment.test.tsx b/apps/web/core/claims/browse/claim-position-comment.test.tsx index 06067f5120..0500d73351 100644 --- a/apps/web/core/claims/browse/claim-position-comment.test.tsx +++ b/apps/web/core/claims/browse/claim-position-comment.test.tsx @@ -44,7 +44,7 @@ describe('ClaimPositionCommentControl', () => { viewerPosition?: boolean | null; promptForComment?: boolean; onRespond?: (position: boolean) => void; - responseKind?: 'stance' | 'veracity'; + responseKind?: 'stance'; positionRowEndSlot?: ReactNode; } = {}) { render( @@ -299,15 +299,9 @@ describe('ClaimPositionCommentControl', () => { expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); }); - it('uses the factual-claim vocabulary in the prompt', () => { - renderControl({ responseKind: 'veracity' }); - - fireEvent.click(screen.getByRole('button', { name: 'Dispute' })); - - expect(screen.getByRole('textbox', { name: 'Why do you dispute?' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Dispute' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Comment' })).toBeDisabled(); - }); + // A case that used to sit here — "uses the factual-claim vocabulary in the prompt" — asserted the + // composer said "Why do you dispute?" on a factual claim. The prompt is built from the action + // label, and there is one label per side now, so it would be a copy of the disagree case above. it('preserves the sign-in flow instead of opening a composer while signed out', () => { const { onRespond } = renderControl({ promptForComment: false }); diff --git a/apps/web/core/claims/browse/claim-position-comment.tsx b/apps/web/core/claims/browse/claim-position-comment.tsx index 8de24180b3..4d2f4c0a88 100644 --- a/apps/web/core/claims/browse/claim-position-comment.tsx +++ b/apps/web/core/claims/browse/claim-position-comment.tsx @@ -8,7 +8,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import type { DebateClaimPositionSummary, MatchmakingReadiness } from '~/core/debates/api'; import { PositionRow } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { usePublishComment } from '~/core/hooks/use-publish-comment'; -import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; +import { CLAIM_RESPONSE_COPY } from '~/core/responses/entity-response'; const MAX_COMMENT_HEIGHT_PX = 120; @@ -140,7 +140,7 @@ export function ClaimPositionCommentControl({ setIsSubmitting(false); }; - const copy = ENTITY_RESPONSE_COPY[responseKind]; + const copy = CLAIM_RESPONSE_COPY; const action = promptedPosition === null ? null : promptedPosition ? copy.positiveAction : copy.negativeAction; return ( diff --git a/apps/web/core/claims/browse/claim-position-summaries.ts b/apps/web/core/claims/browse/claim-position-summaries.ts index 6024f74c02..fa3812615c 100644 --- a/apps/web/core/claims/browse/claim-position-summaries.ts +++ b/apps/web/core/claims/browse/claim-position-summaries.ts @@ -11,14 +11,11 @@ import { type ActiveResponseDirection, responsePositionLabel } from '~/core/resp * * The on-chain summary resolves independently of geo-chat, so it can stand in for both cases. */ -export function viewerResponseFromDirection( - direction: ActiveResponseDirection | null, - responseKind: 'stance' | 'veracity' -): DebateResponseSummary | null { +export function viewerResponseFromDirection(direction: ActiveResponseDirection | null): DebateResponseSummary | null { if (direction === null) return null; const position = direction === 'positive'; - return { position, position_label: responsePositionLabel(responseKind, position) }; + return { position, position_label: responsePositionLabel(position) }; } /** @@ -28,15 +25,13 @@ export function viewerResponseWithIndexedFallback({ viewerResponse, indexedDirection, isIndexedLoading, - responseKind, }: { viewerResponse: DebateResponseSummary | null | undefined; indexedDirection: ActiveResponseDirection | null | undefined; isIndexedLoading: boolean; - responseKind: 'stance' | 'veracity'; }): DebateResponseSummary | null { if (viewerResponse || isIndexedLoading) return viewerResponse ?? null; - return viewerResponseFromDirection(indexedDirection ?? null, responseKind); + return viewerResponseFromDirection(indexedDirection ?? null); } /** @@ -55,7 +50,6 @@ export function viewerResponseWithIndexedFallback({ export function positionSummariesFromCounts( positive: number, negative: number, - responseKind: 'stance' | 'veracity', row: DebateClaim | null ): DebateClaimPositionSummary[] { return [true, false].map(position => { @@ -64,8 +58,14 @@ export function positionSummariesFromCounts( return { position, - // A server-supplied label wins, so an authoritative Verify/Dispute survives. - position_label: choice?.position_label ?? responsePositionLabel(responseKind, position), + // Our label, not geo-chat's. + // + // This used to prefer `choice.position_label` so that an authoritative Verify/Dispute + // survived. That is exactly what must not happen now: geo-chat still labels the sides of a + // claim minted before the vocabularies merged, so deferring to it would put "Verify" back on + // a pill this app no longer has a way to publish. The two sides of a claim are Agree and + // Disagree, and that is not geo-chat's to override. + position_label: responsePositionLabel(position), total_count: count, available_now_count: choice?.participant_count ?? 0, present_count: choice?.participants.length ?? 0, diff --git a/apps/web/core/claims/browse/claim-summary.test.tsx b/apps/web/core/claims/browse/claim-summary.test.tsx index c5b4032b49..80b1c4fea4 100644 --- a/apps/web/core/claims/browse/claim-summary.test.tsx +++ b/apps/web/core/claims/browse/claim-summary.test.tsx @@ -38,7 +38,7 @@ function renderSummary(value: ClaimResponseSummary) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( - + ); } diff --git a/apps/web/core/claims/browse/claim-summary.tsx b/apps/web/core/claims/browse/claim-summary.tsx index 232abb674c..c6c56d1a0d 100644 --- a/apps/web/core/claims/browse/claim-summary.tsx +++ b/apps/web/core/claims/browse/claim-summary.tsx @@ -12,7 +12,7 @@ import { getEntityResponders } from '~/core/io/queries'; import { fetchProfilesBySpaceIds } from '~/core/io/subgraph/fetch-profile'; import { type ActiveResponseDirection, - ENTITY_RESPONSE_COPY, + CLAIM_RESPONSE_COPY, type ResponseKind, entityResponderProfilesQueryKey, entityRespondersQueryKey, @@ -63,7 +63,7 @@ export function ClaimSummary({ */ layout?: 'stacked' | 'inline'; }) { - const copy = ENTITY_RESPONSE_COPY[responseKind]; + const copy = CLAIM_RESPONSE_COPY; const tier = claimSummaryTier(summary.total); // Nothing where the counts never answered, before asking what the tier is. @@ -119,7 +119,7 @@ export function ClaimSummary({ {/* The bar takes the middle and gives way first: `min-w-0` so a narrow card shortens the rail rather than wrapping the reading off the end of it. Thinner than the stacked one — at full width it is a chart, inline it is a rule between two readings. */} - + {responders} ); @@ -127,7 +127,7 @@ export function ClaimSummary({ return (
- +
{share} {responders} @@ -149,14 +149,12 @@ export function ClaimSummary({ */ export function ClaimSplitBar({ percent, - responseKind, className, }: { percent: number; - responseKind: ResponseKind; className?: string; }) { - const copy = ENTITY_RESPONSE_COPY[responseKind]; + const copy = CLAIM_RESPONSE_COPY; return (
{ - it('blocks responding while the factual flag has an unpublished local edit', () => { + it('lets someone respond while the factual flag has an unpublished local edit', () => { const result = render( entityWith([{ spaceId: SPACE, property: { id: CLAIM_IS_FACTUAL }, value: '1', isLocal: true }]) ); - expect(result.current.responseBlockedReason).toBe('Publish the claim type change before responding.'); - }); - - it('does not block once that edit has been published', () => { - const result = render( - entityWith([ - { spaceId: SPACE, property: { id: CLAIM_IS_FACTUAL }, value: '1', isLocal: true, hasBeenPublished: true }, - ]) - ); - expect(result.current.responseBlockedReason).toBeNull(); }); - it('ignores a draft edit made in a different space', () => { - // Responses are published per space, so an edit elsewhere says nothing about this one's - // vocabulary. + it('still blocks responding while the Claim type itself has an unpublished local edit', () => { const result = render( - entityWith([ - { spaceId: 'da4a6c1f9d4446f9832ff3b49a4400aa', property: { id: CLAIM_IS_FACTUAL }, value: '1', isLocal: true }, - ]) + entityWith( + [], + [ + { + spaceId: SPACE, + type: { id: SystemIds.TYPES_PROPERTY }, + toEntity: { id: CLAIM_TYPE_ID }, + isLocal: true, + }, + ] + ) ); - expect(result.current.responseBlockedReason).toBeNull(); + expect(result.current.responseBlockedReason).toBe('Publish the claim type change before responding.'); }); it('leaves an ordinary claim alone', () => { diff --git a/apps/web/core/claims/browse/use-claim-response-state.ts b/apps/web/core/claims/browse/use-claim-response-state.ts index 2d793ea7b1..de3d67e963 100644 --- a/apps/web/core/claims/browse/use-claim-response-state.ts +++ b/apps/web/core/claims/browse/use-claim-response-state.ts @@ -6,18 +6,18 @@ import type { DebateClaim, DebateClaimPositionSummary, DebateClaimSummary, + DebateResponseKind, MatchmakingReadiness, } from '~/core/debates/api'; import { hasUnpublishedClaimResponseKindEdit } from '~/core/responses/entity-response'; import type { Entity } from '~/core/types'; -import { claimResponseKind } from '../response-kind'; import { positionSummariesFromCounts, viewerResponseWithIndexedFallback } from './claim-position-summaries'; import { type ClaimResponseSummary, useClaimResponseSummary } from './claim-response-summary'; export type ClaimResponseState = { /** Which vocabulary labels the sides: Agree/Disagree, or Verify/Dispute on a factual claim. */ - responseKind: 'stance' | 'veracity'; + responseKind: DebateResponseKind; /** * Whether `responseKind` is an answer or still the fallback. * @@ -74,22 +74,16 @@ export type ClaimResponseState = { * differ is what happens to them afterwards, which is all of this. */ /** - * Which vocabulary a claim uses, from the two sources that can answer. + * Which vocabulary a claim uses. There is only one: Agree/Disagree. * - * geo-chat's copy wins where it has a row; the graph answers for the spaces it does not index. The - * order matters and has to be the same everywhere, because this kind selects `voteKind` on both the - * count query and the write — a surface that resolved it differently would count one vote kind - * while publishing another, which is a bug this codebase has already had. - * - * Exported for the space claims page, which needs every claim's kind before it renders any of them - * in order to batch the response reads. Everything else gets it from {@link useClaimResponseState}. + * Kept as a function, and still called where a kind is needed, because the thing it guarantees is + * worth a name — every claim surface publishes and counts the *same* vote kind. This used to read + * geo-chat's row first and the graph's "Is factual" flag second, and the order mattered: resolving + * it differently on one surface meant counting one vote kind while publishing another, a bug this + * codebase has already had. A constant cannot have that bug. */ -export function resolveClaimResponseKind( - row: Pick | null, - entity: Entity | null, - spaceId: string -): 'stance' | 'veracity' { - return row?.response_kind ?? (entity ? claimResponseKind(entity, spaceId) : 'stance'); +export function resolveClaimResponseKind(): DebateResponseKind { + return 'stance'; } export function useClaimResponseState({ @@ -113,7 +107,7 @@ export function useClaimResponseState({ /** False to hold the response reads back — a feed card below the fold. */ enabled?: boolean; }): ClaimResponseState { - const responseKind = resolveClaimResponseKind(row, entity, spaceId); + const responseKind = resolveClaimResponseKind(); const isResponseKindResolved = row !== null || entity !== null; // An unpublished edit to the claim's own vocabulary blocks responding, as it did before. @@ -153,8 +147,8 @@ export function useClaimResponseState({ ); const positions = React.useMemo( - () => positionSummariesFromCounts(summary.positive, summary.negative, responseKind, row), - [responseKind, row, summary.negative, summary.positive] + () => positionSummariesFromCounts(summary.positive, summary.negative, row), + [row, summary.negative, summary.positive] ); const readiness = React.useMemo( @@ -168,7 +162,6 @@ export function useClaimResponseState({ viewerResponse: row?.viewer_response, indexedDirection: summary.indexedViewerDirection, isIndexedLoading: summary.isViewerResponseLoading, - responseKind, }), viewer_debate_ready: row?.viewer_debate_ready ?? false, readiness_disabled_reason: row?.readiness_disabled_reason ?? null, diff --git a/apps/web/core/claims/response-kind.ts b/apps/web/core/claims/response-kind.ts deleted file mode 100644 index 1b5de2231d..0000000000 --- a/apps/web/core/claims/response-kind.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { uuidToHex } from '~/core/id/normalize'; - -import { getChecked } from '~/design-system/checkbox'; - -import { CLAIM_IS_FACTUAL_PROPERTY_ID } from './ontology'; - -/** The subset of an entity this reads. A full `Entity` satisfies it structurally. */ -type ClaimValues = { - values?: Array<{ isDeleted?: boolean; property: { id: string }; spaceId: string; value: string }>; -}; - -/** - * Which vocabulary labels a claim's two sides: Verify/Dispute for a factual claim, Agree/Disagree - * otherwise. - * - * Read per space on purpose. "Is factual" is a value like any other, so two spaces can disagree - * about the same claim, and the side labels have to match the space the response is published - * against. - */ -export function claimResponseKind(claim: ClaimValues, spaceId: string): 'stance' | 'veracity' { - const isFactual = claim.values?.find( - value => - value.isDeleted !== true && - uuidToHex(value.spaceId) === uuidToHex(spaceId) && - uuidToHex(value.property.id) === uuidToHex(CLAIM_IS_FACTUAL_PROPERTY_ID) - )?.value; - - return getChecked(isFactual) === true ? 'veracity' : 'stance'; -} diff --git a/apps/web/core/debates/api.test.ts b/apps/web/core/debates/api.test.ts index 89fe52178e..1dbacb7062 100644 --- a/apps/web/core/debates/api.test.ts +++ b/apps/web/core/debates/api.test.ts @@ -654,7 +654,7 @@ describe('claim response indexing notifications', () => { vi.stubGlobal('fetch', fetch); await expect( - notifyClaimResponseIndexed('space-1', 'claim-1', 'veracity', false, vi.fn(), 'user-a') + notifyClaimResponseIndexed('space-1', 'claim-1', 'stance', false, vi.fn(), 'user-a') ).resolves.toBeUndefined(); expect(fetch).toHaveBeenCalledWith('http://localhost:8080/spaces/space-1/claims/claim-1/response-indexed', { @@ -663,7 +663,7 @@ describe('claim response indexing notifications', () => { Authorization: 'Bearer access-token', 'Content-Type': 'application/json', }, - body: JSON.stringify({ response_kind: 'veracity', position: false }), + body: JSON.stringify({ response_kind: 'stance', position: false }), signal: undefined, }); }); diff --git a/apps/web/core/debates/api.ts b/apps/web/core/debates/api.ts index 6783151022..793f901036 100644 --- a/apps/web/core/debates/api.ts +++ b/apps/web/core/debates/api.ts @@ -9,7 +9,17 @@ export type DebateStatus = 'ready' | 'connecting' | 'preflight' | 'in_progress' export type DebateRecordingSource = 'local'; export type DebateRematchStatus = 'deciding' | 'browsing' | 'request_pending' | 'converted' | 'ended' | 'expired'; export type DebateRematchRequestStatus = 'pending' | 'accepted' | 'rejected' | 'expired'; -export type DebateResponseKind = 'stance' | 'veracity'; +/** + * How a claim's two sides are labelled: Agree and Disagree, for every claim. + * + * This used to be `'stance' | 'veracity'`, and a claim carrying the "Is factual" flag took the + * second vocabulary — Verify and Dispute, published as its own vote kind. That split is gone. + * + * geo-chat may still put `"veracity"` on the wire for a claim minted before the change. Nothing + * reads the field to pick a vocabulary any more — every consumer uses `'stance'` outright — so a + * stale value cannot reach a label, a glyph or a vote kind. We send `'stance'` on the way back. + */ +export type DebateResponseKind = 'stance'; export type DebateParticipantSummary = { user_id: string; @@ -484,7 +494,7 @@ export type DebateResponseSummary = { /** Everything the hub needs to render a claim's readiness state alongside the viewer's response. */ export type MatchmakingReadiness = { - /** Which vocabulary labels the sides: Agree/Disagree for `stance`, Verify/Dispute for `veracity`. */ + /** Legacy; see {@link DebateResponseKind}. Every claim is Agree/Disagree. */ response_kind: DebateResponseKind; /** Present whenever the viewer has an active response — including while readiness is off. */ viewer_response: DebateResponseSummary | null; diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx index 954f213704..a63d1a39a5 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -29,7 +29,7 @@ const mocks = vi.hoisted(() => ({ viewerPosition: null as boolean | null, /** The crowd's share of positive responses, or null on a claim nobody has answered. */ percent: null as number | null, - responseKind: 'stance' as 'stance' | 'veracity', + responseKind: 'stance' as const, respond: vi.fn(), })); @@ -215,15 +215,17 @@ describe('DebateClaimTickerCard', () => { expect(screen.getByText('65% agree')).toBeInTheDocument(); }); - // "65% agree" on "the SEC sued Coinbase" is the wrong sentence; the share takes the same verb - // the rest of the app uses for the claim's own vocabulary. - it("reads the share with the claim's own vocabulary verb", () => { + // This used to read "65% verify" on a factual claim, from that claim's own vocabulary. There is + // one vocabulary now, so the share says "agree" whatever the claim is flagged as — asserted here + // on the case that used to differ. + it('reads the share with agree even on a claim geo-chat still calls factual', () => { mocks.percent = 65; - mocks.responseKind = 'veracity'; + mocks.responseKind = 'veracity' as 'stance'; renderCard(); - expect(screen.getByText('65% verify')).toBeInTheDocument(); + expect(screen.getByText('65% agree')).toBeInTheDocument(); + expect(screen.queryByText('65% verify')).not.toBeInTheDocument(); }); // A genuine 0% and "nobody has answered" are different statements, and the great majority of diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index 7c4d254819..003b473710 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -21,7 +21,7 @@ import { orderedParticipants, speakerLabel } from '~/core/debates/playback-utils import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; import { uuidToHex } from '~/core/id/normalize'; -import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; +import { CLAIM_RESPONSE_COPY, type ResponseKind } from '~/core/responses/entity-response'; import { useQueryEntities } from '~/core/sync/use-store'; import type { Entity } from '~/core/types'; @@ -799,7 +799,7 @@ function TickerClaimHeader({ onAnswered(claimId, position); }, [position, claimId, onAnswered]); - const copy = ENTITY_RESPONSE_COPY[responseKind]; + const copy = CLAIM_RESPONSE_COPY; // Null on a claim nobody has answered, which is most of them — and a genuine 0% is a different // statement from "no responses", so the share drops out rather than printing a zero. const percent = summary.percent; @@ -891,7 +891,7 @@ function ClaimIconButton({ title, onClick, }: { - responseKind: 'stance' | 'veracity' | 'curation'; + responseKind: ResponseKind; position: boolean; label: string; selected: boolean; @@ -931,8 +931,6 @@ function ClaimIconButton({ : 'text-white/55 hover:bg-white/15 hover:text-white disabled:hover:bg-transparent disabled:hover:text-white/55' )} > - {/* A veracity chevron has no filled form to switch to, so its selected state is the - background and colour above rather than the glyph. See `ResponsePositionIcon`. */} ); diff --git a/apps/web/core/debates/debate-entity-response-controls.test.tsx b/apps/web/core/debates/debate-entity-response-controls.test.tsx index c755e4ce22..3a0427d6e9 100644 --- a/apps/web/core/debates/debate-entity-response-controls.test.tsx +++ b/apps/web/core/debates/debate-entity-response-controls.test.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { DebateEntityResponseControls } from './debate-entity-response-controls'; vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ - EntityVoteButtons: ({ responseKind }: { responseKind?: 'stance' | 'veracity' | null }) => ( + EntityVoteButtons: ({ responseKind }: { responseKind?: 'stance' | null }) => ( {responseKind ?? 'unavailable'} ), })); @@ -15,9 +15,9 @@ afterEach(cleanup); describe('DebateEntityResponseControls', () => { it('passes the backend response kind to the shared response controls', () => { - render(); + render(); - expect(screen.getByText('veracity')).toBeInTheDocument(); + expect(screen.getByText('stance')).toBeInTheDocument(); }); it('preserves an unavailable backend response kind instead of inferring curation', () => { diff --git a/apps/web/core/debates/debate-gateway.test.ts b/apps/web/core/debates/debate-gateway.test.ts index 3a03b7d08f..0fd92366a9 100644 --- a/apps/web/core/debates/debate-gateway.test.ts +++ b/apps/web/core/debates/debate-gateway.test.ts @@ -333,9 +333,9 @@ describe('DebateGatewayClient', () => { queryClient.setQueryData(['debates', 'claims', 'space-1', ['claim-1']], {}); queryClient.setQueryData(['debates', 'claims', 'space-1', ['claim-2']], {}); queryClient.setQueryData(['claim-response-summaries', 'profile-1', 'space-1', ['claim-1:stance']], new Map()); - queryClient.setQueryData(['claim-response-summaries', 'profile-1', 'space-1', ['claim-2:veracity']], new Map()); - queryClient.setQueryData(['claim-response-summary-data', 'profile-1', 'space-1', ['claim-2:veracity']], new Map()); - queryClient.setQueryData(['claim-response-summaries', 'profile-1', 'space-2', ['claim-2:veracity']], new Map()); + queryClient.setQueryData(['claim-response-summaries', 'profile-1', 'space-1', ['claim-2:stance']], new Map()); + queryClient.setQueryData(['claim-response-summary-data', 'profile-1', 'space-1', ['claim-2:stance']], new Map()); + queryClient.setQueryData(['claim-response-summaries', 'profile-1', 'space-2', ['claim-2:stance']], new Map()); queryClient.setQueryData(['debates', 'account', 'user-a', 'rematch', 'session-1', 'claims', ['claim-9']], {}); queryClient.setQueryData(['debates', 'account', 'user-a', 'rematch', 'session-1', 'claims', ['claim-2']], {}); queryClient.setQueryData(['debates', 'account', 'user-a', 'rematch', 'session-1', 'claims', []], {}); @@ -379,14 +379,14 @@ describe('DebateGatewayClient', () => { predicate!( queryClient .getQueryCache() - .find({ queryKey: ['claim-response-summaries', 'profile-1', 'space-1', ['claim-2:veracity']] })! + .find({ queryKey: ['claim-response-summaries', 'profile-1', 'space-1', ['claim-2:stance']] })! ) ).toBe(true); expect( predicate!( queryClient .getQueryCache() - .find({ queryKey: ['claim-response-summary-data', 'profile-1', 'space-1', ['claim-2:veracity']] })! + .find({ queryKey: ['claim-response-summary-data', 'profile-1', 'space-1', ['claim-2:stance']] })! ) ).toBe(true); // The rematch picker draws both participants' sides, so a claim change has to reach the batch @@ -419,7 +419,7 @@ describe('DebateGatewayClient', () => { predicate!( queryClient .getQueryCache() - .find({ queryKey: ['claim-response-summaries', 'profile-1', 'space-2', ['claim-2:veracity']] })! + .find({ queryKey: ['claim-response-summaries', 'profile-1', 'space-2', ['claim-2:stance']] })! ) ).toBe(false); // The rematch picker reads both participants' sides straight from the graph in one query; @@ -441,10 +441,10 @@ describe('DebateGatewayClient', () => { await flushInvalidations(); invalidateQueries.mockRestore(); - const responseTargets = ['claim-1:stance', 'claim-2:veracity']; + const responseTargets = ['claim-1:stance', 'claim-2:stance']; const summaryDataKey = ['claim-response-summary-data', 'profile-1', 'space-1', responseTargets] as const; const responseBatchKey = ['claim-response-summaries', 'profile-1', 'space-1', responseTargets] as const; - const fetchSummaryData = vi.fn(async () => new Map([['claim-2:veracity', { negative: 1 }]])); + const fetchSummaryData = vi.fn(async () => new Map([['claim-2:stance', { negative: 1 }]])); const observer = new QueryObserver(queryClient, { queryKey: responseBatchKey, queryFn: () => diff --git a/apps/web/core/debates/debate-gateway.ts b/apps/web/core/debates/debate-gateway.ts index 63de628e7b..8b67cb9ac1 100644 --- a/apps/web/core/debates/debate-gateway.ts +++ b/apps/web/core/debates/debate-gateway.ts @@ -557,7 +557,7 @@ export class DebateGatewayClient { for (const claimEntityId of claimEntityIds) changedClaims.add(claimEntityId); const changedResponseTargets = new Set( [...changedClaims].flatMap(entityId => - (['stance', 'veracity'] as const).map(responseKind => claimResponseTargetKey({ entityId, responseKind })) + (['stance'] as const).map(responseKind => claimResponseTargetKey({ entityId, responseKind })) ) ); this.queueInvalidation(`claims:${spaceId}`, { diff --git a/apps/web/core/debates/hooks.test.tsx b/apps/web/core/debates/hooks.test.tsx index fe655259ec..68716c1d37 100644 --- a/apps/web/core/debates/hooks.test.tsx +++ b/apps/web/core/debates/hooks.test.tsx @@ -638,13 +638,13 @@ describe('useGeoChatAuth', () => { invalidateQueries.mockClear(); act(() => { - queryClient.setQueryData(entityResponseIndexingQueryKey('profile-1', 'claim-1', 'space-1', 'veracity'), { + queryClient.setQueryData(entityResponseIndexingQueryKey('profile-1', 'claim-1', 'space-1', 'stance'), { status: 'indexed', pending: { entityId: 'claim-1', expectedResponse: 'negative', personalSpaceId: 'profile-1', - responseKind: 'veracity', + responseKind: 'stance', spaceId: 'space-1', }, runId: 'run-1', @@ -693,13 +693,13 @@ describe('useGeoChatAuth', () => { await waitFor(() => expect(mocks.listDebateRematchClaims).toHaveBeenCalledTimes(1)); act(() => { - queryClient.setQueryData(entityResponseIndexingQueryKey('profile-1', 'claim-1', 'space-1', 'veracity'), { + queryClient.setQueryData(entityResponseIndexingQueryKey('profile-1', 'claim-1', 'space-1', 'stance'), { status: 'indexed', pending: { entityId: 'claim-1', expectedResponse: 'negative', personalSpaceId: 'profile-1', - responseKind: 'veracity', + responseKind: 'stance', spaceId: 'space-1', }, runId: 'run-1', diff --git a/apps/web/core/debates/matchmaking/claims-tab.tsx b/apps/web/core/debates/matchmaking/claims-tab.tsx index 3940f0630e..1ff1b7c4da 100644 --- a/apps/web/core/debates/matchmaking/claims-tab.tsx +++ b/apps/web/core/debates/matchmaking/claims-tab.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { useAtom } from 'jotai'; -import { claimResponseKind } from '~/core/claims/response-kind'; +import { resolveClaimResponseKind } from '~/core/claims/browse/use-claim-response-state'; import { DEBATE_TAG_ID } from '~/core/debates/ontology'; import { useInfiniteScrollSentinel } from '~/core/hooks/use-infinite-scroll-sentinel'; import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; @@ -457,7 +457,7 @@ export function ClaimsTab({ return taggedPage.map(({ claim, spaceId }) => { const row = rowsBySpaceAndClaim.get(`${ID.uuidToHex(spaceId)}:${ID.uuidToHex(claim.entity.id)}`); - const responseKind = row?.response_kind ?? claimResponseKind(claim.entity, spaceId); + const responseKind = resolveClaimResponseKind(); return { claim: { @@ -473,7 +473,7 @@ export function ClaimsTab({ viewer_position: row?.viewer_response?.position ?? null, viewer_debate_ready: row?.viewer_debate_ready ?? false, readiness_disabled_reason: row?.readiness_disabled_reason ?? null, - positions: taggedPositionSummaries(row, responseKind), + positions: taggedPositionSummaries(row), // The index's ranking score, which this list is ordered by on the server and doesn't re-sort. score: 0, active_debate: Boolean(row?.active_debate), @@ -1195,17 +1195,15 @@ export function HubStickyControls({ children }: { children: React.ReactNode }) { * off `total_count`, so the online count stands in for it: it is the only count this endpoint * gives, and undercounting a side is better than claiming a total it never told us. */ -function taggedPositionSummaries( - row: DebateClaim | undefined, - responseKind: 'stance' | 'veracity' -): DebateClaimPositionSummary[] { +function taggedPositionSummaries(row: DebateClaim | undefined): DebateClaimPositionSummary[] { return [true, false].map(position => { const choice = row?.online_choices.find(candidate => candidate.position === position); return { position, // A server-supplied label wins, so an authoritative Verify/Dispute survives. - position_label: choice?.position_label ?? responsePositionLabel(responseKind, position), + // Our label, never geo-chat's stale Verify/Dispute — see `positionSummariesFromCounts`. + position_label: responsePositionLabel(position), total_count: choice?.participant_count ?? 0, available_now_count: choice?.participant_count ?? 0, // These are `online_choices`, so the count already *is* the present population — the same diff --git a/apps/web/core/debates/matchmaking/matches-list.test.tsx b/apps/web/core/debates/matchmaking/matches-list.test.tsx index b89009c2e7..c965c13113 100644 --- a/apps/web/core/debates/matchmaking/matches-list.test.tsx +++ b/apps/web/core/debates/matchmaking/matches-list.test.tsx @@ -245,12 +245,16 @@ describe('MatchesList', () => { expect(screen.queryByRole('button', { name: /Upvote|Downvote|vote/i })).not.toBeInTheDocument(); }); - it('uses the veracity vocabulary for a factual claim', () => { - mocks.matches = [match({ response_kind: 'veracity', positions: [] })]; + // Inverted rather than deleted: a claim geo-chat still labels `veracity` is exactly the case that + // has to come out Agree/Disagree now. + it('uses Agree and Disagree for a claim geo-chat still calls factual', () => { + mocks.matches = [match({ response_kind: 'veracity' as 'stance', positions: [] })]; render(); - expect(screen.getByRole('button', { name: /^Verify/ })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /^Dispute/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Agree/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Disagree/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Verify/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Dispute/ })).not.toBeInTheDocument(); }); it('publishes the opposite response when the other side is chosen', () => { diff --git a/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx b/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx index 00c4c355d2..71e0030199 100644 --- a/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx +++ b/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx @@ -533,41 +533,10 @@ describe('position avatar stack', () => { expect(agree).toHaveAttribute('title', 'Loading this claim\u2019s responses\u2026'); }); - /** - * And a remembered side belongs to the vocabulary it was read under. - * - * The summary query is keyed by response kind, so a claim that changes from stance to - * Verify/Dispute starts a fresh read — and a memory that ignored the kind would hand that read's - * question the previous one's answer while it was still out, treating an Agree as a Verify and - * enabling the controls over it. - */ - it('does not carry a side across a change of vocabulary', () => { - mocks.summaryIndexedViewerDirection = 'negative'; - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const card = (responseKind: 'stance' | 'veracity') => ( - - - - ); - const view = render(card('stance')); - expect(screen.getByRole('button', { name: /^Disagree/ })).toBeEnabled(); - - // The kind changes, so its read starts again — and is in flight. - mocks.summaryViewerResponseLoading = true; - view.rerender(card('veracity')); - - // The pills take their labels from the positions, so they read the same; what changes is that - // the card no longer claims to know the side. - const negative = screen.getByRole('button', { name: /^Disagree/ }); - expect(negative).toBeDisabled(); - expect(negative).toHaveAttribute('title', 'Loading this claim\u2019s responses\u2026'); - }); + // A case that used to sit here — "does not carry a side across a change of vocabulary" — is + // gone with the vocabularies. A claim had two possible kinds and could move between them, so a + // remembered side had to be dropped when it did. There is one kind now, so there is no change + // to carry a side across. // And it does hand back, once geo-chat says the same thing. it('retires it when geo-chat answers with the side the viewer took', () => { diff --git a/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx b/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx index 6ebcc27671..4982515253 100644 --- a/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx +++ b/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx @@ -21,7 +21,7 @@ import { useNearViewport } from '~/core/hooks/use-near-viewport'; import { useProfilesBySpaceIds } from '~/core/hooks/use-profiles-by-space-ids'; import { spaceLabel, useSpaceLabels } from '~/core/hooks/use-space-labels'; import { ID } from '~/core/id'; -import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; +import { CLAIM_RESPONSE_COPY, responsePositionLabel } from '~/core/responses/entity-response'; import { useClaimResponseBatchState } from '~/core/responses/use-claim-response-summaries'; import { usePendingPersonalSpace } from '~/core/state/pending-personal-space'; import { NavUtils, validateEntityId, validateSpaceId } from '~/core/utils/utils'; @@ -431,7 +431,7 @@ export function useClaimPositionControl({ // the claim page does. const { isPending: isAccountSetupPending } = usePendingPersonalSpace(); - const copy = ENTITY_RESPONSE_COPY[readiness.response_kind]; + const copy = CLAIM_RESPONSE_COPY; const [responseError, setResponseError] = React.useState(null); // The offer and the faces it implies, from one fact. Same shared query the end slot reads, so this @@ -471,7 +471,6 @@ export function useClaimPositionControl({ ? positionsWithOpponents : withViewerPosition({ positions: positionsWithOpponents, - responseKind: readiness.response_kind, // `undefined` where the host cannot say, which is not the same as "no position" — see // `viewerResponseUnknown`. serverPosition: viewerResponseUnknown ? undefined : (readiness.viewer_response?.position ?? null), @@ -483,7 +482,6 @@ export function useClaimPositionControl({ [ personalSpaceId, positionsWithOpponents, - readiness.response_kind, readiness.viewer_response?.position, viewerIdentityPending, viewerPosition, @@ -695,7 +693,6 @@ function RespondableControls({ viewerResponse: readiness.viewer_response, indexedDirection: settledDirection === 'none' ? null : settledDirection, isIndexedLoading: settledDirection === null, - responseKind: readiness.response_kind, }); return viewerResponse === (readiness.viewer_response ?? null) ? readiness @@ -805,7 +802,6 @@ function RespondableControls({ */ export function withViewerPosition({ positions, - responseKind, serverPosition, viewerPosition, viewerSpaceId, @@ -813,7 +809,6 @@ export function withViewerPosition({ viewerAvatarUrl, }: { positions: DebateClaimPositionSummary[]; - responseKind: MatchmakingReadiness['response_kind']; /** * The position geo-chat currently reports for the viewer, or `undefined` where it has not * answered — which is not the same as an answer of "no position". See `viewerResponseUnknown`. @@ -849,7 +844,6 @@ export function withViewerPosition({ positions.some(side => side.position === viewerPosition && side.participants.some(heldByViewer)); if (viewerPosition === serverPosition && !listedOnAnotherSide && listedOnHeldSide) return positions; - const copy = ENTITY_RESPONSE_COPY[responseKind]; const viewer = { // Not geo-chat's id for this user — we don't have it here. Keyed on the personal space instead, // which is unique per viewer and is what the avatar renders from anyway. @@ -914,7 +908,7 @@ export function withViewerPosition({ if (viewerPosition !== null && !adjusted.some(side => side.position === viewerPosition)) { adjusted.push({ position: viewerPosition, - position_label: viewerPosition ? copy.positiveAction : copy.negativeAction, + position_label: responsePositionLabel(viewerPosition), total_count: 1, available_now_count: 0, present_count: 1, @@ -1019,7 +1013,7 @@ export function PositionRow({ /** A compact third action, kept beside both positions at narrow and wide card widths. */ endSlot?: React.ReactNode; }) { - const copy = ENTITY_RESPONSE_COPY[responseKind]; + const copy = CLAIM_RESPONSE_COPY; const forSide = positions.find(position => position.position === true); const againstSide = positions.find(position => position.position === false); @@ -1057,9 +1051,10 @@ export function PositionRow({ the width the pill had as a grid item. */}
{ // and so are withdrawn responses, which are a vote *type* and mean "no side". it('counts positions through the shared filter', () => { expect(buildPersonRecordsDocument([A]).variables.positionFilter).toBe(POSITION_VOTE_FILTER); - expect(POSITION_VOTE_FILTER.voteKind.in).toEqual([1, 2]); + // Stance alone: kind 0 is curation, and kind 2 is the retired veracity response this app no + // longer reads. + expect(POSITION_VOTE_FILTER.voteKind.in).toEqual([1]); expect(POSITION_VOTE_FILTER.voteKind.in).not.toContain(0); + expect(POSITION_VOTE_FILTER.voteKind.in).not.toContain(2); expect(POSITION_VOTE_FILTER.voteType.in).toEqual([0, 1]); expect(POSITION_VOTE_FILTER.objectType.is).toBe(0); }); @@ -137,9 +140,9 @@ describe('readPersonRecords', () => { expect(records.get(B)).toMatchObject({ positions: 1, debateIds: ['d3'] }); }); - // A `userVotes` row is not a position. The same claim answered on both the stance and the veracity - // axis is two rows, and one answered in two spaces is two more — both happen on the live graph, and - // a row count would say a bigger number than the positions the rest of the app lists for them. + // A `userVotes` row is not a position. A claim answered in two spaces is two rows — which happens + // on the live graph, and a row count would say a bigger number than the positions the rest of the + // app lists for them. it('counts a claim answered twice as one position', () => { const records = readPersonRecords( { diff --git a/apps/web/core/debates/matchmaking/position-row.test.tsx b/apps/web/core/debates/matchmaking/position-row.test.tsx index f77180b8d0..9ea3bf1201 100644 --- a/apps/web/core/debates/matchmaking/position-row.test.tsx +++ b/apps/web/core/debates/matchmaking/position-row.test.tsx @@ -5,8 +5,6 @@ import * as React from 'react'; import { afterEach, describe, expect, it } from 'vitest'; -import { ChevronDown } from '~/design-system/icons/chevron-down'; -import { ChevronUp } from '~/design-system/icons/chevron-up'; import { ThumbDown } from '~/design-system/icons/thumb-down'; import { ThumbUp } from '~/design-system/icons/thumb-up'; @@ -141,51 +139,84 @@ describe('PositionRow', () => { expect(screen.getByText('+3')).toBeInTheDocument(); }); - it('keeps the vocabulary for the response kind on both pills', () => { - render(); + /** + * The pills name the sides Agree and Disagree for every claim. + * + * This case used to render `responseKind="veracity"` and expect Verify and Dispute. The flag that + * selected that vocabulary no longer selects anything, so the assertion is inverted rather than + * deleted: the claims that used to read Verify/Dispute are exactly the ones this has to prove now + * read Agree/Disagree. + */ + it('names both sides Agree and Disagree', () => { + render(); + + expect(screen.getByText('Agree')).toBeInTheDocument(); + expect(screen.getByText('Disagree')).toBeInTheDocument(); + expect(screen.queryByText('Verify')).not.toBeInTheDocument(); + expect(screen.queryByText('Dispute')).not.toBeInTheDocument(); + }); + + /** + * A server label must not put the retired vocabulary back. + * + * geo-chat still labels the sides of a claim minted before the change, and the pills used to + * prefer `position_label` over their own copy — so a stale "Verify" would render on a control + * that can only publish an Agree. The label the pill shows and the response it sends have to be + * the same word. + */ + it('ignores a stale Verify/Dispute label from the server', () => { + const labelled: DebateClaimPositionSummary[] = [ + { + position: true, + position_label: 'Verify', + total_count: 1, + available_now_count: 0, + present_count: 0, + participants: [], + }, + { + position: false, + position_label: 'Dispute', + total_count: 1, + available_now_count: 0, + present_count: 0, + participants: [], + }, + ]; + + render(); - expect(screen.getByText('Verify')).toBeInTheDocument(); - expect(screen.getByText('Dispute')).toBeInTheDocument(); + expect(screen.queryByText('Verify')).not.toBeInTheDocument(); + expect(screen.queryByText('Dispute')).not.toBeInTheDocument(); + expect(screen.getByText('Agree')).toBeInTheDocument(); + expect(screen.getByText('Disagree')).toBeInTheDocument(); }); /** * The glyph a pill draws, pinned against the icon it should be. * - * Not asserted as "not the chevron": a thumb, a vote arrow and an empty span all satisfy that, - * so a pill that never got the veracity branch — the bug this file's chevron cases exist to - * catch — would pass its own regression test. Comparing the rendered icon says which glyph it - * is, and re-rendering the expectation from the component means redrawing an icon's art does - * not fail these. + * Not asserted as "not a chevron": a vote arrow and an empty span satisfy that too. Comparing the + * rendered icon says which glyph it is, and re-rendering the expectation from the component means + * redrawing an icon's art does not fail these. */ const glyphMarkup = (label: string) => screen.getByText(label).closest('span')?.parentElement?.querySelector('svg')?.outerHTML ?? null; const iconMarkup = (node: React.ReactNode) => render(<>{node}).container.innerHTML; - // A thumb is an opinion and Verify/Dispute is not one — it says the claim is or is not true. The - // claim ticker over the video already splits its glyphs this way, so a pill that thumbed both - // kinds made the same claim read differently in the panel and on the video. - it('draws chevrons for a factual claim rather than thumbs', () => { - render(); - - expect(glyphMarkup('Verify')).toBe(iconMarkup()); - expect(glyphMarkup('Dispute')).toBe(iconMarkup()); - }); - - it('keeps the thumbs on a stance claim', () => { + it('draws thumbs on both pills', () => { render(); expect(glyphMarkup('Agree')).toBe(iconMarkup()); expect(glyphMarkup('Disagree')).toBe(iconMarkup()); }); - // A chevron has no filled form, so the pill's own fill is the only thing left saying which side - // the viewer holds. Losing it would leave a factual claim with no visible record of a response. - it('still marks the held side on a factual claim, where the glyph cannot', () => { - render(); - - const verify = screen.getByText('Verify').closest('div.flex.min-h-7') as HTMLElement; + it('fills the pill and its thumb on the side the viewer holds', () => { + render(); - expect([...verify.classList]).toContain('bg-divider'); + expect(glyphMarkup('Agree')).toBe(iconMarkup()); + expect([...(screen.getByText('Agree').closest('div.flex.min-h-7') as HTMLElement).classList]).toContain( + 'bg-divider' + ); }); }); diff --git a/apps/web/core/debates/matchmaking/with-viewer-position.test.ts b/apps/web/core/debates/matchmaking/with-viewer-position.test.ts index 4dfe5bd6d4..75ed161ab0 100644 --- a/apps/web/core/debates/matchmaking/with-viewer-position.test.ts +++ b/apps/web/core/debates/matchmaking/with-viewer-position.test.ts @@ -34,7 +34,6 @@ function place( ) { return withViewerPosition({ positions, - responseKind: 'stance', serverPosition, viewerPosition, viewerSpaceId: VIEWER_SPACE, @@ -194,7 +193,6 @@ describe('withViewerPosition', () => { const sides = withViewerPosition({ positions, - responseKind: 'stance', serverPosition: undefined, viewerPosition: null, viewerSpaceId: VIEWER_SPACE, diff --git a/apps/web/core/debates/participant-positions.test.ts b/apps/web/core/debates/participant-positions.test.ts index bdba383af6..a99083072b 100644 --- a/apps/web/core/debates/participant-positions.test.ts +++ b/apps/web/core/debates/participant-positions.test.ts @@ -46,8 +46,8 @@ const REMOTE: DebateRematchParticipant = { describe('fetchParticipantPositions', () => { // Positions are on-chain claim responses, which the graph indexes as `userVotes` keyed on the - // responder's personal space. One filter for both people, active responses only, both kinds. - it('asks for both participants’ active stance and veracity responses in one filter', async () => { + // responder's personal space. One filter for both people, active responses only. + it('asks for both participants’ active stance responses in one filter', async () => { const fetchPage = vi.fn().mockResolvedValue([]); await fetchParticipantPositions([LOCAL.profile_space_id, REMOTE.profile_space_id], undefined, fetchPage); @@ -57,7 +57,8 @@ describe('fetchParticipantPositions', () => { userId: { in: [LOCAL.profile_space_id, REMOTE.profile_space_id] }, objectType: { is: 0 }, voteType: { in: [0, 1] }, - voteKind: { in: [1, 2] }, + // Kind 2 — the retired veracity responses — is deliberately not asked for. + voteKind: { in: [1] }, }); }); @@ -67,9 +68,10 @@ describe('fetchParticipantPositions', () => { expect(fetchPage).not.toHaveBeenCalled(); }); - it('decodes rows into sides, dropping anything that is not an active stance or veracity response', async () => { + it('decodes rows into sides, dropping anything that is not an active stance response', async () => { const fetchPage = vi.fn().mockResolvedValue([ { userId: LOCAL.profile_space_id, objectId: 'claim-1', spaceId: 'space-1', voteType: 0, voteKind: 1 }, + // A retired veracity response. It is no longer a position this app reports. { userId: REMOTE.profile_space_id, objectId: 'claim-1', spaceId: 'space-1', voteType: 1, voteKind: 2 }, // A curation vote is not a position. { userId: REMOTE.profile_space_id, objectId: 'claim-2', spaceId: 'space-1', voteType: 0, voteKind: 0 }, @@ -87,13 +89,6 @@ describe('fetchParticipantPositions', () => { responseKind: 'stance', position: true, }, - { - profileSpaceId: REMOTE.profile_space_id, - claimId: 'claim-1', - spaceId: 'space-1', - responseKind: 'veracity', - position: false, - }, ]); }); diff --git a/apps/web/core/debates/participant-positions.ts b/apps/web/core/debates/participant-positions.ts index 6b5c488860..c31a80c33e 100644 --- a/apps/web/core/debates/participant-positions.ts +++ b/apps/web/core/debates/participant-positions.ts @@ -51,10 +51,10 @@ export function isParticipantPositionsQueryKey(queryKey: readonly unknown[]) { return queryKey[0] === PARTICIPANT_POSITIONS_QUERY_ROOT; } -const VOTE_KIND_TO_RESPONSE_KIND = new Map([ - [responseKindToVoteKind('stance'), 'stance'], - [responseKindToVoteKind('veracity'), 'veracity'], -]); +// Vote kind 2 — the old veracity responses — is deliberately absent. Those rows are no longer read +// anywhere, so a participant's position on a claim they verified before the vocabularies were +// merged does not show. See the PR that removed Verify/Dispute. +const VOTE_KIND_TO_RESPONSE_KIND = new Map([[responseKindToVoteKind('stance'), 'stance']]); /** * Hand-written rather than generated so it doesn't require regenerating `gql.ts`. The generated diff --git a/apps/web/core/hooks/use-curator-onboarding-status.ts b/apps/web/core/hooks/use-curator-onboarding-status.ts index 5426135a72..b33372e3a5 100644 --- a/apps/web/core/hooks/use-curator-onboarding-status.ts +++ b/apps/web/core/hooks/use-curator-onboarding-status.ts @@ -39,7 +39,19 @@ function emptyCompletion(): CuratorOnboardingCompletion { * ticked the voting step too. With a claim step beside it that would credit one action as two. */ const ENTITY_VOTE_KINDS = [responseKindToVoteKind('curation')] as const; -const CLAIM_POSITION_VOTE_KINDS = [responseKindToVoteKind('stance'), responseKindToVoteKind('veracity')] as const; + +/** + * `2` is the retired veracity vote kind, kept here on purpose. + * + * This step asks whether the person has *ever* taken a position on a claim, and someone who did so + * back when factual claims were verified rather than agreed with did the thing the checklist is + * asking about. Dropping the kind would un-tick a step they had already completed, which is a + * regression with nothing to recommend it — unlike the claim tallies, where reading the retired + * kind is what we deliberately stopped doing. Nothing here labels a side or publishes a vote, so + * there is no vocabulary to get wrong. + */ +const LEGACY_VERACITY_VOTE_KIND = 2; +const CLAIM_POSITION_VOTE_KINDS = [responseKindToVoteKind('stance'), LEGACY_VERACITY_VOTE_KIND] as const; async function personalSpaceHasEntityType( personalSpaceId: string, diff --git a/apps/web/core/hooks/use-entity-vote.test.tsx b/apps/web/core/hooks/use-entity-vote.test.tsx index 17edc2798d..38ef63ba27 100644 --- a/apps/web/core/hooks/use-entity-vote.test.tsx +++ b/apps/web/core/hooks/use-entity-vote.test.tsx @@ -174,7 +174,7 @@ describe('useEntityResponse indexing reconciliation', () => { mocks.runEffectEither.mockResolvedValue({ _tag: 'Left', left: new Error('User rejected') }); const { wrapper } = createHarness(); const { result } = renderHook( - () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'veracity' }), + () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), { wrapper } ); await act(async () => { @@ -366,7 +366,7 @@ describe('useEntityResponse indexing reconciliation', () => { const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); const setQueryData = vi.spyOn(queryClient, 'setQueryData'); const { result } = renderHook( - () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'veracity' }), + () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), { wrapper } ); @@ -383,7 +383,7 @@ describe('useEntityResponse indexing reconciliation', () => { expectNoVotedListRefresh(invalidateQueries); expect(queryClient.getQueryData(votedEntityIdsPendingQueryKey(PERSONAL_SPACE_ID, 'down'))).toEqual({ - added: [expect.objectContaining({ entityId: 'claim-1', voteKind: 2 })], + added: [expect.objectContaining({ entityId: 'claim-1', voteKind: 1 })], removed: [], }); }); @@ -662,7 +662,7 @@ describe('useEntityResponse indexing reconciliation', () => { }); describe('useEntityResponse claim-space membership', () => { - it.each(['stance', 'veracity'] as const)( + it.each(['stance'] as const)( 'requests membership of the claim space after a %s response lands', async responseKind => { mocks.fetchResponse.mockReturnValue('positive'); @@ -685,7 +685,7 @@ describe('useEntityResponse claim-space membership', () => { mocks.fetchResponse.mockReturnValue('negative'); const { wrapper } = createHarness(); const { result } = renderHook( - () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'veracity' }), + () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), { wrapper } ); diff --git a/apps/web/core/io/queries.ts b/apps/web/core/io/queries.ts index d209a837fe..ef75fb770b 100644 --- a/apps/web/core/io/queries.ts +++ b/apps/web/core/io/queries.ts @@ -43,7 +43,6 @@ import { type ActiveResponseDirection, type ResponseKind, type ResponseObjectType, - type ResponseVoteKind, decodeActiveResponseDirection, entityResponseQueryVariables, } from '~/core/responses/entity-response'; @@ -1469,11 +1468,12 @@ export function getUserEntityResponse( * veracity (2) are a position on a claim, and the onboarding checklist counts those as two * different things a person can have done. */ -export function getUserHasVoteOfKind( - userId: string, - voteKinds: readonly ResponseVoteKind[], - signal?: AbortController['signal'] -) { +/** + * `number` rather than `ResponseVoteKind`, because this asks the vote table a historical question + * and the table holds kinds the app no longer publishes — the retired veracity kind, `2`, is a + * real value here even though nothing can write one any more. + */ +export function getUserHasVoteOfKind(userId: string, voteKinds: readonly number[], signal?: AbortController['signal']) { return graphql({ query: UserHasVoteOfKindDocument, decoder: data => (data.userVotes?.length ?? 0) > 0, diff --git a/apps/web/core/responses/claim-response-summaries.test.ts b/apps/web/core/responses/claim-response-summaries.test.ts index 9502e3f1c9..f9792a39cb 100644 --- a/apps/web/core/responses/claim-response-summaries.test.ts +++ b/apps/web/core/responses/claim-response-summaries.test.ts @@ -23,45 +23,53 @@ import { } from './entity-response'; const targets = [ - { entityId: 'claim-veracity', responseKind: 'veracity' as const }, - { entityId: 'claim-stance', responseKind: 'stance' as const }, + { entityId: 'claim-b', responseKind: 'stance' as const }, + { entityId: 'claim-a', responseKind: 'stance' as const }, ]; describe('claim response summaries', () => { it('normalizes duplicate claim-kind pairs deterministically', () => { expect(normalizeClaimResponseTargets([targets[0], targets[1], targets[0]])).toEqual([ - { entityId: 'claim-stance', responseKind: 'stance' }, - { entityId: 'claim-veracity', responseKind: 'veracity' }, + { entityId: 'claim-a', responseKind: 'stance' }, + { entityId: 'claim-b', responseKind: 'stance' }, ]); }); - it('builds an exact-space entity filter with one exact branch per active vote kind', () => { + it('builds an exact-space entity filter with one exact branch per claim', () => { expect(buildClaimResponseSummaryFilter('space-1', targets)).toEqual({ spaceId: { is: 'space-1' }, objectType: { is: 0 }, voteType: { in: [0, 1] }, or: [ - { objectId: { is: 'claim-stance' }, voteKind: { is: 1 } }, - { objectId: { is: 'claim-veracity' }, voteKind: { is: 2 } }, + { objectId: { is: 'claim-a' }, voteKind: { is: 1 } }, + { objectId: { is: 'claim-b' }, voteKind: { is: 1 } }, ], }); }); - it('groups active positive and negative rows and ignores cleared or inactive-kind rows', () => { + /** + * The retired veracity rows are the interesting half here. + * + * Vote kind 2 is what a Verify or a Dispute was published as, and there are real ones indexed. + * Dropping the kind was the deliberate choice when Verify/Dispute was removed, so a legacy row + * must not reappear in a claim's tally — and it must not reappear as the *viewer's* position + * either, which would draw a side as held that this app can no longer publish or clear. + */ + it('counts only stance rows, leaving retired veracity rows out of the tally', () => { const summaries = groupClaimResponseSummaryRows( targets, [ - row('claim-stance', 1, 'viewer', 0), - row('claim-stance', 1, 'negative-user', 1), - row('claim-stance', 1, 'cleared-user', 2), - row('claim-stance', 2, 'wrong-kind', 0), - row('claim-veracity', 2, 'verifier', 0), + row('claim-a', 1, 'viewer', 0), + row('claim-a', 1, 'negative-user', 1), + row('claim-a', 1, 'cleared-user', 2), + row('claim-a', 2, 'legacy-verifier', 0), + row('claim-b', 2, 'viewer', 0), row('another-claim', 1, 'wrong-claim', 0), ], 'viewer' ); - expect(summaries.get('claim-stance:stance')).toEqual({ + expect(summaries.get('claim-a:stance')).toEqual({ counts: { positive: 1, negative: 1 }, viewerResponse: 'positive', responders: [ @@ -69,21 +77,24 @@ describe('claim response summaries', () => { { userId: 'negative-user', direction: 'negative' }, ], }); - expect(summaries.get('claim-veracity:veracity')).toEqual({ - counts: { positive: 1, negative: 0 }, + + // Every row this claim has is a retired verify, the viewer's own among them. It reads as a + // claim nobody has answered, which is the agreed cost of the clean break. + expect(summaries.get('claim-b:stance')).toEqual({ + counts: { positive: 0, negative: 0 }, viewerResponse: null, - responders: [{ userId: 'verifier', direction: 'positive' }], + responders: [], }); }); it('does not assign a viewer response for anonymous requests', () => { const summaries = groupClaimResponseSummaryRows( - [{ entityId: 'claim-stance', responseKind: 'stance' }], - [row('claim-stance', 1, 'user-1', 1)], + [{ entityId: 'claim-a', responseKind: 'stance' }], + [row('claim-a', 1, 'user-1', 1)], null ); - expect(summaries.get('claim-stance:stance')?.viewerResponse).toBeNull(); + expect(summaries.get('claim-a:stance')?.viewerResponse).toBeNull(); }); it('paginates in deterministic 1,000-row pages and deduplicates rows repeated across pages', async () => { diff --git a/apps/web/core/responses/entity-response.test.ts b/apps/web/core/responses/entity-response.test.ts index e08008ed6a..c42c3dcbf7 100644 --- a/apps/web/core/responses/entity-response.test.ts +++ b/apps/web/core/responses/entity-response.test.ts @@ -4,8 +4,6 @@ import { describe, expect, it, vi } from 'vitest'; import { CLAIM_IS_FACTUAL_PROPERTY_ID, CLAIM_TYPE_ID } from '~/core/claims/ontology'; -import { getChecked } from '~/design-system/checkbox'; - import { ENTITY_RESPONSE_COPY, decodeActiveResponseDirection, @@ -18,6 +16,7 @@ import { hasUnpublishedClaimResponseKindEdit, resolveEntityResponseKind, responseKindToVoteKind, + responsePositionLabel, userEntityResponseQueryKey, waitForIndexedEntityResponse, } from './entity-response'; @@ -59,39 +58,47 @@ function claimEntity(factualValue?: string): ResolveEntity { describe('entity response semantics', () => { it.each([ - [{ isClaim: false, isFactual: false }, 'curation'], - [{ isClaim: false, isFactual: true }, 'curation'], - [{ isClaim: true, isFactual: false }, 'stance'], - [{ isClaim: true, isFactual: true }, 'veracity'], + [{ isClaim: false }, 'curation'], + [{ isClaim: true }, 'stance'], ] as const)('selects the active response kind for %o', (input, expected) => { expect(getEntityResponseKind(input)).toBe(expected); }); - it.each([ - ['1', 'veracity'], - ['0', 'stance'], - [undefined, 'stance'], - ['true', 'stance'], - ['yes', 'stance'], - ['malformed', 'stance'], - ] as const)('uses canonical checked semantics for factual value %s', (factualValue, expected) => { - expect(getEntityResponseKind({ isClaim: true, isFactual: getChecked(factualValue) === true })).toBe(expected); + it('resolves plain entities to curation', () => { + expect(resolveEntityResponseKind(plainEntity())).toBe('curation'); + expect(resolveEntityResponseKind(null)).toBe('curation'); }); - it('resolves plain entities to curation', () => { - expect(resolveEntityResponseKind(plainEntity(), SPACE_ID)).toBe('curation'); - expect(resolveEntityResponseKind(null, SPACE_ID)).toBe('curation'); + /** + * The point of the change, stated directly: a claim flagged factual is answered exactly like one + * that is not. `'1'` is the checked value that used to select Verify/Dispute and its own vote + * kind, and it is the case this has to keep pinned — the others are here so a regression that + * reintroduced the branch on any spelling of the flag still fails. + */ + it.each([['1'], ['0'], [undefined], ['true'], ['yes'], ['malformed']] as const)( + 'answers a claim with a stance whatever its factual value (%s) says', + factualValue => { + expect(resolveEntityResponseKind(claimEntity(factualValue))).toBe('stance'); + } + ); + + it('publishes a factual claim against the same vote kind as any other claim', () => { + const factual = responseKindToVoteKind(resolveEntityResponseKind(claimEntity('1'))); + const ordinary = responseKindToVoteKind(resolveEntityResponseKind(claimEntity('0'))); + + // Not merely equal — equal to the *stance* kind. Both resolving to the retired veracity kind + // would satisfy an equality check and would be the bug. + expect(factual).toBe(ordinary); + expect(factual).toBe(responseKindToVoteKind('stance')); }); - it.each([ - ['1', 'veracity'], - ['0', 'stance'], - [undefined, 'stance'], - ['true', 'stance'], - ['yes', 'stance'], - ['malformed', 'stance'], - ] as const)('resolves claim factual value %s with canonical checked semantics', (factualValue, expected) => { - expect(resolveEntityResponseKind(claimEntity(factualValue), SPACE_ID)).toBe(expected); + it('names both sides of a factual claim Agree and Disagree', () => { + const copy = ENTITY_RESPONSE_COPY[resolveEntityResponseKind(claimEntity('1'))]; + + expect(copy.positiveAction).toBe('Agree'); + expect(copy.negativeAction).toBe('Disagree'); + expect(responsePositionLabel(true)).toBe('Agree'); + expect(responsePositionLabel(false)).toBe('Disagree'); }); it('ignores factual values from other spaces when resolving kind', () => { @@ -113,13 +120,12 @@ describe('entity response semantics', () => { ], } as unknown as ResolveEntity; - expect(resolveEntityResponseKind(entity, SPACE_ID)).toBe('stance'); + expect(resolveEntityResponseKind(entity)).toBe('stance'); }); it.each([ ['curation', 0], ['stance', 1], - ['veracity', 2], ] as const)('maps %s to backend voteKind %i', (kind, expected) => { expect(responseKindToVoteKind(kind)).toBe(expected); }); @@ -131,9 +137,6 @@ describe('entity response semantics', () => { ['stance', 'positive', 'agree'], ['stance', 'negative', 'disagree'], ['stance', 'clear', 'unagree'], - ['veracity', 'positive', 'verify'], - ['veracity', 'negative', 'dispute'], - ['veracity', 'clear', 'unverify'], ] as const)('routes %s/%s to geo.responses.%s', (kind, direction, expected) => { expect(getResponseActionMethod(kind, direction)).toBe(expected); }); @@ -141,7 +144,6 @@ describe('entity response semantics', () => { it.each([ ['curation', 'Upvote', 'Downvote', 'No votes yet'], ['stance', 'Agree', 'Disagree', 'No stances yet'], - ['veracity', 'Verify', 'Dispute', 'No veracity responses yet'], ] as const)('uses semantic %s response copy', (kind, positive, negative, empty) => { expect(ENTITY_RESPONSE_COPY[kind]).toMatchObject({ positiveAction: positive, @@ -161,7 +163,12 @@ describe('entity response semantics', () => { expect(decodeActiveResponseDirection(voteType)).toBe(expected); }); - it('blocks responses while an exact-space factual edit is still unpublished', () => { + /** + * The guard used to stop here, because the flag chose between two vote kinds and responding + * across the edit published the wrong one. The flag chooses nothing now, so blocking on it would + * only disable the pills on a claim whose draft cannot change how a response is published. + */ + it('lets someone respond while a factual edit is still unpublished', () => { const entity = { relations: [], values: [ @@ -174,10 +181,31 @@ describe('entity response semantics', () => { ], } as unknown as NonNullable[0]>; + expect(hasUnpublishedClaimResponseKindEdit(entity, SPACE_ID)).toBe(false); + }); + + /** + * And the half that still matters. Adding the Claim type moves an entity between curation and + * stance, which really are different vote kinds — so this one still has to stop a response. + */ + it('still blocks responses while an unpublished Claim type edit could change the vote kind', () => { + const entity = { + values: [], + relations: [ + { + spaceId: SPACE_ID, + type: { id: SystemIds.TYPES_PROPERTY }, + toEntity: { id: CLAIM_TYPE_ID }, + isLocal: true, + hasBeenPublished: false, + }, + ], + } as unknown as NonNullable[0]>; + expect(hasUnpublishedClaimResponseKindEdit(entity, SPACE_ID)).toBe(true); expect(hasUnpublishedClaimResponseKindEdit(entity, OTHER_SPACE_ID)).toBe(false); - entity.values[0]!.hasBeenPublished = true; + entity.relations[0]!.hasBeenPublished = true; expect(hasUnpublishedClaimResponseKindEdit(entity, SPACE_ID)).toBe(false); }); @@ -299,13 +327,13 @@ describe('entity response query keys', () => { 0, 'stance', ]); - expect(userEntityResponseQueryKey('user', 'entity', 'space', 0, 'veracity')).toEqual([ + expect(userEntityResponseQueryKey('user', 'entity', 'space', 0, 'stance')).toEqual([ 'user-entity-response', 'user', 'entity', 'space', 0, - 'veracity', + 'stance', ]); expect(entityRespondersQueryKey('entity', 'space', 0, 'curation')).toEqual([ 'entity-responders', diff --git a/apps/web/core/responses/entity-response.ts b/apps/web/core/responses/entity-response.ts index 4b95b40406..fe851f8b61 100644 --- a/apps/web/core/responses/entity-response.ts +++ b/apps/web/core/responses/entity-response.ts @@ -1,29 +1,24 @@ import { SystemIds } from '@geoprotocol/geo-sdk/lite'; -import { CLAIM_IS_FACTUAL_PROPERTY_ID, CLAIM_TYPE_ID } from '~/core/claims/ontology'; +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; import { uuidToHex } from '~/core/id/normalize'; import type { Entity } from '~/core/types'; import { sleep } from '~/core/utils/utils'; -import { getChecked } from '~/design-system/checkbox'; - -export type ResponseKind = 'curation' | 'stance' | 'veracity'; +export type ResponseKind = 'curation' | 'stance'; export type ResponseDirection = 'positive' | 'negative' | 'clear'; export type ActiveResponseDirection = Exclude; -export type ResponseVoteKind = 0 | 1 | 2; +export type ResponseVoteKind = 0 | 1; export type ResponseObjectType = 0 | 1; -export type ResponseActionMethod = - 'upvote' | 'downvote' | 'unvote' | 'agree' | 'disagree' | 'unagree' | 'verify' | 'dispute' | 'unverify'; +export type ResponseActionMethod = 'upvote' | 'downvote' | 'unvote' | 'agree' | 'disagree' | 'unagree'; const RESPONSE_VOTE_KIND: Record = { curation: 0, stance: 1, - veracity: 2, }; const CLAIM_TYPE = uuidToHex(CLAIM_TYPE_ID); -const CLAIM_IS_FACTUAL = uuidToHex(CLAIM_IS_FACTUAL_PROPERTY_ID); const TYPES_PROPERTY = uuidToHex(SystemIds.TYPES_PROPERTY); const RESPONSE_ACTION_METHOD: Record> = { @@ -37,11 +32,6 @@ const RESPONSE_ACTION_METHOD: Record = { signIn: 'Sign in to respond', connect: 'Connect wallet to respond', }, - veracity: { - positiveAction: 'Verify', - negativeAction: 'Dispute', - removePositive: 'Remove verification', - removeNegative: 'Remove dispute', - firstResponsePrompt: 'Be the first to verify this claim.', - empty: 'No veracity responses yet', - loading: 'Loading responders…', - viewResponders: 'View veracity responses', - signIn: 'Sign in to respond', - connect: 'Connect wallet to respond', - }, }; -export function responsePositionLabel(responseKind: 'stance' | 'veracity' | null, position: boolean) { - const copy = ENTITY_RESPONSE_COPY[responseKind ?? 'stance']; - return position ? copy.positiveAction : copy.negativeAction; +/** + * What to call one side of a claim: Agree or Disagree. + * + * It used to take the response kind, because a factual claim's sides were called Verify and + * Dispute. Every claim is answered the same way now, so there is nothing left to choose between + * and no caller has to work out which vocabulary a claim is in before it can name a side. + */ +/** + * The copy for a claim's two sides, reached without indexing anything. + * + * Claim surfaces must not do `ENTITY_RESPONSE_COPY[kind]` with a kind that came off the wire. + * geo-chat still labels a claim minted before the vocabularies merged `"veracity"`, and there is no + * key by that name any more — the lookup returns `undefined` and the surface throws on the first + * field it reads, which is a blank ticker rather than a wrong word. TypeScript cannot catch it, + * because the wire value is typed as the narrowed {@link DebateResponseKind} it no longer matches. + * + * So the claim surfaces read this instead, and the untrusted value is never a key. + */ +export const CLAIM_RESPONSE_COPY = ENTITY_RESPONSE_COPY.stance; + +export function responsePositionLabel(position: boolean) { + return position ? CLAIM_RESPONSE_COPY.positiveAction : CLAIM_RESPONSE_COPY.negativeAction; } -export function getEntityResponseKind({ isClaim, isFactual }: { isClaim: boolean; isFactual: boolean }): ResponseKind { - if (!isClaim) return 'curation'; - return isFactual ? 'veracity' : 'stance'; +/** + * Curation for an ordinary entity, a stance for a claim. + * + * A claim used to take a third vocabulary — Verify/Dispute, published as its own vote kind — + * whenever it carried the "Is factual" flag. That split is gone: every claim is answered with + * Agree/Disagree now, whatever the flag says. The flag itself is still written and still read by + * the debate surfaces that care; it just no longer decides how a claim is answered. + */ +export function getEntityResponseKind({ isClaim }: { isClaim: boolean }): ResponseKind { + return isClaim ? 'stance' : 'curation'; } -export function resolveEntityResponseKind( - entity: Pick | null | undefined, - spaceId: string -): ResponseKind { +/** + * No `spaceId`. It used to take one because "Is factual" is a per-space value, so the same claim + * could be factual in one space and not in another. Being a claim is not per-space in that way — + * the Types relation is read across every space the entity lives in, which is what lets a claim + * collected into another space still draw the claim controls. + */ +export function resolveEntityResponseKind(entity: Pick | null | undefined): ResponseKind { const activeRelations = entity?.relations.filter(relation => !relation.isDeleted) ?? []; - const activeValues = entity?.values.filter(value => !value.isDeleted) ?? []; const isClaim = activeRelations.some( relation => uuidToHex(relation.type.id) === TYPES_PROPERTY && uuidToHex(relation.toEntity.id) === CLAIM_TYPE ); - const isFactual = - isClaim && - getChecked( - activeValues.find( - value => uuidToHex(value.spaceId) === uuidToHex(spaceId) && uuidToHex(value.property.id) === CLAIM_IS_FACTUAL - )?.value - ) === true; - - return getEntityResponseKind({ isClaim, isFactual }); + return getEntityResponseKind({ isClaim }); } export function hasUnpublishedClaimResponseKindEdit( @@ -147,15 +145,12 @@ export function hasUnpublishedClaimResponseKindEdit( // that omits `values`, a partial built for a list. A missing field here should cost a false // negative, which is the pills staying live on a claim that has a draft edit; the alternative is // an exception thrown during render, which takes the whole surface down. + // Only the *type* edit is read now. This used to watch the "Is factual" value too, because that + // flag chose between two vote kinds and responding across the edit published the wrong one. It + // no longer chooses anything, so watching it would only block responses on a claim whose draft + // cannot change how the response is published. Adding or removing the Claim type still flips + // curation and stance, which is a real kind change and still worth stopping. return ( - entity?.values?.some( - value => - value?.property?.id != null && - uuidToHex(value.spaceId) === uuidToHex(spaceId) && - uuidToHex(value.property.id) === CLAIM_IS_FACTUAL && - value.isLocal === true && - value.hasBeenPublished !== true - ) || entity?.relations?.some( relation => relation?.type?.id != null && @@ -165,8 +160,7 @@ export function hasUnpublishedClaimResponseKindEdit( uuidToHex(relation.toEntity.id) === CLAIM_TYPE && relation.isLocal === true && relation.hasBeenPublished !== true - ) || - false + ) || false ); } diff --git a/apps/web/core/responses/use-claim-response-summaries.test.tsx b/apps/web/core/responses/use-claim-response-summaries.test.tsx index 36fec23614..33a7931031 100644 --- a/apps/web/core/responses/use-claim-response-summaries.test.tsx +++ b/apps/web/core/responses/use-claim-response-summaries.test.tsx @@ -44,7 +44,7 @@ describe('useClaimResponseSummaryBatch', () => { it('loads all 50 visible claim-kind pairs through one batch query function', async () => { const targets = Array.from({ length: 50 }, (_, index) => ({ entityId: `claim-${index}`, - responseKind: index % 2 === 0 ? ('stance' as const) : ('veracity' as const), + responseKind: 'stance' as const, })); const { wrapper } = createHarness(); @@ -74,7 +74,7 @@ describe('useClaimResponseSummaryBatch', () => { mocks.loadCaches.mockResolvedValue(loaded); const { result, rerender } = renderHook( - ({ targets }: { targets: { entityId: string; responseKind: 'stance' | 'veracity' }[] }) => + ({ targets }: { targets: { entityId: string; responseKind: 'stance' }[] }) => useClaimResponseSummaryBatch({ spaceId: 'space-1', targets, enabled: true }), { wrapper, initialProps: { targets: [{ entityId: 'claim-1', responseKind: 'stance' as const }] } } ); diff --git a/apps/web/core/topics/browse/topic-debates.tsx b/apps/web/core/topics/browse/topic-debates.tsx index 8b33716f49..c041b41ac0 100644 --- a/apps/web/core/topics/browse/topic-debates.tsx +++ b/apps/web/core/topics/browse/topic-debates.tsx @@ -8,7 +8,6 @@ import { DebateRow, type DebateSide, relationTargets, useWinnerShares } from '~/ import { CursorPager, useCursorPages } from '~/core/claims/browse/use-cursor-pages'; import { useDebateKeyframes } from '~/core/claims/browse/use-debate-keyframes'; import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; -import { claimResponseKind } from '~/core/claims/response-kind'; import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_OPPOSED_BY_PROPERTY_ID, @@ -110,40 +109,6 @@ export function TopicDebates({ topicId, spaceId }: { topicId: string; spaceId: s ); const { profilesBySpaceId } = useProfilesBySpaceIds(participantSpaceIds, participantSpaceIds.length > 0); - /** - * How each debate's sides should be labelled, read from the claim it argues. - * - * A factual claim is verified or disputed; everything else is agreed or disagreed with. The - * section used to label every row `stance`, which is right for most claims and simply wrong on a - * factual one — and the claim is already in hand, since the debates were found through it. - * - * A debate spanning claims of both kinds takes the first one it names that this page loaded, - * which is the claim the row is here on behalf of. There is no correct single label for such a - * debate, and picking the claim that put it on the page at least makes the label match the - * section around it. - */ - const responseKindByDebateId = React.useMemo(() => { - const claimsById = new Map(claims.map(claim => [claim.id, claim])); - const kinds = new Map(); - - for (const debate of debates) { - const argued = relationTargets(debate.relations, DEBATE_CLAIMS_PROPERTY_ID) - .map(id => claimsById.get(id)) - .find(Boolean); - // The claim's own space, not the route's: `claimResponseKind` reads a space-scoped value, and - // a topic gathers across spaces, so reading it in the route's space finds nothing and every - // factual claim quietly falls back to `stance` — the bug this is fixing. - // - // Which is also why `spaces[0]` won't do. That list counts spaces holding a relation authored - // from the claim and is rank-sorted, so its head is whichever citing space ranks highest — - // `Is factual` isn't there either, and the fallback fires just the same, from a line that - // looks like it addressed the problem. `resolveEntitySpaceId` asks where the claim is - // actually placed, and its own docs name this flag as the case it exists for. - if (argued) kinds.set(debate.id, claimResponseKind(argued, resolveEntitySpaceId(argued, spaceId))); - } - return kinds; - }, [claims, debates, spaceId]); - const debateIds = React.useMemo(() => debates.map(debate => debate.id), [debates]); const winnerShareByDebateId = useWinnerShares(debateIds); const keyframeByDebateId = useDebateKeyframes(debates); @@ -168,10 +133,6 @@ export function TopicDebates({ topicId, spaceId }: { topicId: string; spaceId: s profilesBySpaceId={profilesBySpaceId} winnerShare={winnerShareByDebateId.get(debate.id) ?? null} keyframeUrl={keyframeByDebateId.get(debate.id) ?? null} - // Sides are labelled in the vocabulary of the claim being argued. `stance` stands in - // only when the debate names no claim this page loaded, which is the one case there - // is nothing to read the vocabulary from. - responseKind={responseKindByDebateId.get(debate.id) ?? 'stance'} /> ))} diff --git a/apps/web/design-system/icons/response-position-icon.test.tsx b/apps/web/design-system/icons/response-position-icon.test.tsx index 06c7cfa778..7f10b0ab26 100644 --- a/apps/web/design-system/icons/response-position-icon.test.tsx +++ b/apps/web/design-system/icons/response-position-icon.test.tsx @@ -7,8 +7,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { ResponseKind } from '~/core/responses/entity-response'; -import { ChevronDown } from '~/design-system/icons/chevron-down'; -import { ChevronUp } from '~/design-system/icons/chevron-up'; import { ThumbDown } from '~/design-system/icons/thumb-down'; import { ThumbUp } from '~/design-system/icons/thumb-up'; import { VoteArrow } from '~/design-system/icons/vote-arrow'; @@ -16,10 +14,12 @@ import { VoteArrow } from '~/design-system/icons/vote-arrow'; import { ResponsePositionIcon } from './response-position-icon'; /** - * The mapping used to be written out at all three surfaces that draw it — the entity vote buttons, - * the claim ticker over the video, and the position pills — and the pills' copy was the one missing - * the veracity branch, so a factual claim was thumbed in the claims side panel while the ticker - * above it drew a chevron for the same claim. These pin the mapping now that there is one of it. + * There are two glyphs now, not three. + * + * A claim carrying the "Is factual" flag used to draw a chevron, because it was verified or + * disputed rather than agreed with. Every claim is a stance now, so a thumb is the only thing a + * claim draws — and `ResponseKind` no longer has a value that could ask for anything else, which + * is what keeps a third glyph from creeping back. */ describe('ResponsePositionIcon', () => { afterEach(cleanup); @@ -43,12 +43,7 @@ describe('ResponsePositionIcon', () => { const glyph = (responseKind: ResponseKind, position: boolean, selected = false) => markup(); - it('draws chevrons for a veracity claim, where the act is confirming rather than approving', () => { - expect(glyph('veracity', true)).toBe(markup()); - expect(glyph('veracity', false)).toBe(markup()); - }); - - it('draws thumbs for a stance claim', () => { + it('draws thumbs for a claim', () => { expect(glyph('stance', true)).toBe(markup()); expect(glyph('stance', false)).toBe(markup()); }); @@ -58,10 +53,7 @@ describe('ResponsePositionIcon', () => { expect(glyph('curation', false)).toBe(markup()); }); - // `selected` is advisory: it fills the glyphs that have a filled form and is inert on the one - // that does not. A chevron that quietly dropped it would read as veracity having a fill that - // never arrives, which is the bug a previous shared `Icon` const shipped. - it('fills the held side of a stance claim', () => { + it('fills the held side of a claim', () => { expect(glyph('stance', true, true)).toBe(markup()); expect(glyph('stance', false, true)).toBe(markup()); }); @@ -70,9 +62,19 @@ describe('ResponsePositionIcon', () => { expect(glyph('curation', true, true)).toBe(markup()); expect(glyph('curation', false, true)).toBe(markup()); }); + /** + * The wire can still say `"veracity"`. + * + * geo-chat labels claims minted before the vocabularies merged with a kind this app no longer + * has, and the value reaches here typed as one it does — TypeScript cannot catch it. An unguarded + * lookup returns `undefined` and throws on the call, which blanks the whole surface: the claims + * ticker over a debate video, or a card in the matches list. + */ + it('falls back to the claim glyph for a kind that is no longer a kind', () => { + const retired = 'veracity' as unknown as ResponseKind; - it('leaves a chevron unchanged when held, having no filled form to switch to', () => { - expect(glyph('veracity', true, true)).toBe(glyph('veracity', true, false)); - expect(glyph('veracity', false, true)).toBe(glyph('veracity', false, false)); + expect(() => glyph(retired, true)).not.toThrow(); + expect(glyph(retired, true)).toBe(markup()); + expect(glyph(retired, false)).toBe(markup()); }); }); diff --git a/apps/web/design-system/icons/response-position-icon.tsx b/apps/web/design-system/icons/response-position-icon.tsx index 2832788efc..7ae9381c15 100644 --- a/apps/web/design-system/icons/response-position-icon.tsx +++ b/apps/web/design-system/icons/response-position-icon.tsx @@ -2,8 +2,6 @@ import * as React from 'react'; import type { ResponseKind } from '~/core/responses/entity-response'; -import { ChevronDown } from '~/design-system/icons/chevron-down'; -import { ChevronUp } from '~/design-system/icons/chevron-up'; import { ThumbDown } from '~/design-system/icons/thumb-down'; import { ThumbUp } from '~/design-system/icons/thumb-up'; import { VoteArrow } from '~/design-system/icons/vote-arrow'; @@ -12,7 +10,7 @@ type Props = { responseKind: ResponseKind; /** The side this glyph stands for: `true` is the positive one (Upvote / Agree / Verify). */ position: boolean; - /** Whether the viewer holds this side. Only the glyphs that have a filled form can say so. */ + /** Whether the viewer holds this side, which fills the glyph. */ selected?: boolean; }; @@ -30,33 +28,29 @@ type Props = { */ const RESPONSE_POSITION_GLYPH: Record>) => React.ReactNode> = { - // A stroke with no interior, so there is no filled form to switch to and `selected` goes - // unread. The control around it says which side is held — a fill, a colour, an `aria-pressed`. - veracity: ({ position }) => (position ? : ), stance: ({ position, selected }) => (position ? : ), curation: ({ position, selected }) => , }; /** - * The glyph for one side of a response, chosen by what the response *means*. + * The glyph for one side of a response: an arrow for curation, a thumb for a claim. * - * An arrow for curation, a thumb for a stance, a chevron for a veracity claim. The three are not - * decoration: upvoting, agreeing and verifying are different acts, and a thumb on "the SEC sued - * Coinbase" reads as approval rather than confirmation. + * There used to be a third, a chevron, for a claim carrying the "Is factual" flag — those were + * verified or disputed rather than agreed with. Claims are all answered the same way now, so the + * chevron has nothing left to mean here and both claim sides are thumbs. * - * It did not start in one place. The mapping was written out three times — the entity vote buttons - * (through a `variant` derived from nothing but the kind), the claim ticker over the video, and the - * position pills — and the pills' copy was the one that never got the veracity branch, so a factual - * claim was thumbed in the claims side panel while the ticker directly above it drew a chevron for - * the very same claim. + * `selected` fills the glyph, so the side you hold reads as taken even in a screenshot. * - * `selected` is deliberately advisory rather than required: the veracity chevron has no filled form - * and ignores it. Callers that pass it anyway are not wrong; it simply has nothing to change there. - * - * No `color` on any of them: each takes `currentColor` from the button it sits in. Pinning a colour - * here would let one glyph answer for its own shade while its neighbours read the control's, which - * is how the three spellings drifted apart in the first place. + * No `color` on either: each takes `currentColor` from the button it sits in. Pinning a colour here + * would let one glyph answer for its own shade while its neighbour reads the control's, which is + * how the spellings of this mapping drifted apart when there were three of them. */ export function ResponsePositionIcon({ responseKind, position, selected = false }: Props) { - return <>{RESPONSE_POSITION_GLYPH[responseKind]({ position, selected })}; + // `?? stance` guards a value that is not a `ResponseKind` at all, which the types say cannot + // happen and the wire disagrees. geo-chat still labels claims minted before the vocabularies + // merged `"veracity"`, and that string reaches these controls typed as the narrowed kind it no + // longer matches — so the lookup misses, and calling the result throws during render. A blank + // debate ticker is a far worse answer than a thumb. + const glyph = RESPONSE_POSITION_GLYPH[responseKind] ?? RESPONSE_POSITION_GLYPH.stance; + return <>{glyph({ position, selected })}; } diff --git a/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx b/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx index dcd056367f..60f9f72399 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx @@ -145,13 +145,16 @@ describe('EntityVoteButtons claims-page batching', () => { expect(mocks.queryEntityOptions.at(-1)).toMatchObject({ enabled: false }); }); - it('renders factual claims with the original chevron controls and no explanatory label', () => { - const view = renderButtons(true, true, 'veracity'); + // This used to assert chevrons — the `0 0 16 16` glyphs a factual claim drew. Claims are thumbs + // now, which are `0 0 12 12`, so the case is kept and its expectation inverted: the chevrons must + // not come back on a claim. + it('renders a claim with thumb controls and no explanatory label', () => { + const view = renderButtons(true, true, 'stance'); expect(view.queryByText('Is factual')).not.toBeInTheDocument(); const responseIcons = [...view.container.querySelectorAll('svg')]; expect(responseIcons).toHaveLength(2); - expect(responseIcons.every(icon => icon.getAttribute('viewBox') === '0 0 16 16')).toBe(true); + expect(responseIcons.every(icon => icon.getAttribute('viewBox') === '0 0 12 12')).toBe(true); }); it('renders persisted curation state in the fullscreen debate pill', () => { @@ -226,7 +229,7 @@ describe('EntityVoteButtons claims-page batching', () => { it('renders 50 batched claims with one summary request and no individual response requests', async () => { const targets = Array.from({ length: 50 }, (_, index) => ({ entityId: `claim-${index}`, - responseKind: index % 2 === 0 ? ('stance' as const) : ('veracity' as const), + responseKind: 'stance' as const, })); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -247,7 +250,7 @@ describe('EntityVoteButtons claims-page batching', () => { }); }); -function BatchedClaims({ targets }: { targets: Array<{ entityId: string; responseKind: 'stance' | 'veracity' }> }) { +function BatchedClaims({ targets }: { targets: Array<{ entityId: string; responseKind: 'stance' }> }) { const batch = useClaimResponseSummaryBatch({ spaceId: 'space-1', targets, enabled: true }); return ( @@ -263,7 +266,7 @@ function BatchedClaims({ targets }: { targets: Array<{ entityId: string; respons ); } -function renderButtons(ready: boolean, seedCaches = false, responseKind: 'stance' | 'veracity' = 'stance') { +function renderButtons(ready: boolean, seedCaches = false, responseKind: 'stance' = 'stance') { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); if (seedCaches) { queryClient.setQueryData(entityResponseCountsQueryKey('claim-1', 'space-1', 0, responseKind), { diff --git a/apps/web/partials/entity-page/entity-vote-buttons.claim-space.test.tsx b/apps/web/partials/entity-page/entity-vote-buttons.claim-space.test.tsx index 2521419d83..7936193a6e 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.claim-space.test.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.claim-space.test.tsx @@ -205,25 +205,28 @@ describe('EntityVoteButtons claim detection across spaces', () => { expect(screen.queryByText('67%')).not.toBeInTheDocument(); }); - // Which *kind* of claim response is asked for stays a per-space question: the space passed in is - // the one being responded in, and "Is factual" is a per-space value. - it('reads the factual flag from the space it was asked to respond in', async () => { - mocks.entity = claimEntity({ isFactualIn: BLOCK_SPACE, alsoIn: BLOCK_SPACE }); - const view = renderButtons(); - - await screen.findByText('67%'); - // Veracity draws chevrons, which are the only 16x16 icons among the response controls. - const icons = [...view.container.querySelectorAll('svg')]; - expect(icons.some(icon => icon.getAttribute('viewBox') === '0 0 16 16')).toBe(true); - }); - - it('ignores a factual flag set in a space other than the one being responded in', async () => { - mocks.entity = claimEntity({ isFactualIn: CLAIM_SPACE, alsoIn: BLOCK_SPACE }); + /** + * Which *kind* of claim response is asked for used to be a per-space question, because + * "Is factual" is a per-space value and it chose between two vocabularies. Two cases sat here + * for it: one where the flag was set in the space being responded in (chevrons expected) and one + * where it was set elsewhere (no chevrons). + * + * Neither can fail now — the flag selects nothing — so they are replaced by the invariant that + * took their place: wherever the flag is set, the controls are the same. `0 0 16 16` is the + * chevron viewBox, the only 16x16 glyph among the response controls, so its absence is what says + * the retired vocabulary has not come back. + */ + it.each([ + ['the space being responded in', { isFactualIn: BLOCK_SPACE, alsoIn: BLOCK_SPACE }], + ['some other space', { isFactualIn: CLAIM_SPACE, alsoIn: BLOCK_SPACE }], + ])('draws the same controls with the factual flag set in %s', async (_where, entityArgs) => { + mocks.entity = claimEntity(entityArgs); const view = renderButtons(); await screen.findByText('67%'); const icons = [...view.container.querySelectorAll('svg')]; expect(icons.some(icon => icon.getAttribute('viewBox') === '0 0 16 16')).toBe(false); + expect(icons.some(icon => icon.getAttribute('viewBox') === '0 0 12 12')).toBe(true); }); }); @@ -251,15 +254,17 @@ describe('EntityVoteButtons response space resolution', () => { expect(mocks.responseSpaceIds.at(-1)).toBe(CLAIM_SPACE); }); - // The kind follows the space: once the claim resolves to its own space, that space's flag is the - // one being responded against. - it('reads the factual flag from the resolved space', async () => { + // The space still decides where the response is published and tallied — that is the case above. + // What it no longer decides is the vocabulary, so a claim resolving to a space that flags it + // factual still draws the same thumbs as any other claim. + it('draws the ordinary claim controls even where the resolved space flags it factual', async () => { mocks.entity = claimEntity({ isFactualIn: CLAIM_SPACE }); const view = renderButtons(); await screen.findByText('67%'); const icons = [...view.container.querySelectorAll('svg')]; - expect(icons.some(icon => icon.getAttribute('viewBox') === '0 0 16 16')).toBe(true); + expect(icons.some(icon => icon.getAttribute('viewBox') === '0 0 16 16')).toBe(false); + expect(icons.some(icon => icon.getAttribute('viewBox') === '0 0 12 12')).toBe(true); }); /** diff --git a/apps/web/partials/entity-page/entity-vote-buttons.selected-state.test.tsx b/apps/web/partials/entity-page/entity-vote-buttons.selected-state.test.tsx index 2261262e5f..2021c71244 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.selected-state.test.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.selected-state.test.tsx @@ -10,11 +10,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ResponseKind } from '~/core/responses/entity-response'; import { EntityVoteButtons } from './entity-vote-buttons'; -import { VOTE_BUTTON_CLASS, VOTE_CHEVRON_SELECTED_CLASS } from './vote-button-styles'; +import { VOTE_BUTTON_CLASS } from './vote-button-styles'; /** * GEO-2792. Four surfaces had four answers for "this is the one you picked": curation said it with - * fill alone, stance darkened to `grey-04`, veracity used a hand-written `#2A2B2E`, and the debates + * fill alone, stance darkened to `grey-04`, and the debates * pill went blue for up and red for down. * * They now all say it the way curation always did — grey, with the filled icon carrying the signal. @@ -112,37 +112,33 @@ describe('the selected vote treatment', () => { }); }); - // Deliberately exempt, and unchanged from what shipped: a chevron has no filled form, so colour - // is the only signal it has. - describe('veracity chevrons', () => { - it('keeps its own darker selected colour', () => { + /** + * The exception is gone with the chevrons. + * + * A veracity claim used to keep its own darker selected colour, because a chevron has no filled + * form and colour was the only signal it had. Claims are all thumbs now, so the held side is said + * by the fill and this control has exactly one shade — which is what this pins, since the + * hand-written `#2A2B2E` that used to override it has been deleted. + */ + describe('a claim', () => { + it('takes the same grey as every other control, held or not', () => { mocks.optimisticResponse = 'positive'; - render(, { wrapper }); + render(, { wrapper }); - expect(inlineButtons().up).toHaveClass(VOTE_CHEVRON_SELECTED_CLASS); - }); - - // Presence is not enough, and this is the assertion the first version of this file was missing. - // `cx` is `classnames`: it concatenates rather than resolving conflicting Tailwind utilities, - // so emitting the grey alongside the darker colour leaves the winner to whichever rule Tailwind - // emits second — and it emitted grey, silently deleting this exception. jsdom evaluates no - // cascade, so only the absence of the competing class can catch it. - it('does not also carry the grey it is meant to override', () => { - mocks.optimisticResponse = 'positive'; - render(, { wrapper }); - - const { up } = inlineButtons(); - expect(up).not.toHaveClass('text-grey-04'); - expect(up.className.match(/(^|\s)text-/g) ?? []).toHaveLength(1); + const { up, down } = inlineButtons(); + expect(up).toHaveClass('text-grey-04'); + expect(down).toHaveClass('text-grey-04'); + expect(up.className).toBe(down.className); }); - it('leaves the direction the viewer did not pick grey', () => { + // `cx` concatenates rather than resolving conflicting Tailwind utilities, so a second `text-` + // class would leave the winner to whichever rule Tailwind emits second. jsdom evaluates no + // cascade, so only counting them can catch it. + it('carries exactly one text colour', () => { mocks.optimisticResponse = 'positive'; - render(, { wrapper }); + render(, { wrapper }); - const { down } = inlineButtons(); - expect(down).not.toHaveClass(VOTE_CHEVRON_SELECTED_CLASS); - expect(down).toHaveClass('text-grey-04'); + expect(inlineButtons().up.className.match(/(^|\s)text-/g) ?? []).toHaveLength(1); }); }); diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index 0eee8ee7e6..bab6a77569 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -45,7 +45,7 @@ import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Skeleton } from '~/design-system/skeleton'; import { ClaimResponderAvatars } from '~/partials/entity-page/claim-voter-avatars'; -import { VOTE_BUTTON_CLASS, VOTE_CHEVRON_SELECTED_CLASS } from '~/partials/entity-page/vote-button-styles'; +import { VOTE_BUTTON_CLASS } from '~/partials/entity-page/vote-button-styles'; import { slideUpPopoverContainerAtom } from '~/atoms'; @@ -107,7 +107,7 @@ export function EntityVoteButtons({ // by design — so that vote is no longer the one displayed. It is still recorded in that space. // Auto-join doesn't widen with it: `useEntityVote` excludes curation from `ensureSpaceMembership`. const spaceId = resolveEntitySpaceId(entity, requestedSpaceId); - const inferredResponseKind = resolveEntityResponseKind(entity, spaceId); + const inferredResponseKind = resolveEntityResponseKind(entity); const responseKind = responseKindOverride === undefined ? inferredResponseKind : responseKindOverride; const hasUnpublishedResponseKindEdit = responseKindOverride === undefined && hasUnpublishedClaimResponseKindEdit(entity, spaceId); @@ -271,20 +271,14 @@ export function EntityVoteButtons({ const isClaimResponse = queryResponseKind !== 'curation'; const displayLabel = isClaimResponse ? percentLabel : scoreLabel; - // Grey either way; the filled icon says which one you picked. The thumbs used to rest lighter - // and darken when picked, and curation got no class at all, pinning its arrows' colour on the - // icon instead — three spellings of a control that should look the same everywhere. See + // Grey whichever side is held; the filled icon says which one you picked. The thumbs used to rest + // lighter and darken when picked, and curation got no class at all, pinning its arrows' colour on + // the icon instead — three spellings of a control that should look the same everywhere. See // `vote-button-styles` for why the shade is `grey-04` rather than the lighter `grey-03`. // - // Chevrons are the exception, unchanged: a chevron has no filled form to switch to, so colour is - // the only signal it has. - // - // One class or the other, never both. `cx` is `classnames`, which concatenates — it does not - // resolve conflicting Tailwind utilities the way `tailwind-merge` would, and this repo does not - // use that. Emitting `text-grey-03` alongside `text-[#2A2B2E]` leaves the winner to whichever - // rule Tailwind happens to emit second, which is not something this file gets to decide. - const responseButtonColor = (active: boolean) => - queryResponseKind === 'veracity' && active ? VOTE_CHEVRON_SELECTED_CLASS : VOTE_BUTTON_CLASS; + // Every response kind takes the same class now, held or not. The exception was the veracity + // chevron, which had no filled form and so needed colour to say it was held; there are no + // chevrons here any more, so this is no longer a choice and `VOTE_BUTTON_CLASS` is used directly. const claimResponderAvatars = isClaimResponse ? ( @@ -397,7 +391,7 @@ export function EntityVoteButtons({ title={negativeTitle} className={cx( 'group/vote flex h-5 w-5 items-center justify-center rounded transition-colors', - responseButtonColor(negativeActive), + VOTE_BUTTON_CLASS, responseDisabled && 'cursor-default opacity-50' )} > diff --git a/apps/web/partials/entity-page/vote-button-styles.ts b/apps/web/partials/entity-page/vote-button-styles.ts index bb9055a01c..e0ab24e8a8 100644 --- a/apps/web/partials/entity-page/vote-button-styles.ts +++ b/apps/web/partials/entity-page/vote-button-styles.ts @@ -16,23 +16,10 @@ * (`#606060`) gives 6.29:1. The pill was already the one surface meeting it, which is why hover * borrows its `text` as well — a control resting at `grey-04` has to go somewhere darker still. * - * The veracity chevrons are the deliberate exception and keep their own selected colour. A chevron - * has no filled form to switch to, so colour is the only signal it has. + * There used to be one exception — the veracity chevrons kept their own selected colour, because a + * chevron has no filled form to switch to and colour was the only signal it had. Claims are all + * answered with thumbs now, so every control here fills its icon and this is the only shade. * * One definition so these cannot drift apart again. */ export const VOTE_BUTTON_CLASS = 'text-grey-04 hover:text-text'; - -/** - * The veracity chevrons' selected colour, kept exactly as it shipped. - * - * Not a token: `#2A2B2E` is a near-black written by hand in about a dozen files, ten units off the - * theme's own `text` (`#202020`). Left alone because this ticket is about the greys, but it is the - * one hardcoded colour still in this control. - * - * Applied *instead of* {@link VOTE_BUTTON_CLASS}, never alongside it. `cx` is `classnames`, which - * concatenates — it does not resolve conflicting Tailwind utilities the way `tailwind-merge` would, - * and this repo does not use that. Emitting both leaves the winner to whichever rule Tailwind - * happens to emit second, which is not something a component gets to decide. - */ -export const VOTE_CHEVRON_SELECTED_CLASS = 'text-[#2A2B2E]'; diff --git a/apps/web/partials/explore/claim-explore-feed-card.test.tsx b/apps/web/partials/explore/claim-explore-feed-card.test.tsx index c566b74e05..0370b56825 100644 --- a/apps/web/partials/explore/claim-explore-feed-card.test.tsx +++ b/apps/web/partials/explore/claim-explore-feed-card.test.tsx @@ -348,20 +348,21 @@ describe('ClaimExploreFeedCard', () => { expect(mocks.summaryEnabledCalls.at(-1)).toBe(false); }); - it('waits for the vocabulary before reading the split, not just for the viewport', () => { + it('waits for the claim’s data before reading the split, not just for the viewport', () => { mocks.entity = factualClaim(); const { rerender } = render(); scrollIntoRange(); rerender(); expect(mocks.summaryEnabledCalls.at(-1)).toBe(true); - // And under the kind the entity supplied, never the fallback it would have used a beat earlier. - expect(mocks.summaryKindCalls.at(-1)).toBe('veracity'); + // One kind for every claim now, the factual ones included — this fixture is a factual claim. + expect(mocks.summaryKindCalls.at(-1)).toBe('stance'); }); - it('will not let anyone answer before the claim’s vocabulary is known', () => { - // `stance` is the fallback while the lookups are out, and the kind selects `voteKind` on the - // write — so a click inside that window publishes the wrong vote on a factual claim. + it('will not let anyone answer before the claim’s responses are known', () => { + // The vocabulary is no longer what is being waited for — there is one. What is still being + // waited for is the claim's own data, without which a pill cannot say which side the viewer + // already holds, and a click would republish rather than clear it. mocks.entity = factualClaim(); render(); // Off-screen nothing has been asked, so nothing has answered — including on a claim whose @@ -376,7 +377,7 @@ describe('ClaimExploreFeedCard', () => { const pills = screen.getByTestId('pills'); expect(pills.getAttribute('data-disabled')).toBe('false'); - expect(pills.getAttribute('data-response-kind')).toBe('veracity'); + expect(pills.getAttribute('data-response-kind')).toBe('stance'); }); it('draws no verdict, and no rule, on a claim nobody has answered', () => { diff --git a/apps/web/partials/explore/claim-explore-feed-card.tsx b/apps/web/partials/explore/claim-explore-feed-card.tsx index 83f6245bf7..b3bdab964e 100644 --- a/apps/web/partials/explore/claim-explore-feed-card.tsx +++ b/apps/web/partials/explore/claim-explore-feed-card.tsx @@ -17,7 +17,7 @@ import type { ExploreFeedItem } from '~/core/explore/fetch-explore-feed'; import { useCommentCount } from '~/core/hooks/use-comment-count'; import { useNearViewport } from '~/core/hooks/use-near-viewport'; import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; -import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; +import { CLAIM_RESPONSE_COPY, type ResponseKind } from '~/core/responses/entity-response'; import { useQueryEntity } from '~/core/sync/use-store'; import { Text } from '~/design-system/text'; @@ -91,7 +91,7 @@ export function ClaimExploreFeedCard({ * only this card knows. Absent everywhere else, which is every surface where * the only answer worth reporting is the reader's own. */ - responseNote?: (responseKind: 'stance' | 'veracity', position: boolean) => React.ReactNode; + responseNote?: (responseKind: ResponseKind, position: boolean) => React.ReactNode; }) { // The feed pre-mounts cards thousands of pixels below the fold, so the counts and the geo-chat // row are gated on proximity rather than on mount — otherwise every claim in every loaded page @@ -387,11 +387,11 @@ export function ClaimVerdictColumn({ }: { entityId: string; spaceId: string; - responseKind: 'stance' | 'veracity'; + responseKind: ResponseKind; summary: ClaimResponseSummary; matchDebatePanelOnMobile: boolean; }) { - const copy = ENTITY_RESPONSE_COPY[responseKind]; + const copy = CLAIM_RESPONSE_COPY; const percent = summary.percent ?? 0; @@ -416,7 +416,7 @@ export function ClaimVerdictColumn({ {copy.positiveAction.toLowerCase()}
- + {/* The Controversial tag is not repeated here — it sits beside the space chip, where it says what kind of claim this is rather than adding a second voice to the split. */} {/* Stacked, because this is the 220px rail and it cannot hold both across. The phone's diff --git a/apps/web/partials/explore/explore-feed-card.tsx b/apps/web/partials/explore/explore-feed-card.tsx index c321311574..ec83833f55 100644 --- a/apps/web/partials/explore/explore-feed-card.tsx +++ b/apps/web/partials/explore/explore-feed-card.tsx @@ -8,6 +8,7 @@ import { useRecordingSources } from '~/core/community-calls/use-recording-source 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 type { ResponseKind } from '~/core/responses/entity-response'; import { normId } from '~/core/utils/norm-id'; import { PublishedRecordingPlayer } from '~/partials/community-calls/published-recording-player'; @@ -34,7 +35,7 @@ type ExploreFeedCardProps = { /** Presentation used for Claim rows; other entity types ignore it. */ claimCardVariant?: ClaimCardVariant; /** See `ClaimExploreFeedCard`. Only a claim can carry one. */ - responseNote?: (responseKind: 'stance' | 'veracity', position: boolean) => React.ReactNode; + responseNote?: (responseKind: ResponseKind, 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`. */ diff --git a/apps/web/partials/profile/claim-response-tag.test.tsx b/apps/web/partials/profile/claim-response-tag.test.tsx index 238d959d19..846af56781 100644 --- a/apps/web/partials/profile/claim-response-tag.test.tsx +++ b/apps/web/partials/profile/claim-response-tag.test.tsx @@ -14,7 +14,7 @@ import { ClaimResponseTag, firstName } from './claim-response-tag'; * record of attention with the verdict left out. * * Two things carry the meaning together. The **wording** follows the claim's own - * question: a claim marked factual asks Verify or Dispute rather than Agree or + * question. A claim marked factual used to ask Verify or Dispute rather than * Disagree, so a tag that always said "agrees" would contradict the control * above it on exactly the claims where the distinction matters — 18 of the * reference account's 208 positions are answered that way. The **placement** @@ -25,27 +25,27 @@ describe('ClaimResponseTag', () => { afterEach(cleanup); it('says how they come down on an ordinary claim', () => { - render(); + render(); expect(screen.getByText('agrees')).toBeInTheDocument(); }); it('says disagrees for the other side', () => { - render(); + render(); expect(screen.getByText('disagrees')).toBeInTheDocument(); }); it('uses the factual vocabulary on a factual claim', () => { - render(); + render(); - expect(screen.getByText('verifies')).toBeInTheDocument(); + expect(screen.getByText('agrees')).toBeInTheDocument(); }); - it('says disputes for the other side of a factual claim', () => { - render(); + it('says disagrees for the other side of a claim that used to be factual', () => { + render(); - expect(screen.getByText('disputes')).toBeInTheDocument(); + expect(screen.getByText('disagrees')).toBeInTheDocument(); }); /** @@ -56,47 +56,19 @@ describe('ClaimResponseTag', () => { * the placement is what the reader is reading. */ it('appears under the button matching their side, and not the other', () => { - const { container } = render( - - ); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); it('puts a disagreement under Disagree rather than Agree', () => { - const { container } = render( - - ); - - expect(container).toBeEmptyDOMElement(); - }); - - // The two answers are separate votes on separate questions, so one does not - // stand in for the other — showing a stance where the card asks about veracity - // would report an answer they never gave to the question on screen. - it('says nothing when they answered the other question', () => { - const { container } = render( - - ); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); - it('shows the answer that matches, when they gave both', () => { - render( - - ); - - expect(screen.getByText('disputes')).toBeInTheDocument(); - expect(screen.queryByText('agrees')).not.toBeInTheDocument(); - }); - it('says nothing for a claim they have not answered', () => { - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); @@ -106,7 +78,7 @@ describe('ClaimResponseTag', () => { // that was never given. In practice the tab filters these claims out before // they reach a card at all, since "neither" is a retraction rather than an // answer; this is the rendering of last resort. - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); @@ -119,16 +91,14 @@ describe('ClaimResponseTag', () => { * reader to work out which of the two it means. */ it('names the person whose record it is', () => { - render( - - ); + render(); expect(screen.getByText(/Susan/)).toBeInTheDocument(); expect(screen.queryByText(/Winter/)).not.toBeInTheDocument(); }); it('falls back to "They" before the name has loaded', () => { - render(); + render(); expect(screen.getByText(/They/)).toBeInTheDocument(); }); diff --git a/apps/web/partials/profile/claim-response-tag.tsx b/apps/web/partials/profile/claim-response-tag.tsx index 18f76482e2..0bcd698d2d 100644 --- a/apps/web/partials/profile/claim-response-tag.tsx +++ b/apps/web/partials/profile/claim-response-tag.tsx @@ -28,20 +28,18 @@ import type { ClaimResponse } from '~/core/profile/person-position-order'; * "Susan agrees" is a fact about the claim as it stands, which is the same thing * the tally to the right of it reports. * - * **Worded from the claim's own question.** A claim marked factual asks Verify - * or Dispute rather than Agree or Disagree, so a tag that always said "agrees" - * would contradict the button directly above it on exactly the claims where the - * distinction matters. The card resolves that kind — the same claim can be - * factual in one space and not in another — and hands it here. + * **One wording, because there is one question.** This used to take the claim's + * response kind, because a claim marked factual asked Verify or Dispute and a + * tag that always said "agrees" would contradict the button above it. Every + * claim asks the same question now, so there is nothing to resolve and nothing + * the card has to hand down. */ export function ClaimResponseTag({ response, - responseKind, personName, forPosition, }: { response: ClaimResponse | undefined; - responseKind: 'stance' | 'veracity'; /** The profile's owner. Falls back to "They" where the name has not loaded. */ personName?: string | null; /** @@ -50,11 +48,10 @@ export function ClaimResponseTag({ */ forPosition: boolean; }) { - const side = responseKind === 'veracity' ? response?.veracity : response?.stance; + const side = response?.stance; - // Nothing to say: they have not answered this question, or answered the other - // one — a stance where the card asks about veracity. An absent tag is the - // honest rendering of both; a greyed one would imply a verdict never given. + // Nothing to say: they have not answered this claim. An absent tag is the + // honest rendering; a greyed one would imply a verdict never given. // // Not the retracted case, which is filtered out of the tab entirely rather // than rendered blank here: `decodeVoteOrder` drops a claim whose answer has @@ -62,7 +59,7 @@ export function ClaimResponseTag({ if (!side) return null; if ((side === 'agree') !== forPosition) return null; - const word = RESPONSE_WORD[responseKind][side]; + const word = RESPONSE_WORD[side]; return (

@@ -89,7 +86,4 @@ export function firstName(name: string | null | undefined): string | null { * Taken from the same pair of verbs the response controls use, so the record and * the button above it cannot describe one act two ways. */ -const RESPONSE_WORD = { - stance: { agree: 'agrees', disagree: 'disagrees' }, - veracity: { agree: 'verifies', disagree: 'disputes' }, -} as const; +const RESPONSE_WORD = { agree: 'agrees', disagree: 'disagrees' } as const; diff --git a/apps/web/partials/profile/gallery-claim-card.tsx b/apps/web/partials/profile/gallery-claim-card.tsx index 5f28b4ed05..1b61bc04aa 100644 --- a/apps/web/partials/profile/gallery-claim-card.tsx +++ b/apps/web/partials/profile/gallery-claim-card.tsx @@ -57,7 +57,6 @@ export function GalleryClaimCard({ isResponseKindResolved, isViewerResponseResolved, responseBlockedReason, - responseKind, claim, positions, readiness, @@ -101,12 +100,7 @@ export function GalleryClaimCard({ // with no tag. noteFor={position => isResponseKindResolved ? ( - + ) : null } /> diff --git a/apps/web/partials/profile/person-record-feed.tsx b/apps/web/partials/profile/person-record-feed.tsx index b445565ede..b2c1564321 100644 --- a/apps/web/partials/profile/person-record-feed.tsx +++ b/apps/web/partials/profile/person-record-feed.tsx @@ -158,7 +158,6 @@ export function PersonRecordFeed({ ? (responseKind, position) => ( From 7869278c9317c68a1db088371778aa9831c0ce0a Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Tue, 22 Sep 2026 18:34:02 -0700 Subject: [PATCH 02/12] fix(claims): stop reading geo-chat's word for the response kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass narrowed the type and patched the two lookups that happened to throw in a test. It left the actual source alone: geo-chat's `response_kind`, which still says "veracity" for a claim minted before the vocabularies merged, was still being read and threaded through the claim UI. TypeScript cannot see it. The field is typed as the narrowed kind it no longer matches, so every use type-checks and then misses at runtime: - `matchmaking-claim-card` handed it to `useEntityResponse`, which is the write. `getResponseActionMethod` selects no SDK method for it, so clicking Agree threw on `undefined['positive']` instead of publishing — on every claim with existing verify or dispute activity. - `ClaimSummary` keyed the responders read on it, where `responseKindToVoteKind` returns `undefined` and the query asks for no vote kind at all. - the backfill forwarded it to geo-chat, recording the retired kind against a response published as a stance. `CLAIM_RESPONSE_KIND` is now the authority and no surface reads the row. The leaf guards stay as defence in depth rather than as the only defence. Three more places were still reading vote kind 2, which the clean break says nothing reads: - the profile's position *count* asked for kinds [1, 2] while the list beside it had already narrowed to [1], so a profile would have printed a number bigger than its own list. The two were separate literals; one now derives from the other. - `decodeVoteOrder` still decoded a kind-2 row into a `veracity` field that nothing renders any more, so a claim answered only that way entered the Positions list with no verdict under either button. - the indexed-response notifier admitted "veracity" through a hand-written guard and an `as DebateResponseKind` that made the cast look safe. Both copies now share one type guard. --- .../core/claims/browse/claim-page-view.tsx | 3 +- .../claims/browse/use-claim-response-state.ts | 4 +- .../backfill-readiness-for-held-position.ts | 15 +++++-- .../claim-response-indexed-notifier.test.tsx | 6 +-- .../claim-response-indexed-notifier.ts | 23 ++++++++-- .../matchmaking-claim-card.test.tsx | 28 +++++++++++- .../matchmaking/matchmaking-claim-card.tsx | 16 ++++--- .../core/io/subgraph/fetch-profile-facts.ts | 18 +++++--- .../web/core/profile/person-position-order.ts | 25 ++++++----- .../profile/person-positions-stance.test.ts | 43 ++++++++++--------- apps/web/core/profile/profile-facts.ts | 14 +++--- apps/web/core/responses/entity-response.ts | 16 ++++++- 12 files changed, 147 insertions(+), 64 deletions(-) diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 373e2ff61e..5921cd9660 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -15,6 +15,7 @@ import { useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking- import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { ID } from '~/core/id'; import { hasRecordToShow } from '~/core/profile/profile-proposer'; +import { CLAIM_RESPONSE_KIND } from '~/core/responses/entity-response'; import { useActiveTabIdForEditor } from '~/core/state/editor/editor-provider'; import { useEntitySidePanelActiveTab } from '~/core/state/entity-side-panel-active-tab'; import { useQueryEntity } from '~/core/sync/use-store'; @@ -531,7 +532,7 @@ function ClaimPositionSection({ entityId={entityId} spaceId={spaceId} positions={control.optimisticPositions} - responseKind={readiness.response_kind} + responseKind={CLAIM_RESPONSE_KIND} viewerPosition={control.viewerPosition} onRespond={control.respond} promptForComment={control.isConnected} diff --git a/apps/web/core/claims/browse/use-claim-response-state.ts b/apps/web/core/claims/browse/use-claim-response-state.ts index de3d67e963..62ed872705 100644 --- a/apps/web/core/claims/browse/use-claim-response-state.ts +++ b/apps/web/core/claims/browse/use-claim-response-state.ts @@ -9,7 +9,7 @@ import type { DebateResponseKind, MatchmakingReadiness, } from '~/core/debates/api'; -import { hasUnpublishedClaimResponseKindEdit } from '~/core/responses/entity-response'; +import { CLAIM_RESPONSE_KIND, hasUnpublishedClaimResponseKindEdit } from '~/core/responses/entity-response'; import type { Entity } from '~/core/types'; import { positionSummariesFromCounts, viewerResponseWithIndexedFallback } from './claim-position-summaries'; @@ -83,7 +83,7 @@ export type ClaimResponseState = { * codebase has already had. A constant cannot have that bug. */ export function resolveClaimResponseKind(): DebateResponseKind { - return 'stance'; + return CLAIM_RESPONSE_KIND; } export function useClaimResponseState({ diff --git a/apps/web/core/debates/backfill-readiness-for-held-position.ts b/apps/web/core/debates/backfill-readiness-for-held-position.ts index 0c38ed0e65..aed86df9d4 100644 --- a/apps/web/core/debates/backfill-readiness-for-held-position.ts +++ b/apps/web/core/debates/backfill-readiness-for-held-position.ts @@ -2,6 +2,8 @@ import * as React from 'react'; +import { CLAIM_RESPONSE_KIND } from '~/core/responses/entity-response'; + import { type MatchmakingReadiness, notifyClaimResponseIndexed } from './api'; import { useGeoChatAuth } from './hooks'; @@ -66,13 +68,18 @@ export function useBackfillReadinessForHeldPosition({ const sentOrder = React.useRef([]); const viewerResponse = readiness?.viewer_response ?? null; - const responseKind = readiness?.response_kind ?? null; + // `readiness.response_kind` is deliberately not read. It can still say "veracity" for a claim + // minted before the vocabularies merged, and this forwards the kind back to geo-chat — so the + // backfill would record the retired kind against a response published as a stance. The field was + // also doing duty as a "readiness has arrived" guard, which is said directly below instead: the + // type makes it non-null, so it was only ever null when the readiness itself was. + const hasReadiness = readiness != null; const alreadyReady = readiness?.viewer_debate_ready ?? false; const disabledReason = readiness?.readiness_disabled_reason ?? null; React.useEffect(() => { if (!ready || !authenticated || !accountKey) return; - if (!viewerResponse || !responseKind) return; + if (!viewerResponse || !hasReadiness) return; if (alreadyReady || disabledReason) return; const key = `${accountKey}:${spaceId}:${entityId}`; @@ -91,7 +98,7 @@ export function useBackfillReadinessForHeldPosition({ void notifyClaimResponseIndexed( spaceId, entityId, - responseKind, + CLAIM_RESPONSE_KIND, viewerResponse.position, getPrivyIdentityToken, accountKey, @@ -106,8 +113,8 @@ export function useBackfillReadinessForHeldPosition({ disabledReason, entityId, getPrivyIdentityToken, + hasReadiness, ready, - responseKind, spaceId, viewerResponse, ]); diff --git a/apps/web/core/debates/claim-response-indexed-notifier.test.tsx b/apps/web/core/debates/claim-response-indexed-notifier.test.tsx index ebe448f23b..f2d81d5947 100644 --- a/apps/web/core/debates/claim-response-indexed-notifier.test.tsx +++ b/apps/web/core/debates/claim-response-indexed-notifier.test.tsx @@ -30,14 +30,14 @@ describe('useClaimResponseIndexedNotifier', () => { const { queryClient, wrapper } = createHarness(); const getPrivyIdentityToken = vi.fn(); renderHook(() => useClaimResponseIndexedNotifier(true, getPrivyIdentityToken, 'account-1'), { wrapper }); - const queryKey = ['entity-response-indexing', 'profile-1', 'claim-1', 'space-1', 'veracity'] as const; + const queryKey = ['entity-response-indexing', 'profile-1', 'claim-1', 'space-1', 'stance'] as const; const indexed = { status: 'indexed', pending: { entityId: 'claim-1', expectedResponse: 'negative', personalSpaceId: 'profile-1', - responseKind: 'veracity', + responseKind: 'stance', spaceId: 'space-1', }, runId: 'run-1', @@ -48,7 +48,7 @@ describe('useClaimResponseIndexedNotifier', () => { expect(mocks.notify).toHaveBeenCalledWith( 'space-1', 'claim-1', - 'veracity', + 'stance', false, getPrivyIdentityToken, 'account-1', diff --git a/apps/web/core/debates/claim-response-indexed-notifier.ts b/apps/web/core/debates/claim-response-indexed-notifier.ts index 70a4b1f701..e503fe6f24 100644 --- a/apps/web/core/debates/claim-response-indexed-notifier.ts +++ b/apps/web/core/debates/claim-response-indexed-notifier.ts @@ -6,6 +6,7 @@ import * as React from 'react'; import type { EntityResponseIndexingState } from '~/core/hooks/use-entity-vote'; import { usePersonalSpaceId } from '~/core/hooks/use-personal-space-id'; +import { CLAIM_RESPONSE_KIND } from '~/core/responses/entity-response'; import { type DebateResponseKind, @@ -76,7 +77,7 @@ export function claimResponseIndexedEvent(queryKey: readonly unknown[], data: un scope !== 'entity-response-indexing' || indexingState?.status !== 'indexed' || !indexingState.pending || - (responseKind !== 'stance' && responseKind !== 'veracity') + !isClaimResponseKind(responseKind) ) { return null; } @@ -84,12 +85,26 @@ export function claimResponseIndexedEvent(queryKey: readonly unknown[], data: un entityId: String(entityId), position: indexingState.pending.expectedResponse === null ? null : indexingState.pending.expectedResponse === 'positive', - responseKind: responseKind as DebateResponseKind, + responseKind, runId: indexingState.runId, spaceId: String(spaceId), }; } +/** + * Whether a query key's response-kind segment is a claim's. + * + * A type guard, so the two parses below narrow rather than cast. They each spelled this inline and + * then asserted the result with `as DebateResponseKind` — which was how `"veracity"` stayed + * admissible here after it stopped being a kind: the cast said it was one. + * + * Only `"stance"` now. Nothing writes an indexing key under the retired kind, and forwarding one to + * geo-chat would record a kind against a response published as a stance. + */ +function isClaimResponseKind(responseKind: unknown): responseKind is DebateResponseKind { + return responseKind === CLAIM_RESPONSE_KIND; +} + /** * The same parse as {@link claimResponseIndexedEvent}, but for a response that is still *in * flight* rather than one the indexer has confirmed (GEO-2784). @@ -109,7 +124,7 @@ export function pendingClaimResponse(queryKey: readonly unknown[], data: unknown if ( scope !== 'entity-response-indexing' || !indexingState?.pending || - (responseKind !== 'stance' && responseKind !== 'veracity') + !isClaimResponseKind(responseKind) ) { return null; } @@ -117,7 +132,7 @@ export function pendingClaimResponse(queryKey: readonly unknown[], data: unknown entityId: String(entityId), position: indexingState.pending.expectedResponse === null ? null : indexingState.pending.expectedResponse === 'positive', - responseKind: responseKind as DebateResponseKind, + responseKind, spaceId: String(spaceId), /** Whose write this is, so a reader can attribute the row without assuming the current viewer. */ personalSpaceId: String(personalSpaceId), diff --git a/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx b/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx index 71e0030199..49e653f195 100644 --- a/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx +++ b/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx @@ -7,7 +7,7 @@ import type { ReactElement } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; +import { ENTITY_RESPONSE_COPY, getResponseActionMethod, type ResponseKind } from '~/core/responses/entity-response'; import type { DebateClaimPositionSummary, DebateClaimSummary, MatchmakingReadiness } from '../api'; import { MatchmakingClaimCard } from './matchmaking-claim-card'; @@ -533,6 +533,32 @@ describe('position avatar stack', () => { expect(agree).toHaveAttribute('title', 'Loading this claim\u2019s responses\u2026'); }); + /** + * The write must never be handed geo-chat's word for the kind. + * + * geo-chat still labels claims minted before the vocabularies merged `"veracity"`, and that + * value arrives typed as the narrowed kind it no longer matches, so nothing catches it. Handed + * to `getResponseActionMethod` it selects no SDK method at all, and the click throws on + * `undefined['positive']` rather than publishing — on every claim with existing verify or + * dispute activity. + */ + it('publishes under a real response kind even when the row still says veracity', () => { + renderCard( + + ); + + const passed = mocks.useEntityResponse.mock.calls.at(-1)?.[0] as { responseKind: ResponseKind }; + + expect(passed.responseKind).toBe('stance'); + // The assertion that actually matters: whatever kind was passed, it names an SDK method. + expect(getResponseActionMethod(passed.responseKind, 'positive')).toBe('agree'); + expect(getResponseActionMethod(passed.responseKind, 'negative')).toBe('disagree'); + }); + // A case that used to sit here — "does not carry a side across a change of vocabulary" — is // gone with the vocabularies. A claim had two possible kinds and could move between them, so a // remembered side had to be dropped when it did. There is one kind now, so there is no change diff --git a/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx b/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx index 4982515253..bd3520d142 100644 --- a/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx +++ b/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx @@ -21,7 +21,7 @@ import { useNearViewport } from '~/core/hooks/use-near-viewport'; import { useProfilesBySpaceIds } from '~/core/hooks/use-profiles-by-space-ids'; import { spaceLabel, useSpaceLabels } from '~/core/hooks/use-space-labels'; import { ID } from '~/core/id'; -import { CLAIM_RESPONSE_COPY, responsePositionLabel } from '~/core/responses/entity-response'; +import { CLAIM_RESPONSE_COPY, CLAIM_RESPONSE_KIND, responsePositionLabel } from '~/core/responses/entity-response'; import { useClaimResponseBatchState } from '~/core/responses/use-claim-response-summaries'; import { usePendingPersonalSpace } from '~/core/state/pending-personal-space'; import { NavUtils, validateEntityId, validateSpaceId } from '~/core/utils/utils'; @@ -422,7 +422,9 @@ export function useClaimPositionControl({ entityId: claim.claim_entity_id, entityName: claim.claim, spaceId: claim.space_id, - responseKind: readiness.response_kind, + // Not `readiness.response_kind`. This target drives the *write*, and geo-chat's field can still + // say "veracity" — which selects no SDK method, so the click throws. See `CLAIM_RESPONSE_KIND`. + responseKind: CLAIM_RESPONSE_KIND, }; const { submitResponse, isConnected, personalSpaceId } = useEntityResponse(target); const responseIndexing = useEntityResponseIndexingSnapshot(target); @@ -628,7 +630,7 @@ function RespondableControls({ const summary = useClaimResponseSummary( claim.claim_entity_id, claim.space_id, - readiness.response_kind, + CLAIM_RESPONSE_KIND, // Or where the index is allowed to answer for the side, since then the kind is the page's and // this read is the thing being waited *for* rather than something waiting behind it. Gating it // on `answersReady` there would deadlock: that flag is false precisely because geo-chat has not @@ -668,7 +670,7 @@ function RespondableControls({ * a stance response is not an answer about a claim that has become Verify/Dispute, and treating * it as one would enable the controls over it. */ - const claimKey = `${claim.space_id}:${claim.claim_entity_id}:${viewerKey ?? 'anon'}:${readiness.response_kind}`; + const claimKey = `${claim.space_id}:${claim.claim_entity_id}:${viewerKey ?? 'anon'}:${CLAIM_RESPONSE_KIND}`; const sideSettling = !summaryEnabled || summary.isViewerResponseLoading; // `'none'` rather than `null` for "settled on no side", so the two facts `null` would otherwise // carry stay apart: nothing held yet, against an answer of nobody. A string rather than an object @@ -748,7 +750,7 @@ function RespondableControls({ />

diff --git a/apps/web/core/io/subgraph/fetch-profile-facts.ts b/apps/web/core/io/subgraph/fetch-profile-facts.ts index 428bca367a..6189bcd49b 100644 --- a/apps/web/core/io/subgraph/fetch-profile-facts.ts +++ b/apps/web/core/io/subgraph/fetch-profile-facts.ts @@ -3,7 +3,13 @@ import { Effect, Either } from 'effect'; import { Environment } from '~/core/environment'; import { DEBATE_OPPOSED_BY_PROPERTY, DEBATE_SUPPORTED_BY_PROPERTY, DEBATE_TYPE } from '~/core/profile/history-ontology'; import { debateVisibilityCounts } from '~/core/profile/profile-debate-visibility'; -import { type ProfileFacts, type ProfileSpace, type Verifier, orderSpaces } from '~/core/profile/profile-facts'; +import { + POSITION_VOTE_KINDS, + type ProfileFacts, + type ProfileSpace, + type Verifier, + orderSpaces, +} from '~/core/profile/profile-facts'; import { graphql } from './graphql'; import { hiddenProfileRelationTargetsConnection } from './hidden-profile-relations-query'; @@ -38,12 +44,14 @@ interface NetworkResult { } /** - * The vote kinds that mean "a position on a claim". + * The vote kinds that mean "a position on a claim", spelled for interpolation into the query. * - * 1 is a stance and 2 is veracity. The table holds other kinds, and counting it - * unfiltered overstates the figure roughly threefold. + * Derived from {@link POSITION_VOTE_KINDS} rather than written out, because this count sits + * directly above the list that hook builds from the same kinds — and the two were separate + * literals, so narrowing the set to drop the retired veracity kind meant editing both by hand and + * hoping. One of them is now the other. */ -const POSITION_KINDS = '[1, 2]'; +const POSITION_KINDS = `[${POSITION_VOTE_KINDS.join(', ')}]`; /** * One side of a debate, pointed at this space. diff --git a/apps/web/core/profile/person-position-order.ts b/apps/web/core/profile/person-position-order.ts index 8f7b38c6ad..3ecddc161b 100644 --- a/apps/web/core/profile/person-position-order.ts +++ b/apps/web/core/profile/person-position-order.ts @@ -169,7 +169,6 @@ export type Stance = 'agree' | 'disagree'; */ export type ClaimResponse = { stance?: Stance; - veracity?: Stance; }; export type PositionOrder = { @@ -224,16 +223,16 @@ export function stanceOf(node: VoteNode): Stance | null { /** * Vote rows to claim ids, in vote order, one entry per claim. * - * Stance and veracity are separate votes on the same claim, so somebody who - * cast both would otherwise appear twice in their own record. First seen wins, - * and the rows arrive newest-first, so the position shown is the current one. + * The same claim answered in two spaces is two rows, so somebody would otherwise + * appear twice in their own record. First seen wins, and the rows arrive + * newest-first, so the position shown is the current one. * - * **Both kinds are kept, apart.** `voteKind` 1 is a stance — do I agree — and 2 - * is veracity — is this true. They are different questions, so they are not - * merged: a card shows whichever one matches the claim's own response kind, and - * that kind is a property of the claim *in a space*, which this decode cannot - * see. Keeping only the stance is what left a claim answered Verify or Dispute - * with no indicator at all. + * **One kind.** `voteKind` 1 is a stance — do I agree — and it is the only + * question a claim asks. Kind 2 was veracity, a separate vote asking whether the + * claim was true; it was decoded into a field of its own here so that a card + * could show whichever matched the claim's own vocabulary. Claims have one + * vocabulary now and nothing reads that field, so a kind-2 row is no longer an + * answer to anything this list can render. * * **A retracted claim is not listed at all.** `voteType` 2 is "neither", and it * is not something anybody chooses: the controls offer two sides, and @@ -270,7 +269,11 @@ export function decodeVoteOrder(nodes: readonly (VoteNode | null)[]): PositionOr if (!id) continue; const key = normId(id); - const field = node.voteKind === 1 ? 'stance' : node.voteKind === 2 ? 'veracity' : null; + // Kind 2 — the retired veracity response — is not an answer any more. It used to decode into a + // `veracity` field of its own, and with that field gone a claim answered only that way would + // still enter the list while rendering no verdict under either button: a record of attention + // with the verdict left out, which is the one thing this list exists to report. + const field = node.voteKind === 1 ? 'stance' : null; const settledKey = `${key}:${field}:${node.spaceId ? normId(node.spaceId) : ''}`; if (field && !settled.has(settledKey)) { diff --git a/apps/web/core/profile/person-positions-stance.test.ts b/apps/web/core/profile/person-positions-stance.test.ts index d2476bcf06..eb1da18642 100644 --- a/apps/web/core/profile/person-positions-stance.test.ts +++ b/apps/web/core/profile/person-positions-stance.test.ts @@ -36,29 +36,32 @@ describe('how a claim was answered', () => { expect(decodeVoteOrder([vote({ voteType: 2 })]).responseByClaimId).toEqual({}); }); - it('records a veracity vote under its own question', () => { - // Not dropped: a factual claim asks Verify or Dispute, and throwing this - // away is what left those claims with nothing to show. + /** + * These three used to assert the other half of a two-question record: a veracity vote decoded + * under its own key, both answers kept side by side, and a claim listed on a veracity answer + * alone. Nothing renders that key now, so a kind-2 row is not an answer this list can show — + * and listing a claim on one would print a row with no verdict under either button, which is + * the one thing the record exists to avoid. + */ + it('gives no side to a retired veracity vote', () => { // Keyed normalised, so the default `claim-1` fixture lands as `claim1`. - expect(decodeVoteOrder([vote({ voteKind: 2, voteType: 1 })]).responseByClaimId).toEqual({ - claim1: { veracity: 'disagree' }, - }); + expect(decodeVoteOrder([vote({ voteKind: 2, voteType: 1 })]).responseByClaimId).toEqual({}); }); - it('keeps both answers when somebody gave both', () => { + it('keeps only the stance when somebody answered both questions', () => { const order = decodeVoteOrder([ vote({ objectId: 'a', voteKind: 1, voteType: 0 }), vote({ objectId: 'a', voteKind: 2, voteType: 1 }), ]); - expect(order.responseByClaimId).toEqual({ a: { stance: 'agree', veracity: 'disagree' } }); + expect(order.responseByClaimId).toEqual({ a: { stance: 'agree' } }); }); - it('lists a claim answered only for veracity', () => { + it('does not list a claim answered only for veracity', () => { const order = decodeVoteOrder([vote({ objectId: 'a', voteKind: 2 })]); - expect(order.entityIds).toEqual(['a']); - expect(order.responseByClaimId).toEqual({ a: { veracity: 'agree' } }); + expect(order.entityIds).toEqual([]); + expect(order.responseByClaimId).toEqual({}); }); it('keeps the newest answer when somebody voted twice', () => { @@ -105,14 +108,15 @@ describe('how a claim was answered', () => { expect(order.entityIds).toEqual(['kept']); }); - it('keeps a claim retracted for one question but answered for the other', () => { + // Retracting the stance retracts the record, even where a retired veracity answer sits beside it. + it('drops a claim whose stance was retracted, whatever the old veracity row says', () => { const order = decodeVoteOrder([ vote({ objectId: 'a', voteKind: 1, voteType: 2 }), vote({ objectId: 'a', voteKind: 2, voteType: 0 }), ]); - expect(order.entityIds).toEqual(['a']); - expect(order.responseByClaimId).toEqual({ a: { veracity: 'agree' } }); + expect(order.entityIds).toEqual([]); + expect(order.responseByClaimId).toEqual({}); }); it('does not let an older neutral vote clear the current side', () => { @@ -156,8 +160,7 @@ describe('how a claim was answered', () => { expect(order.entityIds).toEqual([]); }); - it('settles each question on its own newest vote', () => { - // A neutral veracity answer must not clear the stance, and vice versa. + it('settles the stance on its own newest vote, ignoring retired rows around it', () => { const order = decodeVoteOrder([ vote({ objectId: 'claim1', voteKind: 2, voteType: 2 }), vote({ objectId: 'claim1', voteKind: 1, voteType: 0 }), @@ -192,7 +195,7 @@ describe('the space a position was taken in', () => { it('takes the space of the answer that stands, not of a retraction beside it', () => { const order = decodeVoteOrder([ vote({ objectId: 'a', voteKind: 1, voteType: 2, spaceId: 'withdrawn' }), - vote({ objectId: 'a', voteKind: 2, voteType: 0, spaceId: 'held' }), + vote({ objectId: 'a', voteKind: 1, voteType: 0, spaceId: 'held' }), ]); expect(order.spacesByClaimId).toEqual({ a: ['held'] }); @@ -216,7 +219,7 @@ describe('the space a position was taken in', () => { it('keeps every space a claim was answered in, newest first', () => { const order = decodeVoteOrder([ vote({ objectId: 'a', spaceId: 'personal' }), - vote({ objectId: 'a', voteKind: 2, spaceId: 'relationships' }), + vote({ objectId: 'a', spaceId: 'relationships' }), ]); expect(order.spacesByClaimId).toEqual({ a: ['personal', 'relationships'] }); @@ -224,8 +227,8 @@ describe('the space a position was taken in', () => { it('lists a space once however many answers were given in it', () => { const order = decodeVoteOrder([ - vote({ objectId: 'a', voteKind: 1, spaceId: 'relationships' }), - vote({ objectId: 'a', voteKind: 2, spaceId: 'relationships' }), + vote({ objectId: 'a', voteKind: 1, voteType: 0, spaceId: 'relationships' }), + vote({ objectId: 'a', voteKind: 1, voteType: 1, spaceId: 'relationships' }), ]); expect(order.spacesByClaimId).toEqual({ a: ['relationships'] }); diff --git a/apps/web/core/profile/profile-facts.ts b/apps/web/core/profile/profile-facts.ts index cd7fe5fa63..f1166ecfa2 100644 --- a/apps/web/core/profile/profile-facts.ts +++ b/apps/web/core/profile/profile-facts.ts @@ -54,12 +54,16 @@ export const NO_FACTS: ProfileFacts = { /** * Vote kinds that are a position on a claim. * - * 1 is a stance, 2 is veracity. The table holds other kinds, so counting it - * unfiltered overstates the figure — and there is a second table, `votes` on - * `voterId`, which is a different thing again and returns roughly three times - * as many rows. Positions is `userVotes` on `userId`, these two kinds. + * 1 is a stance. The table holds other kinds, so counting it unfiltered + * overstates the figure — and there is a second table, `votes` on `voterId`, + * which is a different thing again and returns roughly three times as many + * rows. Positions is `userVotes` on `userId`, this kind. + * + * Kind 2 was the veracity response. It is out for the same reason it is out of + * the tallies: nothing reads those rows, so counting them would put a number + * above a list that does not contain them. */ -export const POSITION_VOTE_KINDS = [1, 2] as const; +export const POSITION_VOTE_KINDS = [1] as const; /** * Subspace relations that mean somebody vouched for this space. diff --git a/apps/web/core/responses/entity-response.ts b/apps/web/core/responses/entity-response.ts index fe851f8b61..f13a8faf2d 100644 --- a/apps/web/core/responses/entity-response.ts +++ b/apps/web/core/responses/entity-response.ts @@ -88,6 +88,20 @@ export const ENTITY_RESPONSE_COPY: Record = { * Dispute. Every claim is answered the same way now, so there is nothing left to choose between * and no caller has to work out which vocabulary a claim is in before it can name a side. */ +/** + * The one kind a claim is answered with. + * + * The authority on this, so that no surface has to read it off geo-chat's row. That field can + * still say `"veracity"` for a claim minted before the vocabularies merged, and it arrives typed as + * the narrowed {@link DebateResponseKind} it no longer matches — so TypeScript cannot stop it being + * used as a key. Fed to `getResponseActionMethod` it selects no SDK method and the *write* throws + * on click; fed to `responseKindToVoteKind` it yields `undefined` and the read asks for no vote + * kind at all. Neither failure is visible until it happens to a real claim. + * + * So claim surfaces take the kind from here and never from the row. + */ +export const CLAIM_RESPONSE_KIND = 'stance' as const satisfies ResponseKind; + /** * The copy for a claim's two sides, reached without indexing anything. * @@ -99,7 +113,7 @@ export const ENTITY_RESPONSE_COPY: Record = { * * So the claim surfaces read this instead, and the untrusted value is never a key. */ -export const CLAIM_RESPONSE_COPY = ENTITY_RESPONSE_COPY.stance; +export const CLAIM_RESPONSE_COPY = ENTITY_RESPONSE_COPY[CLAIM_RESPONSE_KIND]; export function responsePositionLabel(position: boolean) { return position ? CLAIM_RESPONSE_COPY.positiveAction : CLAIM_RESPONSE_COPY.negativeAction; From d874ffa7c6a3b4e2482f004d55f23e1798728f0d Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 23 Sep 2026 10:43:08 -0700 Subject: [PATCH 03/12] fix(debates): name every side from the position, not geo-chat's label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. "Our label wins" only held for the pills; eight other surfaces still printed geo-chat's `position_label`, which reads "Verify" or "Dispute" on a claim it still calls factual — the rematch page, the debate room, the pre-join screen, the request dialog and the request parties row. Every participant type already carries `position` beside the label, and geo-chat's label is only ever one of four fixed words (`position_label_for_ response` in crates/debates), so the label is fully derivable and nothing is lost by deriving it. `DebateVideoTile` now takes only the side and names it itself, which removes the prop from four call sites. Also from the review: - `DebateEntityResponseControls` is deleted. It had no callers outside its own test — already true on master — and passed a wire-typed kind straight into `EntityVoteButtons`, so it was a trap for whoever revived it. - `responsePositionLabel`'s doc comment was orphaned above `CLAIM_RESPONSE_KIND` when that constant was inserted; it is back on the function. `getUserHas VoteOfKind` had two stacked comments, the first still describing veracity as a live kind; they are one comment that says why the retired kind is still passed. - Comments that still described Verify/Dispute as current: the ticker's card and share, the `ClaimResponseState.responseKind` docs, the two in `use-claim-response-state`, the `position` prop on `ResponsePositionIcon`, and the end-slot prop in `claim-position-comment`. Not fixed here, because it cannot be: geo-chat still resolves a claim's kind from the graph's "Is factual" flag and rejects a mismatching response kind, so "Request debate" will not appear on factual claims until geo-chat changes too. Verified against geo-chat main at f8a2129. See the PR thread. --- .../[debateId]/debate-room-page-client.tsx | 11 ++++-- .../[sessionId]/rematch-page-client.tsx | 20 +++++----- .../claims/browse/claim-position-comment.tsx | 2 +- .../claims/browse/use-claim-response-state.ts | 39 +++++++++---------- .../debates/browse/debate-claim-ticker.tsx | 13 +++---- .../debate-entity-response-controls.test.tsx | 28 ------------- .../debate-entity-response-controls.tsx | 22 ----------- .../core/debates/debate-pre-join-screen.tsx | 2 - .../debates/debate-request-dialog.test.tsx | 15 +++++-- .../core/debates/debate-request-dialog.tsx | 4 +- apps/web/core/debates/debate-video-tile.tsx | 17 ++++++-- .../debates/matchmaking/request-parties.tsx | 11 +++++- .../debates/matchmaking/requests-tab.test.tsx | 5 ++- apps/web/core/io/queries.ts | 16 ++++---- apps/web/core/responses/entity-response.ts | 14 +++---- .../icons/response-position-icon.tsx | 2 +- 16 files changed, 99 insertions(+), 122 deletions(-) delete mode 100644 apps/web/core/debates/debate-entity-response-controls.test.tsx delete mode 100644 apps/web/core/debates/debate-entity-response-controls.tsx diff --git a/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.tsx index 6d907f2a7a..69a87f31ae 100644 --- a/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.tsx @@ -87,6 +87,7 @@ import { usePrefetchClaimSpaceAllowlist } from '~/core/debates/use-prefetch-clai import { useRelatedDebateClaims } from '~/core/debates/use-related-debate-claims'; import { useScrollLock } from '~/core/debates/use-scroll-lock'; import { ExtendedReconnectPolicy } from '~/core/livekit/extended-reconnect-policy'; +import { responsePositionLabel } from '~/core/responses/entity-response'; import { useFeatureFlag } from '~/core/state/feature-flags'; import { Button } from '~/design-system/button'; @@ -2633,7 +2634,8 @@ function DebateRoomSurface({ spaceId, debateId }: DebateRoomPageClientProps) { className="inline-flex max-w-full items-center rounded-md border border-grey-02 bg-bg px-2 py-1 text-[0.8125rem] text-text" > - {participant.display_name || participant.profile_space_id} · {participant.position_label} + {participant.display_name || participant.profile_space_id} ·{' '} + {responsePositionLabel(participant.position)} ))} @@ -2859,7 +2861,6 @@ function DebateRecordingModal({ participant.participant_slot === slot)?.position_label ?? 'Position'; + // Named from the side rather than read off `position_label`, which says "Verify" on a claim + // geo-chat still calls factual — see `positionSummariesFromCounts`. + const participant = debate.participants.find(candidate => candidate.participant_slot === slot); + return participant ? responsePositionLabel(participant.position) : 'Position'; } function speakerName(participant: Pick) { diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index c25b31b09b..8e81a6c56c 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -2086,14 +2086,13 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { ? session.participants.map(participant => { const requester = participant.user_id === incomingRequest.requester_user_id; const position = requester ? incomingRequest.requester_position : incomingRequest.recipient_position; - const positionLabel = requester - ? incomingRequest.requester_position_label - : incomingRequest.recipient_position_label; - return { ...participant, position, - position_label: positionLabel ?? responsePositionLabel(position), + // Our word, not the request's. geo-chat labels the sides of a claim it still calls + // factual "Verify" and "Dispute", and this pair sits beside pills that can only + // publish an Agree — see `positionSummariesFromCounts`. + position_label: responsePositionLabel(position), }; }) : []; @@ -2691,10 +2690,7 @@ function RematchClaimCard({ request != null && idEquals(request.claim.claim_entity_id, claim.claim.claim_entity_id); - const positions = React.useMemo( - () => rematchPositionSummaries(claim, session), - [claim, session] - ); + const positions = React.useMemo(() => rematchPositionSummaries(claim, session), [claim, session]); // geo-chat's copy, deliberately — not the optimistic one. The card reads the viewer's own // in-flight response off the indexing snapshot for display, and uses this field for the two @@ -2839,8 +2835,10 @@ function rematchPositionSummaries( return { position, - // A server-supplied label wins, so an authoritative Verify/Dispute survives. - position_label: holders.find(holder => holder.position_label)?.position_label ?? responsePositionLabel(position), + // Our word, not the holder's — see `positionSummariesFromCounts`. This used to prefer a + // server-supplied label so that an authoritative Verify/Dispute survived, which is exactly + // what must not happen now. + position_label: responsePositionLabel(position), total_count: holders.length, // Only meaningful for the hub's "available now" counts; a rematch is already a fixed pair, // so there is nobody here the viewer would send a request to. diff --git a/apps/web/core/claims/browse/claim-position-comment.tsx b/apps/web/core/claims/browse/claim-position-comment.tsx index 4d2f4c0a88..f9aed9e00a 100644 --- a/apps/web/core/claims/browse/claim-position-comment.tsx +++ b/apps/web/core/claims/browse/claim-position-comment.tsx @@ -57,7 +57,7 @@ export function ClaimPositionCommentControl({ titleFor?: (position: boolean) => string; noteFor?: (position: boolean) => React.ReactNode; positionRowClassName?: string; - /** Compact action rendered after Disagree/Dispute, such as the Explore comments-panel opener. */ + /** Compact action rendered after Disagree, such as the Explore comments-panel opener. */ positionRowEndSlot?: React.ReactNode; }) { const [promptedPosition, setPromptedPosition] = React.useState(null); diff --git a/apps/web/core/claims/browse/use-claim-response-state.ts b/apps/web/core/claims/browse/use-claim-response-state.ts index 62ed872705..ca9bc3f5d0 100644 --- a/apps/web/core/claims/browse/use-claim-response-state.ts +++ b/apps/web/core/claims/browse/use-claim-response-state.ts @@ -16,18 +16,17 @@ import { positionSummariesFromCounts, viewerResponseWithIndexedFallback } from ' import { type ClaimResponseSummary, useClaimResponseSummary } from './claim-response-summary'; export type ClaimResponseState = { - /** Which vocabulary labels the sides: Agree/Disagree, or Verify/Dispute on a factual claim. */ + /** How a claim's sides are labelled. One vocabulary: Agree/Disagree. */ responseKind: DebateResponseKind; /** - * Whether `responseKind` is an answer or still the fallback. + * Whether this claim's own data has arrived. * - * Callers gate their pills on this. `stance` is what we assume before either lookup answers, and - * a click made inside that window publishes a *stance* response against a claim that wants - * Verify/Dispute — the kind selects `voteKind` on the write, so it is the wrong vote rather than - * the wrong label. - * - * Answered, not merely settled: a failed graph read stops loading too, and reading that as "no - * factual flag" is the same bug with a longer fuse. Something has to have said so. + * Callers gate their pills on it. It used to mean "the vocabulary is an answer rather than the + * `stance` fallback", back when a factual claim wanted Verify/Dispute and a click made before + * the lookups answered published the wrong *vote kind* rather than merely the wrong label. + * There is one kind now, so nothing about the write depends on this — what still does is the + * viewer's own side, which a pill needs before a click can clear a position rather than + * republish it. */ isResponseKindResolved: boolean; /** @@ -113,13 +112,13 @@ export function useClaimResponseState({ // An unpublished edit to the claim's own vocabulary blocks responding, as it did before. // // `EntityVoteButtons` — the control every one of these surfaces used to render — refused outright - // while the "Is factual" value or the Claim type had a local edit that had not been published, - // and said so. Replacing it with the shared card dropped that, and the failure it prevents is the - // one this file exists to stop: the kind selects `voteKind` on the write, so a draft flag would - // publish a veracity response against a claim the graph still calls a stance one, or the reverse. + // while the Claim type had a local edit that had not been published, and said so. Replacing it + // with the shared card dropped that, and the failure it prevents is real: the kind selects + // `voteKind` on the write, so a draft type edit would publish a claim's stance against an entity + // the graph still calls an ordinary one, or the reverse. // - // A row does not settle it either. geo-chat indexes the *published* graph, so its kind is the - // stale half of exactly the disagreement the edit creates. + // It used to watch the "Is factual" value for the same reason. That flag no longer chooses a + // kind, so a draft edit to it cannot change what gets published. // // Only ever true where the entity carries local edits at all: the surfaces that read it through a // narrow projection have no `isLocal` to find, and this is false for them. @@ -127,12 +126,12 @@ export function useClaimResponseState({ ? 'Publish the claim type change before responding.' : null; - // Withheld until the vocabulary is an answer rather than the `stance` fallback. + // Withheld until the claim's own data has arrived. // - // The kind is part of both query keys, so asking early does not just waste a pair of requests on - // a factual claim — it populates the summary from the *stance* counts, and a card can draw that - // split for as long as the entity takes to arrive, then swap it for the veracity one. The pills - // being disabled stops the wrong write; it does not stop the wrong number. + // This used to be about the vocabulary: the kind is part of both query keys, so asking before it + // was known populated the summary from the wrong counts and a card could draw that split until + // the entity landed. One kind now, so the keys are stable — what is still worth waiting for is + // the row, which carries the viewer's own side. const summary = useClaimResponseSummary(claimId, spaceId, responseKind, enabled && isResponseKindResolved); const claim = React.useMemo( diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index 003b473710..4c0a8609ae 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -755,8 +755,7 @@ function ClaimBacklogChip({ count, expanded, onClick }: { count: number; expande * query that stacks its two pills vertically below ~230px, which is exactly the width this card * wants to be. Reusing it would force the card wide enough to cover the face it sits beside. What * matters is shared underneath — `useClaimResponseState` and `useClaimPositionControl` resolve the - * vocabulary and publish the response, so a factual claim still reads Verify/Dispute here and the - * share is the same number the claim page prints. + * response and publish it, so the share is the same number the claim page prints. * * The crowd split is shown up front, per the Figma card. It is worth knowing that this cuts against * the usual argument for withholding it — a viewer who sees "65% agree" before answering is being @@ -835,8 +834,8 @@ function TickerClaimHeader({ · )} - {/* Same wording as the verdict on the claim page — "65% agree", or "65% verify" on a - factual claim, so the share reads the same wherever it is printed. */} + {/* Same wording as the verdict on the claim page — "65% agree" — so the share reads + the same wherever it is printed. */} {percent}% {copy.positiveAction.toLowerCase()} @@ -878,9 +877,9 @@ function TickerClaimHeader({ * about it. The label survives as the accessible name and the tooltip, so nothing is lost to * anyone reading it aloud or hovering. * - * Thumbs for a stance claim, chevrons for a factual one, which is the split the rest of the app - * already draws: agreeing with a position and verifying a fact are different acts, and a thumb on - * "the SEC sued Coinbase" reads as approval rather than confirmation. + * Thumbs, on every claim. A factual one used to draw chevrons here, because verifying a fact and + * agreeing with a position were different acts; claims ask one question now, so there is one + * glyph. */ function ClaimIconButton({ responseKind, diff --git a/apps/web/core/debates/debate-entity-response-controls.test.tsx b/apps/web/core/debates/debate-entity-response-controls.test.tsx deleted file mode 100644 index 3a0427d6e9..0000000000 --- a/apps/web/core/debates/debate-entity-response-controls.test.tsx +++ /dev/null @@ -1,28 +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 { DebateEntityResponseControls } from './debate-entity-response-controls'; - -vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ - EntityVoteButtons: ({ responseKind }: { responseKind?: 'stance' | null }) => ( - {responseKind ?? 'unavailable'} - ), -})); - -afterEach(cleanup); - -describe('DebateEntityResponseControls', () => { - it('passes the backend response kind to the shared response controls', () => { - render(); - - expect(screen.getByText('stance')).toBeInTheDocument(); - }); - - it('preserves an unavailable backend response kind instead of inferring curation', () => { - render(); - - expect(screen.getByText('unavailable')).toBeInTheDocument(); - }); -}); diff --git a/apps/web/core/debates/debate-entity-response-controls.tsx b/apps/web/core/debates/debate-entity-response-controls.tsx deleted file mode 100644 index e56a8a7a5b..0000000000 --- a/apps/web/core/debates/debate-entity-response-controls.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; - -import type { DebateResponseKind } from './api'; - -export function DebateEntityResponseControls({ - entityId, - spaceId, - responseKind, -}: { - entityId: string; - spaceId: string; - responseKind: DebateResponseKind | null; -}) { - return ( - - ); -} diff --git a/apps/web/core/debates/debate-pre-join-screen.tsx b/apps/web/core/debates/debate-pre-join-screen.tsx index 3ccfdd5c93..ae18ac9a2f 100644 --- a/apps/web/core/debates/debate-pre-join-screen.tsx +++ b/apps/web/core/debates/debate-pre-join-screen.tsx @@ -198,7 +198,6 @@ export function DebatePreScreen({ { expect(within(dialog).getByText('You')).toBeInTheDocument(); expect(within(dialog).getByText('Remote speaker')).toBeInTheDocument(); expect(within(dialog).getByText('VS')).toBeInTheDocument(); - expect(within(within(dialog).getByText('You').parentElement!).getByText('Yes')).toBeInTheDocument(); - expect(within(within(dialog).getByText('Remote speaker').parentElement!).getByText('No')).toBeInTheDocument(); + // Named from each side, not from the `position_label` the fixtures carry. geo-chat's label + // reads "Verify"/"Dispute" on a claim it still calls factual, and this dialog is the invitation + // to debate a claim whose pills can only publish an Agree. + expect(within(within(dialog).getByText('You').parentElement!).getByText('Agree')).toBeInTheDocument(); + expect( + within(within(dialog).getByText('Remote speaker').parentElement!).getByText('Disagree') + ).toBeInTheDocument(); + expect(within(dialog).queryByText('Yes')).not.toBeInTheDocument(); + expect(within(dialog).queryByText('No')).not.toBeInTheDocument(); expect(within(dialog).getAllByText('1m')).toHaveLength(2); expect(within(dialog).getAllByText('45s')).toHaveLength(2); @@ -112,9 +119,9 @@ describe('DebateRequestDialog', () => { const remoteParticipant = within(dialog).getByText('Remote speaker').parentElement!; expect(remoteParticipant.compareDocumentPosition(localParticipant) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(within(remoteParticipant).getByText('For')).toBeInTheDocument(); + expect(within(remoteParticipant).getByText('Agree')).toBeInTheDocument(); expect(within(remoteParticipant).getByAltText('Remote speaker')).toBeInTheDocument(); - expect(within(localParticipant).getByText('Against')).toBeInTheDocument(); + expect(within(localParticipant).getByText('Disagree')).toBeInTheDocument(); expect(within(localParticipant).getByAltText('Local speaker')).toBeInTheDocument(); const localFirstTurn = within(dialog).getByText('You make an argument'); diff --git a/apps/web/core/debates/debate-request-dialog.tsx b/apps/web/core/debates/debate-request-dialog.tsx index cf232a94b2..ef2139b9b2 100644 --- a/apps/web/core/debates/debate-request-dialog.tsx +++ b/apps/web/core/debates/debate-request-dialog.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; +import { responsePositionLabel } from '~/core/responses/entity-response'; + import { Avatar } from '~/design-system/avatar'; import { Text } from '~/design-system/text'; @@ -233,7 +235,7 @@ function ParticipantSummary({ {label} - {participant.position_label} + {responsePositionLabel(participant.position)}
); diff --git a/apps/web/core/debates/debate-video-tile.tsx b/apps/web/core/debates/debate-video-tile.tsx index d000b67c9c..e59d9102f3 100644 --- a/apps/web/core/debates/debate-video-tile.tsx +++ b/apps/web/core/debates/debate-video-tile.tsx @@ -4,6 +4,8 @@ import * as React from 'react'; import cx from 'classnames'; +import { responsePositionLabel } from '~/core/responses/entity-response'; + import { Text } from '~/design-system/text'; import { MutedMicrophoneIndicator } from './debate-room-controls'; @@ -93,9 +95,16 @@ export function DebatePositionChip({ * same geometry. Everything past the video is optional: the intro passes a label and an overlay, * the debate adds turn countdowns and phase overlays. */ +/** + * The side this tile's participant took, named. + * + * Derived rather than passed. Every caller had a `position_label` from geo-chat to hand down + * beside the position itself — and that label reads "Verify" or "Dispute" on a claim geo-chat + * still calls factual, which is a word this app no longer has a way to publish. The side is the + * same boolean either way, so the name is ours to say. + */ export function DebateVideoTile({ participantPosition, - positionLabel, active, overlayText, overlayCompact = false, @@ -118,7 +127,6 @@ export function DebateVideoTile({ children, }: { participantPosition: boolean | null; - positionLabel: string | null; active: boolean; overlayText?: string | null; /** For overlays that are a sentence rather than a label: smaller, wrapped and centred. */ @@ -149,6 +157,7 @@ export function DebateVideoTile({ status?: React.ReactNode; children: React.ReactNode; }) { + const positionLabel = participantPosition === null ? null : responsePositionLabel(participantPosition); const showInactiveIndicator = showMutedIndicator || (inactive && !revealInactive && !countdown && !overlayText && !endingTurn); @@ -200,10 +209,10 @@ export function DebateVideoTile({ own width first, so the label gives up the space instead and truncates. Everywhere there is room for equal columns, which is every width from ~360px up, the two resolve equal and the controls land exactly on the centre line. */} - {(positionLabel || tileControls || status) && ( + {(positionLabel !== null || tileControls || status) && (
- {positionLabel && } + {positionLabel !== null && }
{tileControls}
{/* No `min-w-0` here, unlike the label: it is what lets the column's `auto` minimum see diff --git a/apps/web/core/debates/matchmaking/request-parties.tsx b/apps/web/core/debates/matchmaking/request-parties.tsx index d4f527bf05..65a8625cbc 100644 --- a/apps/web/core/debates/matchmaking/request-parties.tsx +++ b/apps/web/core/debates/matchmaking/request-parties.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; +import { responsePositionLabel } from '~/core/responses/entity-response'; + import { Avatar } from '~/design-system/avatar'; import type { DebateParticipantSummary, DebateRequestParty } from '../api'; @@ -13,8 +15,15 @@ import { speakerLabel } from '../playback-utils'; */ type RequestPartyLike = DebateParticipantSummary | DebateRequestParty; +/** + * Named from the side the party took, not from geo-chat's `position_label`. + * + * The label reads "Verify" or "Dispute" on a claim geo-chat still calls factual, which is a word + * this app no longer has a way to publish — see `positionSummariesFromCounts`. `position` is the + * same boolean either way, and its absence is what distinguishes a claimless challenge. + */ function positionLabel(party: RequestPartyLike): string | null { - return 'position_label' in party ? party.position_label : null; + return 'position' in party ? responsePositionLabel(party.position) : null; } /** diff --git a/apps/web/core/debates/matchmaking/requests-tab.test.tsx b/apps/web/core/debates/matchmaking/requests-tab.test.tsx index c0a443d864..6097b88929 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.test.tsx @@ -381,7 +381,10 @@ describe('RequestsTab', () => { render(); const parties = screen.getByText('Arturas').closest('div')!; - expect(within(parties).getByText('No')).toBeInTheDocument(); + // Named from the side rather than from the fixture's `position_label` — geo-chat's label reads + // "Dispute" on a claim it still calls factual, which this app cannot publish. + expect(within(parties).getByText('Disagree')).toBeInTheDocument(); + expect(within(parties).queryByText('No')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'More options' })); fireEvent.click(screen.getByRole('button', { name: 'Block Arturas' })); diff --git a/apps/web/core/io/queries.ts b/apps/web/core/io/queries.ts index ef75fb770b..67623ac145 100644 --- a/apps/web/core/io/queries.ts +++ b/apps/web/core/io/queries.ts @@ -1464,14 +1464,14 @@ export function getUserEntityResponse( /** * Has this user cast a vote of any of the given kinds? * - * Takes the kinds rather than assuming them: curation (0) is an entity upvote, stance (1) and - * veracity (2) are a position on a claim, and the onboarding checklist counts those as two - * different things a person can have done. - */ -/** - * `number` rather than `ResponseVoteKind`, because this asks the vote table a historical question - * and the table holds kinds the app no longer publishes — the retired veracity kind, `2`, is a - * real value here even though nothing can write one any more. + * Takes the kinds rather than assuming them: curation (0) is an entity upvote and stance (1) is a + * position on a claim, and the onboarding checklist counts those as two different things a person + * can have done. + * + * `number` rather than `ResponseVoteKind`, because this asks the vote table a *historical* + * question and the table holds kinds the app no longer publishes. The retired veracity kind, 2, is + * a real value here — the onboarding checklist still passes it, since somebody who answered a + * claim back when it asked Verify or Dispute has done the thing the checklist asks about. */ export function getUserHasVoteOfKind(userId: string, voteKinds: readonly number[], signal?: AbortController['signal']) { return graphql({ diff --git a/apps/web/core/responses/entity-response.ts b/apps/web/core/responses/entity-response.ts index f13a8faf2d..1d42d7b462 100644 --- a/apps/web/core/responses/entity-response.ts +++ b/apps/web/core/responses/entity-response.ts @@ -81,13 +81,6 @@ export const ENTITY_RESPONSE_COPY: Record = { }, }; -/** - * What to call one side of a claim: Agree or Disagree. - * - * It used to take the response kind, because a factual claim's sides were called Verify and - * Dispute. Every claim is answered the same way now, so there is nothing left to choose between - * and no caller has to work out which vocabulary a claim is in before it can name a side. - */ /** * The one kind a claim is answered with. * @@ -115,6 +108,13 @@ export const CLAIM_RESPONSE_KIND = 'stance' as const satisfies ResponseKind; */ export const CLAIM_RESPONSE_COPY = ENTITY_RESPONSE_COPY[CLAIM_RESPONSE_KIND]; +/** + * What to call one side of a claim: Agree or Disagree. + * + * It used to take the response kind, because a factual claim's sides were called Verify and + * Dispute. Every claim is answered the same way now, so there is nothing left to choose between + * and no caller has to work out which vocabulary a claim is in before it can name a side. + */ export function responsePositionLabel(position: boolean) { return position ? CLAIM_RESPONSE_COPY.positiveAction : CLAIM_RESPONSE_COPY.negativeAction; } diff --git a/apps/web/design-system/icons/response-position-icon.tsx b/apps/web/design-system/icons/response-position-icon.tsx index 7ae9381c15..29d8ee7f5b 100644 --- a/apps/web/design-system/icons/response-position-icon.tsx +++ b/apps/web/design-system/icons/response-position-icon.tsx @@ -8,7 +8,7 @@ import { VoteArrow } from '~/design-system/icons/vote-arrow'; type Props = { responseKind: ResponseKind; - /** The side this glyph stands for: `true` is the positive one (Upvote / Agree / Verify). */ + /** The side this glyph stands for: `true` is the positive one (Upvote / Agree). */ position: boolean; /** Whether the viewer holds this side, which fills the glyph. */ selected?: boolean; From e816f9feb369d16483bf5ac5b81dc833176d3ff3 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 11:15:22 -0700 Subject: [PATCH 04/12] fix(og): name the share card's speaker sides from the position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up, found by re-sweeping after the rebase. The debate share card took each speaker's side from geo-chat's `position_label`, which still reads "Verify" or "Dispute" on a claim geo-chat calls factual. This is the worst remaining place for the retired word to land. The card's own comment says it: generated once at publish time and never revisited, so a wrong label is baked into the share image permanently and cannot be corrected. `cardSpeaker` now names the side itself, like every other surface. Nothing was lost — `position_label` is only ever one of four fixed words, and the `position` boolean it is derived from sits right beside it on the participant. The existing fixtures carry `position` but no `position_label`, which is why this went unnoticed: the field was simply `undefined` in every test. The new cases pass one, so a regression has something to fail against. Also dropped a reimplementation of `responsePositionLabel` in the claim card's action-title helper. --- .../matchmaking/matchmaking-claim-card.tsx | 2 +- .../core/debates/server/debate-source.test.ts | 78 ++++++++++++++++++- apps/web/core/debates/server/debate-source.ts | 7 +- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx b/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx index bd3520d142..3446a59da4 100644 --- a/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx +++ b/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx @@ -533,7 +533,7 @@ export function useClaimPositionControl({ if (!isConnected) return copy.connect; if (isAccountSetupPending) return 'Finishing account setup…'; if (viewerPosition === position) return position ? copy.removePositive : copy.removeNegative; - return position ? copy.positiveAction : copy.negativeAction; + return responsePositionLabel(position); }; return { diff --git a/apps/web/core/debates/server/debate-source.test.ts b/apps/web/core/debates/server/debate-source.test.ts index 8070e5967e..dea28b2228 100644 --- a/apps/web/core/debates/server/debate-source.test.ts +++ b/apps/web/core/debates/server/debate-source.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { applyClaimReusePolicy } from './claim-reuse'; -import { DebateNotPublishableError, listSweepCandidateDebateIds, loadDebatePublishSource } from './debate-source'; +import { + DebateNotPublishableError, + listSweepCandidateDebateIds, + loadDebateOgPreview, + loadDebatePublishSource, +} from './debate-source'; // The reuse policy needs a graph read and its own flag, both covered in `claim-reuse.test.ts`. Here it // passes claims through, so what the loader decodes from geo-chat is observable on the input. @@ -328,3 +333,74 @@ describe('listSweepCandidateDebateIds', () => { await expect(listSweepCandidateDebateIds('space-1')).resolves.toEqual(['eligible']); }); }); + +/** + * The share card names each speaker's side, and it is the least forgiving place to get that wrong: + * the card is generated once at publish time and never revisited, so a label baked in now cannot be + * corrected later. + * + * geo-chat sends a `position_label` per participant, and it still reads "Verify"/"Dispute" for a + * claim it calls factual — a word this app no longer has any way to publish. The card takes the + * side instead and names it itself. + */ +describe('the debate share card’s speaker sides', () => { + /** The card path also resolves a presigned still per speaker, which the shared mock does not answer. */ + function mockCardFetch(debate = debateBody()) { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.includes('/media/artifacts/url')) { + return new Response(JSON.stringify({ upload: { url: 'https://stills.example/still.png' } }), { + status: 200, + }); + } + if (url.endsWith('/media')) { + return new Response( + JSON.stringify({ + job: { status: 'succeeded' }, + artifacts: [{ kind: 'speaker_still_slot_1' }, { kind: 'speaker_still_slot_2' }], + }), + { status: 200 } + ); + } + return new Response(JSON.stringify(debate), { status: 200 }); + }) + ); + } + + it('names each side Agree and Disagree', async () => { + mockCardFetch(); + + const card = await loadDebateOgPreview(DEBATE_ID); + + expect(card?.speakers.map(speaker => speaker.stance)).toEqual(['Agree', 'Disagree']); + }); + + it('ignores a retired Verify/Dispute label geo-chat still sends', async () => { + mockCardFetch( + debateBody({ + participants: [ + { + profile_space_id: 'space-1', + display_name: 'Specter', + position: true, + participant_slot: 1, + position_label: 'Verify', + }, + { + profile_space_id: 'space-2', + display_name: 'Antispecter', + position: false, + participant_slot: 2, + position_label: 'Dispute', + }, + ], + }) + ); + + const card = await loadDebateOgPreview(DEBATE_ID); + + expect(card?.speakers.map(speaker => speaker.stance)).toEqual(['Agree', 'Disagree']); + }); +}); diff --git a/apps/web/core/debates/server/debate-source.ts b/apps/web/core/debates/server/debate-source.ts index 62a8a5354f..7a34f49b18 100644 --- a/apps/web/core/debates/server/debate-source.ts +++ b/apps/web/core/debates/server/debate-source.ts @@ -3,6 +3,7 @@ import { type DebateOgSpeaker, generateDebateOgImageResponse, } from '~/core/debates/debate-og-image'; +import { responsePositionLabel } from '~/core/responses/entity-response'; import { uploadGeoImage } from '~/core/sdk/geo-client'; import { getImagePath } from '~/core/utils/utils'; @@ -335,7 +336,11 @@ type DebateSpeakerLike = Debate['participants'][number]; function cardSpeaker(participant: DebateSpeakerLike, stillSrc: string): DebateOgSpeaker { return { name: participant.display_name ?? 'Anonymous', - stance: participant.position_label, + // Named from the side, not from geo-chat's `position_label`. That field still reads "Verify" or + // "Dispute" on a claim geo-chat calls factual, and this card is the worst place for the retired + // word to land: it is generated once at publish time and never revisited, so a wrong label is + // baked into the share image permanently. See `positionSummariesFromCounts`. + stance: responsePositionLabel(participant.position), avatarSrc: participant.avatar_cid ? getImagePath(participant.avatar_cid) : null, stillSrc, }; From 8978de7ecca1bab7e7396b483a0c23ef4b0f977c Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 12:13:25 -0700 Subject: [PATCH 05/12] fix(claims): type the wire kind apart, and stop gating the profile tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review, plus the preference rule. **The wire type was lying.** `DebateResponseKind` was narrowed to `'stance'` while the endpoint still returns `'veracity'`, and `geoChatRequest` casts raw JSON straight to its type parameter — so there is no parse step that could make the narrowing true. The tests carried `as 'stance'` casts to work around it, which is the lie showing through. `WireResponseKind` now describes what geo-chat can send and types the inbound `response_kind` fields; `DebateResponseKind` stays what the app resolves to and sends back. The casts are gone, and the compiler guards the boundary instead of a comment: a wire value no longer fits where a vocabulary gets chosen. That turned up two props typed off the wire field which should always have been the app's kind. It also made one test's input unrepresentable — the ticker's share verb comes from the hook's kind, which cannot be `veracity` — so that case is gone and the type is the guard. The claim-payload cases keep their meaning and now compile without a cast; breaking `WireResponseKind` proves they rely on it. **The profile tag no longer waits on claim metadata.** It was held until the response kind resolved, because a factual claim would have read "agrees" for a beat and then corrected itself to "verifies". One wording now, so the wait can change nothing — and `isResponseKindResolved` is `row !== null || entity !== null`, so on a claim where neither ever lands it never flips and the tag was hidden for good. The gallery card renders it directly; the interactive controls keep their gate, which is a different question. `responseNote` loses its response-kind parameter across all three callers. **Preference, per the new rule.** Someone holding both a verify and an agree resolves to the agree, because kind 2 is not read anywhere a position or label comes from. Already true; now pinned, including the sharp case where the two disagree — a Verify beside a Disagree reads as Disagree, not as the agreement the verify row implies. --- .../[sessionId]/rematch-page-client.test.tsx | 6 +- apps/web/core/claims/browse/claim-debates.tsx | 1 - .../claims/browse/claim-position-comment.tsx | 4 +- apps/web/core/debates/api.ts | 34 ++++--- .../browse/debate-claim-ticker.test.tsx | 17 +--- .../debates/matchmaking/matches-list.test.tsx | 2 +- .../matchmaking-claim-card.test.tsx | 2 +- .../matchmaking/matchmaking-claim-card.tsx | 11 ++- .../debates/participant-positions.test.ts | 28 ++++++ .../web/core/profile/person-position-order.ts | 13 ++- .../profile/person-positions-stance.test.ts | 22 ++++- .../explore/claim-explore-feed-card.tsx | 13 ++- .../partials/explore/explore-feed-card.tsx | 3 +- .../profile/claim-response-tag.test.tsx | 15 +-- .../profile/gallery-claim-card.test.tsx | 92 +++++++++++++++++++ .../partials/profile/gallery-claim-card.tsx | 16 ++-- .../partials/profile/person-record-feed.tsx | 6 +- 17 files changed, 213 insertions(+), 72 deletions(-) create mode 100644 apps/web/partials/profile/gallery-claim-card.test.tsx diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index d8e6c1bbf8..20410c8311 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -1128,10 +1128,10 @@ describe('DebateRematchPageClient', () => { }); it('uses Agree and Disagree for a claim geo-chat still calls factual', async () => { - mocks.claims = [{ ...sharedClaim(), response_kind: 'veracity' as 'stance' }]; + mocks.claims = [{ ...sharedClaim(), response_kind: 'veracity' }]; mocks.positions = [ - { ...position('profile-local', CLAIM_SHARED, SPACE_1, true), responseKind: 'veracity' as 'stance' }, - { ...position('profile-remote', CLAIM_SHARED, SPACE_1, false), responseKind: 'veracity' as 'stance' }, + position('profile-local', CLAIM_SHARED, SPACE_1, true), + position('profile-remote', CLAIM_SHARED, SPACE_1, false), ]; render(); diff --git a/apps/web/core/claims/browse/claim-debates.tsx b/apps/web/core/claims/browse/claim-debates.tsx index 5c387bd486..066e8da22d 100644 --- a/apps/web/core/claims/browse/claim-debates.tsx +++ b/apps/web/core/claims/browse/claim-debates.tsx @@ -69,7 +69,6 @@ export function ClaimDebates({ }: { claimId: string; spaceId: string; - /** Labels each debater's side in the claim's own vocabulary — Agree/Disagree or Verify/Dispute. */ }) { // A page at a time rather than an accumulating list: appending pushes everything below the // section down the page as the reader loads more, where swapping keeps the layout where they diff --git a/apps/web/core/claims/browse/claim-position-comment.tsx b/apps/web/core/claims/browse/claim-position-comment.tsx index f9aed9e00a..5cd235cc0b 100644 --- a/apps/web/core/claims/browse/claim-position-comment.tsx +++ b/apps/web/core/claims/browse/claim-position-comment.tsx @@ -8,7 +8,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import type { DebateClaimPositionSummary, MatchmakingReadiness } from '~/core/debates/api'; import { PositionRow } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { usePublishComment } from '~/core/hooks/use-publish-comment'; -import { CLAIM_RESPONSE_COPY } from '~/core/responses/entity-response'; +import { CLAIM_RESPONSE_COPY, type ResponseKind } from '~/core/responses/entity-response'; const MAX_COMMENT_HEIGHT_PX = 120; @@ -48,7 +48,7 @@ export function ClaimPositionCommentControl({ entityId: string; spaceId: string; positions: DebateClaimPositionSummary[]; - responseKind: MatchmakingReadiness['response_kind']; + responseKind: ResponseKind; viewerPosition: boolean | null; onRespond: (position: boolean) => void; /** False while signed out; the first click should open sign-in rather than an unusable composer. */ diff --git a/apps/web/core/debates/api.ts b/apps/web/core/debates/api.ts index 793f901036..4d0e43f30a 100644 --- a/apps/web/core/debates/api.ts +++ b/apps/web/core/debates/api.ts @@ -13,14 +13,26 @@ export type DebateRematchRequestStatus = 'pending' | 'accepted' | 'rejected' | ' * How a claim's two sides are labelled: Agree and Disagree, for every claim. * * This used to be `'stance' | 'veracity'`, and a claim carrying the "Is factual" flag took the - * second vocabulary — Verify and Dispute, published as its own vote kind. That split is gone. - * - * geo-chat may still put `"veracity"` on the wire for a claim minted before the change. Nothing - * reads the field to pick a vocabulary any more — every consumer uses `'stance'` outright — so a - * stale value cannot reach a label, a glyph or a vote kind. We send `'stance'` on the way back. + * second vocabulary — Verify and Dispute, published as its own vote kind. That split is gone, so + * this is what the app resolves to and what it sends back. */ export type DebateResponseKind = 'stance'; +/** + * What geo-chat can actually put on a `response_kind` field, which is not the same thing. + * + * It still says `"veracity"` for a claim minted before the vocabularies merged. `geoChatRequest` + * casts raw JSON straight to its type parameter, so there is no parse step that could narrow it — + * declaring these fields as {@link DebateResponseKind} would be the type telling a lie the compiler + * then enforces on everyone downstream. + * + * Typed apart instead, so the lie is gone and the compiler does the guarding: a `WireResponseKind` + * will not fit anywhere a {@link DebateResponseKind} is wanted, which is every place a vocabulary + * gets chosen. Nothing reads these fields today — surfaces take the kind from `CLAIM_RESPONSE_KIND` + * — and anything that starts to has to say out loud what it means to do with a retired value. + */ +export type WireResponseKind = DebateResponseKind | 'veracity'; + export type DebateParticipantSummary = { user_id: string; profile_space_id: string; @@ -39,7 +51,7 @@ export type DebateClaimSummary = { export type DebateMatch = { id: string; status: DebateMatchStatus; - response_kind: DebateResponseKind | null; + response_kind: WireResponseKind | null; cancellation_reason?: string | null; claim: DebateClaimSummary; participants: DebateMatchParticipant[]; @@ -208,7 +220,7 @@ export type Debate = { id: string; claim: DebateClaimSummary; status: DebateStatus; - response_kind: DebateResponseKind | null; + response_kind: WireResponseKind | null; room_name: string; first_participant_slot: ParticipantSlot; current_turn_index: number; @@ -304,7 +316,7 @@ export type DebateRematchRequest = { requester_position_label?: string | null; recipient_position: boolean; recipient_position_label?: string | null; - response_kind?: DebateResponseKind | null; + response_kind?: WireResponseKind | null; cancellation_reason?: string | null; turn_format_id: string; created_at: string; @@ -335,7 +347,7 @@ export type DebateRematchClaimPosition = { export type DebateRematchClaim = { claim: DebateClaimSummary; - response_kind: DebateResponseKind | null; + response_kind: WireResponseKind | null; participants: DebateRematchClaimPosition[]; shared_preference: boolean; recently_rejected: boolean; @@ -399,7 +411,7 @@ export type DebateClaim = { claim_entity_id: string; claim: string; description: string | null; - response_kind: DebateResponseKind; + response_kind: WireResponseKind; viewer_response: { position: boolean; position_label: string } | null; viewer_debate_ready: boolean; readiness_disabled_reason: string | null; @@ -495,7 +507,7 @@ export type DebateResponseSummary = { /** Everything the hub needs to render a claim's readiness state alongside the viewer's response. */ export type MatchmakingReadiness = { /** Legacy; see {@link DebateResponseKind}. Every claim is Agree/Disagree. */ - response_kind: DebateResponseKind; + response_kind: WireResponseKind; /** Present whenever the viewer has an active response — including while readiness is off. */ viewer_response: DebateResponseSummary | null; viewer_debate_ready: boolean; diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx index a63d1a39a5..4c1a66f3f0 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -215,18 +215,11 @@ describe('DebateClaimTickerCard', () => { expect(screen.getByText('65% agree')).toBeInTheDocument(); }); - // This used to read "65% verify" on a factual claim, from that claim's own vocabulary. There is - // one vocabulary now, so the share says "agree" whatever the claim is flagged as — asserted here - // on the case that used to differ. - it('reads the share with agree even on a claim geo-chat still calls factual', () => { - mocks.percent = 65; - mocks.responseKind = 'veracity' as 'stance'; - - renderCard(); - - expect(screen.getByText('65% agree')).toBeInTheDocument(); - expect(screen.queryByText('65% verify')).not.toBeInTheDocument(); - }); + // A case that used to sit here — "reads the share with agree even on a claim geo-chat still calls + // factual" — is gone, and the type is why. The share verb comes from the kind this hook returns, + // which is `DebateResponseKind` and so can only be `stance`; geo-chat's own word for it never + // reaches here. Setting `mocks.responseKind = 'veracity'` no longer compiles, and with the input + // unrepresentable the case is the one directly above it. // A genuine 0% and "nobody has answered" are different statements, and the great majority of // claims are the second one. diff --git a/apps/web/core/debates/matchmaking/matches-list.test.tsx b/apps/web/core/debates/matchmaking/matches-list.test.tsx index c965c13113..8ad47d847a 100644 --- a/apps/web/core/debates/matchmaking/matches-list.test.tsx +++ b/apps/web/core/debates/matchmaking/matches-list.test.tsx @@ -248,7 +248,7 @@ describe('MatchesList', () => { // Inverted rather than deleted: a claim geo-chat still labels `veracity` is exactly the case that // has to come out Agree/Disagree now. it('uses Agree and Disagree for a claim geo-chat still calls factual', () => { - mocks.matches = [match({ response_kind: 'veracity' as 'stance', positions: [] })]; + mocks.matches = [match({ response_kind: 'veracity', positions: [] })]; render(); expect(screen.getByRole('button', { name: /^Agree/ })).toBeInTheDocument(); diff --git a/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx b/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx index 49e653f195..12c507ec8e 100644 --- a/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx +++ b/apps/web/core/debates/matchmaking/matchmaking-claim-card.test.tsx @@ -547,7 +547,7 @@ describe('position avatar stack', () => { ); diff --git a/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx b/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx index 3446a59da4..a817a39f15 100644 --- a/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx +++ b/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx @@ -21,7 +21,12 @@ import { useNearViewport } from '~/core/hooks/use-near-viewport'; import { useProfilesBySpaceIds } from '~/core/hooks/use-profiles-by-space-ids'; import { spaceLabel, useSpaceLabels } from '~/core/hooks/use-space-labels'; import { ID } from '~/core/id'; -import { CLAIM_RESPONSE_COPY, CLAIM_RESPONSE_KIND, responsePositionLabel } from '~/core/responses/entity-response'; +import { + CLAIM_RESPONSE_COPY, + CLAIM_RESPONSE_KIND, + type ResponseKind, + responsePositionLabel, +} from '~/core/responses/entity-response'; import { useClaimResponseBatchState } from '~/core/responses/use-claim-response-summaries'; import { usePendingPersonalSpace } from '~/core/state/pending-personal-space'; import { NavUtils, validateEntityId, validateSpaceId } from '~/core/utils/utils'; @@ -997,7 +1002,7 @@ export function PositionRow({ endSlot, }: { positions: DebateClaimPositionSummary[]; - responseKind: MatchmakingReadiness['response_kind']; + responseKind: ResponseKind; viewerPosition: boolean | null; onRespond?: (position: boolean) => void; disabled?: boolean; @@ -1130,7 +1135,7 @@ function PositionButton({ }: { label: string; summary: DebateClaimPositionSummary | undefined; - responseKind: MatchmakingReadiness['response_kind']; + responseKind: ResponseKind; position: boolean; selected: boolean; onRespond?: (position: boolean) => void; diff --git a/apps/web/core/debates/participant-positions.test.ts b/apps/web/core/debates/participant-positions.test.ts index a99083072b..ff802e9232 100644 --- a/apps/web/core/debates/participant-positions.test.ts +++ b/apps/web/core/debates/participant-positions.test.ts @@ -92,6 +92,34 @@ describe('fetchParticipantPositions', () => { ]); }); + /** + * One person, one claim, both kinds — and they disagree. + * + * Somebody could Verify a claim (kind 2, position true) and separately Disagree with it (kind 1, + * position false): two answers to two different questions, which is what the old vocabulary made + * possible. There is one question now, and the stance is the one that answers it — so the side + * reported is Disagree, not the Verify sitting beside it. + * + * Pinned because "ignore kind 2" and "prefer kind 1" only look the same while nobody holds both. + */ + it('reports the stance when a person holds both kinds on one claim, even opposite ones', async () => { + const fetchPage = vi.fn().mockResolvedValue([ + { userId: LOCAL.profile_space_id, objectId: 'claim-1', spaceId: 'space-1', voteType: 0, voteKind: 2 }, + { userId: LOCAL.profile_space_id, objectId: 'claim-1', spaceId: 'space-1', voteType: 1, voteKind: 1 }, + ]); + + await expect(fetchParticipantPositions([LOCAL.profile_space_id], undefined, fetchPage)).resolves.toEqual([ + { + profileSpaceId: LOCAL.profile_space_id, + claimId: 'claim-1', + spaceId: 'space-1', + responseKind: 'stance', + // `false` — the stance. Not the `true` the verify row carries. + position: false, + }, + ]); + }); + it('pages until a short page comes back', async () => { const full = Array.from({ length: 500 }, (_, index) => ({ userId: LOCAL.profile_space_id, diff --git a/apps/web/core/profile/person-position-order.ts b/apps/web/core/profile/person-position-order.ts index 3ecddc161b..2d3968d799 100644 --- a/apps/web/core/profile/person-position-order.ts +++ b/apps/web/core/profile/person-position-order.ts @@ -158,14 +158,13 @@ const ORDER_MAX_PAGES = 20; export type Stance = 'agree' | 'disagree'; /** - * How this person answered a claim, by the question they were answering. + * How this person answered a claim. * - * **Both kinds, not just the stance.** A claim marked factual asks Verify or - * Dispute rather than Agree or Disagree, and that answer is a `voteKind` 2 vote - * — which the stance-only shape threw away, so 18 of the reference account's 208 - * positions had no indicator anywhere and nothing said why. Which one a card - * shows is the card's to decide: it resolves the claim's response kind itself, - * and the same claim can be factual in one space and not in another. + * One field, because there is one question. A claim marked factual used to ask Verify or Dispute + * rather than Agree or Disagree, and that answer was a `voteKind` 2 vote carried here in a + * `veracity` field of its own — so that a card could show whichever matched the claim's own + * vocabulary, which was a per-space property the decode could not see. Claims ask one question + * now: the stance is the answer, and a kind-2 row is not read at all. */ export type ClaimResponse = { stance?: Stance; diff --git a/apps/web/core/profile/person-positions-stance.test.ts b/apps/web/core/profile/person-positions-stance.test.ts index eb1da18642..46dca3c3cd 100644 --- a/apps/web/core/profile/person-positions-stance.test.ts +++ b/apps/web/core/profile/person-positions-stance.test.ts @@ -48,13 +48,27 @@ describe('how a claim was answered', () => { expect(decodeVoteOrder([vote({ voteKind: 2, voteType: 1 })]).responseByClaimId).toEqual({}); }); - it('keeps only the stance when somebody answered both questions', () => { + /** + * Both kinds on one claim, disagreeing — and the veracity row is the newer one. + * + * Rows arrive newest-first and the decode takes the first it sees per field, so putting the + * retired row first is the ordering that would win if it were still read at all. The stance is + * the answer either way. + * + * Both directions, because "the stance wins" and "agree wins" only look the same in the first + * case: a Verify sitting beside a Disagree must read as Disagree, not as the agreement the + * verify row would otherwise imply. + */ + it.each([ + ['a stance of agree under a dispute', 0, 1, 'agree'], + ['a stance of disagree under a verify', 1, 0, 'disagree'], + ] as const)('keeps only %s', (_case, stanceVoteType, veracityVoteType, expected) => { const order = decodeVoteOrder([ - vote({ objectId: 'a', voteKind: 1, voteType: 0 }), - vote({ objectId: 'a', voteKind: 2, voteType: 1 }), + vote({ objectId: 'a', voteKind: 2, voteType: veracityVoteType }), + vote({ objectId: 'a', voteKind: 1, voteType: stanceVoteType }), ]); - expect(order.responseByClaimId).toEqual({ a: { stance: 'agree' } }); + expect(order.responseByClaimId).toEqual({ a: { stance: expected } }); }); it('does not list a claim answered only for veracity', () => { diff --git a/apps/web/partials/explore/claim-explore-feed-card.tsx b/apps/web/partials/explore/claim-explore-feed-card.tsx index b3bdab964e..838fef816e 100644 --- a/apps/web/partials/explore/claim-explore-feed-card.tsx +++ b/apps/web/partials/explore/claim-explore-feed-card.tsx @@ -91,7 +91,7 @@ export function ClaimExploreFeedCard({ * only this card knows. Absent everywhere else, which is every surface where * the only answer worth reporting is the reader's own. */ - responseNote?: (responseKind: ResponseKind, position: boolean) => React.ReactNode; + responseNote?: (position: boolean) => React.ReactNode; }) { // The feed pre-mounts cards thousands of pixels below the fold, so the counts and the geo-chat // row are gated on proximity rather than on mount — otherwise every claim in every loaded page @@ -183,13 +183,12 @@ export function ClaimExploreFeedCard({ * "Susan agrees" beneath Agree. The pills hold the card's own grid row, so * nothing else moves. * - * Held back until the response kind is known, or a factual claim reads - * "agrees" for a beat and then corrects itself to "verifies". + * No longer held back on the claim's metadata. It was, because a factual claim would have read + * "agrees" for a beat and then corrected itself to "verifies" — and there is one wording now, so + * there is nothing to correct. Waiting only delayed an answer already in hand, and hid it for + * good on a card where both metadata reads fail. */ - const noteFor = React.useCallback( - (position: boolean) => (isResponseKindResolved ? responseNote?.(responseKind, position) : null), - [isResponseKindResolved, responseKind, responseNote] - ); + const noteFor = React.useCallback((position: boolean) => responseNote?.(position), [responseNote]); const hasVerdict = !summary.isLoading && summary.hasCounts && summary.total > 0; const matchesDebatePanelOnMobile = variant === 'debate-panel-mobile'; diff --git a/apps/web/partials/explore/explore-feed-card.tsx b/apps/web/partials/explore/explore-feed-card.tsx index ec83833f55..0e1220e0d5 100644 --- a/apps/web/partials/explore/explore-feed-card.tsx +++ b/apps/web/partials/explore/explore-feed-card.tsx @@ -8,7 +8,6 @@ import { useRecordingSources } from '~/core/community-calls/use-recording-source 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 type { ResponseKind } from '~/core/responses/entity-response'; import { normId } from '~/core/utils/norm-id'; import { PublishedRecordingPlayer } from '~/partials/community-calls/published-recording-player'; @@ -35,7 +34,7 @@ type ExploreFeedCardProps = { /** Presentation used for Claim rows; other entity types ignore it. */ claimCardVariant?: ClaimCardVariant; /** See `ClaimExploreFeedCard`. Only a claim can carry one. */ - responseNote?: (responseKind: ResponseKind, position: boolean) => React.ReactNode; + responseNote?: (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`. */ diff --git a/apps/web/partials/profile/claim-response-tag.test.tsx b/apps/web/partials/profile/claim-response-tag.test.tsx index 846af56781..614305ae43 100644 --- a/apps/web/partials/profile/claim-response-tag.test.tsx +++ b/apps/web/partials/profile/claim-response-tag.test.tsx @@ -13,13 +13,14 @@ import { ClaimResponseTag, firstName } from './claim-response-tag'; * A visitor could see *which* claims somebody had answered and not *how* — a * record of attention with the verdict left out. * - * Two things carry the meaning together. The **wording** follows the claim's own - * question. A claim marked factual used to ask Verify or Dispute rather than - * Disagree, so a tag that always said "agrees" would contradict the control - * above it on exactly the claims where the distinction matters — 18 of the - * reference account's 208 positions are answered that way. The **placement** - * says which side: the tag renders inside one of the two response cells, so - * `forPosition` decides whether it appears at all. + * The **placement** is what says which side: the tag renders inside one of the + * two response cells, so `forPosition` decides whether it appears at all. + * + * The **wording** used to carry half of it too. A claim marked factual asked + * Verify or Dispute rather than Agree or Disagree, so a tag that always said + * "agrees" would have contradicted the control above it on exactly the claims + * where the distinction mattered. Every claim asks the same question now, so + * there is one pair of words and nothing for the tag to resolve. */ describe('ClaimResponseTag', () => { afterEach(cleanup); diff --git a/apps/web/partials/profile/gallery-claim-card.test.tsx b/apps/web/partials/profile/gallery-claim-card.test.tsx new file mode 100644 index 0000000000..b7a9a30801 --- /dev/null +++ b/apps/web/partials/profile/gallery-claim-card.test.tsx @@ -0,0 +1,92 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +import * as React from 'react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ExploreFeedRow } from '~/core/explore/explore-card-item'; + +import { GalleryClaimCard } from './gallery-claim-card'; + +/** + * Both claim lookups answer with nothing, which is the state the tag used to be hidden behind. + * + * `isResponseKindResolved` is `row !== null || entity !== null`, so this is the case where it never + * becomes true — a claim geo-chat does not index whose entity read also comes back empty. + */ +vi.mock('~/core/debates/hooks', () => ({ + useDebateClaims: () => ({ data: { claims: [] }, isLoading: false, error: null }), +})); + +vi.mock('~/core/sync/use-store', () => ({ + useQueryEntity: () => ({ entity: null, isLoading: false }), +})); + +/** + * `isResponseKindResolved: false` is the state under test — set here rather than inferred from the + * two lookups above, so the case cannot quietly stop being reachable if the hook's definition of + * "resolved" changes. The real hook also reaches wagmi, which this suite has no use for. + */ +vi.mock('~/core/claims/browse/use-claim-response-state', () => ({ + useClaimResponseState: () => ({ + claim: { id: 'claim-1', space_id: 'space-1', claim_entity_id: 'claim-1', claim: 'A claim', description: null }, + positions: [], + readiness: { + response_kind: 'stance', + viewer_response: null, + viewer_debate_ready: false, + readiness_disabled_reason: null, + }, + isResponseKindResolved: false, + isViewerResponseResolved: false, + responseBlockedReason: null, + }), +})); + +vi.mock('~/core/hooks/use-near-viewport', () => ({ + useNearViewport: () => [vi.fn(), true] as const, +})); + +vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => vi.fn() })); + +/** The card under test only has to hand the note down; the pills themselves are another suite's. */ +vi.mock('~/core/debates/matchmaking/matchmaking-claim-card', () => ({ + MatchmakingClaimCard: ({ noteFor }: { noteFor?: (position: boolean) => React.ReactNode }) => ( +
+ {noteFor?.(true)} + {noteFor?.(false)} +
+ ), +})); + +const row = { + entityId: '3bf9b841187f8c71b74f892ba4e83b75', + spaceId: 'c9f267dcb0d270718c2a3c45a64afd32', +} as unknown as ExploreFeedRow; + +afterEach(cleanup); + +/** + * The tag reports how *this person* answered, and the caller already knows — it comes from their + * record, not from the claim. + * + * It used to be held back until the claim's response kind resolved, because a factual claim would + * have read "agrees" for a beat and then corrected itself to "verifies". Every claim asks the same + * question now, so the wait cannot change the wording — and on a claim whose lookups never answer, + * `isResponseKindResolved` never flips, so the wait was permanent rather than a beat. + */ +describe('GalleryClaimCard response tag', () => { + it('names the side even when neither claim lookup ever answers', () => { + render(); + + expect(screen.getByText('agrees')).toBeInTheDocument(); + }); + + it('says nothing for a claim the person has not answered', () => { + render(); + + expect(screen.queryByText('agrees')).not.toBeInTheDocument(); + expect(screen.queryByText('disagrees')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/partials/profile/gallery-claim-card.tsx b/apps/web/partials/profile/gallery-claim-card.tsx index 1b61bc04aa..7c2617d7e8 100644 --- a/apps/web/partials/profile/gallery-claim-card.tsx +++ b/apps/web/partials/profile/gallery-claim-card.tsx @@ -95,14 +95,14 @@ export function GalleryClaimCard({ // pills speak for the viewer. Under the pill that matches, which is // where the feed's card puts it too, so one claim reads the same way // in both places. - // Held back until the kind is known: labelling a factual claim - // "agrees" and then correcting it to "verifies" is worse than a beat - // with no tag. - noteFor={position => - isResponseKindResolved ? ( - - ) : null - } + // Not held back on the claim's metadata. It was, because labelling a + // factual claim "agrees" and then correcting it to "verifies" was + // worse than a beat with no tag — and there is one wording now. The + // answer is already in `response`; waiting on a lookup that cannot + // change the wording only hid it, permanently where both reads fail. + noteFor={position => ( + + )} /> ) : ( // Held at the card's own height rather than collapsed, so the row does diff --git a/apps/web/partials/profile/person-record-feed.tsx b/apps/web/partials/profile/person-record-feed.tsx index b2c1564321..e66921b681 100644 --- a/apps/web/partials/profile/person-record-feed.tsx +++ b/apps/web/partials/profile/person-record-feed.tsx @@ -151,11 +151,11 @@ export function PersonRecordFeed({ ( + ? position => ( Date: Thu, 24 Sep 2026 13:05:52 -0700 Subject: [PATCH 06/12] chore(claims): drop a type import the switch to ResponseKind left behind `claim-position-comment` stopped typing its prop off `MatchmakingReadiness` last commit and kept importing it. Worth saying how it survived: the lint gate I had been checking each round was running against an empty file list. The command built its list with `git diff -- apps/web` from *inside* `apps/web`, where that pathspec matches nothing, so eslint was handed no files and reported no problems. Seventy files were going unchecked and reading as clean. Re-run against the real list, the whole diff has exactly this one warning, so the broken gate had hidden one stale import rather than a backlog. --- apps/web/core/claims/browse/claim-position-comment.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/core/claims/browse/claim-position-comment.tsx b/apps/web/core/claims/browse/claim-position-comment.tsx index 5cd235cc0b..608e06028a 100644 --- a/apps/web/core/claims/browse/claim-position-comment.tsx +++ b/apps/web/core/claims/browse/claim-position-comment.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import cx from 'classnames'; import { AnimatePresence, motion } from 'framer-motion'; -import type { DebateClaimPositionSummary, MatchmakingReadiness } from '~/core/debates/api'; +import type { DebateClaimPositionSummary } from '~/core/debates/api'; import { PositionRow } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { usePublishComment } from '~/core/hooks/use-publish-comment'; import { CLAIM_RESPONSE_COPY, type ResponseKind } from '~/core/responses/entity-response'; From 0c9aea46201ef25ccde97dd3c38f9543f7834293 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 13:27:27 -0700 Subject: [PATCH 07/12] fix(votes): describe an entity by its newest vote row, not its oldest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from Patrick's review, and both are the same migration doing the damage: Verify is gone, so a factual claim's responder answers it again with Agree — and their kind-2 row stays on chain, because nothing can clear it any more. That person now holds two vote rows on one claim. **The Agreed tab dropped those claims.** `voteKindByObjectId` was built with `Object.fromEntries`, which gives a repeated key its *last* value, and rows arrive `VOTED_AT_DESC` — so the map described every entity by its *oldest* vote. On a Verify-then-Agree claim it reported kind 2, `useVoteTabEntities` compared that against the kind 1 the claim resolves to now, and dropped it. Before this PR the claim resolved to kind 2 as well, so it matched and showed: the regression is ours, on exactly the path this PR sends people down. Extracted as `indexVoteRowsByObject` so it can be tested at all, and it now takes the first row per entity. `votedAtByObjectId` is built in the same pass: read from a different row it can disagree with the kind, and it is the list's sort key. The cross-page merge in `use-user-voted-entity-ids` had the same last-wins shape for an entity whose rows straddle a page boundary. Note this changes the Agreed/Disagreed tab's sort position for a claim holding both kinds — it now sorts by the Agree, which is the vote being shown. **The Positions order still spent its budget on retired rows.** The query asked for kinds 1 and 2; the decode stopped treating kind 2 as an answer last commit, but the rows still arrived, still took a slot in the vote order, and still counted against `ORDER_MAX_PAGES`. So a claim Agreed last month and Verified yesterday sorted by yesterday, and enough retired rows could push real stance rows past the cap. The filter asks for kind 1 alone. Swept the class: the only other `Object.fromEntries` over query rows is a static step list, and the remaining `voteKind: { in: … }` filters are the onboarding checklist's documented exception and one already narrowed to stance. --- .../core/hooks/use-user-voted-entity-ids.ts | 9 +++- apps/web/core/io/queries.test.ts | 42 +++++++++++++++++++ apps/web/core/io/queries.ts | 37 +++++++++++++++- .../web/core/profile/person-position-order.ts | 2 +- .../profile/person-positions-stance.test.ts | 24 ++++++++++- 5 files changed, 109 insertions(+), 5 deletions(-) diff --git a/apps/web/core/hooks/use-user-voted-entity-ids.ts b/apps/web/core/hooks/use-user-voted-entity-ids.ts index bc789e7b39..85026f00f6 100644 --- a/apps/web/core/hooks/use-user-voted-entity-ids.ts +++ b/apps/web/core/hooks/use-user-voted-entity-ids.ts @@ -230,8 +230,15 @@ export function useUserVotedEntityIds(direction: EntityVoteDirectionFilter, enab } for (const page of fetchedPages) { - Object.assign(votedAtById, page.votedAtByObjectId); + // The earlier page wins, for the same reason the earlier row does inside one (see + // `getUserEntityVotesByType`): pages arrive newest-first, so an entity carrying vote rows + // that straddle a page boundary would otherwise be described by its oldest. + for (const [id, votedAt] of Object.entries(page.votedAtByObjectId)) { + if (id in votedAtById) continue; + votedAtById[id] = votedAt; + } for (const [id, voteKind] of Object.entries(page.voteKindByObjectId)) { + if (voteKinds.has(id)) continue; voteKinds.set(id, voteKind); } const pageIds: string[] = []; diff --git a/apps/web/core/io/queries.test.ts b/apps/web/core/io/queries.test.ts index 92bad54f3f..a57e6ce2c1 100644 --- a/apps/web/core/io/queries.test.ts +++ b/apps/web/core/io/queries.test.ts @@ -8,6 +8,7 @@ import { getEntityBacklinks, groupRestResults, hasDefaultSearchExcludedType, + indexVoteRowsByObject, shouldIncludeRestSearchResult, } from './queries'; import { MAX_SEARCH_QUERY_LENGTH } from './search-query'; @@ -430,3 +431,44 @@ describe('getEntityBacklinks', () => { expect(graphqlMock).toHaveBeenCalledTimes(1); }); }); + +/** + * GEO-2993. Verify is gone, so a factual claim's responder answers it again with Agree — and their + * old kind-2 row stays on chain, because nothing can clear it any more. That person now holds two + * vote rows on one claim, which is the state these lookups have to describe correctly. + */ +describe('indexVoteRowsByObject', () => { + const CLAIM = '4c81561d1f9541319cdddd20ab831ba2'; + + /** Newest first, as `VOTED_AT_DESC` returns them: today's Agree, then August's Verify. */ + const bothKinds = [ + { objectId: CLAIM, voteKind: 1, votedAt: '2026-09-24T00:00:00.000Z' }, + { objectId: CLAIM, voteKind: 2, votedAt: '2026-08-06T00:00:00.000Z' }, + ]; + + it('describes an entity by its newest vote row, not its oldest', () => { + const { voteKindByObjectId, votedAtByObjectId } = indexVoteRowsByObject(bothKinds); + + // Kind 1. `Object.fromEntries` gave the last row the key and reported 2, which is the vote the + // person no longer holds a way to cast. + expect(voteKindByObjectId[CLAIM]).toBe(1); + expect(votedAtByObjectId[CLAIM]).toBe('2026-09-24T00:00:00.000Z'); + }); + + it('keeps both lookups on the same row', () => { + const { voteKindByObjectId, votedAtByObjectId } = indexVoteRowsByObject(bothKinds); + + // Read from different rows these disagree, and the timestamp is the list's sort key — so the + // entity would sort by one vote and be filtered by another. + const kindRow = bothKinds.find(row => row.voteKind === voteKindByObjectId[CLAIM]); + expect(kindRow?.votedAt).toBe(votedAtByObjectId[CLAIM]); + }); + + it('leaves an entity with one row alone', () => { + const { voteKindByObjectId } = indexVoteRowsByObject([ + { objectId: CLAIM, voteKind: 0, votedAt: '2026-09-01T00:00:00.000Z' }, + ]); + + expect(voteKindByObjectId[CLAIM]).toBe(0); + }); +}); diff --git a/apps/web/core/io/queries.ts b/apps/web/core/io/queries.ts index 67623ac145..7ad0d5d22a 100644 --- a/apps/web/core/io/queries.ts +++ b/apps/web/core/io/queries.ts @@ -1554,6 +1554,40 @@ export const USER_ENTITY_VOTES_PAGE_SIZE = 50; type UserEntityVoteRow = { objectId: string; voteKind: number; votedAt: string }; +/** + * The current vote row per entity, as two lookups. + * + * **The first row wins, not the last.** One entity can carry more than one row: a claim answered + * Verify before the vocabularies merged and Agree after it holds a vote of each kind, and both come + * back from a query that filters on direction rather than kind. Rows arrive `VOTED_AT_DESC`, so the + * first one is the current answer. + * + * This was `Object.fromEntries`, which gives a repeated key its *last* value — the oldest row. On + * the claim above that reported kind 2, and `useVoteTabEntities` drops any claim whose recorded + * kind is not the one it resolves to now, so the claim vanished from the Agreed tab while the + * person still held a live stance on it. That is the path every factual-claim responder takes once + * Verify is gone, so it is the common case rather than an edge. + * + * Both maps are built here together so a single entity's kind and timestamp always describe the + * same row; read from different rows they can disagree, and the timestamp is the list's sort key. + */ +export function indexVoteRowsByObject(nodes: readonly UserEntityVoteRow[]): { + voteKindByObjectId: Record; + votedAtByObjectId: Record; +} { + const voteKindByObjectId: Record = {}; + const votedAtByObjectId: Record = {}; + + for (const node of nodes) { + const id = uuidToHex(node.objectId); + if (id in voteKindByObjectId) continue; + voteKindByObjectId[id] = node.voteKind; + votedAtByObjectId[id] = node.votedAt; + } + + return { voteKindByObjectId, votedAtByObjectId }; +} + export type UserEntityVoteObjectIdsPage = { objectIds: string[]; voteKindByObjectId: Record; @@ -1586,8 +1620,7 @@ export function getUserEntityVoteObjectIdsPage( const nodes = rows.filter(node => Boolean(node.objectId)); const objectIds = nodes.map(node => node.objectId); - const voteKindByObjectId = Object.fromEntries(nodes.map(node => [uuidToHex(node.objectId), node.voteKind])); - const votedAtByObjectId = Object.fromEntries(nodes.map(node => [uuidToHex(node.objectId), node.votedAt])); + const { voteKindByObjectId, votedAtByObjectId } = indexVoteRowsByObject(nodes); return { objectIds, diff --git a/apps/web/core/profile/person-position-order.ts b/apps/web/core/profile/person-position-order.ts index 2d3968d799..28e83d05d2 100644 --- a/apps/web/core/profile/person-position-order.ts +++ b/apps/web/core/profile/person-position-order.ts @@ -54,7 +54,7 @@ const VOTE_ORDER_SOURCE = /* GraphQL */ ` first: $first after: $after orderBy: VOTED_AT_DESC - filter: { userId: { is: $userId }, or: [{ voteKind: { is: 1 } }, { voteKind: { is: 2 } }] } + filter: { userId: { is: $userId }, voteKind: { is: 1 } } ) { pageInfo { hasNextPage diff --git a/apps/web/core/profile/person-positions-stance.test.ts b/apps/web/core/profile/person-positions-stance.test.ts index 46dca3c3cd..37923f0394 100644 --- a/apps/web/core/profile/person-positions-stance.test.ts +++ b/apps/web/core/profile/person-positions-stance.test.ts @@ -1,6 +1,7 @@ +import { print } from 'graphql'; import { describe, expect, it } from 'vitest'; -import { decodeVoteOrder } from './person-position-order'; +import { decodeVoteOrder, personVoteOrderDocument } from './person-position-order'; import { applyFilter, heldPositionsCount } from './use-person-positions'; /** @@ -362,3 +363,24 @@ describe('heldPositionsCount', () => { expect(heldPositionsCount({ total: null, isError: true }, 211)).toBe(211); }); }); + +/** + * GEO-2993. The decode ignores kind-2 rows, but ignoring them after they arrive is not enough. + * + * Every row that comes back takes a slot in the vote order and a slot in the page budget, whether + * or not it becomes an answer. A claim answered Agree last month and Verified yesterday would sort + * by yesterday's retired vote, and enough retired rows push real stance rows past `ORDER_MAX_PAGES` + * and out of the list entirely. Asked for correctly, neither can happen. + */ +describe('the positions query', () => { + const source = print(personVoteOrderDocument); + + it('asks only for stance votes', () => { + // `print` normalises the document, so this is the filter as printed rather than as written. + expect(source).toContain('voteKind: {is: 1}'); + }); + + it('does not ask for the retired veracity kind', () => { + expect(source).not.toContain('voteKind: {is: 2}'); + }); +}); From de8ee6b98ee3c1a7d6254bcc2a3d32dbba7c2c8b Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 14:00:26 -0700 Subject: [PATCH 08/12] test(claims): cover the two stale-wire guards, and correct their docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's "previously missed" set, all three mine. **The backfill's normalization had nothing holding it.** It sends `CLAIM_RESPONSE_KIND` rather than the row's word, and every fixture in the suite was already `response_kind: 'stance'` — so reading the row produced the same call and the assertion could not tell the two apart. A case with a `'veracity'` row now asserts we still send `stance`. Sweeping that class turned up the same gap one file over: `claim-response-indexed-notifier` guards on `isClaimResponseKind`, and every negative case there differed by *account* rather than by kind, so the guard itself was unasserted. A key written under the retired kind now has to be ignored. Both fail when their guard is reverted. The other normalization sites already had discriminating fixtures — the card's write path, the ticker, matches-list, the rematch page, the share card and the glyph fallback all feed a veracity value and assert the stance result. **Two comments were stale, and one was self-contradicting.** `taggedPositionSummaries` carried both "a server-supplied label wins" and the line replacing it, which is worse than either alone. The `CLAIM_RESPONSE_KIND` and `CLAIM_RESPONSE_COPY` docs both said TypeScript could not catch a wire value being used as a kind. That was true when they were written and stopped being true two commits later, when `WireResponseKind` split the inbound type from the app's. They now describe the split that does the guarding rather than the hazard it removed. --- ...kfill-readiness-for-held-position.test.tsx | 17 +++++++++++ .../claim-response-indexed-notifier.test.tsx | 30 +++++++++++++++++++ .../core/debates/matchmaking/claims-tab.tsx | 1 - apps/web/core/responses/entity-response.ts | 20 ++++++------- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/apps/web/core/debates/backfill-readiness-for-held-position.test.tsx b/apps/web/core/debates/backfill-readiness-for-held-position.test.tsx index d117480367..aa9904f024 100644 --- a/apps/web/core/debates/backfill-readiness-for-held-position.test.tsx +++ b/apps/web/core/debates/backfill-readiness-for-held-position.test.tsx @@ -58,6 +58,23 @@ describe('useBackfillReadinessForHeldPosition', () => { expect(mocks.notify.mock.calls[0]?.slice(0, 4)).toEqual(['space-1', 'claim-1', 'stance', true]); }); + /** + * The kind sent back is ours, not the row's. + * + * geo-chat still labels a claim minted before the vocabularies merged `"veracity"`, and this hook + * forwards a kind to geo-chat — so reading the row would record the retired kind against a + * response that was published as a stance. + * + * The case above cannot catch that: its fixture is already `'stance'`, so reading the row and + * ignoring it produce the same call. This one differs only in the row's word. + */ + it('sends stance even when the row still says veracity', () => { + render(); + + expect(mocks.notify).toHaveBeenCalledTimes(1); + expect(mocks.notify.mock.calls[0]?.slice(0, 4)).toEqual(['space-1', 'claim-1', 'stance', true]); + }); + it('sends once per claim however often the row refetches', () => { const view = render(); view.rerender(); diff --git a/apps/web/core/debates/claim-response-indexed-notifier.test.tsx b/apps/web/core/debates/claim-response-indexed-notifier.test.tsx index f2d81d5947..5eebb06324 100644 --- a/apps/web/core/debates/claim-response-indexed-notifier.test.tsx +++ b/apps/web/core/debates/claim-response-indexed-notifier.test.tsx @@ -299,6 +299,36 @@ describe('useClaimResponseIndexedNotifier', () => { expect(mocks.notify).not.toHaveBeenCalled(); }); + /** + * A key written under the retired kind is not a report this app can make. + * + * `isClaimResponseKind` exists to reject it, and every other negative case here differs by + * account rather than by kind — so the guard itself had nothing asserting it. Forwarding one + * would tell geo-chat a response was published as a veracity vote when it was published as a + * stance. + */ + it('ignores an indexing key written under the retired kind', async () => { + const { queryClient, wrapper } = createHarness(); + renderHook(() => useClaimResponseIndexedNotifier(true, vi.fn(), 'account-1'), { wrapper }); + + act(() => + queryClient.setQueryData(['entity-response-indexing', 'profile-1', 'claim-1', 'space-1', 'veracity'], { + status: 'indexed', + pending: { + entityId: 'claim-1', + expectedResponse: 'positive', + personalSpaceId: 'profile-1', + responseKind: 'veracity', + spaceId: 'space-1', + }, + runId: 'run-retired', + }) + ); + + await new Promise(resolve => setTimeout(resolve, 20)); + expect(mocks.notify).not.toHaveBeenCalled(); + }); + it('gives each report its own Retry-After budget', async () => { const rateLimited = () => new GeoChatRequestError('rate limited', 'rate_limited', 429, 10); let rejectFirst: ((error: unknown) => void) | undefined; diff --git a/apps/web/core/debates/matchmaking/claims-tab.tsx b/apps/web/core/debates/matchmaking/claims-tab.tsx index 1ff1b7c4da..333fb66f58 100644 --- a/apps/web/core/debates/matchmaking/claims-tab.tsx +++ b/apps/web/core/debates/matchmaking/claims-tab.tsx @@ -1201,7 +1201,6 @@ function taggedPositionSummaries(row: DebateClaim | undefined): DebateClaimPosit return { position, - // A server-supplied label wins, so an authoritative Verify/Dispute survives. // Our label, never geo-chat's stale Verify/Dispute — see `positionSummariesFromCounts`. position_label: responsePositionLabel(position), total_count: choice?.participant_count ?? 0, diff --git a/apps/web/core/responses/entity-response.ts b/apps/web/core/responses/entity-response.ts index 1d42d7b462..6dbaf392ed 100644 --- a/apps/web/core/responses/entity-response.ts +++ b/apps/web/core/responses/entity-response.ts @@ -85,13 +85,13 @@ export const ENTITY_RESPONSE_COPY: Record = { * The one kind a claim is answered with. * * The authority on this, so that no surface has to read it off geo-chat's row. That field can - * still say `"veracity"` for a claim minted before the vocabularies merged, and it arrives typed as - * the narrowed {@link DebateResponseKind} it no longer matches — so TypeScript cannot stop it being - * used as a key. Fed to `getResponseActionMethod` it selects no SDK method and the *write* throws - * on click; fed to `responseKindToVoteKind` it yields `undefined` and the read asks for no vote - * kind at all. Neither failure is visible until it happens to a real claim. + * still say `"veracity"` for a claim minted before the vocabularies merged, and those fields are + * typed `WireResponseKind` to say so — which is what stops one being used as a kind: it will not + * fit where a `ResponseKind` is wanted. Were it to get through, `getResponseActionMethod` would + * select no SDK method and the *write* would throw on click, and `responseKindToVoteKind` would + * yield `undefined` so the read asked for no vote kind at all. * - * So claim surfaces take the kind from here and never from the row. + * The types keep that from compiling; this is what surfaces use instead of the row. */ export const CLAIM_RESPONSE_KIND = 'stance' as const satisfies ResponseKind; @@ -100,11 +100,11 @@ export const CLAIM_RESPONSE_KIND = 'stance' as const satisfies ResponseKind; * * Claim surfaces must not do `ENTITY_RESPONSE_COPY[kind]` with a kind that came off the wire. * geo-chat still labels a claim minted before the vocabularies merged `"veracity"`, and there is no - * key by that name any more — the lookup returns `undefined` and the surface throws on the first - * field it reads, which is a blank ticker rather than a wrong word. TypeScript cannot catch it, - * because the wire value is typed as the narrowed {@link DebateResponseKind} it no longer matches. + * key by that name any more — the lookup would return `undefined` and the surface would throw on + * the first field it read, which is a blank ticker rather than a wrong word. * - * So the claim surfaces read this instead, and the untrusted value is never a key. + * `WireResponseKind` is what keeps that from compiling. This exists so there is something to reach + * for instead: the copy, without a lookup, so no key is involved at all. */ export const CLAIM_RESPONSE_COPY = ENTITY_RESPONSE_COPY[CLAIM_RESPONSE_KIND]; From 2dcd3a87439dd59d94838bcceaf2a5fd5d3fbdbe Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 15:13:02 -0700 Subject: [PATCH 09/12] fix(votes): skip retired rows rather than out-ordering them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot found the reverse of Patrick's case, and my fix for his did not cover it. The two kinds are independent, so the Verify can be the *newer* row: answer a claim Agree while it is ordinary, have it flagged factual, answer it again with Verify. "Newest wins" then reports kind 2 and `useVoteTabEntities` drops the claim from the Agreed tab, with the kind-1 stance still live — the same symptom his finding had, reached from the other direction. Nothing resolves to kind 2 any more, so a retired row can never be an entity's current answer. `indexVoteRowsByObject` skips them instead of ranking them. `RETIRED_VERACITY_VOTE_KIND` moves to `entity-response.ts`, beside the vocabulary, since two places now have to name it and they do opposite things with it: a tally or a current side ignores it, the onboarding checklist counts it because it asks what somebody has ever done. Swept the rest of the vote-row queries: the response-count and summary queries pin an exact kind, the positions and participant queries were narrowed earlier, and `UserHasVoteOfKind` admits kind 2 on purpose for that checklist. This was the only query returning mixed kinds into a current-answer computation. Not taking the other half of the suggestion — excluding kind 2 in the query itself. It would also stop retired rows using pagination slots, but the tab pages on scroll rather than capping, so those rows defer a few later rows by one page rather than losing any; against that, the document is codegen'd and the config points at a remote schema, so changing it means either regenerating `gql.ts` wholesale or hand-writing the query the way `participant-positions` does to avoid exactly that. Not worth it for 178 rows that can only shrink. --- .../hooks/use-curator-onboarding-status.ts | 15 +++++----- apps/web/core/io/queries.test.ts | 29 +++++++++++++++++++ apps/web/core/io/queries.ts | 7 +++++ apps/web/core/responses/entity-response.ts | 10 +++++++ 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/apps/web/core/hooks/use-curator-onboarding-status.ts b/apps/web/core/hooks/use-curator-onboarding-status.ts index b33372e3a5..e6441ef460 100644 --- a/apps/web/core/hooks/use-curator-onboarding-status.ts +++ b/apps/web/core/hooks/use-curator-onboarding-status.ts @@ -19,7 +19,7 @@ import { getUserHasVoteOfKind, } from '~/core/io/queries'; import { RANK_TYPE_ID } from '~/core/ranking-block-ids'; -import { responseKindToVoteKind } from '~/core/responses/entity-response'; +import { RETIRED_VERACITY_VOTE_KIND, responseKindToVoteKind } from '~/core/responses/entity-response'; export type CuratorOnboardingCompletion = Record; @@ -41,17 +41,16 @@ function emptyCompletion(): CuratorOnboardingCompletion { const ENTITY_VOTE_KINDS = [responseKindToVoteKind('curation')] as const; /** - * `2` is the retired veracity vote kind, kept here on purpose. + * The retired veracity kind is counted here on purpose — the one place that does. * * This step asks whether the person has *ever* taken a position on a claim, and someone who did so * back when factual claims were verified rather than agreed with did the thing the checklist is - * asking about. Dropping the kind would un-tick a step they had already completed, which is a - * regression with nothing to recommend it — unlike the claim tallies, where reading the retired - * kind is what we deliberately stopped doing. Nothing here labels a side or publishes a vote, so - * there is no vocabulary to get wrong. + * asking about. Dropping it would un-tick a step they had already completed, which is a regression + * with nothing to recommend it — unlike a tally or a current side, where reading the retired kind + * is what we deliberately stopped doing. Nothing here labels a side or publishes a vote, so there + * is no vocabulary to get wrong. */ -const LEGACY_VERACITY_VOTE_KIND = 2; -const CLAIM_POSITION_VOTE_KINDS = [responseKindToVoteKind('stance'), LEGACY_VERACITY_VOTE_KIND] as const; +const CLAIM_POSITION_VOTE_KINDS = [responseKindToVoteKind('stance'), RETIRED_VERACITY_VOTE_KIND] as const; async function personalSpaceHasEntityType( personalSpaceId: string, diff --git a/apps/web/core/io/queries.test.ts b/apps/web/core/io/queries.test.ts index a57e6ce2c1..5a1895e337 100644 --- a/apps/web/core/io/queries.test.ts +++ b/apps/web/core/io/queries.test.ts @@ -464,6 +464,35 @@ describe('indexVoteRowsByObject', () => { expect(kindRow?.votedAt).toBe(votedAtByObjectId[CLAIM]); }); + /** + * The reverse ordering, which taking the newest row does not survive on its own. + * + * The two kinds are independent, so the Verify can be the *newer* of the pair: answer a claim + * Agree while it is an ordinary claim, have it flagged factual, answer it again with Verify. The + * kind-1 stance is still live, but the kind-2 row is newer — so "newest wins" reports 2, and + * `useVoteTabEntities` drops the claim from the Agreed tab exactly as it did before that fix. + * + * Nothing resolves to kind 2 any more, so a retired row can never be an entity's current answer + * and is skipped rather than merely out-ordered. + */ + it('ignores a retired row even when it is the newest', () => { + const { voteKindByObjectId, votedAtByObjectId } = indexVoteRowsByObject([ + { objectId: CLAIM, voteKind: 2, votedAt: '2026-09-24T00:00:00.000Z' }, + { objectId: CLAIM, voteKind: 1, votedAt: '2026-08-06T00:00:00.000Z' }, + ]); + + expect(voteKindByObjectId[CLAIM]).toBe(1); + expect(votedAtByObjectId[CLAIM]).toBe('2026-08-06T00:00:00.000Z'); + }); + + it('reports nothing for an entity whose only row is retired', () => { + const { voteKindByObjectId } = indexVoteRowsByObject([ + { objectId: CLAIM, voteKind: 2, votedAt: '2026-08-06T00:00:00.000Z' }, + ]); + + expect(CLAIM in voteKindByObjectId).toBe(false); + }); + it('leaves an entity with one row alone', () => { const { voteKindByObjectId } = indexVoteRowsByObject([ { objectId: CLAIM, voteKind: 0, votedAt: '2026-09-01T00:00:00.000Z' }, diff --git a/apps/web/core/io/queries.ts b/apps/web/core/io/queries.ts index 7ad0d5d22a..df62579097 100644 --- a/apps/web/core/io/queries.ts +++ b/apps/web/core/io/queries.ts @@ -41,6 +41,7 @@ import { uuidToHex } from '~/core/id/normalize'; import { RANKING_BLOCK_TYPE_ID } from '~/core/ranking-block-ids'; import { type ActiveResponseDirection, + RETIRED_VERACITY_VOTE_KIND, type ResponseKind, type ResponseObjectType, decodeActiveResponseDirection, @@ -1568,6 +1569,11 @@ type UserEntityVoteRow = { objectId: string; voteKind: number; votedAt: string } * person still held a live stance on it. That is the path every factual-claim responder takes once * Verify is gone, so it is the common case rather than an edge. * + * **Retired rows are skipped, not merely out-ordered.** Taking the newest row is not enough on its + * own: the two kinds are independent, so the Verify can be the *newer* of the pair — answer a claim + * Agree, have it flagged factual, answer it again Verify. Nothing resolves to kind 2 any more, so + * such a row can never match and can only shadow the live stance underneath it. + * * Both maps are built here together so a single entity's kind and timestamp always describe the * same row; read from different rows they can disagree, and the timestamp is the list's sort key. */ @@ -1579,6 +1585,7 @@ export function indexVoteRowsByObject(nodes: readonly UserEntityVoteRow[]): { const votedAtByObjectId: Record = {}; for (const node of nodes) { + if (node.voteKind === RETIRED_VERACITY_VOTE_KIND) continue; const id = uuidToHex(node.objectId); if (id in voteKindByObjectId) continue; voteKindByObjectId[id] = node.voteKind; diff --git a/apps/web/core/responses/entity-response.ts b/apps/web/core/responses/entity-response.ts index 6dbaf392ed..28e744c8f3 100644 --- a/apps/web/core/responses/entity-response.ts +++ b/apps/web/core/responses/entity-response.ts @@ -95,6 +95,16 @@ export const ENTITY_RESPONSE_COPY: Record = { */ export const CLAIM_RESPONSE_KIND = 'stance' as const satisfies ResponseKind; +/** + * The vote kind a Verify or a Dispute was published as, which nothing can write any more. + * + * Kept as a name because the rows are still there — 178 of them — and code has to say what it means + * to do about that. Two answers, and they are opposite on purpose: a *tally* or a *current side* + * ignores them, because a claim asks one question now and a retired row is not an answer to it; a + * question about what somebody has **ever** done counts them, because they did do it. + */ +export const RETIRED_VERACITY_VOTE_KIND = 2; + /** * The copy for a claim's two sides, reached without indexing anything. * From 0616fab069c853c5b3d01240e69d23355a941623 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 16:02:19 -0700 Subject: [PATCH 10/12] fix(votes): let a page report only the ids it can describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skipping retired rows in the page's lookups left them in its id list, and the two are read by different halves of the same hook. `useUserVotedEntityIds` binds an id to the first page it appears on and reads its kind from the lookups merged across every page, so a claim whose retired row ended one page and whose live stance began the next was claimed by the earlier page — which had no kind for it — and skipped as a duplicate on the later one, which did. `useVoteTabEntities` banks a page against its ids, and those do not change when the kind arrives, so the claim was never re-hydrated and stayed missing from the tab for the rest of the session. Deriving the ids in the same pass also stops a retired-only entity costing an entity fetch it could never be classified by. `decodeVoteOrder` already has this shape — its `entityIds` are the ids a response survived for. --- apps/web/core/io/queries.test.ts | 36 ++++++++++++++++++++++++++++++++ apps/web/core/io/queries.ts | 21 ++++++++++++++----- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/web/core/io/queries.test.ts b/apps/web/core/io/queries.test.ts index 5a1895e337..6c9dcd45aa 100644 --- a/apps/web/core/io/queries.test.ts +++ b/apps/web/core/io/queries.test.ts @@ -493,6 +493,42 @@ describe('indexVoteRowsByObject', () => { expect(CLAIM in voteKindByObjectId).toBe(false); }); + /** + * The ids have to drop the retired row too, not just the lookups. + * + * `useUserVotedEntityIds` binds an id to the first page it appears on and reads its kind from the + * lookups merged across every page. An id reported here without a kind is claimed by this page, + * skipped as a duplicate on the page that *can* describe it, and hydrated where nothing can + * classify it — and `useVoteTabEntities` banks a page against its ids, which do not change when + * the kind later arrives, so the claim stays missing from the tab. + */ + it('reports no id it cannot describe', () => { + const OTHER = 'a1b2c3d4e5f6478899aabbccddeeff00'; + + const { objectIds, voteKindByObjectId } = indexVoteRowsByObject([ + { objectId: CLAIM, voteKind: 2, votedAt: '2026-09-24T00:00:00.000Z' }, + { objectId: OTHER, voteKind: 1, votedAt: '2026-09-01T00:00:00.000Z' }, + ]); + + expect(objectIds.every(id => id in voteKindByObjectId)).toBe(true); + expect(objectIds).toEqual([OTHER]); + }); + + /** + * The straddle itself: the pair split across a page boundary, which is the only arrangement the + * skip in the lookups did not already cover. The retired row ends one page and the live stance + * begins the next, so the live page has to be the one that owns the id. + */ + it('gives the id to the page holding the live stance, not the retired row', () => { + const retiredPage = indexVoteRowsByObject([ + { objectId: CLAIM, voteKind: 2, votedAt: '2026-09-24T00:00:00.000Z' }, + ]); + const stancePage = indexVoteRowsByObject([{ objectId: CLAIM, voteKind: 1, votedAt: '2026-08-06T00:00:00.000Z' }]); + + expect(retiredPage.objectIds).toEqual([]); + expect(stancePage.objectIds).toEqual([CLAIM]); + }); + it('leaves an entity with one row alone', () => { const { voteKindByObjectId } = indexVoteRowsByObject([ { objectId: CLAIM, voteKind: 0, votedAt: '2026-09-01T00:00:00.000Z' }, diff --git a/apps/web/core/io/queries.ts b/apps/web/core/io/queries.ts index df62579097..c2b0ecfb0f 100644 --- a/apps/web/core/io/queries.ts +++ b/apps/web/core/io/queries.ts @@ -1556,7 +1556,7 @@ export const USER_ENTITY_VOTES_PAGE_SIZE = 50; type UserEntityVoteRow = { objectId: string; voteKind: number; votedAt: string }; /** - * The current vote row per entity, as two lookups. + * The rows a page can describe: its entity ids, and the current vote row of each. * * **The first row wins, not the last.** One entity can carry more than one row: a claim answered * Verify before the vocabularies merged and Agree after it holds a vote of each kind, and both come @@ -1574,13 +1574,24 @@ type UserEntityVoteRow = { objectId: string; voteKind: number; votedAt: string } * Agree, have it flagged factual, answer it again Verify. Nothing resolves to kind 2 any more, so * such a row can never match and can only shadow the live stance underneath it. * - * Both maps are built here together so a single entity's kind and timestamp always describe the + * **The ids come from the same pass**, so the page never reports one it cannot describe. Skipping a + * retired row in the lookups alone was not enough: `useUserVotedEntityIds` binds an id to the first + * page it appears on and reads its kind from the merged lookups, so a claim whose retired row ended + * one page and whose live stance began the next was claimed by the earlier page — which had no kind + * for it — and skipped as a duplicate by the later one, which did. `useVoteTabEntities` banks a page + * against its ids, and those did not change when the kind arrived, so the claim was never + * re-hydrated and stayed missing from the tab for the rest of the session. `decodeVoteOrder` keeps + * the same shape for the same reason: its `entityIds` are the ids a response survived for. + * + * All three are built here together so a single entity's kind and timestamp always describe the * same row; read from different rows they can disagree, and the timestamp is the list's sort key. */ export function indexVoteRowsByObject(nodes: readonly UserEntityVoteRow[]): { + objectIds: string[]; voteKindByObjectId: Record; votedAtByObjectId: Record; } { + const objectIds: string[] = []; const voteKindByObjectId: Record = {}; const votedAtByObjectId: Record = {}; @@ -1590,9 +1601,10 @@ export function indexVoteRowsByObject(nodes: readonly UserEntityVoteRow[]): { if (id in voteKindByObjectId) continue; voteKindByObjectId[id] = node.voteKind; votedAtByObjectId[id] = node.votedAt; + objectIds.push(node.objectId); } - return { voteKindByObjectId, votedAtByObjectId }; + return { objectIds, voteKindByObjectId, votedAtByObjectId }; } export type UserEntityVoteObjectIdsPage = { @@ -1626,8 +1638,7 @@ export function getUserEntityVoteObjectIdsPage( }); const nodes = rows.filter(node => Boolean(node.objectId)); - const objectIds = nodes.map(node => node.objectId); - const { voteKindByObjectId, votedAtByObjectId } = indexVoteRowsByObject(nodes); + const { objectIds, voteKindByObjectId, votedAtByObjectId } = indexVoteRowsByObject(nodes); return { objectIds, From b28829d5e05f894f9219bc003daa85baba710f5a Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 16:37:07 -0700 Subject: [PATCH 11/12] fix(claims): carry the merged vocabulary into master's new debate surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two features landed on master while this branch was open, and both were written against the two-kind world the branch removes. Neither conflicted textually, so the rebase produced code that was wrong rather than code that refused to apply. The feed player's new position chip (#2543) read geo-chat's `position_label`, which still says "Verify" on a claim it calls factual — the one word this app can no longer publish, printed over a debater's video. It now names the side from `position` like every other caller. Its fixture keeps the retired label deliberately, so the chip ignoring it is what the assertions prove; the test for an empty label is gone, because `position` is a non-null boolean and there is nothing left for the server to leave blank. The People tab's disagreement counts (#2556) spelled the kinds again as a local `'stance' | 'veracity'` literal and hand-rolled the Verify/Dispute wording beside `responsePositionLabel`. The type now comes from `ParticipantPosition` so the two cannot diverge a second time, and the badge uses the shared label. Kind stays in the pairing key: it is half of what makes two responses comparable even while it holds one value. Three of those tests used veracity as a second axis to prove a claim is counted once. A second space is the only axis left, so they use that, and the "different response kinds" case is noted as unreachable rather than quietly rewritten into something that passes for another reason. --- .../browse/debate-feed-player.test.tsx | 35 ++++++++++++------- .../debates/browse/debate-feed-player.tsx | 13 ++++--- apps/web/core/debates/debate-video-tile.tsx | 16 ++++----- .../matchmaking/disagreement-counts.test.ts | 34 ++++++++++-------- .../matchmaking/disagreement-counts.ts | 14 ++++++-- .../debates/matchmaking/people-tab.test.tsx | 11 +++--- .../matchmaking/person-disagreements.tsx | 3 +- 7 files changed, 78 insertions(+), 48 deletions(-) diff --git a/apps/web/core/debates/browse/debate-feed-player.test.tsx b/apps/web/core/debates/browse/debate-feed-player.test.tsx index 2f646f8b8f..62dd3964b0 100644 --- a/apps/web/core/debates/browse/debate-feed-player.test.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.test.tsx @@ -68,11 +68,17 @@ vi.mock('./debate-claim-ticker', () => ({ ClaimScrubberMarkers: () => null, })); +/** + * `position_label` is set to the retired wording on purpose. geo-chat still sends "Verify" and + * "Dispute" for a claim it calls factual, and the chip is named from `position` instead — so the + * chip reading "Agree"/"Disagree" below is what proves the server's label is not the source. + */ const participant = (slot: 1 | 2): DebateParticipant => ({ participant_slot: slot, profile_space_id: slot === 1 ? SPACE_1_DASHED : SPACE_2, - position_label: slot === 1 ? 'For' : 'Against', + position: slot === 1, + position_label: slot === 1 ? 'Verify' : 'Dispute', }) as unknown as DebateParticipant; /** Only what the player reads: its id, and the space the ticker looks for claims in. */ @@ -197,8 +203,8 @@ describe('player layout', () => { const { getByText } = within(container); for (const [name, position, affiliation] of [ - [SPACE_1_DASHED, 'For', 'A deliberately much longer affiliation than the participant name'], - [SPACE_2, 'Against', 'Another affiliation whose width must not place the position chip'], + [SPACE_1_DASHED, 'Agree', 'A deliberately much longer affiliation than the participant name'], + [SPACE_2, 'Disagree', 'Another affiliation whose width must not place the position chip'], ]) { const chip = getByText(position); const nameNode = getByText(name); @@ -218,20 +224,25 @@ describe('player layout', () => { const { container } = render(); - fireEvent.click(within(container).getByText('For')); + fireEvent.click(within(container).getByText('Agree')); expect(controller.togglePlayback).toHaveBeenCalledOnce(); }); - it('draws no chip for a debater whose position has no label', () => { - mocks.controller = { - ...controllerFixture({ mutedByUser: true, turnSlot: 1 }), - slot1Participant: { ...participant(1), position_label: '' }, - }; + /** + * A test here covered an empty `position_label`, which typed non-null but arrived from geo-chat + * and would have drawn a bare pill. The chip is named from `position` now — a non-null boolean — + * so there is no label for the server to leave blank and the case is gone rather than untested. + * What replaces it is the fixture above: a stale server label that must not reach the chip. + */ + it('names the side itself rather than repeating the label geo-chat sent', () => { + mocks.controller = controllerFixture({ mutedByUser: true, turnSlot: 1 }); const { container } = render(); - const { queryByText } = within(container); + const { queryByText, getByText } = within(container); - expect(queryByText('For')).toBeNull(); - expect(queryByText('Against')).not.toBeNull(); + expect(getByText('Agree')).not.toBeNull(); + expect(getByText('Disagree')).not.toBeNull(); + expect(queryByText('Verify')).toBeNull(); + expect(queryByText('Dispute')).toBeNull(); }); it('shows each participant affiliation below their name and exposes the full line on hover', () => { diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 6503d8f3e4..3b1d90897e 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -9,6 +9,7 @@ import type { ClaimMarker } from '~/core/debates/claim-ticker'; import { DebatePositionChip } from '~/core/debates/debate-video-tile'; import { useParticipantAffiliations } from '~/core/debates/participant-affiliations'; import { validateSpaceId } from '~/core/io/rest/validation'; +import { responsePositionLabel } from '~/core/responses/entity-response'; import { type TurnState, clampSeconds, speakerLabel } from '~/core/debates/playback-utils'; import { useDebatePlayback } from '~/core/debates/use-debate-playback'; import { usePlaybackAnalytics } from '~/core/debates/use-playback-analytics'; @@ -981,11 +982,13 @@ function DebaterVideo({ {name} - {/* Guarded on the text rather than only on the participant: `position_label` is - typed non-null but arrives from geo-chat, and an empty one would draw a bare pill - that says nothing. The room tile guards it the same way. */} - {participant?.position_label && ( - + {/* Named from the side rather than read off `position_label`, for the same reason + the room tile is: geo-chat still calls a factual claim's sides "Verify" and + "Dispute", which is a word this app no longer has a way to publish. `position` + is a non-null boolean, so the label is always a real one and the participant is + the only thing left to guard. */} + {participant && ( + )} {affiliation && ( diff --git a/apps/web/core/debates/debate-video-tile.tsx b/apps/web/core/debates/debate-video-tile.tsx index e59d9102f3..82ed839c2f 100644 --- a/apps/web/core/debates/debate-video-tile.tsx +++ b/apps/web/core/debates/debate-video-tile.tsx @@ -62,8 +62,9 @@ export function DebateTileChip({ /** * Which side of the claim a debater is arguing, as it reads over their video: the tile chip in the - * shared fill, carrying whatever the claim's response kind calls that side — "Agree"/"Disagree" on - * a stance claim, "Verify"/"Dispute" on a factual one. + * shared fill, carrying "Agree" or "Disagree". Every claim asks the same question, so those are the + * only two words it renders — pass a label from {@link responsePositionLabel} rather than geo-chat's + * `position_label`, which still says "Verify" on a claim it calls factual. * * One component rather than the recipe twice, because the room and the feed player state the same * fact about the same person in the same corner of the same picture; the only difference is where @@ -94,14 +95,11 @@ export function DebatePositionChip({ * One participant's tile, shared by the intro screen and the recording modal so the two have the * same geometry. Everything past the video is optional: the intro passes a label and an overlay, * the debate adds turn countdowns and phase overlays. - */ -/** - * The side this tile's participant took, named. * - * Derived rather than passed. Every caller had a `position_label` from geo-chat to hand down - * beside the position itself — and that label reads "Verify" or "Dispute" on a claim geo-chat - * still calls factual, which is a word this app no longer has a way to publish. The side is the - * same boolean either way, so the name is ours to say. + * **The side is named here, not passed.** Every caller had a `position_label` from geo-chat to + * hand down beside the position itself — and that label reads "Verify" or "Dispute" on a claim + * geo-chat still calls factual, which is a word this app no longer has a way to publish. The side + * is the same boolean either way, so the name is ours to say. */ export function DebateVideoTile({ participantPosition, diff --git a/apps/web/core/debates/matchmaking/disagreement-counts.test.ts b/apps/web/core/debates/matchmaking/disagreement-counts.test.ts index 26c2262b43..57db9a88e7 100644 --- a/apps/web/core/debates/matchmaking/disagreement-counts.test.ts +++ b/apps/web/core/debates/matchmaking/disagreement-counts.test.ts @@ -7,6 +7,7 @@ import { analyzeMatchingClaims } from './disagreement-counts'; const VIEWER = '019fedae-72b6-7ab2-927a-df044d57c500'; const OTHER = '019fedae-72b6-7ab2-927a-df044d57c501'; const THIRD = '019fedae-72b6-7ab2-927a-df044d57c502'; +const SECOND_SPACE = '019fedae-72b6-7ab2-927a-df044d57c601'; function position( profileSpaceId: string, @@ -23,9 +24,11 @@ describe('analyzeMatchingClaims', () => { const positions = groupParticipantPositions([ position(VIEWER, 'claim-1', true), position(OTHER, 'claim-1', false), - // A second opposing response on the same claim must not count the claim twice. - position(VIEWER, 'claim-1', true, undefined, 'veracity'), - position(OTHER, 'claim-1', false, undefined, 'veracity'), + // A second opposing response on the same claim must not count the claim twice. That used to + // be a veracity response beside the stance; with one kind left, a second space is the only + // way one pair can oppose each other twice on one claim. + position(VIEWER, 'claim-1', true, SECOND_SPACE), + position(OTHER, 'claim-1', false, SECOND_SPACE), position(VIEWER, 'claim-2', false), position(OTHER, 'claim-2', true), // A third person's count is independent. @@ -35,13 +38,15 @@ describe('analyzeMatchingClaims', () => { expect(analyzeMatchingClaims(positions, VIEWER).byProfile.get(OTHER.replaceAll('-', ''))).toHaveLength(2); }); - it('does not compare positions from different spaces or response kinds', () => { - const otherSpace = '019fedae-72b6-7ab2-927a-df044d57c601'; + /** + * This also covered response kinds, pairing a stance against a veracity response and expecting + * no match. Kind is still half the key (see `positionContext`), but it holds one value now, so + * there is no second kind to pair a stance against — the case is unreachable rather than fixed. + */ + it('does not compare positions from different spaces', () => { const positions = groupParticipantPositions([ position(VIEWER, 'claim-1', true), - position(OTHER, 'claim-1', false, otherSpace), - position(VIEWER, 'claim-2', true, undefined, 'stance'), - position(OTHER, 'claim-2', false, undefined, 'veracity'), + position(OTHER, 'claim-1', false, SECOND_SPACE), position(VIEWER, 'claim-3', true), position(OTHER, 'claim-3', true), ]); @@ -68,8 +73,8 @@ describe('analyzeMatchingClaims', () => { it('keeps the claim context needed to open each disagreement', () => { const spaceId = '019fedae-72b6-7ab2-927a-df044d57c600'; const positions = groupParticipantPositions([ - position(VIEWER, 'claim-1', false, spaceId, 'veracity'), - position(OTHER, 'claim-1', true, spaceId, 'veracity'), + position(VIEWER, 'claim-1', false, spaceId), + position(OTHER, 'claim-1', true, spaceId), ]); expect(analyzeMatchingClaims(positions, VIEWER).byProfile).toEqual( @@ -80,7 +85,7 @@ describe('analyzeMatchingClaims', () => { { claimId: 'claim-1', spaceId, - responseKind: 'veracity', + responseKind: 'stance', viewerPosition: false, personPosition: true, }, @@ -96,9 +101,10 @@ describe('analyzeMatchingClaims', () => { const positions = groupParticipantPositions([ position(VIEWER, 'claim-1', true, firstSpace), position(OTHER, 'claim-1', false, firstSpace), - // Another opposing axis on the same claim and space still counts once. - position(VIEWER, 'claim-1', true, firstSpace, 'veracity'), - position(OTHER, 'claim-1', false, firstSpace, 'veracity'), + // A repeated opposing pair in the same space still counts once. This used to be a veracity + // response alongside the stance; with one kind left, only a duplicate row can do it. + position(VIEWER, 'claim-1', true, firstSpace), + position(OTHER, 'claim-1', false, firstSpace), // The same claim can be a match in another space too. position(VIEWER, 'claim-1', true, secondSpace), position(OTHER, 'claim-1', false, secondSpace), diff --git a/apps/web/core/debates/matchmaking/disagreement-counts.ts b/apps/web/core/debates/matchmaking/disagreement-counts.ts index ebef29dc8d..21c3c5173c 100644 --- a/apps/web/core/debates/matchmaking/disagreement-counts.ts +++ b/apps/web/core/debates/matchmaking/disagreement-counts.ts @@ -1,11 +1,16 @@ import { normId } from '~/core/utils/norm-id'; -import type { ParticipantPositionsByClaim } from '../participant-positions'; +import type { ParticipantPosition, ParticipantPositionsByClaim } from '../participant-positions'; export type ClaimMatch = { claimId: string; spaceId: string; - responseKind: 'stance' | 'veracity'; + /** + * Taken from the row rather than written out. It was its own `'stance' | 'veracity'` literal, and + * a claim only ever asks one question now — so the two spellings had already diverged by the time + * they met here. + */ + responseKind: ParticipantPosition['responseKind']; viewerPosition: boolean; personPosition: boolean; }; @@ -21,7 +26,10 @@ export type MatchingClaimsAnalysis = { * Claims on which the viewer and each other person hold comparable, opposite positions. * * A response is scoped by both space and kind: agreeing with a claim in one space is not the - * opposite of disputing its veracity somewhere else. The person-level list counts a claim once; + * opposite of disagreeing with it somewhere else. Kind is a single value today — every claim asks + * whether you agree — but it stays in the key because it is half of what makes two responses + * comparable, and dropping it would silently pair responses to different questions if a second + * kind ever returns. The person-level list counts a claim once; * the space breakdown counts it once in every space where the pair actually opposes each other. * Both projections are built in one pass so the People tab cannot drift between two definitions * of a match or scan the same graph result twice. diff --git a/apps/web/core/debates/matchmaking/people-tab.test.tsx b/apps/web/core/debates/matchmaking/people-tab.test.tsx index e412691530..edd050af30 100644 --- a/apps/web/core/debates/matchmaking/people-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.test.tsx @@ -483,8 +483,8 @@ describe('PeopleTab', () => { [ 'claim-2', [ - { profileSpaceId: viewer, claimId: 'claim-2', spaceId, responseKind: 'veracity', position: false }, - { profileSpaceId: arturas, claimId: 'claim-2', spaceId, responseKind: 'veracity', position: true }, + { profileSpaceId: viewer, claimId: 'claim-2', spaceId, responseKind: 'stance', position: false }, + { profileSpaceId: arturas, claimId: 'claim-2', spaceId, responseKind: 'stance', position: true }, ], ], ]); @@ -506,11 +506,14 @@ describe('PeopleTab', () => { expect(within(firstMatch).getByText('Disagree')).toBeInTheDocument(); expect(firstMatch.querySelectorAll('svg')).toHaveLength(2); + // The second claim used to be a veracity match and read Dispute/Verify here. Every claim asks + // the same question now, so the sides are named the same way on both rows — the two matches + // still differ by side, which is what the row is for. const secondMatch = within(list).getByText('Is this true?').closest('a')!; expect(within(secondMatch).getByText('You:')).toBeInTheDocument(); - expect(within(secondMatch).getByText('Dispute')).toBeInTheDocument(); + expect(within(secondMatch).getByText('Disagree')).toBeInTheDocument(); expect(within(secondMatch).getByText('Arturas:')).toBeInTheDocument(); - expect(within(secondMatch).getByText('Verify')).toBeInTheDocument(); + expect(within(secondMatch).getByText('Agree')).toBeInTheDocument(); }); it('distinguishes an untitled claim from unavailable claim metadata', async () => { diff --git a/apps/web/core/debates/matchmaking/person-disagreements.tsx b/apps/web/core/debates/matchmaking/person-disagreements.tsx index 9597ce0020..f2c024dd8a 100644 --- a/apps/web/core/debates/matchmaking/person-disagreements.tsx +++ b/apps/web/core/debates/matchmaking/person-disagreements.tsx @@ -5,6 +5,7 @@ import * as Popover from '@radix-ui/react-popover'; import * as React from 'react'; import { type SpaceLabel, spaceLabel } from '~/core/hooks/use-space-labels'; +import { responsePositionLabel } from '~/core/responses/entity-response'; import { normId } from '~/core/utils/norm-id'; import { NavUtils } from '~/core/utils/utils'; @@ -136,7 +137,7 @@ function PositionBadge({ responseKind: ClaimMatch['responseKind']; position: boolean; }) { - const action = responseKind === 'veracity' ? (position ? 'Verify' : 'Dispute') : position ? 'Agree' : 'Disagree'; + const action = responsePositionLabel(position); return ( From 3d37ec8e3ab7bc24a52fddab108c5df2bafb55a2 Mon Sep 17 00:00:00 2001 From: Patrick O'Reilly Date: Fri, 25 Sep 2026 10:58:08 -0700 Subject: [PATCH 12/12] fix(claims): name the scheduled request card's sides from the position master's rematch-request-card (#2566) still preferred geo-chat's label and passed a response kind, which the merged vocabulary no longer takes. --- .../debates/rematches/[sessionId]/rematch-request-card.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx index 8940315a6e..edbdac8e15 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx @@ -42,12 +42,11 @@ export function RematchRequestCard({ const parties = participants.map(participant => { const isRequester = participant.user_id === request.requester_user_id; const position = isRequester ? request.requester_position : request.recipient_position; - const label = isRequester ? request.requester_position_label : request.recipient_position_label; return { ...participant, position, - position_label: label ?? responsePositionLabel(request.response_kind ?? null, position), + position_label: responsePositionLabel(position), }; });