Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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"
>
<span className="truncate">
{participant.display_name || participant.profile_space_id} · {participant.position_label}
{participant.display_name || participant.profile_space_id} ·{' '}
{responsePositionLabel(participant.position)}
</span>
</span>
))}
Expand Down Expand Up @@ -2859,7 +2861,6 @@ function DebateRecordingModal({
<DebateVideoTile
key="local"
participantPosition={localParticipant?.position ?? null}
positionLabel={localParticipant?.position_label ?? null}
active={
countdown.effectiveStatus === 'in_progress' &&
(countdown.activeSlot === localSlot || countdown.yieldingSlot === localSlot)
Expand Down Expand Up @@ -2911,7 +2912,6 @@ function DebateRecordingModal({
<DebateVideoTile
key="remote"
participantPosition={remoteParticipant?.position ?? null}
positionLabel={remoteParticipant?.position_label ?? null}
active={
countdown.effectiveStatus === 'in_progress' &&
(countdown.activeSlot === remoteParticipant?.participant_slot ||
Expand Down Expand Up @@ -4036,7 +4036,10 @@ function rematchDestination(session: DebateRematchSession | null | undefined) {
}

function labelForSlot(debate: Debate, slot: ParticipantSlot) {
return debate.participants.find(participant => 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<Debate['participants'][number], 'display_name' | 'profile_space_id'>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1127,20 +1127,21 @@ 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(<DebateRematchPageClient sessionId="rematch-1" />);
await showOpponentClaims();

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]
);
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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),
};
})
: [];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
};
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
});

Expand Down
31 changes: 14 additions & 17 deletions apps/web/core/blocks/ranking/use-vote-tab-entities.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)]];
Expand All @@ -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([]);
});

Expand Down
2 changes: 1 addition & 1 deletion apps/web/core/blocks/ranking/use-vote-tab-entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: a claim answered with both vote kinds drops out of the Agreed/Disagreed tab.

This now compares against kind 1 (stance) for every claim, but voteKindById holds the kind of the oldest vote on each entity:

  • getUserEntityVotesByType builds its map with Object.fromEntries(nodes.map(...)) over rows ordered VOTED_AT_DESC, so the last (oldest) row wins.
  • In use-user-voted-entity-ids.ts (~233-236), later (older) pages overwrite earlier ones too.

Scenario: someone clicked Verify on a factual claim before this PR (a kind-2 positive vote, which stays live, since the UI can no longer clear it). After this PR they click Agree on the same claim (a kind-1 positive vote). The by-direction query returns both rows, the older kind 2 wins, this check fails, and the claim disappears from their Agreed tab even though they hold a live stance on it.

Before this PR the claim resolved to kind 2, so it matched and showed. The Verify-then-Agree path is exactly the migration this PR sends every factual-claim responder down.

Suggested fix: when an entity has votes of both kinds, prefer kind 1 (or take the newest row's kind rather than the oldest).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 16669fe. This is the best find on the PR — a regression we caused, on exactly the path we send every factual-claim responder down.

I verified both halves of the mechanism rather than taking them on trust: Object.fromEntries gives a repeated key its last value, rows arrive VOTED_AT_DESC, so the map described each entity by its oldest vote. And use-user-voted-entity-ids had the same shape across pages, for an entity whose rows straddle a boundary.

I took the "newest row" option rather than "prefer kind 1". Preferring kind 1 special-cases the retired kind and leaves the underlying defect — the map is meant to hold the vote kind for an entity and was holding an arbitrary one. Newest-wins is what every consumer already assumes, and it fixes the migration path as a consequence rather than as a special case.

Two things that came with it:

  • votedAtByObjectId had the identical defect and is built in the same pass now. Read from a different row it can disagree with the kind, and it is the list's sort key — so an entity could sort by one vote and be filtered by another. Worth flagging: this changes the Agreed/Disagreed sort position for a claim holding both kinds. It now sorts by the Agree, which is the vote being displayed. I think that is right, but it is a visible change and I would rather name it than have it turn up.
  • The logic was untestable where it sat, inside a decoder closure. Extracted as indexVoteRowsByObject, which is also how the repo tends to make this kind of thing assertable.

Three tests in queries.test.ts, on your exact scenario — today's Agree over August's Verify. Restoring Object.fromEntries makes the first fail.

Swept the class: the only other Object.fromEntries over query rows is a static step list with no repeatable keys.

return [toRankingEntryDisplay(entity, entitySpaceId)];
}),
[entities, voteKindById, spaceId]
Expand Down
12 changes: 6 additions & 6 deletions apps/web/core/claims/browse/claim-comment-position.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -98,7 +98,7 @@ describe('claim comment position badges', () => {
<ClaimCommentPositionProvider
entityId="claim-1"
spaceId="space-1"
responseKind="veracity"
responseKind="stance"
viewerDirection="positive"
viewerSpaceId="viewer-space"
isViewerResponseLoading
Expand All @@ -108,8 +108,8 @@ 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 () => {
Expand Down Expand Up @@ -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]);
});
});
7 changes: 3 additions & 4 deletions apps/web/core/claims/browse/claim-comment-position.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -129,7 +128,7 @@ export function ClaimCommentPositionBadge({ authorSpaceId }: { authorSpaceId: st
positive ? 'bg-successTertiary' : 'bg-errorTertiary'
)}
>
{responsePositionLabel(context.responseKind, positive)}
{responsePositionLabel(positive)}
</span>
);
}
8 changes: 1 addition & 7 deletions apps/web/core/claims/browse/claim-debates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -144,7 +141,6 @@ export function ClaimDebates({
profilesBySpaceId={profilesBySpaceId}
winnerShare={winnerShareByDebateId.get(debate.id) ?? null}
keyframeUrl={keyframeByDebateId.get(debate.id) ?? null}
responseKind={responseKind}
/>
</li>
))}
Expand Down Expand Up @@ -295,15 +291,13 @@ export function DebateRow({
profilesBySpaceId,
winnerShare,
keyframeUrl,
responseKind,
}: {
debate: Entity;
spaceId: string;
sides: DebateSide[];
profilesBySpaceId: Map<string, { name?: string | null; avatarUrl?: string | null }>;
winnerShare: WinnerShare | null;
keyframeUrl: string | null;
responseKind: 'stance' | 'veracity';
}) {
const nameFor = (participantSpaceId: string) => profilesBySpaceId.get(participantSpaceId)?.name ?? 'Unnamed debater';

Expand Down Expand Up @@ -344,7 +338,7 @@ export function DebateRow({
side.position ? 'bg-successTertiary text-text' : 'bg-errorTertiary text-text'
)}
>
{responsePositionLabel(responseKind, side.position)}
{responsePositionLabel(side.position)}
</span>
</span>
</React.Fragment>
Expand Down
Loading
Loading