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.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index 72f07c5b08..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 @@ -1127,11 +1127,11 @@ describe('DebateRematchPageClient', () => { expect(within(syntheticClaimCard!).getByRole('button', { name: /^Disagree/ })).toBeEnabled(); }); - it('uses Verify and Dispute for factual claims', async () => { + it('uses Agree and Disagree for a claim geo-chat still calls factual', async () => { mocks.claims = [{ ...sharedClaim(), response_kind: 'veracity' }]; 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), + position('profile-remote', CLAIM_SHARED, SPACE_1, false), ]; 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..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 @@ -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, @@ -2088,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(incomingRequest.response_kind ?? null, 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), }; }) : []; @@ -2590,7 +2587,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, @@ -2693,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, responseKind), - [claim, responseKind, 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 @@ -2716,7 +2710,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 +2825,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); @@ -2842,9 +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(responseKind, 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/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), }; }); 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..066e8da22d 100644 --- a/apps/web/core/claims/browse/claim-debates.tsx +++ b/apps/web/core/claims/browse/claim-debates.tsx @@ -66,12 +66,9 @@ 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 +141,6 @@ export function ClaimDebates({ profilesBySpaceId={profilesBySpaceId} winnerShare={winnerShareByDebateId.get(debate.id) ?? null} keyframeUrl={keyframeByDebateId.get(debate.id) ?? null} - responseKind={responseKind} /> ))} @@ -295,7 +291,6 @@ export function DebateRow({ profilesBySpaceId, winnerShare, keyframeUrl, - responseKind, }: { debate: Entity; spaceId: string; @@ -303,7 +298,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 +338,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-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/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..608e06028a 100644 --- a/apps/web/core/claims/browse/claim-position-comment.tsx +++ b/apps/web/core/claims/browse/claim-position-comment.tsx @@ -5,10 +5,10 @@ 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 { ENTITY_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. */ @@ -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); @@ -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..ca9bc3f5d0 100644 --- a/apps/web/core/claims/browse/use-claim-response-state.ts +++ b/apps/web/core/claims/browse/use-claim-response-state.ts @@ -6,28 +6,27 @@ import type { DebateClaim, DebateClaimPositionSummary, DebateClaimSummary, + 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 { 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'; + /** 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; /** @@ -74,22 +73,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. - * - * 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. + * Which vocabulary a claim uses. There is only one: Agree/Disagree. * - * 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 CLAIM_RESPONSE_KIND; } export function useClaimResponseState({ @@ -113,19 +106,19 @@ 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. // // `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. @@ -133,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( @@ -153,8 +146,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 +161,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..4d0e43f30a 100644 --- a/apps/web/core/debates/api.ts +++ b/apps/web/core/debates/api.ts @@ -9,7 +9,29 @@ 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, 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; @@ -29,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[]; @@ -198,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; @@ -294,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; @@ -325,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; @@ -389,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; @@ -484,8 +506,8 @@ 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`. */ - response_kind: DebateResponseKind; + /** Legacy; see {@link DebateResponseKind}. Every claim is Agree/Disagree. */ + 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/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/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/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx index 954f213704..4c1a66f3f0 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,16 +215,11 @@ 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", () => { - mocks.percent = 65; - mocks.responseKind = 'veracity'; - - renderCard(); - - expect(screen.getByText('65% verify')).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/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx index 7c4d254819..4c0a8609ae 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'; @@ -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 @@ -799,7 +798,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; @@ -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, @@ -891,7 +890,7 @@ function ClaimIconButton({ title, onClick, }: { - responseKind: 'stance' | 'veracity' | 'curation'; + responseKind: ResponseKind; position: boolean; label: string; selected: boolean; @@ -931,8 +930,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/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/claim-response-indexed-notifier.test.tsx b/apps/web/core/debates/claim-response-indexed-notifier.test.tsx index ebe448f23b..5eebb06324 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', @@ -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/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/debate-entity-response-controls.test.tsx b/apps/web/core/debates/debate-entity-response-controls.test.tsx deleted file mode 100644 index c755e4ce22..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' | 'veracity' | null }) => ( - {responseKind ?? 'unavailable'} - ), -})); - -afterEach(cleanup); - -describe('DebateEntityResponseControls', () => { - it('passes the backend response kind to the shared response controls', () => { - render(); - - expect(screen.getByText('veracity')).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-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/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..82ed839c2f 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'; @@ -60,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 @@ -92,10 +95,14 @@ 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 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, - positionLabel, active, overlayText, overlayCompact = false, @@ -118,7 +125,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 +155,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 +207,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/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..333fb66f58 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,14 @@ 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/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/matches-list.test.tsx b/apps/web/core/debates/matchmaking/matches-list.test.tsx index b89009c2e7..8ad47d847a 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', () => { + // 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', 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..12c507ec8e 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'; @@ -534,41 +534,36 @@ describe('position avatar stack', () => { }); /** - * And a remembered side belongs to the vocabulary it was read under. + * The write must never be handed geo-chat's word for the kind. * - * 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. + * 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('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') => ( - - - + it('publishes under a real response kind even when the row still says veracity', () => { + renderCard( + ); - 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')); + const passed = mocks.useEntityResponse.mock.calls.at(-1)?.[0] as { responseKind: ResponseKind }; - // 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'); + 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 + // 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', () => { mocks.indexing = { diff --git a/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx b/apps/web/core/debates/matchmaking/matchmaking-claim-card.tsx index 6ebcc27671..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 { ENTITY_RESPONSE_COPY } 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'; @@ -422,7 +427,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); @@ -431,7 +438,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 +478,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 +489,6 @@ export function useClaimPositionControl({ [ personalSpaceId, positionsWithOpponents, - readiness.response_kind, readiness.viewer_response?.position, viewerIdentityPending, viewerPosition, @@ -533,7 +538,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 { @@ -630,7 +635,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 @@ -670,7 +675,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 @@ -695,7 +700,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 @@ -751,7 +755,7 @@ function RespondableControls({ /> 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 +915,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, @@ -978,7 +979,7 @@ function UnresolvableControls({ />
@@ -1001,7 +1002,7 @@ export function PositionRow({ endSlot, }: { positions: DebateClaimPositionSummary[]; - responseKind: MatchmakingReadiness['response_kind']; + responseKind: ResponseKind; viewerPosition: boolean | null; onRespond?: (position: boolean) => void; disabled?: boolean; @@ -1019,7 +1020,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 +1058,10 @@ export function PositionRow({ the width the pill had as a grid item. */}
void; 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 ( diff --git a/apps/web/core/debates/matchmaking/person-records-document.test.ts b/apps/web/core/debates/matchmaking/person-records-document.test.ts index b6fc208baa..c62cc5a717 100644 --- a/apps/web/core/debates/matchmaking/person-records-document.test.ts +++ b/apps/web/core/debates/matchmaking/person-records-document.test.ts @@ -49,8 +49,11 @@ describe('buildPersonRecordsDocument', () => { // 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/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/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..ff802e9232 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,11 +89,32 @@ describe('fetchParticipantPositions', () => { responseKind: 'stance', position: true, }, + ]); + }); + + /** + * 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: REMOTE.profile_space_id, + profileSpaceId: LOCAL.profile_space_id, claimId: 'claim-1', spaceId: 'space-1', - responseKind: 'veracity', + responseKind: 'stance', + // `false` — the stance. Not the `true` the verify row carries. 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/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, }; diff --git a/apps/web/core/hooks/use-curator-onboarding-status.ts b/apps/web/core/hooks/use-curator-onboarding-status.ts index 5426135a72..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; @@ -39,7 +39,18 @@ 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; + +/** + * 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 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 CLAIM_POSITION_VOTE_KINDS = [responseKindToVoteKind('stance'), RETIRED_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/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..6c9dcd45aa 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,109 @@ 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]); + }); + + /** + * 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); + }); + + /** + * 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' }, + ]); + + expect(voteKindByObjectId[CLAIM]).toBe(0); + }); +}); diff --git a/apps/web/core/io/queries.ts b/apps/web/core/io/queries.ts index d209a837fe..c2b0ecfb0f 100644 --- a/apps/web/core/io/queries.ts +++ b/apps/web/core/io/queries.ts @@ -41,9 +41,9 @@ 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, - type ResponseVoteKind, decodeActiveResponseDirection, entityResponseQueryVariables, } from '~/core/responses/entity-response'; @@ -1465,15 +1465,16 @@ 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. + * 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 ResponseVoteKind[], - signal?: AbortController['signal'] -) { +export function getUserHasVoteOfKind(userId: string, voteKinds: readonly number[], signal?: AbortController['signal']) { return graphql({ query: UserHasVoteOfKindDocument, decoder: data => (data.userVotes?.length ?? 0) > 0, @@ -1554,6 +1555,58 @@ export const USER_ENTITY_VOTES_PAGE_SIZE = 50; type UserEntityVoteRow = { objectId: string; voteKind: number; votedAt: string }; +/** + * 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 + * 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. + * + * **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. + * + * **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 = {}; + + 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; + votedAtByObjectId[id] = node.votedAt; + objectIds.push(node.objectId); + } + + return { objectIds, voteKindByObjectId, votedAtByObjectId }; +} + export type UserEntityVoteObjectIdsPage = { objectIds: string[]; voteKindByObjectId: Record; @@ -1585,9 +1638,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 { objectIds, voteKindByObjectId, votedAtByObjectId } = indexVoteRowsByObject(nodes); return { objectIds, 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..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 @@ -158,18 +158,16 @@ 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; - veracity?: Stance; }; export type PositionOrder = { @@ -224,16 +222,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 +268,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..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'; /** @@ -36,29 +37,46 @@ 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', () => { + /** + * 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', veracity: 'disagree' } }); + expect(order.responseByClaimId).toEqual({ a: { stance: expected } }); }); - 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 +123,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 +175,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 +210,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 +234,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 +242,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'] }); @@ -345,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}'); + }); +}); 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/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..28e744c8f3 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; +/** + * 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 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. + * + * 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; + +/** + * 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. + * + * 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 would return `undefined` and the surface would throw on + * the first field it read, which is a blank ticker rather than a wrong word. + * + * `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]; + +/** + * 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; } -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 +169,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 +184,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..29d8ee7f5b 100644 --- a/apps/web/design-system/icons/response-position-icon.tsx +++ b/apps/web/design-system/icons/response-position-icon.tsx @@ -2,17 +2,15 @@ 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'; 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. 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..838fef816e 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?: (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'; @@ -387,11 +386,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 +415,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..0e1220e0d5 100644 --- a/apps/web/partials/explore/explore-feed-card.tsx +++ b/apps/web/partials/explore/explore-feed-card.tsx @@ -34,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: 'stance' | 'veracity', 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 238d959d19..614305ae43 100644 --- a/apps/web/partials/profile/claim-response-tag.test.tsx +++ b/apps/web/partials/profile/claim-response-tag.test.tsx @@ -13,39 +13,40 @@ 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 asks Verify or Dispute rather than Agree or - * 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); 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 +57,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( - - ); + 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( - - ); - - 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 +79,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 +92,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.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 5f28b4ed05..7c2617d7e8 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, @@ -96,19 +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 b445565ede..e66921b681 100644 --- a/apps/web/partials/profile/person-record-feed.tsx +++ b/apps/web/partials/profile/person-record-feed.tsx @@ -151,14 +151,13 @@ export function PersonRecordFeed({ ( + ? position => (