From 03ba9933615305c73aa78b34e901e322a74281c4 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 23 Sep 2026 12:56:42 -0700 Subject: [PATCH 01/35] feat(claims): put debates and their extracted claims in the claim's thread (GEO-3008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A claim page ended in a comment thread that listed only what people typed, with the debates held on that claim parked in a gallery above it. Two places showing the same argument, and neither of them showing the claims that came out of it — so the thing a reader most wants to follow, what was actually said and by whom, was the one thing the page never surfaced. The comment thread becomes Activity: debates are ordered in among the comments by when they happened, and expanding one lists the claims extracted from its transcript — attributed to the debater who said them, in the order they were said, each votable in place and each linking back to the moment on the video. Ordering under a debate comes from `claim-timing.ts` and nowhere else. The graph's own relation order looks chronological and is not: those relations are published with `Position.generate()`, which is random rather than monotonic, so reading them as a transcript would produce a stable, arbitrary, plausible-looking sequence. Claims nothing could place sit in a tail after the timed ones rather than being sorted among themselves. A timing good enough to sort by is not always good enough to state. The timecode chip and the jump link appear only above `isAssertableMoment`'s bar, so a block fallback — which lands at the start of the turn, sometimes most of a minute early — orders the row correctly and claims nothing about when it happened. The debate row shows the ordinary entity upvote/downvote rather than a who-won tally, and its thumbnail is the published keyframe rather than a mounted player: three debate rows would otherwise be three players fetching and decoding for a reader scrolling past all of them. Also corrects two comments claiming published timecodes exist for "all of them bar the test debate". Measured against testnet on 2026-09-23: 921 of 1,072 statements carry one, and 23 of 25 debates are timed all the way through — which is what lets `useClaimTimings` skip the transcript fetch for almost every debate. --- .../browse/claim-activity-order.test.ts | 149 ++++++++++ .../claims/browse/claim-activity-order.ts | 104 +++++++ apps/web/core/claims/browse/claim-debates.tsx | 2 +- .../claims/browse/claim-page-view.test.tsx | 43 ++- .../core/claims/browse/claim-page-view.tsx | 67 ++++- .../claims/browse/debate-activity-row.tsx | 257 ++++++++++++++++++ .../browse/extracted-claim-row.test.tsx | 149 ++++++++++ .../claims/browse/extracted-claim-row.tsx | 154 +++++++++++ .../claims/browse/use-claim-activity-rows.tsx | 105 +++++++ .../debates/browse/debate-entity-view.tsx | 16 +- .../debates/browse/debate-feed-player.tsx | 37 ++- apps/web/core/debates/browse/debate-feed.tsx | 16 +- apps/web/core/debates/debate-timecode.test.ts | 103 +++++++ apps/web/core/debates/debate-timecode.ts | 100 +++++++ apps/web/core/debates/transcript-claims.ts | 6 +- .../io/debate-transcript-claims-document.ts | 4 +- .../comments/comment-activity-rows.test.tsx | 108 ++++++++ .../partials/comments/comments-section.tsx | 86 ++++-- apps/web/partials/comments/types.ts | 16 ++ 19 files changed, 1470 insertions(+), 52 deletions(-) create mode 100644 apps/web/core/claims/browse/claim-activity-order.test.ts create mode 100644 apps/web/core/claims/browse/claim-activity-order.ts create mode 100644 apps/web/core/claims/browse/debate-activity-row.tsx create mode 100644 apps/web/core/claims/browse/extracted-claim-row.test.tsx create mode 100644 apps/web/core/claims/browse/extracted-claim-row.tsx create mode 100644 apps/web/core/claims/browse/use-claim-activity-rows.tsx create mode 100644 apps/web/core/debates/debate-timecode.test.ts create mode 100644 apps/web/core/debates/debate-timecode.ts create mode 100644 apps/web/partials/comments/comment-activity-rows.test.tsx diff --git a/apps/web/core/claims/browse/claim-activity-order.test.ts b/apps/web/core/claims/browse/claim-activity-order.test.ts new file mode 100644 index 0000000000..2f854b0402 --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-order.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import type { ClaimTiming } from '~/core/debates/claim-timing'; +import type { TranscriptClaim } from '~/core/debates/transcript-claims'; + +import { activityTime, mergeActivityRows, orderExtractedClaims } from './claim-activity-order'; + +function claim(id: string, overrides: Partial = {}): TranscriptClaim { + return { + id, + text: `claim ${id}`, + spaceId: 'space', + blockId: 'block', + publishedTiming: null, + relationEntityId: null, + restated: false, + ...overrides, + }; +} + +function timing(startMs: number, overrides: Partial = {}): ClaimTiming { + return { startMs, endMs: startMs + 1_000, confidence: 1, source: 'published', ...overrides }; +} + +describe('orderExtractedClaims', () => { + it('orders timed claims by when they were said, not by arrival', () => { + const claims = [claim('aa'), claim('bb'), claim('cc')]; + const timings = new Map([ + ['aa', timing(30_000)], + ['bb', timing(10_000)], + ['cc', timing(20_000)], + ]); + + const { timed, untimed } = orderExtractedClaims(claims, timings); + + expect(timed.map(c => c.id)).toEqual(['bb', 'cc', 'aa']); + expect(untimed).toEqual([]); + }); + + it('puts claims with no moment after the timed ones, in arrival order', () => { + const claims = [claim('aa'), claim('bb'), claim('cc'), claim('dd')]; + const timings = new Map([ + ['cc', timing(20_000)], + ['dd', timing(10_000)], + ]); + + const { timed, untimed } = orderExtractedClaims(claims, timings); + + expect(timed.map(c => c.id)).toEqual(['dd', 'cc']); + // Not sorted among themselves — there is nothing to sort them by. + expect(untimed.map(c => c.id)).toEqual(['aa', 'bb']); + }); + + it('carries the timing onto the row so a caller need not look it up again', () => { + const { timed } = orderExtractedClaims([claim('aa')], new Map([['aa', timing(5_000, { confidence: 0.6 })]])); + + expect(timed[0]!.timing).toEqual({ startMs: 5_000, endMs: 6_000, confidence: 0.6, source: 'published' }); + }); + + it('gives an untimed row a null timing rather than leaving the field off', () => { + const { untimed } = orderExtractedClaims([claim('aa')], new Map()); + + expect(untimed[0]!.timing).toBeNull(); + }); + + // `resolveClaimTimings` declines to time a restated claim, so it reaches here with no entry. + it('leaves a restated claim in the tail', () => { + const claims = [claim('aa', { restated: true }), claim('bb')]; + const { timed, untimed } = orderExtractedClaims(claims, new Map([['bb', timing(1_000)]])); + + expect(timed.map(c => c.id)).toEqual(['bb']); + expect(untimed.map(c => c.id)).toEqual(['aa']); + }); + + it('matches timings keyed by canonical id regardless of the id format on the claim', () => { + const hyphenated = '3e4a0955-699a-4c8f-813f-cc803a3335ba'; + const { timed } = orderExtractedClaims([claim(hyphenated)], new Map([['3e4a0955699a4c8f813fcc803a3335ba', timing(7_000)]])); + + expect(timed).toHaveLength(1); + expect(timed[0]!.timing?.startMs).toBe(7_000); + }); + + it('breaks a shared start on id so the order does not shuffle between renders', () => { + const claims = [claim('bb'), claim('aa')]; + const timings = new Map([ + ['aa', timing(10_000)], + ['bb', timing(10_000)], + ]); + + expect(orderExtractedClaims(claims, timings).timed.map(c => c.id)).toEqual(['aa', 'bb']); + }); +}); + +describe('mergeActivityRows', () => { + const comments = [ + { id: 'c1', createdAt: '2026-09-20T10:00:00Z' }, + { id: 'c2', createdAt: '2026-09-22T10:00:00Z' }, + ]; + const debates = [ + { id: 'd1', createdAt: '2026-09-21T10:00:00Z' }, + { id: 'd2', createdAt: '2026-09-23T10:00:00Z' }, + ]; + + it('interleaves both kinds newest first', () => { + expect(mergeActivityRows(comments, debates, 'newest').map(entry => entry.row.id)).toEqual(['d2', 'c2', 'd1', 'c1']); + }); + + it('interleaves both kinds oldest first', () => { + expect(mergeActivityRows(comments, debates, 'oldest').map(entry => entry.row.id)).toEqual(['c1', 'd1', 'c2', 'd2']); + }); + + it('tags each row so a caller can render the right component', () => { + const merged = mergeActivityRows(comments, debates, 'newest'); + + expect(merged.map(entry => entry.kind)).toEqual(['extra', 'comment', 'extra', 'comment']); + }); + + it('is a plain sort of one side when the other is empty', () => { + expect(mergeActivityRows(comments, [], 'newest').map(entry => entry.row.id)).toEqual(['c2', 'c1']); + expect(mergeActivityRows([], debates, 'oldest').map(entry => entry.row.id)).toEqual(['d1', 'd2']); + }); + + it('keeps comments ahead of extras published at the same instant', () => { + const merged = mergeActivityRows( + [{ id: 'c1', createdAt: '2026-09-20T10:00:00Z' }], + [{ id: 'd1', createdAt: '2026-09-20T10:00:00Z' }], + 'newest' + ); + + expect(merged.map(entry => entry.row.id)).toEqual(['c1', 'd1']); + }); + + // A row with no usable date is not the newest thing that ever happened. + it('sorts a row with an unreadable date to the end of a newest-first list', () => { + const merged = mergeActivityRows(comments, [{ id: 'dx', createdAt: 'not a date' }], 'newest'); + + expect(merged.map(entry => entry.row.id)).toEqual(['c2', 'c1', 'dx']); + }); +}); + +describe('activityTime', () => { + it('reads an ISO timestamp', () => { + expect(activityTime({ id: 'a', createdAt: '2026-09-20T10:00:00Z' })).toBe(Date.parse('2026-09-20T10:00:00Z')); + }); + + it('answers -Infinity rather than NaN for an unreadable one', () => { + expect(activityTime({ id: 'a', createdAt: 'nope' })).toBe(Number.NEGATIVE_INFINITY); + }); +}); diff --git a/apps/web/core/claims/browse/claim-activity-order.ts b/apps/web/core/claims/browse/claim-activity-order.ts new file mode 100644 index 0000000000..a1583d770e --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-order.ts @@ -0,0 +1,104 @@ +import { uuidToHex } from '~/core/id/normalize'; + +import type { ClaimTiming } from '~/core/debates/claim-timing'; +import type { TranscriptClaim } from '~/core/debates/transcript-claims'; + +/** + * Ordering for the claim page's activity feed. + * + * Two orders, because the levels are answering different questions. The top level is a feed and + * takes the reader's sort. Under a debate the rows are a transcript, and a transcript has one + * correct order: the order the words were said in. + */ + +export type OrderedTranscriptClaim = TranscriptClaim & { + /** When it was said, or null where no source could place it. */ + timing: ClaimTiming | null; +}; + +/** + * A debate's extracted claims, in the order they were spoken, with the unplaceable ones after. + * + * **Not** `position` order. `groupTranscriptClaims` returns claims ordered by the relation's + * `position`, and those are published with `Position.generate()`, which is random rather than + * monotonic. That order is stable across reads, so it looks deliberate — it is arbitrary, and + * reading it as chronology is the mistake this function exists to prevent. Real order comes from + * `claim-timing.ts` and nowhere else. + * + * Claims with no timing keep their arrival order in the tail rather than being sorted among + * themselves. There is nothing to sort them by, and imposing an order on them would say they have + * one. Two cases land here: a debate whose transcript could not place a phrase, and a `restated` + * claim, which `resolveClaimTimings` deliberately declines to time because the same claim entity + * linked from two turns cannot answer "when" for either of them. + */ +export function orderExtractedClaims( + claims: TranscriptClaim[], + timings: Map +): { timed: OrderedTranscriptClaim[]; untimed: OrderedTranscriptClaim[] } { + const timed: OrderedTranscriptClaim[] = []; + const untimed: OrderedTranscriptClaim[] = []; + + for (const claim of claims) { + const timing = timings.get(uuidToHex(claim.id)) ?? null; + if (timing === null) { + untimed.push({ ...claim, timing: null }); + } else { + timed.push({ ...claim, timing }); + } + } + + // Ties broken by id, so two claims sharing a start — the same phrase matched to one window — do + // not swap places between renders and make the list look like it is still loading. + timed.sort((a, b) => a.timing!.startMs - b.timing!.startMs || a.id.localeCompare(b.id)); + + return { timed, untimed }; +} + +/** Anything the top-level feed can order: a comment, or a row standing in for another entity. */ +export type ActivityOrderable = { + id: string; + /** ISO timestamp. Unparseable values sort last rather than to 1970 — see {@link activityTime}. */ + createdAt: string; +}; + +/** + * The instant a row claims, for ordering purposes. + * + * An unparseable or missing timestamp answers `-Infinity` for newest-first, which puts the row at + * the end of the list rather than at the top of it. A row with no date is not the newest thing that + * ever happened, and `new Date(undefined).getTime()` is `NaN`, which makes every comparison false + * and leaves a sort silently unstable rather than merely wrong. + */ +export function activityTime(row: ActivityOrderable): number { + const ms = new Date(row.createdAt).getTime(); + return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms; +} + +/** + * Comments and non-comment rows in one time-ordered sequence. + * + * Generic over both sides rather than typed to `CommentWithReplies`, so the comment list can stay + * the only thing that knows what a comment is. Callers get back a tagged union and render each arm + * with the component that owns it. + * + * Stable within a timestamp: rows that arrive at the same instant keep the order their own lists + * gave them, and comments come first. A debate and a comment published in the same second is not a + * tie worth breaking on identity, and reordering on every render is worse than either choice. + */ +export function mergeActivityRows( + comments: C[], + extras: E[], + order: 'newest' | 'oldest' +): Array<{ kind: 'comment'; row: C } | { kind: 'extra'; row: E }> { + const merged: Array<{ kind: 'comment'; row: C } | { kind: 'extra'; row: E }> = [ + ...comments.map(row => ({ kind: 'comment' as const, row })), + ...extras.map(row => ({ kind: 'extra' as const, row })), + ]; + + const direction = order === 'newest' ? -1 : 1; + // `sort` is stable in every engine we target, so equal timestamps keep insertion order — which + // above is "comments, then extras". Said out loud because the tie-break is a decision, not luck. + merged.sort((a, b) => direction * (activityTime(a.row) - activityTime(b.row))); + + return merged; +} diff --git a/apps/web/core/claims/browse/claim-debates.tsx b/apps/web/core/claims/browse/claim-debates.tsx index 47facc27e9..3e6a4f09ce 100644 --- a/apps/web/core/claims/browse/claim-debates.tsx +++ b/apps/web/core/claims/browse/claim-debates.tsx @@ -421,7 +421,7 @@ function DebateMeta({ debate, totalVotes }: { debate: Entity; totalVotes: number * happened" — `updatedAt` moves whenever anything touches the entity, including a backlink from * some unrelated edit. */ -function debateDate(debate: Entity): Date | null { +export function debateDate(debate: Entity): Date | null { const raw = debate.createdAt ?? debate.updatedAt; if (raw === undefined || raw === null) return null; diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index 82d520c0ba..fea0c36f4c 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -13,6 +13,8 @@ import { ClaimPageView, resolveClaimTab } from './claim-page-view'; const mocks = vi.hoisted(() => ({ entity: null as Record | null, + /** Non-comment rows the Overview orders into its activity thread — the debates on this claim. */ + activityRows: [] as Array<{ id: string; createdAt: string; content: unknown }>, /** How many responses the claim has; zero means the hero draws no verdict column. */ responseTotal: 11, /** Whether the response counts are still out, which is what the hero reserves its column for. */ @@ -113,6 +115,12 @@ vi.mock('~/core/sync/use-store', () => ({ useQueryEntity: () => ({ entity: mocks.entity, isLoading: false }), })); +// What the Overview puts in its activity thread besides comments. Its own fetching — debates, +// profiles, keyframes — is covered by the rows' suites; this file is about page composition. +vi.mock('./use-claim-activity-rows', () => ({ + useClaimActivityRows: () => ({ rows: mocks.activityRows, isLoading: false }), +})); + vi.mock('~/core/debates/hooks', () => ({ useDebateClaims: () => ({ data: { claims: [] } }), })); @@ -204,7 +212,9 @@ vi.mock('~/partials/profile/profile-activity-section', () => ({ })); vi.mock('~/partials/editor/editor', () => ({ Editor: () =>
})); vi.mock('~/partials/comments/comments-section', () => ({ - CommentSection: () =>
, + CommentSection: ({ title, activityRows }: { title?: string; activityRows?: Array<{ id: string }> }) => ( +
r.id).join(',')} /> + ), })); function claimEntity(description: string | null) { @@ -218,6 +228,7 @@ function claimEntity(description: string | null) { } beforeEach(() => { + mocks.activityRows = []; mocks.responseTotal = 11; mocks.summaryLoading = false; mocks.hasCounts = true; @@ -463,11 +474,34 @@ describe('ClaimPageView record', () => { render(); const kinds = mocks.activity?.kinds as Array<{ key: string; onSeeAll?: () => void }>; - kinds.find(kind => kind.key === 'debates')?.onSeeAll?.(); kinds.find(kind => kind.key === 'claims')?.onSeeAll?.(); - expect(setActiveSystemTab).toHaveBeenNthCalledWith(1, 'debates'); - expect(setActiveSystemTab).toHaveBeenNthCalledWith(2, 'claims'); + expect(setActiveSystemTab).toHaveBeenNthCalledWith(1, 'claims'); + }); + + it('hands the debates on this claim to the thread, and names it Activity', () => { + mocks.activityRows = [ + { id: 'debate-1', createdAt: '2026-09-20T10:00:00Z', content: null }, + { id: 'debate-2', createdAt: '2026-09-21T10:00:00Z', content: null }, + ]; + + render(); + + const comments = screen.getByTestId('comments'); + expect(comments).toHaveAttribute('data-activity-rows', 'debate-1,debate-2'); + // Not "Comments": the list holds more than comments now, and the count says how much has + // happened to this claim rather than how many people typed. + expect(comments).toHaveAttribute('data-title', 'Activity'); + }); + + // GEO-3008: debates moved into the activity thread, where they are ordered among the comments. + // A gallery of them above that thread would have shown the same debates twice and left the + // reader working out whether they were the same ones. + it('leaves debates to the activity thread rather than giving them a gallery of their own', () => { + render(); + + const kinds = mocks.activity?.kinds as Array<{ key: string }>; + expect(kinds.map(kind => kind.key)).toEqual(['claims']); }); it('marks only failed record counts unavailable in Activity', () => { @@ -480,7 +514,6 @@ describe('ClaimPageView record', () => { const kinds = mocks.activity?.kinds as Array<{ key: string; isCountUnavailable?: boolean }>; expect(kinds.find(kind => kind.key === 'claims')?.isCountUnavailable).toBe(true); - expect(kinds.find(kind => kind.key === 'debates')?.isCountUnavailable).toBe(false); }); it('hands the full side-panel tab the claim record scope', () => { diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 373e2ff61e..1476d5ada8 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -45,6 +45,7 @@ import { ClaimRecordTab } from './claim-record-tab'; import { getClaimSources } from './claim-sources'; import { ClaimSourcesTab } from './claim-sources-tab'; import { ClaimTopicsTab } from './claim-topics-tab'; +import { useClaimActivityRows } from './use-claim-activity-rows'; import { useClaimRecord } from './use-claim-record'; import { type ClaimResponseState, useClaimResponseState } from './use-claim-response-state'; @@ -409,19 +410,51 @@ function ClaimTabPanel({ return ; } + return ( + + ); +} + +/** + * The claim's overview: the related-claims gallery, then everything that has happened to it. + * + * Debates used to have a gallery of their own beside Claims. They are rows in the activity feed + * now, which is the whole point of GEO-3008 — a debate belongs in the account of what happened to + * this claim rather than in a shelf above it, and two places showing the same debates left the + * reader to work out whether they were the same debates. The Debates *tab* is still the complete + * index; the gallery was the duplicate. + * + * Its own component because the panel above returns early for every other tab, and a hook cannot + * live behind an early return. + */ +function ClaimOverviewTab({ + entityId, + spaceId, + responseKind, + summary, + record, + hrefs, + onSelectSystemTab, +}: { + entityId: string; + spaceId: string; + responseKind: ClaimResponseState['responseKind']; + summary: ClaimResponseState['summary']; + record: ReturnType; + hrefs: { debates: string; claims: string }; + onSelectSystemTab?: (tab: ClaimSystemTab) => void; +}) { + const activity = useClaimActivityRows({ claimId: entityId, spaceId }); + const kinds: ActivityKind[] = [ - { - key: 'debates', - label: 'Debates', - rows: record.debateRows, - total: record.debatesTotal, - isLoading: record.debatesLoading, - isError: record.debatesError, - isCountUnavailable: record.debatesCountUnavailable, - href: hrefs.debates, - seeAllLabel: 'View all debates', - onSeeAll: onSelectSystemTab ? () => onSelectSystemTab('debates') : undefined, - }, { key: 'claims', label: 'Claims', @@ -467,7 +500,15 @@ function ClaimTabPanel({ viewerSpaceId={summary.viewerSpaceId} isViewerResponseLoading={summary.isViewerResponseLoading} > - + {/* "Activity", because the list now holds debates as well as comments — and the count says + how much has happened to this claim rather than how many people typed. */} + ); diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx new file mode 100644 index 0000000000..1c93beb16c --- /dev/null +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -0,0 +1,257 @@ +'use client'; + +import * as React from 'react'; + +import { useClaimTimings } from '~/core/debates/use-claim-timings'; +import { useDebateClaims } from '~/core/debates/hooks'; +import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; +import { useEntityCommentsPanel } from '~/core/hooks/use-entity-comments-panel'; +import { uuidToHex } from '~/core/id/normalize'; +import type { ResponseKind } from '~/core/responses/entity-response'; +import type { Entity } from '~/core/types'; +import { NavUtils } from '~/core/utils/utils'; + +import { GeoImage } from '~/design-system/geo-image'; +import { PrefetchLink as Link } from '~/design-system/prefetch-link'; +import { Skeleton } from '~/design-system/skeleton'; +import { Text } from '~/design-system/text'; + +import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; +import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; + +import { orderExtractedClaims } from './claim-activity-order'; +import { ExtractedClaimRow, type SpeakerProfile } from './extracted-claim-row'; + +/** + * How many extracted claims a debate shows before it asks. + * + * Depth is capped separately (replies below level two are behind their own button); this is the + * breadth cap, and it is the one that actually bites. A debate can carry twenty extracted claims + * without ever exceeding the depth cap, which would put the next debate below a screen and a half + * of someone else's transcript. + */ +const EXTRACTED_CLAIM_PAGE_SIZE = 4; + +export type DebateActivityRowProps = { + debate: Entity; + /** The space the claim page is being read through, which is where the debate was published. */ + spaceId: string; + /** Debater names and faces, resolved once for the whole feed. */ + profilesBySpaceId: Map; + participantSpaceIds: string[]; + keyframeUrl: string | null; + publishedAt: Date | null; +}; + +/** + * A debate, as a row in the claim's activity feed, with the claims pulled out of it as its replies. + * + * The thumbnail is the still the debate was published with rather than a mounted player. Three + * debate rows on a page would otherwise be three `DebateFeedPlayer`s fetching and decoding at once + * for a reader who is scrolling past all of them; the still costs one image, and Watch is one click. + * + * The score is the ordinary entity upvote/downvote, not a who-won tally. Winner voting is being + * sunset, and a row whose number needed a footnote to say which kind of voting produced it was + * exactly the confusion worth removing. + */ +export function DebateActivityRow({ + debate, + spaceId, + profilesBySpaceId, + participantSpaceIds, + keyframeUrl, + publishedAt, +}: DebateActivityRowProps) { + const [expanded, setExpanded] = React.useState(false); + const [visibleClaims, setVisibleClaims] = React.useState(EXTRACTED_CLAIM_PAGE_SIZE); + const { openComments } = useEntityCommentsPanel(); + + const debaters = participantSpaceIds + .map(id => profilesBySpaceId.get(id)?.name?.trim()) + .filter((name): name is string => Boolean(name)); + const title = debaters.length > 0 ? debaters.join(' vs. ') : (debate.name ?? 'Debate'); + + return ( +
+
+ {/* Portrait, because that is the shape the video actually is: `Debate videos` declares + 540 × 820, so a 16:9 tile would letterbox every still it ever showed. Same geometry + `ClaimDebates` uses, so the two surfaces show a debate the same way. */} + + {keyframeUrl && } + + +
+
+ + {title} + + Debate + {publishedAt && ( + + {publishedAt.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })} + + )} +
+ +
+ + + {/* A reply to a debate is a comment on the debate, so it goes where that entity's + comments already live rather than into this claim's thread. The panel is the + existing surface for commenting on an entity you are not currently reading. */} + + + Watch debate + +
+
+
+ + setExpanded(true)} + visibleCount={visibleClaims} + onShowMore={() => setVisibleClaims(count => count + EXTRACTED_CLAIM_PAGE_SIZE)} + /> +
+ ); +} + +/** + * The debate's extracted claims, in the order they were said. + * + * Held behind an expand rather than loaded with the row. The traversal is one request per debate + * and the timing resolver may want the Whisper transcript on top of it, so a feed of five debates + * would be ten requests for rows the reader has not asked to see. Expanding one debate is one + * debate's worth of work. + */ +function ExtractedClaims({ + debateId, + spaceId, + profilesBySpaceId, + expanded, + onExpand, + visibleCount, + onShowMore, +}: { + debateId: string; + spaceId: string; + profilesBySpaceId: Map; + expanded: boolean; + onExpand: () => void; + visibleCount: number; + onShowMore: () => void; +}) { + const { claims, isLoading } = useDebateTranscriptClaims(debateId, spaceId, expanded); + // Free for the 86% of statements that carry a published offset — the resolver only reaches for + // the transcript when a debate has one that does not. See `useClaimTimings`. + const { timings, isReady } = useClaimTimings(debateId, claims, expanded); + + const claimIds = React.useMemo(() => claims.all.map(claim => claim.id), [claims.all]); + // One call for every extracted claim on screen. Without it each row's own vote control would read + // the entity to work out whether it takes thumbs or chevrons — one request per row. + const claimRows = useDebateClaims(spaceId, claimIds, expanded && claimIds.length > 0); + const responseKindByClaimId = React.useMemo(() => { + const map = new Map(); + for (const row of claimRows.data?.claims ?? []) { + map.set(uuidToHex(row.claim_entity_id), row.response_kind === 'veracity' ? 'veracity' : 'stance'); + } + return map; + }, [claimRows.data?.claims]); + + const speakerBySourceBlockId = React.useMemo(() => { + const map = new Map(); + for (const block of claims.blocks) { + if (block.authorSpaceId) map.set(uuidToHex(block.id), block.authorSpaceId); + } + return map; + }, [claims.blocks]); + + const ordered = React.useMemo(() => orderExtractedClaims(claims.all, timings), [claims.all, timings]); + + if (!expanded) { + return ( + + ); + } + + if (isLoading || !isReady) { + // Held rather than painted unordered. `isReady` is false only while a timing source is still + // arriving, and it reports ready on a failed transcript fetch — so this cannot hang forever on + // a debate whose transcript is gone; that degrades to "all untimed, arrival order". + return ; + } + + // Timed first, in order, then the ones nothing could place. The tail is not sorted among itself: + // there is nothing to sort it by, and imposing an order would say there was. + const rows = [...ordered.timed, ...ordered.untimed]; + + if (rows.length === 0) { + return ( + + No claims were extracted from this debate. + + ); + } + + const visible = rows.slice(0, visibleCount); + const remaining = rows.length - visible.length; + + return ( +
+ {visible.map(claim => { + const speakerSpaceId = speakerBySourceBlockId.get(uuidToHex(claim.blockId)) ?? null; + return ( + + ); + })} + + {remaining > 0 && ( + + )} +
+ ); +} diff --git a/apps/web/core/claims/browse/extracted-claim-row.test.tsx b/apps/web/core/claims/browse/extracted-claim-row.test.tsx new file mode 100644 index 0000000000..6c17cedb18 --- /dev/null +++ b/apps/web/core/claims/browse/extracted-claim-row.test.tsx @@ -0,0 +1,149 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ClaimTiming } from '~/core/debates/claim-timing'; + +import type { OrderedTranscriptClaim } from './claim-activity-order'; +import { ExtractedClaimRow } from './extracted-claim-row'; + +// The two controls are covered by their own suites and both reach for wallet/query context. What +// this file is about is what the row decides: which timing may be shown, and whether the claim can +// be acted on at all. +vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ + EntityVoteButtons: ({ entityId, spaceId, responseKind, claimResponderAvatarsPosition }: Record) => ( +
+ ), +})); + +// `PrefetchLink` warms the sync engine on hover, so it needs that provider. The hrefs are what +// this file asserts, and a plain anchor carries those. +vi.mock('~/design-system/prefetch-link', () => ({ + PrefetchLink: ({ children, ...props }: { children?: React.ReactNode } & Record) => ( + )}>{children} + ), +})); + +vi.mock('~/partials/comments/entity-comments-button', () => ({ + EntityCommentsButton: ({ entityId }: { entityId: string }) => ( +
+ ), +})); + +function claim(overrides: Partial = {}): OrderedTranscriptClaim { + return { + id: 'claim-1', + text: 'Practical effects age better than CGI.', + spaceId: 'claim-space', + blockId: 'block-1', + publishedTiming: null, + relationEntityId: null, + restated: false, + timing: null, + ...overrides, + }; +} + +function timing(overrides: Partial = {}): ClaimTiming { + return { startMs: 724_000, endMs: 730_000, confidence: 1, source: 'published', ...overrides }; +} + +function renderRow(overrides: Partial> = {}) { + return render( + + ); +} + +describe('ExtractedClaimRow', () => { + afterEach(cleanup); + + it('shows the speaker the turn was attributed to', () => { + renderRow(); + + expect(screen.getByText('Ada Reyes')).toBeInTheDocument(); + }); + + it('falls back to a placeholder rather than a blank byline when the speaker has no name', () => { + renderRow({ speaker: { spaceId: 'speaker-space', name: null } }); + + expect(screen.getByText('Unnamed debater')).toBeInTheDocument(); + }); + + it('links a published moment back to the debate, backing off by the pre-roll', () => { + renderRow({ claim: claim({ timing: timing() }) }); + + const link = screen.getByRole('link', { name: 'Watch from 12:04' }); + expect(link).toHaveTextContent('12:04'); + // 724s minus the two-second lead-in, so the sentence is not clipped by its own start. + expect(link).toHaveAttribute('href', expect.stringContaining('t=722')); + expect(link).toHaveAttribute('href', expect.stringContaining('debate-space')); + expect(link).toHaveAttribute('href', expect.stringContaining('debate-1')); + }); + + it('still offers the jump for a transcript match that clears the assertable bar', () => { + renderRow({ claim: claim({ timing: timing({ confidence: 0.62, source: 'segment' }) }) }); + + expect(screen.getByRole('link', { name: 'Watch from 12:04' })).toBeInTheDocument(); + }); + + // A block fallback places the claim at the start of the turn, which can be most of a minute + // early. Good enough to order by, not good enough to state or to seek to. + it('prints no timecode and offers no jump for a timing below the assertable bar', () => { + renderRow({ claim: claim({ timing: timing({ confidence: 0, source: 'block' }) }) }); + + expect(screen.queryByRole('link', { name: /Watch from/ })).not.toBeInTheDocument(); + expect(screen.getByText('moment not found')).toBeInTheDocument(); + }); + + it('says so when nothing could place the claim at all', () => { + renderRow({ claim: claim({ timing: null }) }); + + expect(screen.getByText('moment not found')).toBeInTheDocument(); + }); + + it('passes the resolved response kind and trailing avatars to the vote control', () => { + renderRow({ responseKind: 'veracity' }); + + const votes = screen.getByTestId('vote-buttons'); + expect(votes).toHaveAttribute('data-kind', 'veracity'); + expect(votes).toHaveAttribute('data-avatars', 'trailing'); + }); + + // The claim lives in the debate's space, not the page's — pointing a vote anywhere else records + // it where the claim does not live, which reads back as a claim nobody answered. + it('votes against the space the claim actually lives in', () => { + renderRow({ claim: claim({ spaceId: 'somewhere-else' }) }); + + expect(screen.getByTestId('vote-buttons')).toHaveAttribute('data-space', 'somewhere-else'); + }); + + it('renders read-only when the graph reports no home space for the claim', () => { + renderRow({ claim: claim({ spaceId: null }) }); + + expect(screen.queryByTestId('vote-buttons')).not.toBeInTheDocument(); + expect(screen.queryByTestId('comments-button')).not.toBeInTheDocument(); + // The sentence is still readable, just not actionable. + expect(screen.getByText('Practical effects age better than CGI.')).toBeInTheDocument(); + }); + + it('links the sentence to the claim in its own space', () => { + renderRow(); + + const link = screen.getByRole('link', { name: 'Practical effects age better than CGI.' }); + expect(link).toHaveAttribute('href', expect.stringContaining('claim-space')); + }); +}); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx new file mode 100644 index 0000000000..c0bec0e48e --- /dev/null +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -0,0 +1,154 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; + +import { isAssertableMoment } from '~/core/debates/claim-timing'; +import { debateSeekSeconds, formatTimecode, withDebateTimecode } from '~/core/debates/debate-timecode'; +import type { ResponseKind } from '~/core/responses/entity-response'; +import { NavUtils } from '~/core/utils/utils'; + +import { Avatar } from '~/design-system/avatar'; +import { PrefetchLink as Link } from '~/design-system/prefetch-link'; +import { Text } from '~/design-system/text'; + +import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; +import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; + +import type { OrderedTranscriptClaim } from './claim-activity-order'; + +export type SpeakerProfile = { name?: string | null; avatarUrl?: string | null }; + +/** + * One claim a debater made, as a row under the debate it was extracted from. + * + * Deliberately not the explore card. That card gives a claim a headline, a 220px verdict rail and + * labelled Agree/Disagree pills, which is right when the claim is the thing being read and far too + * much when six of them hang off a single debate two levels into a thread. What survives the cut is + * everything the reader needs to act without leaving: the split, who is on it, a vote, and the way + * back to the moment it was said. The full treatment is one click away on the claim's own page. + * + * Nothing here is new machinery. `EntityVoteButtons` already draws responder faces beside the + * control and already picks its icons from the response kind — thumbs for a stance, chevrons for + * veracity — so a factual claim cannot end up with a thumb labelled "agree". + */ +export function ExtractedClaimRow({ + claim, + debateId, + debateSpaceId, + responseKind, + speaker, + className, +}: { + claim: OrderedTranscriptClaim; + /** The debate this was said in, which is where the timecode link goes. */ + debateId: string; + debateSpaceId: string; + /** + * Resolved once for the whole debate rather than per row. + * + * `EntityVoteButtons` derives this itself when the prop is omitted, at the cost of an entity read + * per instance — twenty extracted claims would mean twenty of them. Passing it down is the + * difference between one lookup and one per row. + */ + responseKind: ResponseKind; + /** The debater this turn is attributed to, or null on a block with no `Authors` relation. */ + speaker: (SpeakerProfile & { spaceId: string }) | null; + className?: string; +}) { + // Null where the graph reports no home space. The claim is then unlinkable and unrespondable — + // pointing a vote at this page's space instead would record it somewhere the claim does not live, + // which reads back as a claim nobody answered. Read-only is the honest rendering. + const claimSpaceId = claim.spaceId; + + // A moment good enough to sort by is not always good enough to state. A `block` fallback timing + // places the claim at the start of the turn it was said in, which can be most of a minute early; + // printing that as a timecode, and offering to seek to it, would both be assertions we cannot + // make. `isAssertableMoment` is the bar the debate surfaces already use for this. + const moment = isAssertableMoment(claim.timing) ? claim.timing : null; + const timecodeHref = moment + ? withDebateTimecode(NavUtils.toEntity(debateSpaceId, debateId), debateSeekSeconds(moment.startMs)) + : null; + + return ( +
+ + + + +
+
+ + {speaker?.name?.trim() || 'Unnamed debater'} + + + {timecodeHref ? ( + + + {formatTimecode(moment!.startMs)} + + ) : ( + // Said, but not placed: a restated claim, or one the matcher could not find. Better to + // say the moment is missing than to leave the row looking like it simply has no time. + + moment not found + + )} +
+ + {claimSpaceId ? ( + + + {claim.text} + + + ) : ( + + {claim.text} + + )} + + {claimSpaceId ? ( +
+ + +
+ ) : null} +
+
+ ); +} + +function PlayGlyph() { + return ( + + ); +} diff --git a/apps/web/core/claims/browse/use-claim-activity-rows.tsx b/apps/web/core/claims/browse/use-claim-activity-rows.tsx new file mode 100644 index 0000000000..db635f87da --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-activity-rows.tsx @@ -0,0 +1,105 @@ +'use client'; + +import * as React from 'react'; + +import { + DEBATE_CLAIMS_PROPERTY_ID, + DEBATE_OPPOSED_BY_PROPERTY_ID, + DEBATE_SUPPORTED_BY_PROPERTY_ID, + DEBATE_TYPE_ID, +} from '~/core/debates/ontology'; +import { useProfilesBySpaceIds } from '~/core/hooks/use-profiles-by-space-ids'; +import { useQueryEntities } from '~/core/sync/use-store'; + +import type { CommentActivityRow } from '~/partials/comments/types'; + +import { debateDate, relationTargets } from './claim-debates'; +import { DebateActivityRow } from './debate-activity-row'; +import { useDebateKeyframes } from './use-debate-keyframes'; + +/** + * How many debates the activity feed carries. + * + * Bounded rather than paged, unlike the Debates tab. These rows are ordered in among the comments, + * so a pager here would be a pager over half a list — and the tab exists precisely to be the + * complete, navigable index. A claim with more debates than this shows its most recent and sends + * the reader to the tab for the rest. + */ +const ACTIVITY_DEBATE_LIMIT = 10; + +const NO_ROWS: CommentActivityRow[] = []; + +/** + * The debates on this claim, as rows for the activity feed. + * + * Space-scoped like everything else on the page: a debate is published into the space its claim + * lives in, so the debates worth listing under this space's view of the claim are that space's. + * + * Every lookup here is batched across the whole set — profiles in one request, keyframes in two + * hops for all debates at once — so the cost is flat in the number of rows rather than per row. + * What is deliberately *not* fetched is anything about a debate's transcript: that waits until the + * reader expands one. + */ +export function useClaimActivityRows({ + claimId, + spaceId, + enabled = true, +}: { + claimId: string; + spaceId: string; + enabled?: boolean; +}): { rows: CommentActivityRow[]; isLoading: boolean } { + const { entities: debates, isLoading } = useQueryEntities({ + where: { + types: [{ id: { equals: DEBATE_TYPE_ID } }], + spaces: [{ equals: spaceId }], + relations: [{ typeOf: { id: { equals: DEBATE_CLAIMS_PROPERTY_ID } }, toEntity: { id: { equals: claimId } } }], + }, + first: ACTIVITY_DEBATE_LIMIT, + enabled, + }); + + const participantsByDebateId = React.useMemo(() => { + const map = new Map(); + for (const debate of debates) { + map.set(debate.id, [ + ...relationTargets(debate.relations, DEBATE_SUPPORTED_BY_PROPERTY_ID), + ...relationTargets(debate.relations, DEBATE_OPPOSED_BY_PROPERTY_ID), + ]); + } + return map; + }, [debates]); + + // Every debater on the page, and every speaker their extracted claims will be attributed to — + // the same set, because a claim is extracted from a turn one of them took. + const participantSpaceIds = React.useMemo( + () => [...new Set([...participantsByDebateId.values()].flat())], + [participantsByDebateId] + ); + const { profilesBySpaceId } = useProfilesBySpaceIds(participantSpaceIds, participantSpaceIds.length > 0); + const keyframeByDebateId = useDebateKeyframes(debates); + + const rows = React.useMemo(() => { + if (debates.length === 0) return NO_ROWS; + + return debates.map(debate => ({ + id: debate.id, + // `createdAt` rather than `updatedAt`: this row's place in the feed is when the debate + // happened, and `updatedAt` moves whenever anything touches the entity — including a backlink + // from an unrelated edit, which would float an old debate to the top of the thread. + createdAt: debateDate(debate)?.toISOString() ?? '', + content: ( + + ), + })); + }, [debates, keyframeByDebateId, participantsByDebateId, profilesBySpaceId, spaceId]); + + return { rows, isLoading }; +} diff --git a/apps/web/core/debates/browse/debate-entity-view.tsx b/apps/web/core/debates/browse/debate-entity-view.tsx index e99ea1e406..9aa41f44d4 100644 --- a/apps/web/core/debates/browse/debate-entity-view.tsx +++ b/apps/web/core/debates/browse/debate-entity-view.tsx @@ -2,6 +2,9 @@ import * as React from 'react'; +import { useSearchParams } from 'next/navigation'; + +import { DEBATE_TIME_PARAM, parseDebateTimeParam } from '~/core/debates/debate-timecode'; import { useUserIsEditing } from '~/core/hooks/use-user-is-editing'; import { DebatesBrowseFeed } from './debate-feed'; @@ -22,6 +25,10 @@ type DebateEntityViewProps = { */ export function DebateEntityView({ spaceId, debateId, editView }: DebateEntityViewProps) { const isEditing = useUserIsEditing(spaceId); + // A link may name the moment it wants — a claim extracted from this debate pointing back at + // where it was said. Null when the link is not asking for one, and also when it asks for + // something that is not a position: see `parseDebateTimeParam` for why that is not clamped to 0. + const initialSeekSeconds = parseDebateTimeParam(useSearchParams().get(DEBATE_TIME_PARAM)); if (isEditing) { return <>{editView}; @@ -29,5 +36,12 @@ export function DebateEntityView({ spaceId, debateId, editView }: DebateEntityVi // Browse mode shows the live video, but if this debate isn't watchable in the space's feed we // fall back to the entity page rather than the feed's "space not found" error. - return ; + return ( + + ); } diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 6503d8f3e4..9e62deb66b 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -62,9 +62,24 @@ type DebateFeedPlayerProps = { * makes arriving at a card feel glitchy (GEO-2895). */ preload?: boolean; + /** + * Start here, in seconds, the first time this debate becomes playable — from a link that named a + * moment, such as a claim row saying where it was said. + * + * Applied once and never again. Every seek on these recordings is a parse walk (they are + * MediaRecorder WebM with one cluster and no Cues), so re-applying it on a later render would + * fight the viewer's own scrubbing and re-open GEO-2828's seek storm. + */ + initialSeekSeconds?: number | null; }; -export function DebateFeedPlayer({ debate, active, preload = false, reducedOverlays = false }: DebateFeedPlayerProps) { +export function DebateFeedPlayer({ + debate, + active, + preload = false, + reducedOverlays = false, + initialSeekSeconds = null, +}: DebateFeedPlayerProps) { // Loading is deliberately wider than playing. `useDebatePlayback`'s flag gates only the URL // fetch and the transcript query — playback is driven by `active` in the effect below — so a // preloading card fetches without autoplaying off-screen. @@ -121,6 +136,26 @@ export function DebateFeedPlayer({ debate, active, preload = false, reducedOverl seekBothRaw(seconds); }; + /** + * Honour a link that named a moment, once. + * + * Waits for `ready` rather than firing on mount: before the media is seekable the request would + * be dropped, and this is the arriving reader's one chance to land where they were sent. Latched + * by debate id so re-renders, a re-activation, or the viewer scrolling back to this card cannot + * yank the playhead back to where the link pointed after they have moved it themselves. + * + * Not routed through the measured `seekBoth` above: this is a seek the reader asked for on the + * previous page, not a scrub performed on this player, and counting it as one would report a + * control interaction nobody made here. + */ + const appliedInitialSeekRef = React.useRef(null); + React.useEffect(() => { + if (initialSeekSeconds == null || !ready) return; + if (appliedInitialSeekRef.current === debate.id) return; + appliedInitialSeekRef.current = debate.id; + seekBothRaw(initialSeekSeconds); + }, [debate.id, initialSeekSeconds, ready, seekBothRaw]); + /** * Stopped, and only a tap will start it. * diff --git a/apps/web/core/debates/browse/debate-feed.tsx b/apps/web/core/debates/browse/debate-feed.tsx index f33dc71b40..1f8b8948c3 100644 --- a/apps/web/core/debates/browse/debate-feed.tsx +++ b/apps/web/core/debates/browse/debate-feed.tsx @@ -49,10 +49,19 @@ const DEBATE_COLUMN_STYLE = { export function DebatesBrowseFeed({ spaceId, initialDebateId, + initialSeekSeconds = null, fallback, }: { spaceId: string; initialDebateId?: string; + /** + * Where to start the anchored debate, in seconds — from a link that named a moment. + * + * Only ever applied to {@link initialDebateId}: a position means nothing on the debates that + * happen to be scrolled to next, and carrying it down the feed would seek every card a reader + * passes. See `debate-timecode.ts` for the param this comes from. + */ + initialSeekSeconds?: number | null; /** Rendered instead of the feed when {@link initialDebateId} can't be resolved in this space. */ fallback?: React.ReactNode; }) { @@ -319,6 +328,9 @@ export function DebatesBrowseFeed({ spaceImage={space?.entity.image} topics={topicsByClaimId.get(debate.claim.claim_entity_id) ?? []} active={activeId === debate.id} + initialSeekSeconds={ + initialDebateId != null && ID.equals(debate.id, initialDebateId) ? initialSeekSeconds : null + } // Resolve the NEXT debate's recordings while the viewer is still on this one. Each // debate needs two signed URLs, and until they land the player shows "Loading…" // instead of a video, which is what makes arriving at a card feel glitchy @@ -407,6 +419,7 @@ function DebateFeedItem({ spaceImage, topics, active, + initialSeekSeconds, preload, root, scrollHint, @@ -421,6 +434,7 @@ function DebateFeedItem({ spaceImage?: string | null; topics: string[]; active: boolean; + initialSeekSeconds: number | null; preload: boolean; root: HTMLElement | null; scrollHint: { isVisible: boolean; isLeaving: boolean } | null; @@ -498,7 +512,7 @@ function DebateFeedItem({ />
- +
{/* Mobile: horizontal bar below the videos. Wrapper controls display so it doesn't collide with the bar's own `flex`. */} diff --git a/apps/web/core/debates/debate-timecode.test.ts b/apps/web/core/debates/debate-timecode.test.ts new file mode 100644 index 0000000000..6c3ba7f6c6 --- /dev/null +++ b/apps/web/core/debates/debate-timecode.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEBATE_TIME_PARAM, + TIMECODE_PREROLL_SECONDS, + debateSeekSeconds, + formatTimecode, + parseDebateTimeParam, + withDebateTimecode, +} from './debate-timecode'; + +describe('debateSeekSeconds', () => { + it('backs off by the pre-roll so the sentence is not clipped', () => { + expect(debateSeekSeconds(724_000)).toBe(724 - TIMECODE_PREROLL_SECONDS); + }); + + it('clamps at zero for a claim inside the pre-roll window', () => { + expect(debateSeekSeconds(0)).toBe(0); + expect(debateSeekSeconds(1_000)).toBe(0); + }); + + it('answers zero rather than NaN for a timing that is not a number', () => { + expect(debateSeekSeconds(Number.NaN)).toBe(0); + expect(debateSeekSeconds(Number.POSITIVE_INFINITY)).toBe(0); + }); +}); + +describe('parseDebateTimeParam', () => { + it('reads a position', () => { + expect(parseDebateTimeParam('722')).toBe(722); + }); + + it('takes the first value of a repeated param', () => { + expect(parseDebateTimeParam(['722', '900'])).toBe(722); + }); + + it('accepts zero as a deliberate position', () => { + expect(parseDebateTimeParam('0')).toBe(0); + }); + + it('floors a fractional position', () => { + expect(parseDebateTimeParam('722.9')).toBe(722); + }); + + it('is null when no position is asked for', () => { + expect(parseDebateTimeParam(null)).toBeNull(); + expect(parseDebateTimeParam(undefined)).toBeNull(); + expect(parseDebateTimeParam('')).toBeNull(); + }); + + // A corrupt param must stay distinguishable from `t=0`: clamping would make a broken link look + // like a request to start from the top, which is what happens with no param at all anyway. + it('is null rather than clamped for a value that is not a position', () => { + expect(parseDebateTimeParam('abc')).toBeNull(); + expect(parseDebateTimeParam('-5')).toBeNull(); + expect(parseDebateTimeParam(String(13 * 60 * 60))).toBeNull(); + }); +}); + +describe('withDebateTimecode', () => { + it('adds the position to a bare path', () => { + expect(withDebateTimecode('/space/abc/def', 722)).toBe(`/space/abc/def?${DEBATE_TIME_PARAM}=722`); + }); + + it('keeps params already on the link', () => { + const href = withDebateTimecode('/space/abc/def?modal=debates', 722); + const params = new URLSearchParams(href.slice(href.indexOf('?') + 1)); + expect(params.get('modal')).toBe('debates'); + expect(params.get(DEBATE_TIME_PARAM)).toBe('722'); + }); + + it('replaces a position already there rather than appending a second one', () => { + const href = withDebateTimecode(`/space/abc/def?${DEBATE_TIME_PARAM}=10`, 722); + const params = new URLSearchParams(href.slice(href.indexOf('?') + 1)); + expect(params.getAll(DEBATE_TIME_PARAM)).toEqual(['722']); + }); + + // A fragment placed before the query is the one order a browser will not read. + it('keeps the hash last', () => { + expect(withDebateTimecode('/space/abc/def#claims', 722)).toBe(`/space/abc/def?${DEBATE_TIME_PARAM}=722#claims`); + }); + + it('never writes a negative or fractional position', () => { + expect(withDebateTimecode('/x', -5)).toBe(`/x?${DEBATE_TIME_PARAM}=0`); + expect(withDebateTimecode('/x', 12.7)).toBe(`/x?${DEBATE_TIME_PARAM}=12`); + }); +}); + +describe('formatTimecode', () => { + it('drops the hour when there is none', () => { + expect(formatTimecode(724_000)).toBe('12:04'); + expect(formatTimecode(4_000)).toBe('0:04'); + }); + + it('keeps padded minutes once there is an hour', () => { + expect(formatTimecode(3_724_000)).toBe('1:02:04'); + }); + + it('reads zero for a position that is not a number', () => { + expect(formatTimecode(Number.NaN)).toBe('0:00'); + expect(formatTimecode(-1)).toBe('0:00'); + }); +}); diff --git a/apps/web/core/debates/debate-timecode.ts b/apps/web/core/debates/debate-timecode.ts new file mode 100644 index 0000000000..b3bdd44798 --- /dev/null +++ b/apps/web/core/debates/debate-timecode.ts @@ -0,0 +1,100 @@ +/** + * Linking to the moment in a debate where something was said. + * + * A claim extracted from a transcript knows when it was spoken (see `claim-timing.ts`), so a row + * showing that claim can offer to open the debate at that point rather than at the beginning. The + * position rides the link as a query param, which is what makes the link shareable and survives a + * cold load — the reader who receives it has no player mounted to seek. + * + * Seconds, not milliseconds: the param is user-visible and gets pasted around, and a debate's + * timeline is not precise enough for a millisecond to mean anything. `ClaimTiming` carries + * milliseconds because the matcher works in them; the conversion happens here, once. + */ + +/** Query param carrying a start position, in whole seconds. Matches the convention YouTube uses. */ +export const DEBATE_TIME_PARAM = 't'; + +/** + * Lead-in subtracted from a claim's start before seeking. + * + * Landing exactly on the first word clips its own beginning — the matcher's start is where the + * phrase begins, not where the speaker drew breath — and the sentence arrives without the turn it + * answers. Two seconds is enough to hear the run-up without replaying the previous point. + */ +export const TIMECODE_PREROLL_SECONDS = 2; + +/** Longest position a link may carry, in seconds. Twelve hours; anything beyond is a corrupt param. */ +const MAX_POSITION_SECONDS = 12 * 60 * 60; + +/** + * Where playback should start for a claim spoken at `startMs`, in whole seconds. + * + * Clamped at zero so a claim in the first two seconds of a recording does not ask for a negative + * position, which `HTMLMediaElement.currentTime` rejects. + */ +export function debateSeekSeconds(startMs: number): number { + if (!Number.isFinite(startMs)) return 0; + return Math.max(0, Math.round(startMs / 1000) - TIMECODE_PREROLL_SECONDS); +} + +/** + * The position a link is asking for, or null when it isn't asking for one. + * + * Deliberately forgiving about the shape and strict about the value. These links are pasted by + * hand and arrive through `useSearchParams`, which hands back `string | string[] | null`, so the + * parser takes all of those. But a value that is not a finite, non-negative, in-range number is + * null rather than clamped: a link that says `t=abc` is not asking to start at zero, it is not + * asking for anything, and starting playback from the top is what happens anyway when no param is + * present. Clamping would make a corrupt link indistinguishable from a deliberate `t=0`. + */ +export function parseDebateTimeParam(raw: string | string[] | null | undefined): number | null { + // `?t=1&t=2` is a malformed link rather than a choice between two positions, so read the first + // and ignore the rest — the same way a router resolves a repeated param. + const value = Array.isArray(raw) ? raw[0] : raw; + if (value == null || value === '') return null; + + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds < 0 || seconds > MAX_POSITION_SECONDS) return null; + + return Math.floor(seconds); +} + +/** + * Adds a start position to a link, replacing one already there. + * + * Works on the relative paths `NavUtils` produces, which have no origin, so `URL` cannot be used + * without inventing a base and stripping it again. The hash is preserved and stays last, because + * a fragment after the query is the only order a browser reads. + */ +export function withDebateTimecode(href: string, seconds: number): string { + const hashIndex = href.indexOf('#'); + const hash = hashIndex === -1 ? '' : href.slice(hashIndex); + const base = hashIndex === -1 ? href : href.slice(0, hashIndex); + + const queryIndex = base.indexOf('?'); + const path = queryIndex === -1 ? base : base.slice(0, queryIndex); + const params = new URLSearchParams(queryIndex === -1 ? '' : base.slice(queryIndex + 1)); + params.set(DEBATE_TIME_PARAM, String(Math.max(0, Math.floor(seconds)))); + + return `${path}?${params.toString()}${hash}`; +} + +/** + * A position on the debate timeline, as a reader reads it. + * + * Hours only when there are hours: `12:04` rather than `00:12:04`, and `1:02:04` once a debate runs + * past the hour. Minutes keep their leading zero inside an hour-long stamp so the columns line up. + */ +export function formatTimecode(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return '0:00'; + + const total = Math.floor(ms / 1000); + const seconds = total % 60; + const minutes = Math.floor(total / 60) % 60; + const hours = Math.floor(total / 3600); + + const paddedSeconds = String(seconds).padStart(2, '0'); + if (hours === 0) return `${minutes}:${paddedSeconds}`; + + return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`; +} diff --git a/apps/web/core/debates/transcript-claims.ts b/apps/web/core/debates/transcript-claims.ts index 97ddde12a2..56d52e70a7 100644 --- a/apps/web/core/debates/transcript-claims.ts +++ b/apps/web/core/debates/transcript-claims.ts @@ -31,8 +31,10 @@ export type TranscriptClaim = { blockId: string; /** * The claim's timecodes as published on the block → claim relation entity, in milliseconds from - * the start of the debate timeline. Null for every debate published before timecodes existed, - * which is nearly all of them — the resolver recovers those from the transcript instead. + * the start of the debate timeline. Present for most of the corpus since the backfill ran — 921 + * of 1,072 statements as of 2026-09-23, and 23 of 25 debates timed all the way through. The + * resolver recovers the rest from the transcript, and `useClaimTimings` skips that fetch entirely + * for a debate whose claims are all published. */ publishedTiming: { startMs: number; endMs: number } | null; /** diff --git a/apps/web/core/io/debate-transcript-claims-document.ts b/apps/web/core/io/debate-transcript-claims-document.ts index 58190725f6..fe5c9a16da 100644 --- a/apps/web/core/io/debate-transcript-claims-document.ts +++ b/apps/web/core/io/debate-transcript-claims-document.ts @@ -65,8 +65,8 @@ const DEBATE_TRANSCRIPT_CLAIMS_SOURCE = /* GraphQL */ ` entityId # The relation's own entity, which is where the claim's timecodes live — not on the # claim, because one claim can be stated in two turns and each statement has its - # own moment. Empty for every debate published before timecodes existed, which is - # all of them bar the test debate; claim-timing.ts falls back to matching. + # own moment. Populated for most of the corpus since the backfill (921 of 1,072 + # statements as of 2026-09-23); claim-timing.ts falls back to matching for the rest. entity { valuesList(filter: { propertyId: { in: $offsetPropertyIds }, spaceId: { is: $spaceId } }) { propertyId diff --git a/apps/web/partials/comments/comment-activity-rows.test.tsx b/apps/web/partials/comments/comment-activity-rows.test.tsx new file mode 100644 index 0000000000..0f95bba13e --- /dev/null +++ b/apps/web/partials/comments/comment-activity-rows.test.tsx @@ -0,0 +1,108 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { CommentWithReplies } from './types'; + +const mocks = vi.hoisted(() => ({ + comments: [] as CommentWithReplies[], +})); + +vi.mock('~/core/hooks/use-comments', () => ({ + useComments: () => ({ comments: mocks.comments, totalCount: mocks.comments.length, isLoading: false }), +})); +vi.mock('~/core/hooks/use-publish-comment', () => ({ + usePublishComment: () => ({ publishComment: vi.fn(), editComment: vi.fn() }), +})); +vi.mock('~/core/hooks/use-personal-space-id', () => ({ usePersonalSpaceId: () => ({ personalSpaceId: null }) })); +vi.mock('~/core/hooks/use-smart-account', () => ({ useSmartAccount: () => ({ smartAccount: null }) })); +vi.mock('~/core/hooks/use-geo-profile', () => ({ useGeoProfile: () => ({ profile: null }) })); +vi.mock('~/core/hooks/use-space-editor-ids', () => ({ + useSpaceRoles: () => ({ + editorSpaceIds: new Set(), + memberSpaceIds: new Set(), + isLoading: false, + isError: false, + }), +})); +vi.mock('~/core/debates/use-debate-votes', () => ({ useDebateVotesByVoter: () => new Map() })); +vi.mock('~/core/governance/use-proposal-comment-attribution', () => ({ + useProposalCommentAttribution: () => new Map(), +})); +vi.mock('~/core/state/sign-in-prompt-store', () => ({ useSignInPrompt: () => ({ open: vi.fn() }) })); +vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ EntityVoteButtons: () => null })); +vi.mock('~/core/claims/browse/claim-comment-position', () => ({ ClaimCommentPositionBadge: () => null })); +vi.mock('~/core/state/editor/markdown-render', () => ({ renderMarkdownDocument: (text: string) => text })); + +import { CommentSection } from './comments-section'; + +function comment(id: string, createdAt: string): CommentWithReplies { + return { + id, + name: id, + markdownContent: `body of ${id}`, + targetEntityId: 'entity-1', + targetSpaceId: 'space-1', + replyToCommentId: null, + replyToCommentSpaceId: null, + author: { spaceId: `author-${id}`, address: `0x${id}`, name: id, avatarUrl: null }, + createdAt, + spaceId: `author-${id}`, + resolved: false, + replies: [], + }; +} + +function renderSection(activityRows: Array<{ id: string; createdAt: string; content: React.ReactNode }>) { + return render( + + ); +} + +describe('CommentSection activity rows', () => { + afterEach(() => { + cleanup(); + mocks.comments = []; + }); + + it('orders non-comment rows in among the comments rather than stacking them above', () => { + mocks.comments = [comment('c-old', '2026-09-20T10:00:00Z'), comment('c-new', '2026-09-22T10:00:00Z')]; + + renderSection([ + { id: 'd-mid', createdAt: '2026-09-21T10:00:00Z', content: debate mid }, + { id: 'd-newest', createdAt: '2026-09-23T10:00:00Z', content: debate newest }, + ]); + + const order = screen + .getAllByText(/body of c-|debate /) + .map(node => node.textContent?.trim()); + + // Newest first, which is the section's default sort — and the two kinds interleave. + expect(order).toEqual(['debate newest', 'body of c-new', 'debate mid', 'body of c-old']); + }); + + it('counts activity rows in the heading alongside the comments', () => { + mocks.comments = [comment('c-1', '2026-09-20T10:00:00Z')]; + + renderSection([{ id: 'd-1', createdAt: '2026-09-21T10:00:00Z', content: debate }]); + + expect(screen.getByText('Activity (2)')).toBeInTheDocument(); + }); + + it('renders the rows on a claim nobody has commented on yet', () => { + renderSection([{ id: 'd-1', createdAt: '2026-09-21T10:00:00Z', content: debate }]); + + expect(screen.getByText('debate')).toBeInTheDocument(); + expect(screen.getByText('Activity (1)')).toBeInTheDocument(); + }); + + it('leaves the thread exactly as it was when no rows are passed', () => { + mocks.comments = [comment('c-1', '2026-09-20T10:00:00Z')]; + + render(); + + expect(screen.getByText('Comments (1)')).toBeInTheDocument(); + expect(screen.getByText('body of c-1')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index 2b1593368a..3746d02728 100644 --- a/apps/web/partials/comments/comments-section.tsx +++ b/apps/web/partials/comments/comments-section.tsx @@ -8,6 +8,7 @@ import { useAtom } from 'jotai'; import { normalizeSpaceId } from '~/core/access/space-access'; import { personProfileOpened } from '~/core/analytics'; +import { mergeActivityRows } from '~/core/claims/browse/claim-activity-order'; import { ClaimCommentPositionBadge } from '~/core/claims/browse/claim-comment-position'; import { PLACEHOLDER_SPACE_IMAGE } from '~/core/constants'; import { Crown } from '~/core/debates/browse/icons'; @@ -48,11 +49,13 @@ import { threadArmCenterPx, threadSpineOffsetPx, } from './comment-density'; -import type { CommentFilter, CommentSortOrder, CommentWithReplies } from './types'; +import type { CommentActivityRow, CommentFilter, CommentSortOrder, CommentWithReplies } from './types'; const CommentDensityContext = React.createContext(PAGE_DENSITY); const NO_REPLIES: never[] = []; +/** Stable identity, so a host that passes no rows doesn't rebuild the merge every render. */ +const NO_ACTIVITY_ROWS: CommentActivityRow[] = []; function useCommentDensity(): CommentDensity { return React.useContext(CommentDensityContext); @@ -235,6 +238,16 @@ interface CommentSectionProps { * layout already supplies its own gap. */ variant?: CommentSectionVariant; + /** + * Rows for other entities, ordered into the same list as the comments. + * + * The claim page uses this to put the debates held on a claim in its thread, so the reader sees + * one account of what happened rather than a gallery above a comment list. The host renders them; + * this component only orders them. + */ + activityRows?: CommentActivityRow[]; + /** Heading noun. "Comments" unless the list holds more than comments. */ + title?: string; } export function CommentSection({ @@ -242,6 +255,8 @@ export function CommentSection({ spaceId, targetEntityType = 'entity', variant = 'page', + activityRows = NO_ACTIVITY_ROWS, + title = 'Comments', }: CommentSectionProps) { const { comments, totalCount, isLoading } = useComments({ entityId, spaceId }); const { publishComment, editComment } = usePublishComment(entityId, spaceId, { @@ -409,7 +424,9 @@ export function CommentSection({
{!isPanel && ( <> -
Comments ({totalCount})
+
+ {title} ({totalCount + activityRows.length}) +
)} @@ -423,7 +440,7 @@ export function CommentSection({ viewerAvatarUrl={viewerAvatarUrl} viewerAvatarSeed={viewerAvatarSeed} /> - {totalCount > 0 && ( + {totalCount + activityRows.length > 0 && ( <>
) : ( - filteredComments.length > 0 && ( + (filteredComments.length > 0 || activityRows.length > 0) && ( <> ) => void; @@ -806,30 +830,40 @@ function CommentList({ const density = useCommentDensity(); if (depth === 0) { + // Activity rows only exist at the top level — a debate is not a reply to a comment — so the + // merge lives inside this branch and the recursive one below is untouched. + const merged = mergeActivityRows(comments, activityRows, activityOrder); + return (
- {comments.map((comment, index) => ( - - ))} + {merged.map((entry, index) => + entry.kind === 'extra' ? ( +
0 && 'border-t border-divider')}> + {entry.row.content} +
+ ) : ( + + ) + )}
); } diff --git a/apps/web/partials/comments/types.ts b/apps/web/partials/comments/types.ts index 97e35cb34f..ff49d8749f 100644 --- a/apps/web/partials/comments/types.ts +++ b/apps/web/partials/comments/types.ts @@ -1,3 +1,5 @@ +import type * as React from 'react'; + export interface CommentEntity { id: string; name: string | null; @@ -55,3 +57,17 @@ export interface CreateCommentParams { export type CommentSortOrder = 'newest' | 'oldest'; export type CommentFilter = 'all' | 'editors'; + +/** + * A non-comment row rendered in the same time-ordered list as the comments. + * + * The claim page's activity feed puts debates alongside comments on one thread, and the two are + * ordered together rather than stacked in blocks. Rather than teach the comment list what a debate + * is, the host hands it already-rendered content plus the one field the ordering needs. + */ +export interface CommentActivityRow { + id: string; + /** ISO timestamp, compared against each comment's `createdAt` to place the row. */ + createdAt: string; + content: React.ReactNode; +} From 9e109ec27bdc52d44f9f823deb9bf928677d6452 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 23 Sep 2026 13:25:13 -0700 Subject: [PATCH 02/35] fix(claims): show extracted claims on arrival, and rank the thread by Best MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six things from reviewing the feed against a real claim page. Claims now load with the debate row instead of behind "Show claims from this debate". What a debate produced is the reason it is in this feed at all, and a row that only says a debate happened is the state the feed was built to replace. The cost is smaller than it looked: the traversal is one request per debate, and the timing resolver only reaches for the transcript on the two debates in the corpus with an unpublished offset. The speaker avatar was rendering at full column width. `Avatar` fills its container whenever it has a real `avatarUrl` — `size` only sizes the generated fallback — so the frame is the caller's job, as it is at the other 56 call sites. It now takes the comment row's own `density.avatarPx`, and the rest of the row takes the same density's type ramp, so a claim row and a comment row in one thread sit on one ramp rather than two. Extracted claims carry the side their speaker argued, through the same tag a commenter's stance uses — extracted from `ClaimCommentPositionBadge` as `ResponsePositionTag` so the two cannot drift. The facts differ (a commenter's tag tracks where they stand now; a debater's is fixed by the debate) but to a reader scanning the thread it is one question, and the answer should not change shape per row. The debate row shows what was argued, and both the text and the still open the video. Without a body it was a byline and a control strip, which beside comments that each say something read as a row that failed to load. Debate dates now age like comments do — `getRelativeTime`, moved to its own module so both use one implementation. "Sep 17, 2026" beside "1d ago" read as two lists that happened to be adjacent. And the thread can be ranked. Sort offers Best, Top, New, Old, defaulting to Best on the claim page: this list is the record of an argument rather than a running conversation. Best takes the net score, so a divisive row falls; Top takes upvotes alone, so a row many people backed holds its place regardless of pushback. Scores are read from the cache each row's own vote control already fills rather than fetched — ordering a list by score should not cost a round trip per row — so an unranked row scores zero and recency breaks every tie. --- .../browse/claim-activity-order.test.ts | 55 +++++++ .../claims/browse/claim-activity-order.ts | 35 ++++- .../claims/browse/claim-comment-position.tsx | 36 +++-- .../core/claims/browse/claim-page-view.tsx | 11 +- .../claims/browse/debate-activity-row.tsx | 147 +++++++++++------- .../browse/extracted-claim-row.test.tsx | 27 ++++ .../claims/browse/extracted-claim-row.tsx | 72 ++++++--- .../claims/browse/use-claim-activity-rows.tsx | 55 ++++++- .../responses/use-entity-response-scores.ts | 89 +++++++++++ .../comments/comment-activity-rows.test.tsx | 46 +++++- apps/web/partials/comments/comment-time.ts | 28 ++++ .../partials/comments/comments-section.tsx | 73 ++++++--- apps/web/partials/comments/types.ts | 12 +- 13 files changed, 554 insertions(+), 132 deletions(-) create mode 100644 apps/web/core/responses/use-entity-response-scores.ts create mode 100644 apps/web/partials/comments/comment-time.ts diff --git a/apps/web/core/claims/browse/claim-activity-order.test.ts b/apps/web/core/claims/browse/claim-activity-order.test.ts index 2f854b0402..d884277699 100644 --- a/apps/web/core/claims/browse/claim-activity-order.test.ts +++ b/apps/web/core/claims/browse/claim-activity-order.test.ts @@ -130,6 +130,61 @@ describe('mergeActivityRows', () => { expect(merged.map(entry => entry.row.id)).toEqual(['c1', 'd1']); }); + describe('ranked orders', () => { + const scores: Record = { + c1: { positive: 1, negative: 0 }, + c2: { positive: 9, negative: 8 }, + d1: { positive: 6, negative: 0 }, + d2: { positive: 0, negative: 0 }, + }; + const scoreFor = (row: { id: string }) => scores[row.id] ?? null; + + it('puts the highest net score first under Best', () => { + expect(mergeActivityRows(comments, debates, 'best', scoreFor).map(entry => entry.row.id)).toEqual([ + 'd1', + 'c2', + 'c1', + 'd2', + ]); + }); + + // Same rows, different question: c2 is divisive (9 up, 8 down) and outranks a quietly-liked + // row on Top while falling behind it on Best. + it('counts upvotes alone under Top, so a divisive row keeps its place', () => { + expect(mergeActivityRows(comments, debates, 'top', scoreFor).map(entry => entry.row.id)).toEqual([ + 'c2', + 'd1', + 'c1', + 'd2', + ]); + }); + + it('breaks a score tie on recency rather than leaving it to arrival order', () => { + const flat = () => ({ positive: 2, negative: 0 }); + expect(mergeActivityRows(comments, debates, 'best', flat).map(entry => entry.row.id)).toEqual([ + 'd2', + 'c2', + 'd1', + 'c1', + ]); + }); + + // Scores arrive from a cache the rows themselves fill, so an unranked row is "not yet known" + // rather than "zero" — treating it as zero is what keeps the list stable while they land. + it('treats an unknown score as zero', () => { + expect(mergeActivityRows(comments, debates, 'best', () => null).map(entry => entry.row.id)).toEqual([ + 'd2', + 'c2', + 'd1', + 'c1', + ]); + }); + + it('needs no score lookup at all', () => { + expect(mergeActivityRows(comments, debates, 'best').map(entry => entry.row.id)).toHaveLength(4); + }); + }); + // A row with no usable date is not the newest thing that ever happened. it('sorts a row with an unreadable date to the end of a newest-first list', () => { const merged = mergeActivityRows(comments, [{ id: 'dx', createdAt: 'not a date' }], 'newest'); diff --git a/apps/web/core/claims/browse/claim-activity-order.ts b/apps/web/core/claims/browse/claim-activity-order.ts index a1583d770e..8f9c210e16 100644 --- a/apps/web/core/claims/browse/claim-activity-order.ts +++ b/apps/web/core/claims/browse/claim-activity-order.ts @@ -61,6 +61,11 @@ export type ActivityOrderable = { createdAt: string; }; +/** How a caller looks up a row's votes. Null where nothing has answered for that row yet. */ +export type ActivityScoreLookup = (row: ActivityOrderable) => { positive: number; negative: number } | null; + +const NO_SCORES: ActivityScoreLookup = () => null; + /** * The instant a row claims, for ordering purposes. * @@ -88,17 +93,37 @@ export function activityTime(row: ActivityOrderable): number { export function mergeActivityRows( comments: C[], extras: E[], - order: 'newest' | 'oldest' + order: 'best' | 'top' | 'newest' | 'oldest', + scoreFor: ActivityScoreLookup = NO_SCORES ): Array<{ kind: 'comment'; row: C } | { kind: 'extra'; row: E }> { const merged: Array<{ kind: 'comment'; row: C } | { kind: 'extra'; row: E }> = [ ...comments.map(row => ({ kind: 'comment' as const, row })), ...extras.map(row => ({ kind: 'extra' as const, row })), ]; - const direction = order === 'newest' ? -1 : 1; - // `sort` is stable in every engine we target, so equal timestamps keep insertion order — which - // above is "comments, then extras". Said out loud because the tie-break is a decision, not luck. - merged.sort((a, b) => direction * (activityTime(a.row) - activityTime(b.row))); + // `sort` is stable in every engine we target, so rows that compare equal keep insertion order — + // which above is "comments, then extras". Said out loud because the tie-break is a decision. + if (order === 'newest' || order === 'oldest') { + const direction = order === 'newest' ? -1 : 1; + merged.sort((a, b) => direction * (activityTime(a.row) - activityTime(b.row))); + return merged; + } + + // Newest breaks a score tie, so a thread where nothing has been voted on yet still reads as a + // thread rather than as whatever order the two lists happened to arrive in. It is also what the + // list falls back to while the counts are still arriving, since an unanswered row scores zero. + const rank = order === 'best' ? netScoreOf : upvotesOf; + merged.sort((a, b) => rank(scoreFor(b.row)) - rank(scoreFor(a.row)) || activityTime(b.row) - activityTime(a.row)); return merged; } + +/** Net of the two directions: a row people disagree about falls behind one they merely like. */ +function netScoreOf(counts: { positive: number; negative: number } | null): number { + return counts ? counts.positive - counts.negative : 0; +} + +/** Upvotes alone: how many backed it, regardless of how many pushed back. */ +function upvotesOf(counts: { positive: number; negative: number } | null): number { + return counts?.positive ?? 0; +} diff --git a/apps/web/core/claims/browse/claim-comment-position.tsx b/apps/web/core/claims/browse/claim-comment-position.tsx index fae6fff86a..bdab9579a8 100644 --- a/apps/web/core/claims/browse/claim-comment-position.tsx +++ b/apps/web/core/claims/browse/claim-comment-position.tsx @@ -114,22 +114,38 @@ export function ClaimCommentPositionProvider({ return {children}; } -/** Current Agree/Disagree or Verify/Dispute state, rendered only inside a claim comment thread. */ -export function ClaimCommentPositionBadge({ authorSpaceId }: { authorSpaceId: string }) { - const context = React.useContext(ClaimCommentPositionContext); - const direction = context?.directions.get(uuidToHex(authorSpaceId)); - if (!context || !direction) return null; - - const positive = direction === 'positive'; - +/** + * Which side somebody is on, in the claim's own vocabulary — Agree/Disagree or Verify/Dispute. + * + * Shared by the comment rows, where it reports the author's current response, and by the extracted + * claim rows, where it reports the side the debater argued. Two different facts wearing one badge on + * purpose: to a reader scanning the thread they are the same question, and the tag that answers it + * should not change shape depending on which kind of row it sits on. + */ +export function ResponsePositionTag({ + responseKind, + position, +}: { + responseKind: DebateResponseKind; + position: boolean; +}) { return ( - {responsePositionLabel(context.responseKind, positive)} + {responsePositionLabel(responseKind, position)} ); } + +/** Current Agree/Disagree or Verify/Dispute state, rendered only inside a claim comment thread. */ +export function ClaimCommentPositionBadge({ authorSpaceId }: { authorSpaceId: string }) { + const context = React.useContext(ClaimCommentPositionContext); + const direction = context?.directions.get(uuidToHex(authorSpaceId)); + if (!context || !direction) return null; + + return ; +} diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 1476d5ada8..e47e036b72 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -452,7 +452,13 @@ function ClaimOverviewTab({ hrefs: { debates: string; claims: string }; onSelectSystemTab?: (tab: ClaimSystemTab) => void; }) { - const activity = useClaimActivityRows({ claimId: entityId, spaceId }); + // The claim's own vocabulary carries into the thread: a debater's side reads Agree/Disagree on + // an opinion claim and Verify/Dispute on a factual one, the same as every commenter's badge. + const activity = useClaimActivityRows({ + claimId: entityId, + spaceId, + responseVocabulary: responseKind === 'veracity' ? 'veracity' : 'stance', + }); const kinds: ActivityKind[] = [ { @@ -508,6 +514,9 @@ function ClaimOverviewTab({ targetEntityType="claim" title="Activity" activityRows={activity.rows} + // Best rather than most-recent: this list is the record of an argument, not a running + // conversation, and the thing worth reading first is what the thread rates highest. + defaultSortOrder="best" /> diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index 1c93beb16c..4ae61b80f9 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -2,8 +2,11 @@ import * as React from 'react'; -import { useClaimTimings } from '~/core/debates/use-claim-timings'; +import cx from 'classnames'; + +import type { DebateResponseKind } from '~/core/debates/api'; import { useDebateClaims } from '~/core/debates/hooks'; +import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; import { useEntityCommentsPanel } from '~/core/hooks/use-entity-comments-panel'; import { uuidToHex } from '~/core/id/normalize'; @@ -14,8 +17,9 @@ import { NavUtils } from '~/core/utils/utils'; import { GeoImage } from '~/design-system/geo-image'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Skeleton } from '~/design-system/skeleton'; -import { Text } from '~/design-system/text'; +import { type CommentDensity, PAGE_DENSITY } from '~/partials/comments/comment-density'; +import { getRelativeTime } from '~/partials/comments/comment-time'; import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; @@ -32,15 +36,25 @@ import { ExtractedClaimRow, type SpeakerProfile } from './extracted-claim-row'; */ const EXTRACTED_CLAIM_PAGE_SIZE = 4; +/** Same 48px the claim page's debates module uses, in the 540 × 820 the videos are published at. */ +const KEYFRAME_WIDTH_PX = 48; + export type DebateActivityRowProps = { debate: Entity; /** The space the claim page is being read through, which is where the debate was published. */ spaceId: string; /** Debater names and faces, resolved once for the whole feed. */ profilesBySpaceId: Map; - participantSpaceIds: string[]; + /** Each debater and the side they argued, from `Supported by` / `Opposed by`. */ + sides: Array<{ spaceId: string; position: boolean }>; + /** The claim page's vocabulary, so a debater's side reads in the claim's own terms. */ + responseVocabulary: DebateResponseKind; + /** What the debate argued. The row's body — without it the row is a byline and a control strip. */ + claimText: string | null; keyframeUrl: string | null; publishedAt: Date | null; + /** The surrounding thread's metrics, so this row sits on the same ramp as the comments. */ + density?: CommentDensity; }; /** @@ -58,17 +72,23 @@ export function DebateActivityRow({ debate, spaceId, profilesBySpaceId, - participantSpaceIds, + sides, + responseVocabulary, + claimText, keyframeUrl, publishedAt, + density = PAGE_DENSITY, }: DebateActivityRowProps) { - const [expanded, setExpanded] = React.useState(false); const [visibleClaims, setVisibleClaims] = React.useState(EXTRACTED_CLAIM_PAGE_SIZE); const { openComments } = useEntityCommentsPanel(); - const debaters = participantSpaceIds - .map(id => profilesBySpaceId.get(id)?.name?.trim()) + const debaters = sides + .map(side => profilesBySpaceId.get(side.spaceId)?.name?.trim()) .filter((name): name is string => Boolean(name)); + const positionBySpaceId = React.useMemo( + () => new Map(sides.map(side => [uuidToHex(side.spaceId), side.position])), + [sides] + ); const title = debaters.length > 0 ? debaters.join(' vs. ') : (debate.name ?? 'Debate'); return ( @@ -80,24 +100,37 @@ export function DebateActivityRow({ - {keyframeUrl && } + {keyframeUrl && ( + + )}
- - {title} - - Debate + {title} + + Debate + {publishedAt && ( - - {publishedAt.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })} - + // Same helper and same classes the comment rows use, so a debate and a comment in one + // thread age identically rather than reading as two lists side by side. + + {getRelativeTime(publishedAt.toISOString())} + )}
+ {claimText && ( + + + {claimText} + + + )} +
@@ -108,11 +141,14 @@ export function DebateActivityRow({ type="button" data-entity-comments-opener onClick={() => openComments(debate.id, spaceId)} - className="text-metadata text-grey-04 transition-colors hover:text-text" + className={cx(density.metaClass, 'text-grey-04 transition-colors hover:text-text')} > Reply - + Watch debate
@@ -123,8 +159,9 @@ export function DebateActivityRow({ debateId={debate.id} spaceId={spaceId} profilesBySpaceId={profilesBySpaceId} - expanded={expanded} - onExpand={() => setExpanded(true)} + positionBySpaceId={positionBySpaceId} + responseVocabulary={responseVocabulary} + density={density} visibleCount={visibleClaims} onShowMore={() => setVisibleClaims(count => count + EXTRACTED_CLAIM_PAGE_SIZE)} /> @@ -135,37 +172,42 @@ export function DebateActivityRow({ /** * The debate's extracted claims, in the order they were said. * - * Held behind an expand rather than loaded with the row. The traversal is one request per debate - * and the timing resolver may want the Whisper transcript on top of it, so a feed of five debates - * would be ten requests for rows the reader has not asked to see. Expanding one debate is one - * debate's worth of work. + * Loaded with the row rather than behind an expand. What a debate *produced* is the reason it is in + * this feed at all, and a row that only says a debate happened is the state the feed was built to + * replace — the reader should not have to ask twice to see the argument. + * + * The cost that bought the expand is smaller than it looks: the traversal is one request per + * debate, and the timing resolver reaches for the Whisper transcript only when a debate has a claim + * with no published offset — which, since the backfill, is two debates in the corpus. The breadth + * cap below is what keeps a long transcript from owning the page. */ function ExtractedClaims({ debateId, spaceId, profilesBySpaceId, - expanded, - onExpand, + positionBySpaceId, + responseVocabulary, + density, visibleCount, onShowMore, }: { debateId: string; spaceId: string; profilesBySpaceId: Map; - expanded: boolean; - onExpand: () => void; + /** Debater space id (canonical) → the side they argued in this debate. */ + positionBySpaceId: Map; + responseVocabulary: DebateResponseKind; + density: CommentDensity; visibleCount: number; onShowMore: () => void; }) { - const { claims, isLoading } = useDebateTranscriptClaims(debateId, spaceId, expanded); - // Free for the 86% of statements that carry a published offset — the resolver only reaches for - // the transcript when a debate has one that does not. See `useClaimTimings`. - const { timings, isReady } = useClaimTimings(debateId, claims, expanded); + const { claims, isLoading } = useDebateTranscriptClaims(debateId, spaceId); + const { timings, isReady } = useClaimTimings(debateId, claims); const claimIds = React.useMemo(() => claims.all.map(claim => claim.id), [claims.all]); // One call for every extracted claim on screen. Without it each row's own vote control would read // the entity to work out whether it takes thumbs or chevrons — one request per row. - const claimRows = useDebateClaims(spaceId, claimIds, expanded && claimIds.length > 0); + const claimRows = useDebateClaims(spaceId, claimIds, claimIds.length > 0); const responseKindByClaimId = React.useMemo(() => { const map = new Map(); for (const row of claimRows.data?.claims ?? []) { @@ -184,42 +226,30 @@ function ExtractedClaims({ const ordered = React.useMemo(() => orderExtractedClaims(claims.all, timings), [claims.all, timings]); - if (!expanded) { - return ( - - ); - } + // The list hangs where a reply to this row would: the thread's own body inset, so an extracted + // claim and a comment reply sit on the same left edge. + const nestedStyle = { marginLeft: density.avatarCenterPx, paddingLeft: density.bodyInsetPx - density.avatarCenterPx }; if (isLoading || !isReady) { // Held rather than painted unordered. `isReady` is false only while a timing source is still // arriving, and it reports ready on a failed transcript fetch — so this cannot hang forever on // a debate whose transcript is gone; that degrades to "all untimed, arrival order". - return ; + return ; } // Timed first, in order, then the ones nothing could place. The tail is not sorted among itself: // there is nothing to sort it by, and imposing an order would say there was. const rows = [...ordered.timed, ...ordered.untimed]; - if (rows.length === 0) { - return ( - - No claims were extracted from this debate. - - ); - } + // Silent rather than apologetic. Claim extraction postdates a chunk of the corpus, so a debate + // with none is ordinary — and a line saying so under every old debate is noise in a feed. + if (rows.length === 0) return null; const visible = rows.slice(0, visibleCount); const remaining = rows.length - visible.length; return ( -
+
{visible.map(claim => { const speakerSpaceId = speakerBySourceBlockId.get(uuidToHex(claim.blockId)) ?? null; return ( @@ -228,9 +258,18 @@ function ExtractedClaims({ claim={claim} debateId={debateId} debateSpaceId={spaceId} + density={density} // Stance unless geo-chat says otherwise. A missing row means the space is not indexed, // not that the claim is factual, and stance is what the graph defaults to as well. responseKind={responseKindByClaimId.get(uuidToHex(claim.id)) ?? 'stance'} + responseVocabulary={responseVocabulary} + // A debater who is somehow not recorded on either side gets no tag rather than a + // guessed one — the same rule the speaker name follows a line below. + speakerPosition={ + speakerSpaceId && !claim.restated + ? (positionBySpaceId.get(uuidToHex(speakerSpaceId)) ?? null) + : null + } speaker={ // A restated claim carries the first relation's block, which cannot answer for both // statements — `transcript-claims.ts` flags it rather than letting one stand in for @@ -247,7 +286,7 @@ function ExtractedClaims({ diff --git a/apps/web/core/claims/browse/extracted-claim-row.test.tsx b/apps/web/core/claims/browse/extracted-claim-row.test.tsx index 6c17cedb18..403eed5b5a 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.test.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.test.tsx @@ -62,7 +62,9 @@ function renderRow(overrides: Partial ); @@ -115,6 +117,31 @@ describe('ExtractedClaimRow', () => { expect(screen.getByText('moment not found')).toBeInTheDocument(); }); + it('tags the speaker with the side they argued in the debate', () => { + renderRow({ speakerPosition: true }); + + expect(screen.getByText('Agree')).toBeInTheDocument(); + + cleanup(); + renderRow({ speakerPosition: false }); + + expect(screen.getByText('Disagree')).toBeInTheDocument(); + }); + + it('uses the claim’s own vocabulary for that tag', () => { + renderRow({ speakerPosition: false, responseVocabulary: 'veracity' }); + + expect(screen.getByText('Dispute')).toBeInTheDocument(); + }); + + // A debater the debate does not record on either side gets no tag rather than a guessed one. + it('draws no side tag when the debate records no side for the speaker', () => { + renderRow({ speakerPosition: null }); + + expect(screen.queryByText('Agree')).not.toBeInTheDocument(); + expect(screen.queryByText('Disagree')).not.toBeInTheDocument(); + }); + it('passes the resolved response kind and trailing avatars to the vote control', () => { renderRow({ responseKind: 'veracity' }); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index c0bec0e48e..7610f6b9a1 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import cx from 'classnames'; +import type { DebateResponseKind } from '~/core/debates/api'; import { isAssertableMoment } from '~/core/debates/claim-timing'; import { debateSeekSeconds, formatTimecode, withDebateTimecode } from '~/core/debates/debate-timecode'; import type { ResponseKind } from '~/core/responses/entity-response'; @@ -11,12 +12,13 @@ import { NavUtils } from '~/core/utils/utils'; import { Avatar } from '~/design-system/avatar'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; -import { Text } from '~/design-system/text'; +import { type CommentDensity, PAGE_DENSITY } from '~/partials/comments/comment-density'; import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import type { OrderedTranscriptClaim } from './claim-activity-order'; +import { ResponsePositionTag } from './claim-comment-position'; export type SpeakerProfile = { name?: string | null; avatarUrl?: string | null }; @@ -29,9 +31,10 @@ export type SpeakerProfile = { name?: string | null; avatarUrl?: string | null } * everything the reader needs to act without leaving: the split, who is on it, a vote, and the way * back to the moment it was said. The full treatment is one click away on the claim's own page. * - * Nothing here is new machinery. `EntityVoteButtons` already draws responder faces beside the - * control and already picks its icons from the response kind — thumbs for a stance, chevrons for - * veracity — so a factual claim cannot end up with a thumb labelled "agree". + * Built out of what the thread around it already uses: the comment row's own density metrics, the + * same `Avatar` framing every other avatar in the app gets, `EntityVoteButtons` (which already + * draws responder faces beside the control and picks its icons from the response kind — thumbs for + * a stance, chevrons for veracity), and `EntityCommentsButton`. Nothing here is a new control. */ export function ExtractedClaimRow({ claim, @@ -39,6 +42,9 @@ export function ExtractedClaimRow({ debateSpaceId, responseKind, speaker, + speakerPosition, + responseVocabulary, + density = PAGE_DENSITY, className, }: { claim: OrderedTranscriptClaim; @@ -55,6 +61,18 @@ export function ExtractedClaimRow({ responseKind: ResponseKind; /** The debater this turn is attributed to, or null on a block with no `Authors` relation. */ speaker: (SpeakerProfile & { spaceId: string }) | null; + /** + * The side that debater argued, from the debate's own `Supported by` / `Opposed by` relations. + * + * Not the same fact as a commenter's badge, which reports where they stand *now*. This one is + * fixed: it is the side they took in the debate this claim came out of, and it cannot drift. + * Null when the speaker is unknown or the debate does not record a side for them. + */ + speakerPosition: boolean | null; + /** The claim page's vocabulary, so the tag reads Agree/Disagree or Verify/Dispute to match. */ + responseVocabulary: DebateResponseKind; + /** The surrounding thread's metrics, so these rows sit on the same ramp as the comments. */ + density?: CommentDensity; className?: string; }) { // Null where the graph reports no home space. The claim is then unlinkable and unrespondable — @@ -72,16 +90,30 @@ export function ExtractedClaimRow({ : null; return ( -
- - + // `gap-3` is the comment header's own 12px avatar gap, so a claim row and a comment row put + // their text on the same left edge. +
+ {/* + `Avatar` fills its container whenever it has a real `avatarUrl` to draw — `size` only sizes + the generated fallback — so the frame is the caller's job. Same shape the comment rows in + this thread use, off the same density, which is what keeps the two kinds of row aligned. + */} + +
- + {speaker?.name?.trim() || 'Unnamed debater'} - + + + {speakerPosition !== null && ( + + )} {timecodeHref ? ( {formatTimecode(moment!.startMs)} @@ -97,25 +132,18 @@ export function ExtractedClaimRow({ ) : ( // Said, but not placed: a restated claim, or one the matcher could not find. Better to // say the moment is missing than to leave the row looking like it simply has no time. - - moment not found - + moment not found )}
{claimSpaceId ? ( - - + + {claim.text} - + ) : ( - - {claim.text} - + {claim.text} )} {claimSpaceId ? ( diff --git a/apps/web/core/claims/browse/use-claim-activity-rows.tsx b/apps/web/core/claims/browse/use-claim-activity-rows.tsx index db635f87da..39a76ed168 100644 --- a/apps/web/core/claims/browse/use-claim-activity-rows.tsx +++ b/apps/web/core/claims/browse/use-claim-activity-rows.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; +import type { DebateResponseKind } from '~/core/debates/api'; import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_OPPOSED_BY_PROPERTY_ID, @@ -10,9 +11,12 @@ import { } from '~/core/debates/ontology'; import { useProfilesBySpaceIds } from '~/core/hooks/use-profiles-by-space-ids'; import { useQueryEntities } from '~/core/sync/use-store'; +import type { Entity } from '~/core/types'; import type { CommentActivityRow } from '~/partials/comments/types'; +import { ID } from '~/core/id'; + import { debateDate, relationTargets } from './claim-debates'; import { DebateActivityRow } from './debate-activity-row'; import { useDebateKeyframes } from './use-debate-keyframes'; @@ -43,10 +47,13 @@ const NO_ROWS: CommentActivityRow[] = []; export function useClaimActivityRows({ claimId, spaceId, + responseVocabulary, enabled = true, }: { claimId: string; spaceId: string; + /** The claim's own Agree/Disagree or Verify/Dispute wording, for the debaters' side tags. */ + responseVocabulary: DebateResponseKind; enabled?: boolean; }): { rows: CommentActivityRow[]; isLoading: boolean } { const { entities: debates, isLoading } = useQueryEntities({ @@ -59,12 +66,21 @@ export function useClaimActivityRows({ enabled, }); - const participantsByDebateId = React.useMemo(() => { - const map = new Map(); + // Who argued, and which side they took. The side is what lets an extracted claim carry the same + // Agree/Disagree tag a comment does — fixed at the debate rather than tracking where the speaker + // stands today, because what they argued in a recorded debate cannot change afterwards. + const sidesByDebateId = React.useMemo(() => { + const map = new Map>(); for (const debate of debates) { map.set(debate.id, [ - ...relationTargets(debate.relations, DEBATE_SUPPORTED_BY_PROPERTY_ID), - ...relationTargets(debate.relations, DEBATE_OPPOSED_BY_PROPERTY_ID), + ...relationTargets(debate.relations, DEBATE_SUPPORTED_BY_PROPERTY_ID).map(spaceId => ({ + spaceId, + position: true, + })), + ...relationTargets(debate.relations, DEBATE_OPPOSED_BY_PROPERTY_ID).map(spaceId => ({ + spaceId, + position: false, + })), ]); } return map; @@ -73,8 +89,8 @@ export function useClaimActivityRows({ // Every debater on the page, and every speaker their extracted claims will be attributed to — // the same set, because a claim is extracted from a turn one of them took. const participantSpaceIds = React.useMemo( - () => [...new Set([...participantsByDebateId.values()].flat())], - [participantsByDebateId] + () => [...new Set([...sidesByDebateId.values()].flat().map(side => side.spaceId))], + [sidesByDebateId] ); const { profilesBySpaceId } = useProfilesBySpaceIds(participantSpaceIds, participantSpaceIds.length > 0); const keyframeByDebateId = useDebateKeyframes(debates); @@ -84,6 +100,10 @@ export function useClaimActivityRows({ return debates.map(debate => ({ id: debate.id, + // The debate entity itself is what the row's upvotes are cast on, in the space the claim page + // is being read through — which is also where the debate was published. + entityId: debate.id, + spaceId, // `createdAt` rather than `updatedAt`: this row's place in the feed is when the debate // happened, and `updatedAt` moves whenever anything touches the entity — including a backlink // from an unrelated edit, which would float an old debate to the top of the thread. @@ -93,13 +113,32 @@ export function useClaimActivityRows({ debate={debate} spaceId={spaceId} profilesBySpaceId={profilesBySpaceId} - participantSpaceIds={participantsByDebateId.get(debate.id) ?? []} + sides={sidesByDebateId.get(debate.id) ?? []} + claimText={debatedClaimText(debate)} + responseVocabulary={responseVocabulary} keyframeUrl={keyframeByDebateId.get(debate.id) ?? null} publishedAt={debateDate(debate)} /> ), })); - }, [debates, keyframeByDebateId, participantsByDebateId, profilesBySpaceId, spaceId]); + }, [debates, keyframeByDebateId, profilesBySpaceId, responseVocabulary, sidesByDebateId, spaceId]); return { rows, isLoading }; } + +/** + * The claim the debate argued, as text. + * + * On this page that is the claim being read, so it is not new information — it is the row's body. + * A debate row otherwise carries only a byline and a control strip, which beside comments that each + * have something to say reads as a row that failed to load rather than as a debate. + * + * Taken from the debate's own `Claims` relation rather than from the page, so the row still says + * what it argued anywhere else this component is reused. + */ +function debatedClaimText(debate: Entity): string | null { + const relation = debate.relations.find( + candidate => candidate.isDeleted !== true && ID.equals(candidate.type.id, DEBATE_CLAIMS_PROPERTY_ID) + ); + return relation?.toEntity.name?.trim() || null; +} diff --git a/apps/web/core/responses/use-entity-response-scores.ts b/apps/web/core/responses/use-entity-response-scores.ts new file mode 100644 index 0000000000..79cb9a676f --- /dev/null +++ b/apps/web/core/responses/use-entity-response-scores.ts @@ -0,0 +1,89 @@ +'use client'; + +import { useQueries } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { uuidToHex } from '~/core/id/normalize'; + +import { type ResponseKind, type ResponseObjectType, entityResponseCountsQueryKey } from './entity-response'; + +/** Entity votes, as opposed to claim-position votes. Same 0 `EntityVoteButtons` writes under. */ +const ENTITY_RESPONSE_OBJECT_TYPE: ResponseObjectType = 0; + +export type ScoredEntity = { + entityId: string; + /** The space the response was recorded against — for a comment, its author's personal space. */ + spaceId: string; + responseKind?: ResponseKind; +}; + +/** + * Net upvote score per entity, read from the cache the vote controls already fill. + * + * Every row that renders `EntityVoteButtons` fetches its own counts under + * {@link entityResponseCountsQueryKey} and keeps them for 30s. This hook subscribes to those same + * entries **without enabling them**, so ordering a list by score costs no request of its own — the + * same arrangement `useCommentCount` has with the comment list, and for the same reason: a feed + * that fetched a number per row to decide the order would pay a round trip per row before it could + * draw anything. + * + * What that buys is also its limit: a score is only known once the row that owns it has asked. + * Callers get `null` for anything unanswered and decide what that means — see + * {@link netScore}. Sorting treats it as zero, which is the honest reading of "nobody has + * told us otherwise" and is what the control itself displays in the meantime. + * + * Space matters. Comment votes are recorded against the commenter's own personal space, not the + * space the thread is being read in, so callers must pass the space each entity actually lives in + * rather than the page's. + */ +export type ResponseCounts = { positive: number; negative: number }; + +export function useEntityResponseScores(targets: ScoredEntity[]): Map { + // Deduped and ordered, so a list that re-renders with the same entities in a different order + // doesn't rebuild every subscription. + const normalized = React.useMemo(() => { + const byKey = new Map(); + for (const target of targets) { + if (!target.entityId || !target.spaceId) continue; + byKey.set(`${uuidToHex(target.entityId)}:${uuidToHex(target.spaceId)}`, target); + } + return [...byKey.entries()].sort(([left], [right]) => left.localeCompare(right)); + }, [targets]); + + const results = useQueries({ + queries: normalized.map(([, target]) => ({ + queryKey: entityResponseCountsQueryKey( + target.entityId, + target.spaceId, + ENTITY_RESPONSE_OBJECT_TYPE, + target.responseKind ?? 'curation' + ), + // A cache subscription, not a second reader. `EntityVoteButtons` owns the fetching; enabling + // these would double every row's request and race its own optimistic writes. + enabled: false, + })), + }); + + return React.useMemo(() => { + const scores = new Map(); + normalized.forEach(([, target], index) => { + const counts = results[index]?.data as ResponseCounts | undefined; + scores.set(uuidToHex(target.entityId), counts ?? null); + }); + return scores; + // `results` is a new array every render by design; its contents are what matter, and the map + // below is cheap. Keyed on the data itself so a genuine count change rebuilds it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [normalized, results.map(result => JSON.stringify(result.data ?? null)).join('|')]); +} + +/** Net score for ordering: unknown counts as zero, which is what the control shows until it answers. */ +export function netScore(counts: ResponseCounts | null | undefined): number { + return counts ? counts.positive - counts.negative : 0; +} + +/** Upvotes alone — how many people backed it, regardless of how many pushed back. */ +export function upvoteCount(counts: ResponseCounts | null | undefined): number { + return counts?.positive ?? 0; +} diff --git a/apps/web/partials/comments/comment-activity-rows.test.tsx b/apps/web/partials/comments/comment-activity-rows.test.tsx index 0f95bba13e..620a0c57ae 100644 --- a/apps/web/partials/comments/comment-activity-rows.test.tsx +++ b/apps/web/partials/comments/comment-activity-rows.test.tsx @@ -1,9 +1,10 @@ import '@testing-library/jest-dom/vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { CommentWithReplies } from './types'; +import type { CommentActivityRow, CommentWithReplies } from './types'; const mocks = vi.hoisted(() => ({ comments: [] as CommentWithReplies[], @@ -54,9 +55,24 @@ function comment(id: string, createdAt: string): CommentWithReplies { }; } -function renderSection(activityRows: Array<{ id: string; createdAt: string; content: React.ReactNode }>) { +// Ranked orders subscribe to the vote-count cache, which needs a client even though nothing here +// fetches — the subscriptions are deliberately disabled. +function withClient(ui: React.ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {ui}; +} + +function renderSection(activityRows: CommentActivityRow[], sort?: 'best' | 'top' | 'newest' | 'oldest') { return render( - + withClient( + + ) ); } @@ -70,8 +86,8 @@ describe('CommentSection activity rows', () => { mocks.comments = [comment('c-old', '2026-09-20T10:00:00Z'), comment('c-new', '2026-09-22T10:00:00Z')]; renderSection([ - { id: 'd-mid', createdAt: '2026-09-21T10:00:00Z', content: debate mid }, - { id: 'd-newest', createdAt: '2026-09-23T10:00:00Z', content: debate newest }, + { id: 'd-mid', entityId: 'd-mid', spaceId: 'space-1', createdAt: '2026-09-21T10:00:00Z', content: debate mid }, + { id: 'd-newest', entityId: 'd-newest', spaceId: 'space-1', createdAt: '2026-09-23T10:00:00Z', content: debate newest }, ]); const order = screen @@ -82,16 +98,30 @@ describe('CommentSection activity rows', () => { expect(order).toEqual(['debate newest', 'body of c-new', 'debate mid', 'body of c-old']); }); + // With nothing voted on, Best has no scores to separate rows by and falls back to recency — + // which is what keeps a fresh thread from looking randomly ordered. + it('falls back to newest within Best while no votes have been counted', () => { + mocks.comments = [comment('c-old', '2026-09-20T10:00:00Z'), comment('c-new', '2026-09-22T10:00:00Z')]; + + renderSection( + [{ id: 'd-mid', entityId: 'd-mid', spaceId: 'space-1', createdAt: '2026-09-21T10:00:00Z', content: debate mid }], + 'best' + ); + + const order = screen.getAllByText(/body of c-|debate /).map(node => node.textContent?.trim()); + expect(order).toEqual(['body of c-new', 'debate mid', 'body of c-old']); + }); + it('counts activity rows in the heading alongside the comments', () => { mocks.comments = [comment('c-1', '2026-09-20T10:00:00Z')]; - renderSection([{ id: 'd-1', createdAt: '2026-09-21T10:00:00Z', content: debate }]); + renderSection([{ id: 'd-1', entityId: 'd-1', spaceId: 'space-1', createdAt: '2026-09-21T10:00:00Z', content: debate }]); expect(screen.getByText('Activity (2)')).toBeInTheDocument(); }); it('renders the rows on a claim nobody has commented on yet', () => { - renderSection([{ id: 'd-1', createdAt: '2026-09-21T10:00:00Z', content: debate }]); + renderSection([{ id: 'd-1', entityId: 'd-1', spaceId: 'space-1', createdAt: '2026-09-21T10:00:00Z', content: debate }]); expect(screen.getByText('debate')).toBeInTheDocument(); expect(screen.getByText('Activity (1)')).toBeInTheDocument(); @@ -100,7 +130,7 @@ describe('CommentSection activity rows', () => { it('leaves the thread exactly as it was when no rows are passed', () => { mocks.comments = [comment('c-1', '2026-09-20T10:00:00Z')]; - render(); + render(withClient()); expect(screen.getByText('Comments (1)')).toBeInTheDocument(); expect(screen.getByText('body of c-1')).toBeInTheDocument(); diff --git a/apps/web/partials/comments/comment-time.ts b/apps/web/partials/comments/comment-time.ts new file mode 100644 index 0000000000..3a18d59e7b --- /dev/null +++ b/apps/web/partials/comments/comment-time.ts @@ -0,0 +1,28 @@ +/** + * How old something in a comment thread is, as the thread says it. + * + * Shared so every row in the thread ages the same way. The claim page's activity feed puts debates + * in this list beside comments, and a debate dated "Sep 17, 2026" next to a comment aged "1d ago" + * reads as two lists that happen to be adjacent rather than one thread. + * + * Recent times are deliberately coarse — "3 hours", not "3h 12m". This is a timestamp a reader + * glances at to place a row relative to the ones around it, not a duration they are measuring. + */ +export function getRelativeTime(dateString: string): string { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ''; + + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSeconds < 60) return 'just now'; + if (diffMinutes < 60) return `${diffMinutes} mins`; + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''}`; + if (diffDays < 7) return `${diffDays}d ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; + return date.toLocaleDateString(); +} diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index 3746d02728..8ec7348bed 100644 --- a/apps/web/partials/comments/comments-section.tsx +++ b/apps/web/partials/comments/comments-section.tsx @@ -9,6 +9,7 @@ import { useAtom } from 'jotai'; import { normalizeSpaceId } from '~/core/access/space-access'; import { personProfileOpened } from '~/core/analytics'; import { mergeActivityRows } from '~/core/claims/browse/claim-activity-order'; +import { useEntityResponseScores } from '~/core/responses/use-entity-response-scores'; import { ClaimCommentPositionBadge } from '~/core/claims/browse/claim-comment-position'; import { PLACEHOLDER_SPACE_IMAGE } from '~/core/constants'; import { Crown } from '~/core/debates/browse/icons'; @@ -49,6 +50,7 @@ import { threadArmCenterPx, threadSpineOffsetPx, } from './comment-density'; +import { getRelativeTime } from './comment-time'; import type { CommentActivityRow, CommentFilter, CommentSortOrder, CommentWithReplies } from './types'; const CommentDensityContext = React.createContext(PAGE_DENSITY); @@ -56,6 +58,8 @@ const CommentDensityContext = React.createContext(PAGE_DENSITY); const NO_REPLIES: never[] = []; /** Stable identity, so a host that passes no rows doesn't rebuild the merge every render. */ const NO_ACTIVITY_ROWS: CommentActivityRow[] = []; +/** Stable identity for the unranked case, so the score subscriptions aren't rebuilt each render. */ +const EMPTY_SCORE_TARGETS: Array<{ entityId: string; spaceId: string }> = []; function useCommentDensity(): CommentDensity { return React.useContext(CommentDensityContext); @@ -248,6 +252,11 @@ interface CommentSectionProps { activityRows?: CommentActivityRow[]; /** Heading noun. "Comments" unless the list holds more than comments. */ title?: string; + /** + * Where the sort starts. Threads default to most-recent; the claim page's activity feed opens on + * Best, because there the list is a record of an argument rather than a running conversation. + */ + defaultSortOrder?: CommentSortOrder; } export function CommentSection({ @@ -257,6 +266,7 @@ export function CommentSection({ variant = 'page', activityRows = NO_ACTIVITY_ROWS, title = 'Comments', + defaultSortOrder = 'newest', }: CommentSectionProps) { const { comments, totalCount, isLoading } = useComments({ entityId, spaceId }); const { publishComment, editComment } = usePublishComment(entityId, spaceId, { @@ -320,7 +330,25 @@ export function CommentSection({ enabled: totalCount > 0, }); - const [sortOrder, setSortOrder] = useState('newest'); + const [sortOrder, setSortOrder] = useState(defaultSortOrder); + + // Vote counts for the top level, so Best and Top can order it. Read from the cache each row's own + // `EntityVoteButtons` fills rather than fetched here — see `useEntityResponseScores`. A comment's + // votes live in its author's personal space, not this thread's, which is why each row carries its + // own space rather than inheriting the page's. + const scoreTargets = React.useMemo( + () => [ + ...comments.map(comment => ({ entityId: comment.id, spaceId: comment.spaceId })), + ...activityRows.map(row => ({ entityId: row.entityId, spaceId: row.spaceId })), + ], + [activityRows, comments] + ); + const isRanked = sortOrder === 'best' || sortOrder === 'top'; + const scoresById = useEntityResponseScores(isRanked ? scoreTargets : EMPTY_SCORE_TARGETS); + const scoreFor = React.useCallback( + (row: { id: string }) => scoresById.get(uuidToHex(row.id)) ?? null, + [scoresById] + ); const [filter, setFilter] = useState('all'); React.useEffect(() => { @@ -467,6 +495,7 @@ export function CommentSection({ comments={filteredComments} activityRows={activityRows} activityOrder={sortOrder} + activityScoreFor={scoreFor} entityId={entityId} spaceId={spaceId} onReply={handleCreateComment} @@ -526,6 +555,15 @@ function collectCommentAuthorSpaceIds(comments: CommentWithReplies[]): string[] // Sub-components // --------------------------------------------------------------------------- +/** The order the options are offered in, which is also the order of preference they imply. */ +const SORT_ORDERS: CommentSortOrder[] = ['best', 'top', 'newest', 'oldest']; +const SORT_LABELS: Record = { + best: 'Best', + top: 'Top', + newest: 'New', + oldest: 'Old', +}; + function CommentFilters({ sortOrder, onSortChange, @@ -540,11 +578,13 @@ function CommentFilters({ return (
{sortOrder === 'newest' ? 'Most recent' : 'Oldest'}} - options={[ - { label: 'Most recent', value: 'newest', disabled: false, onClick: () => onSortChange('newest') }, - { label: 'Oldest', value: 'oldest', disabled: false, onClick: () => onSortChange('oldest') }, - ]} + trigger={{SORT_LABELS[sortOrder]}} + options={SORT_ORDERS.map(value => ({ + label: SORT_LABELS[value], + value, + disabled: false, + onClick: () => onSortChange(value), + }))} /> {filter === 'all' ? 'All' : 'Editors replies'}} @@ -745,6 +785,7 @@ function CommentList({ comments, activityRows = NO_ACTIVITY_ROWS, activityOrder = 'newest', + activityScoreFor, entityId, spaceId, onReply, @@ -766,6 +807,8 @@ function CommentList({ /** Ordered into the top level alongside the comments; ignored at any other depth. */ activityRows?: CommentActivityRow[]; activityOrder?: CommentSortOrder; + /** Looks up a top-level row's votes, for the ranked orders. */ + activityScoreFor?: (row: { id: string }) => { positive: number; negative: number } | null; entityId: string; spaceId: string; onReply: (text: string, ancestorComments?: Array<{ id: string; spaceId: string }>) => void; @@ -832,7 +875,7 @@ function CommentList({ if (depth === 0) { // Activity rows only exist at the top level — a debate is not a reply to a comment — so the // merge lives inside this branch and the recursive one below is untouched. - const merged = mergeActivityRows(comments, activityRows, activityOrder); + const merged = mergeActivityRows(comments, activityRows, activityOrder, activityScoreFor); return (
@@ -1449,19 +1492,3 @@ function CommentItem({ ); } -function getRelativeTime(dateString: string): string { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffSeconds = Math.floor(diffMs / 1000); - const diffMinutes = Math.floor(diffSeconds / 60); - const diffHours = Math.floor(diffMinutes / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffSeconds < 60) return 'just now'; - if (diffMinutes < 60) return `${diffMinutes} mins`; - if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''}`; - if (diffDays < 7) return `${diffDays}d ago`; - if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; - return date.toLocaleDateString(); -} diff --git a/apps/web/partials/comments/types.ts b/apps/web/partials/comments/types.ts index ff49d8749f..ad4e3b8f65 100644 --- a/apps/web/partials/comments/types.ts +++ b/apps/web/partials/comments/types.ts @@ -55,7 +55,14 @@ export interface CreateCommentParams { ancestorComments?: Array<{ id: string; spaceId: string }>; } -export type CommentSortOrder = 'newest' | 'oldest'; +/** + * How the thread orders its top level. + * + * `best` and `top` both read the rows' vote counts and differ in what they reward: `best` takes the + * net score, so a divisive row falls; `top` takes upvotes alone, so a row lots of people backed + * stays up regardless of how many pushed back. `newest`/`oldest` are the thread's original orders. + */ +export type CommentSortOrder = 'best' | 'top' | 'newest' | 'oldest'; export type CommentFilter = 'all' | 'editors'; /** @@ -69,5 +76,8 @@ export interface CommentActivityRow { id: string; /** ISO timestamp, compared against each comment's `createdAt` to place the row. */ createdAt: string; + /** The entity whose votes rank this row, and the space they were cast in. */ + entityId: string; + spaceId: string; content: React.ReactNode; } From f3112243f3fccf4f1dfad496497917b6ff100a9d Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 23 Sep 2026 15:20:53 -0700 Subject: [PATCH 03/35] fix(claims): real comment counts, every extracted claim, and shared thread connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from the second pass over the feed. The comment count on a debate read 0 on a debate that had comments. The button seeds itself from a count its host supplies and then only ever corrects that seed *downward*, from a list somebody has actually opened — so a host with nothing to give passes zero and the number stays wrong until the panel is opened and the comments are found. Wrong in the one direction that tells a reader not to look. `useEntityCommentCounts` asks the server for the counts of many entities at once, through the same backlink aggregate the explore cards already use for their own pill, so a debate row and every claim under it get a real number for one request. Extracted claims are no longer paged. "Show 17 more claims" asked a reader who had scrolled to a debate to ask a second time for the thing the debate produced, which is the reason the debate is in this feed at all. The whole branch collapses in one press instead, which is the control that actually shortens the page. And a debate connects to its claims the way a comment connects to its replies. The spine, the elbow into the last row and the arm into the rest are now shared components over the geometry `comment-density.ts` already owned; `CommentList` draws its spine from the same one, and the segment classes moved beside the geometry so a hover cannot light one branch and not the other. Two drawings of "this row belongs to that one" would have said they were different relationships. --- .../claims/browse/debate-activity-row.tsx | 200 ++++++++++++------ .../browse/extracted-claim-row.test.tsx | 10 +- .../claims/browse/extracted-claim-row.tsx | 18 +- .../use-entity-comment-counts.test.ts | 39 ++++ .../comments/use-entity-comment-counts.ts | 86 ++++++++ apps/web/partials/comments/comment-density.ts | 13 ++ .../partials/comments/comments-section.tsx | 71 ++----- apps/web/partials/comments/thread-branch.tsx | 167 +++++++++++++++ 8 files changed, 479 insertions(+), 125 deletions(-) create mode 100644 apps/web/core/comments/use-entity-comment-counts.test.ts create mode 100644 apps/web/core/comments/use-entity-comment-counts.ts create mode 100644 apps/web/partials/comments/thread-branch.tsx diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index 4ae61b80f9..602b6e8ccc 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -8,6 +8,7 @@ import type { DebateResponseKind } from '~/core/debates/api'; import { useDebateClaims } from '~/core/debates/hooks'; import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; +import { useEntityCommentCounts } from '~/core/comments/use-entity-comment-counts'; import { useEntityCommentsPanel } from '~/core/hooks/use-entity-comments-panel'; import { uuidToHex } from '~/core/id/normalize'; import type { ResponseKind } from '~/core/responses/entity-response'; @@ -21,21 +22,12 @@ import { Skeleton } from '~/design-system/skeleton'; import { type CommentDensity, PAGE_DENSITY } from '~/partials/comments/comment-density'; import { getRelativeTime } from '~/partials/comments/comment-time'; import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; +import { ThreadArm, ThreadElbow, ThreadSpine } from '~/partials/comments/thread-branch'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import { orderExtractedClaims } from './claim-activity-order'; import { ExtractedClaimRow, type SpeakerProfile } from './extracted-claim-row'; -/** - * How many extracted claims a debate shows before it asks. - * - * Depth is capped separately (replies below level two are behind their own button); this is the - * breadth cap, and it is the one that actually bites. A debate can carry twenty extracted claims - * without ever exceeding the depth cap, which would put the next debate below a screen and a half - * of someone else's transcript. - */ -const EXTRACTED_CLAIM_PAGE_SIZE = 4; - /** Same 48px the claim page's debates module uses, in the 540 × 820 the videos are published at. */ const KEYFRAME_WIDTH_PX = 48; @@ -79,8 +71,11 @@ export function DebateActivityRow({ publishedAt, density = PAGE_DENSITY, }: DebateActivityRowProps) { - const [visibleClaims, setVisibleClaims] = React.useState(EXTRACTED_CLAIM_PAGE_SIZE); + const [collapsed, setCollapsed] = React.useState(false); const { openComments } = useEntityCommentsPanel(); + // One aggregate for this row's own comment count. Without it the button seeds itself at zero and + // only ever corrects downward, so a debate with comments read "0" until the panel was opened. + const debateCommentCount = useEntityCommentCounts(React.useMemo(() => [debate.id], [debate.id])); const debaters = sides .map(side => profilesBySpaceId.get(side.spaceId)?.name?.trim()) @@ -92,8 +87,8 @@ export function DebateActivityRow({ const title = debaters.length > 0 ? debaters.join(' vs. ') : (debate.name ?? 'Debate'); return ( -
-
+
+
{/* Portrait, because that is the shape the video actually is: `Debate videos` declares 540 × 820, so a 16:9 tile would letterbox every still it ever showed. Same geometry `ClaimDebates` uses, so the two surfaces show a debate the same way. */} @@ -133,7 +128,11 @@ export function DebateActivityRow({
- + {/* A reply to a debate is a comment on the debate, so it goes where that entity's comments already live rather than into this claim's thread. The panel is the existing surface for commenting on an entity you are not currently reading. */} @@ -162,8 +161,8 @@ export function DebateActivityRow({ positionBySpaceId={positionBySpaceId} responseVocabulary={responseVocabulary} density={density} - visibleCount={visibleClaims} - onShowMore={() => setVisibleClaims(count => count + EXTRACTED_CLAIM_PAGE_SIZE)} + collapsed={collapsed} + onToggleCollapsed={() => setCollapsed(value => !value)} />
); @@ -188,8 +187,8 @@ function ExtractedClaims({ positionBySpaceId, responseVocabulary, density, - visibleCount, - onShowMore, + collapsed, + onToggleCollapsed, }: { debateId: string; spaceId: string; @@ -198,8 +197,8 @@ function ExtractedClaims({ positionBySpaceId: Map; responseVocabulary: DebateResponseKind; density: CommentDensity; - visibleCount: number; - onShowMore: () => void; + collapsed: boolean; + onToggleCollapsed: () => void; }) { const { claims, isLoading } = useDebateTranscriptClaims(debateId, spaceId); const { timings, isReady } = useClaimTimings(debateId, claims); @@ -208,6 +207,7 @@ function ExtractedClaims({ // One call for every extracted claim on screen. Without it each row's own vote control would read // the entity to work out whether it takes thumbs or chevrons — one request per row. const claimRows = useDebateClaims(spaceId, claimIds, claimIds.length > 0); + const commentCounts = useEntityCommentCounts(claimIds); const responseKindByClaimId = React.useMemo(() => { const map = new Map(); for (const row of claimRows.data?.claims ?? []) { @@ -226,15 +226,45 @@ function ExtractedClaims({ const ordered = React.useMemo(() => orderExtractedClaims(claims.all, timings), [claims.all, timings]); - // The list hangs where a reply to this row would: the thread's own body inset, so an extracted - // claim and a comment reply sit on the same left edge. - const nestedStyle = { marginLeft: density.avatarCenterPx, paddingLeft: density.bodyInsetPx - density.avatarCenterPx }; + // The branch hangs where a reply to this row would: indented to the parent's avatar centre and + // then out to the thread's body inset, so an extracted claim and a comment reply share a left + // edge and the connectors reach back to the same point. + const branchStyle = { + marginLeft: density.avatarCenterPx, + paddingLeft: density.bodyInsetPx - density.avatarCenterPx, + }; + + // Measured rather than computed: the spine has to stop at the last row's arm, and the rows are + // variable height (a claim sentence wraps to one line or three). Same approach `CommentList` takes + // for exactly the same reason. + const containerRef = React.useRef(null); + const lastRowRef = React.useRef(null); + const [spineHeightPx, setSpineHeightPx] = React.useState(null); + + const measureSpine = React.useCallback(() => { + const container = containerRef.current; + const lastRow = lastRowRef.current; + if (!container || !lastRow) { + setSpineHeightPx(null); + return; + } + setSpineHeightPx(lastRow.getBoundingClientRect().top - container.getBoundingClientRect().top); + }, []); + + React.useLayoutEffect(() => { + measureSpine(); + const container = containerRef.current; + if (container == null || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => measureSpine()); + observer.observe(container); + return () => observer.disconnect(); + }); if (isLoading || !isReady) { // Held rather than painted unordered. `isReady` is false only while a timing source is still // arriving, and it reports ready on a failed transcript fetch — so this cannot hang forever on // a debate whose transcript is gone; that degrades to "all untimed, arrival order". - return ; + return ; } // Timed first, in order, then the ones nothing could place. The tail is not sorted among itself: @@ -245,52 +275,96 @@ function ExtractedClaims({ // with none is ordinary — and a line saying so under every old debate is noise in a feed. if (rows.length === 0) return null; - const visible = rows.slice(0, visibleCount); - const remaining = rows.length - visible.length; + // Every claim, not a page of them. These are what the debate produced; a reader who has scrolled + // to a debate has asked for them, and "Show 17 more claims" was asking a second time. The spine + // collapses the whole branch in one press, which is the control that actually shortens the page. + const collapseLabel = { + expand: `Expand ${rows.length} claims from this debate`, + collapse: `Collapse ${rows.length} claims from this debate`, + }; + + if (collapsed) { + return ( +
+ +
+ ); + } return ( -
- {visible.map(claim => { +
+ + + {rows.map((claim, index) => { const speakerSpaceId = speakerBySourceBlockId.get(uuidToHex(claim.blockId)) ?? null; + const isLast = index === rows.length - 1; return ( - + className="comment-branch-row relative" + ref={isLast ? lastRowRef : undefined} + > +
+ {isLast ? ( +
+ +
+ ) : ( + + )} +
+ + +
); })} - - {remaining > 0 && ( - - )}
); } + +/** The branch has no cross-row highlight of its own yet; the spine still collapses on press. */ +function noop() {} diff --git a/apps/web/core/claims/browse/extracted-claim-row.test.tsx b/apps/web/core/claims/browse/extracted-claim-row.test.tsx index 403eed5b5a..7690310cc5 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.test.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.test.tsx @@ -32,8 +32,8 @@ vi.mock('~/design-system/prefetch-link', () => ({ })); vi.mock('~/partials/comments/entity-comments-button', () => ({ - EntityCommentsButton: ({ entityId }: { entityId: string }) => ( -
+ EntityCommentsButton: ({ entityId, count }: { entityId: string; count: number }) => ( +
), })); @@ -167,6 +167,12 @@ describe('ExtractedClaimRow', () => { expect(screen.getByText('Practical effects age better than CGI.')).toBeInTheDocument(); }); + it('shows the server-counted comments rather than seeding the button at zero', () => { + renderRow({ commentCount: 4 }); + + expect(screen.getByTestId('comments-button')).toHaveAttribute('data-count', '4'); + }); + it('links the sentence to the claim in its own space', () => { renderRow(); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index 7610f6b9a1..005ec87195 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -44,6 +44,7 @@ export function ExtractedClaimRow({ speaker, speakerPosition, responseVocabulary, + commentCount = 0, density = PAGE_DENSITY, className, }: { @@ -71,6 +72,14 @@ export function ExtractedClaimRow({ speakerPosition: boolean | null; /** The claim page's vocabulary, so the tag reads Agree/Disagree or Verify/Dispute to match. */ responseVocabulary: DebateResponseKind; + /** + * How many comments the claim has, counted by the server. + * + * Passed rather than left to the button's own seed: that seed only ever corrects *downward*, from + * a list somebody has opened, so a row with no count to give reads "0" on a claim that has + * comments — a number wrong in the one direction that tells the reader not to look. + */ + commentCount?: number; /** The surrounding thread's metrics, so these rows sit on the same ramp as the comments. */ density?: CommentDensity; className?: string; @@ -158,14 +167,7 @@ export function ExtractedClaimRow({ // read as one group. claimResponderAvatarsPosition="trailing" /> - +
) : null}
diff --git a/apps/web/core/comments/use-entity-comment-counts.test.ts b/apps/web/core/comments/use-entity-comment-counts.test.ts new file mode 100644 index 0000000000..f8abd7dde5 --- /dev/null +++ b/apps/web/core/comments/use-entity-comment-counts.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeEntityCommentCounts } from './use-entity-comment-counts'; + +describe('decodeEntityCommentCounts', () => { + it('keys counts by canonical id, so a hyphenated lookup still finds them', () => { + const counts = decodeEntityCommentCounts({ + entities: [{ id: '3e4a0955-699a-4c8f-813f-cc803a3335ba', backlinks: { totalCount: 3 } }], + }); + + expect(counts.get('3e4a0955699a4c8f813fcc803a3335ba')).toBe(3); + }); + + it('reads an entity with no comments as zero rather than leaving it out', () => { + const counts = decodeEntityCommentCounts({ entities: [{ id: 'aa', backlinks: { totalCount: 0 } }] }); + + expect(counts.get('aa')).toBe(0); + }); + + // A missing aggregate is the server declining to count, not a claim that there are none — but a + // row has to render something, and zero is what the button already shows while it waits. + it('falls back to zero when the aggregate is absent', () => { + const counts = decodeEntityCommentCounts({ entities: [{ id: 'aa', backlinks: null }, { id: 'bb' }] }); + + expect(counts.get('aa')).toBe(0); + expect(counts.get('bb')).toBe(0); + }); + + it('skips rows the API returned as null', () => { + const counts = decodeEntityCommentCounts({ entities: [null, { id: null }, { id: 'aa', backlinks: { totalCount: 2 } }] }); + + expect([...counts.keys()]).toEqual(['aa']); + }); + + it('is an empty map when nothing came back', () => { + expect(decodeEntityCommentCounts({}).size).toBe(0); + expect(decodeEntityCommentCounts({ entities: null }).size).toBe(0); + }); +}); diff --git a/apps/web/core/comments/use-entity-comment-counts.ts b/apps/web/core/comments/use-entity-comment-counts.ts new file mode 100644 index 0000000000..5316b126f0 --- /dev/null +++ b/apps/web/core/comments/use-entity-comment-counts.ts @@ -0,0 +1,86 @@ +'use client'; + +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { useQuery } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { Effect } from 'effect'; +import { parse } from 'graphql'; + +import { COMMENT_REPLY_TO_ID } from '~/core/comment-ids'; +import { uuidToHex } from '~/core/id/normalize'; +import { graphql } from '~/core/io/graphql-client'; + +/** + * How many comments each of a set of entities has, in one request. + * + * The aggregate the explore cards already use for their comment pill — incoming `Reply to` + * backlinks, counted by the server — asked for many entities at once rather than one card at a + * time. A feed that shows a comment count beside a row it does not own needs exactly this: a + * number, not the comments. + * + * The alternative is what this replaces. `EntityCommentsButton` seeds itself from a count its host + * supplies and only ever corrects that seed *downward* from a list someone has actually opened + * (see `useCommentCount`) — so a host with no count to give passes zero, and the button reads "0" + * on an entity with comments until the reader opens the panel and finds them. That is a number + * being wrong in the one direction that tells the reader not to look. + */ +const ENTITY_COMMENT_COUNTS_SOURCE = /* GraphQL */ ` + query EntityCommentCounts($ids: [UUID!], $replyToTypeId: UUID!) { + entities(filter: { id: { in: $ids } }) { + id + backlinks(filter: { typeId: { is: $replyToTypeId } }) { + totalCount + } + } + } +`; + +const entityCommentCountsDocument = parse(ENTITY_COMMENT_COUNTS_SOURCE) as TypedDocumentNode; + +type CountsResponse = { + entities?: Array<{ id?: string | null; backlinks?: { totalCount?: number | null } | null } | null> | null; +}; + +const NO_COUNTS = new Map(); + +export function decodeEntityCommentCounts(data: CountsResponse): Map { + const counts = new Map(); + for (const entity of data.entities ?? []) { + if (!entity?.id) continue; + counts.set(uuidToHex(entity.id), entity.backlinks?.totalCount ?? 0); + } + return counts; +} + +export const entityCommentCountsQueryKey = (ids: string[]) => ['entity-comment-counts', ids] as const; + +/** + * Comment counts keyed by canonical entity id. Absent means "not answered yet", not "none" — + * callers should hold the button's own live count rather than rendering a zero they invented. + */ +export function useEntityCommentCounts(entityIds: string[], enabled = true): Map { + // Sorted and deduped so the same set of rows in a different order is the same query. + const ids = React.useMemo( + () => [...new Set(entityIds.filter(Boolean).map(uuidToHex))].sort(), + [entityIds] + ); + + const { data } = useQuery({ + queryKey: entityCommentCountsQueryKey(ids), + queryFn: ({ signal }) => + Effect.runPromise( + graphql({ + query: entityCommentCountsDocument, + decoder: decodeEntityCommentCounts, + variables: { ids, replyToTypeId: COMMENT_REPLY_TO_ID }, + signal, + }) + ), + enabled: enabled && ids.length > 0, + staleTime: 60_000, + }); + + return data ?? NO_COUNTS; +} diff --git a/apps/web/partials/comments/comment-density.ts b/apps/web/partials/comments/comment-density.ts index 8cc39175f8..9eda4e858a 100644 --- a/apps/web/partials/comments/comment-density.ts +++ b/apps/web/partials/comments/comment-density.ts @@ -96,3 +96,16 @@ export function threadArmCenterPx(density: CommentDensity): number { export function avatarBottomInRowPx(density: CommentDensity): number { return threadArmCenterPx(density) + density.avatarPx / 2; } + +/** + * Class hooks and colours for a thread's connector segments. + * + * Here rather than beside the comment list because the claim page's activity feed draws the same + * connectors for the claims hanging off a debate, and a hover that lit one branch and not the other + * is exactly the symptom of there being two copies of this. + */ +export const THREAD_LEVEL_BRANCH_SEGMENT = 'thread-level-branch-segment'; +export const THREAD_SEGMENT_DIM = 'bg-grey-02'; +export const THREAD_SEGMENT_DIM_STROKE = 'stroke-[var(--color-grey-02)]'; +export const THREAD_SEGMENT_HI = 'bg-grey-03'; +export const THREAD_SEGMENT_HI_STROKE = 'stroke-[var(--color-grey-03)]'; diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index 8ec7348bed..5eeb1f9b4b 100644 --- a/apps/web/partials/comments/comments-section.tsx +++ b/apps/web/partials/comments/comments-section.tsx @@ -46,10 +46,16 @@ import { type CommentDensity, PAGE_DENSITY, PANEL_DENSITY, + THREAD_LEVEL_BRANCH_SEGMENT, + THREAD_SEGMENT_DIM, + THREAD_SEGMENT_DIM_STROKE, + THREAD_SEGMENT_HI, + THREAD_SEGMENT_HI_STROKE, avatarBottomInRowPx, threadArmCenterPx, threadSpineOffsetPx, } from './comment-density'; +import { THREAD_BRANCH_HIT_PX, ThreadSpine, branchPointerBlurProps } from './thread-branch'; import { getRelativeTime } from './comment-time'; import type { CommentActivityRow, CommentFilter, CommentSortOrder, CommentWithReplies } from './types'; @@ -64,14 +70,6 @@ const EMPTY_SCORE_TARGETS: Array<{ entityId: string; spaceId: string }> = []; function useCommentDensity(): CommentDensity { return React.useContext(CommentDensityContext); } -const COMMENT_THREAD_LINE_HIT_PX = 20; - -const THREAD_LEVEL_BRANCH_SEGMENT = 'thread-level-branch-segment'; - -const THREAD_SEGMENT_DIM = 'bg-grey-02'; -const THREAD_SEGMENT_DIM_STROKE = 'stroke-[var(--color-grey-02)]'; -const THREAD_SEGMENT_HI = 'bg-grey-03'; -const THREAD_SEGMENT_HI_STROKE = 'stroke-[var(--color-grey-03)]'; type BranchFocus = { kind: 'parent-thread'; threadCommentId: string } | { kind: 'row-connectors'; commentId: string }; @@ -209,23 +207,6 @@ function rowDefersConnectorHighlightToNestedRow(row: CommentWithReplies, focus: return false; } -function branchPointerBlurProps( - clearFocus: () => void -): Pick, 'onPointerLeave' | 'onBlur'> { - return { - onPointerLeave: e => { - const next = e.relatedTarget; - // relatedTarget is EventTarget | null; only Node is valid for Element.contains(). - if (next instanceof Node && e.currentTarget.contains(next)) return; - clearFocus(); - }, - onBlur: e => { - const next = e.relatedTarget; - if (next instanceof Node && e.currentTarget.contains(next)) return; - clearFocus(); - }, - }; -} export type CommentSectionVariant = 'page' | 'panel' | 'tab'; @@ -932,32 +913,18 @@ function CommentList({ return (
{/* Single continuous vertical line from top to just before the last reply's curve */} - {lastReplyTop != null && parentCommentId != null && ( - + {parentCommentId != null && ( + toggleThreadCollapsed(parentCommentId)} + onFocusBranch={() => hi.setParentThreadFocus(parentCommentId)} + onPressBranch={() => hi.pressSpineForListParent(parentCommentId)} + onClearFocus={hi.clearFocus} + label={{ expand: 'Expand comment thread', collapse: 'Collapse comment thread' }} + /> )} {comments.map((comment, index) => { const isLastReply = index === comments.length - 1; @@ -1463,7 +1430,7 @@ function CommentItem({ // Starts just below the avatar it descends from. top: `${avatarBottomInRowPx(density)}px`, height: `${parentLineHeight}px`, - width: `${COMMENT_THREAD_LINE_HIT_PX}px`, + width: `${THREAD_BRANCH_HIT_PX}px`, }} > void +): Pick, 'onPointerLeave' | 'onBlur'> { + const leaveIfOutside = (event: React.PointerEvent | React.FocusEvent) => { + const next = event.relatedTarget; + // `relatedTarget` is EventTarget | null; only a Node is valid for `contains`. + if (next instanceof Node && event.currentTarget.contains(next)) return; + clearFocus(); + }; + return { onPointerLeave: leaveIfOutside, onBlur: leaveIfOutside }; +} + +/** + * The elbow the last row in a branch gets: down the spine, then a quarter-turn into the row. + * + * Drawn rather than composed from a border radius because it has to land on the row's avatar centre, + * which moves with density — at the panel's 20px avatar the arm sits 16px down a 32px row, not 10px. + */ +export function threadElbowPath(density: CommentDensity): string { + const spineOffsetPx = threadSpineOffsetPx(density); + const armY = threadArmCenterPx(density) - 0.5; + const radius = Math.min(9.5, Math.max(0, armY)); + return `M 0.5 0 L 0.5 ${armY - radius} Q 0.5 ${armY}, ${0.5 + radius} ${armY} L ${spineOffsetPx} ${armY}`; +} + +/** + * The continuous vertical line down the left of a branch, which collapses it when pressed. + * + * Stops at the last row rather than running the branch's full height: below that point the line + * would be pointing at nothing, and the elbow is what carries the eye into the final row. + */ +export function ThreadSpine({ + density, + heightPx, + lit, + collapsed, + onToggle, + onFocusBranch, + onPressBranch, + onClearFocus, + label, +}: { + density: CommentDensity; + /** Distance to the last row's arm. Null while unmeasured — the spine simply isn't drawn yet. */ + heightPx: number | null; + lit: boolean; + collapsed: boolean; + onToggle: () => void; + onFocusBranch: () => void; + onPressBranch: () => void; + onClearFocus: () => void; + label: { expand: string; collapse: string }; +}) { + if (heightPx == null) return null; + + return ( + + ); +} + +/** The quarter-turn into the final row of a branch. */ +export function ThreadElbow({ density, lit }: { density: CommentDensity; lit: boolean }) { + const spineOffsetPx = threadSpineOffsetPx(density); + const armCenterPx = threadArmCenterPx(density); + + return ( + + ); +} + +/** The straight horizontal tick from the spine into a row that is not the last one. */ +export function ThreadArm({ density, lit }: { density: CommentDensity; lit: boolean }) { + return ( +
+ ); +} From 1dcd6e1f1ffa7d6ed86b19aaa0172c588c04e876 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 23 Sep 2026 16:21:53 -0700 Subject: [PATCH 04/35] fix(claims): draw a debate's branch the way a comment thread draws its replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connectors were a half-copy: arms floating at the wrong height, no spine between them, and none of the ⊖ controls that make a comment thread collapsible. They now come from one implementation rather than two that resemble each other. `thread-branch.tsx` owns the parent spine, the list spine, the arm, the elbow and the collapse toggle, and `CommentList` and `CommentItem` draw theirs from it — so a change to how a branch looks cannot land on one surface and miss the other. Each piece takes pixels rather than a `CommentDensity`, because the two ends of a connector can belong to rows of different sizes: a claim hanging off a debate reaches back to a 44px keyframe column and lands on a 32px avatar, where a reply does both at 32. A single density would have quietly assumed those were one number, which is the class of bug the geometry helpers exist to prevent. A debate row is now laid out as what it is — a row with a branch of rows that belong to it. The spine descends from the thumbnail, the ⊖ threaded onto it in the action row closes the whole branch, and ⊕ in the thumbnail's place reopens it. And a debate's own comments load into that branch, after the claims. They never appeared before because they reply to the debate, not to the claim, so the claim's backlinks do not know about them: the reader saw a debate, the claims it produced, and no sign of the conversation happening on it one click away. Claims first, because they are what the debate is and the comments are a reaction to them. The fetch is gated on the count the feed already has, so debates with no comments — most of them — cost nothing to rule out. --- .../claims/browse/debate-activity-row.tsx | 374 +++++++++++------- .../core/claims/browse/debate-comment-row.tsx | 101 +++++ apps/web/core/hooks/use-comments.ts | 10 +- .../partials/comments/comments-section.tsx | 99 ++--- apps/web/partials/comments/thread-branch.tsx | 204 +++++++--- 5 files changed, 533 insertions(+), 255 deletions(-) create mode 100644 apps/web/core/claims/browse/debate-comment-row.tsx diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index 602b6e8ccc..b7f5f49e01 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -4,11 +4,12 @@ import * as React from 'react'; import cx from 'classnames'; +import { useEntityCommentCounts } from '~/core/comments/use-entity-comment-counts'; import type { DebateResponseKind } from '~/core/debates/api'; import { useDebateClaims } from '~/core/debates/hooks'; import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; -import { useEntityCommentCounts } from '~/core/comments/use-entity-comment-counts'; +import { useComments } from '~/core/hooks/use-comments'; import { useEntityCommentsPanel } from '~/core/hooks/use-entity-comments-panel'; import { uuidToHex } from '~/core/id/normalize'; import type { ResponseKind } from '~/core/responses/entity-response'; @@ -19,17 +20,48 @@ import { GeoImage } from '~/design-system/geo-image'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Skeleton } from '~/design-system/skeleton'; -import { type CommentDensity, PAGE_DENSITY } from '~/partials/comments/comment-density'; +import { type CommentDensity, PAGE_DENSITY, threadArmCenterPx } from '~/partials/comments/comment-density'; import { getRelativeTime } from '~/partials/comments/comment-time'; import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; -import { ThreadArm, ThreadElbow, ThreadSpine } from '~/partials/comments/thread-branch'; +import { + ThreadArm, + ThreadCollapseToggle, + ThreadElbow, + ThreadListSpine, + ThreadParentSpine, +} from '~/partials/comments/thread-branch'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import { orderExtractedClaims } from './claim-activity-order'; +import { DebateCommentRow } from './debate-comment-row'; import { ExtractedClaimRow, type SpeakerProfile } from './extracted-claim-row'; -/** Same 48px the claim page's debates module uses, in the 540 × 820 the videos are published at. */ -const KEYFRAME_WIDTH_PX = 48; +/** + * Geometry for a debate row, which is a comment row with a wider left column. + * + * A debate's thumbnail is the still it was published with — 540 × 820, so portrait — and it needs + * more than a 32px avatar's width to be legible at all. Everything downstream is derived from these + * numbers rather than assumed, which is what lets the branch beneath reach back to the right place + * without any of the connector code knowing a debate exists. + */ +const KEYFRAME_WIDTH_PX = 44; +/** The still's own aspect, so it is never letterboxed or cropped. */ +const KEYFRAME_HEIGHT_PX = Math.round((KEYFRAME_WIDTH_PX * 820) / 540); +/** Gap between the thumbnail's bottom edge and where its spine starts. */ +const SPINE_START_GAP_PX = 4; + +const DEBATE_DENSITY: CommentDensity = { + ...PAGE_DENSITY, + avatarPx: KEYFRAME_WIDTH_PX, + avatarCenterPx: KEYFRAME_WIDTH_PX / 2, + // The same 12px gap a comment puts between its avatar and its text, so the two rows' bodies line + // up on the left even though their left columns are different widths. + bodyInsetPx: KEYFRAME_WIDTH_PX + 12, + headerMinHeightPx: KEYFRAME_HEIGHT_PX, +}; + +/** How far the branch's connectors reach back to find this row's spine. */ +const BRANCH_REACH_PX = DEBATE_DENSITY.bodyInsetPx - DEBATE_DENSITY.avatarCenterPx; export type DebateActivityRowProps = { debate: Entity; @@ -45,20 +77,18 @@ export type DebateActivityRowProps = { claimText: string | null; keyframeUrl: string | null; publishedAt: Date | null; - /** The surrounding thread's metrics, so this row sits on the same ramp as the comments. */ - density?: CommentDensity; }; /** - * A debate, as a row in the claim's activity feed, with the claims pulled out of it as its replies. + * A debate, as a row in the claim's activity feed, with everything it produced hanging off it. + * + * Laid out exactly like a comment with replies, because that is what it is: a row, then a branch of + * rows that belong to it. The spine descends from the thumbnail, the ⊖ threaded onto it closes the + * branch, and the rows below connect back with the same arms and elbow a reply gets. * * The thumbnail is the still the debate was published with rather than a mounted player. Three * debate rows on a page would otherwise be three `DebateFeedPlayer`s fetching and decoding at once * for a reader who is scrolling past all of them; the still costs one image, and Watch is one click. - * - * The score is the ordinary entity upvote/downvote, not a who-won tally. Winner voting is being - * sunset, and a row whose number needed a footnote to say which kind of voting produced it was - * exactly the confusion worth removing. */ export function DebateActivityRow({ debate, @@ -69,7 +99,6 @@ export function DebateActivityRow({ claimText, keyframeUrl, publishedAt, - density = PAGE_DENSITY, }: DebateActivityRowProps) { const [collapsed, setCollapsed] = React.useState(false); const { openComments } = useEntityCommentsPanel(); @@ -85,48 +114,105 @@ export function DebateActivityRow({ [sides] ); const title = debaters.length > 0 ? debaters.join(' vs. ') : (debate.name ?? 'Debate'); + const debateHref = NavUtils.toEntity(spaceId, debate.id); + + // Measured from this row's top down to where the branch begins, so the spine stops exactly there + // rather than guessing at the body's height. Same approach, and the same reason, as `CommentItem`. + const rowRef = React.useRef(null); + const branchRef = React.useRef(null); + const [spineHeightPx, setSpineHeightPx] = React.useState(null); + + const measureSpine = React.useCallback(() => { + const row = rowRef.current; + const branch = branchRef.current; + if (!row || !branch) { + setSpineHeightPx(null); + return; + } + // Starts below the thumbnail, so drop that much off its length. + setSpineHeightPx( + branch.getBoundingClientRect().top - row.getBoundingClientRect().top - KEYFRAME_HEIGHT_PX - SPINE_START_GAP_PX + ); + }, []); + + React.useLayoutEffect(() => { + measureSpine(); + const row = rowRef.current; + if (row == null || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => measureSpine()); + observer.observe(row); + return () => observer.disconnect(); + }); + + const branchLabel = { expand: 'Expand this debate', collapse: 'Collapse this debate' }; return ( -
-
- {/* Portrait, because that is the shape the video actually is: `Debate videos` declares - 540 × 820, so a 16:9 tile would letterbox every still it ever showed. Same geometry - `ClaimDebates` uses, so the two surfaces show a debate the same way. */} - - {keyframeUrl && ( - +
+ {!collapsed && ( + setCollapsed(true)} + /> + )} + +
+
+ {collapsed ? ( + setCollapsed(false)} /> + ) : ( + // Portrait, because that is the shape the video actually is: `Debate videos` declares + // 540 × 820, so a 16:9 tile would letterbox every still it ever showed. + + {keyframeUrl && ( + + )} + )} - +
- {title} - + {title} + Debate {publishedAt && ( - // Same helper and same classes the comment rows use, so a debate and a comment in one + // Same helper and classes the comment rows use, so a debate and a comment in one // thread age identically rather than reading as two lists side by side. - + {getRelativeTime(publishedAt.toISOString())} )}
{claimText && ( - - + + {claimText} )} -
+
+ {!collapsed && ( + setCollapsed(true)} + /> + )} {/* A reply to a debate is a comment on the debate, so it goes where that entity's - comments already live rather than into this claim's thread. The panel is the - existing surface for commenting on an entity you are not currently reading. */} + comments already live rather than into this claim's thread. */} Watch debate
+ + {!collapsed && ( +
+ setCollapsed(true)} + branchLabel={branchLabel} + commentCount={debateCommentCount.get(uuidToHex(debate.id)) ?? 0} + /> +
+ )}
- - setCollapsed(value => !value)} - /> -
+
); } /** - * The debate's extracted claims, in the order they were said. + * Everything that hangs off a debate: the claims it produced, then the comments people left on it. * - * Loaded with the row rather than behind an expand. What a debate *produced* is the reason it is in - * this feed at all, and a row that only says a debate happened is the state the feed was built to - * replace — the reader should not have to ask twice to see the argument. - * - * The cost that bought the expand is smaller than it looks: the traversal is one request per - * debate, and the timing resolver reaches for the Whisper transcript only when a debate has a claim - * with no published offset — which, since the backfill, is two debates in the corpus. The breadth - * cap below is what keeps a long transcript from owning the page. + * Claims first because they are what the debate *is* — the comments are a reaction to it, and a + * reaction reads better after the thing it reacts to. Within the claims the order is the order they + * were said; the comments keep the thread's own newest-first order. */ -function ExtractedClaims({ +function DebateBranch({ debateId, spaceId, profilesBySpaceId, positionBySpaceId, responseVocabulary, - density, - collapsed, - onToggleCollapsed, + onCollapse, + branchLabel, + commentCount, }: { debateId: string; spaceId: string; @@ -196,12 +280,22 @@ function ExtractedClaims({ /** Debater space id (canonical) → the side they argued in this debate. */ positionBySpaceId: Map; responseVocabulary: DebateResponseKind; - density: CommentDensity; - collapsed: boolean; - onToggleCollapsed: () => void; + onCollapse: () => void; + branchLabel: { expand: string; collapse: string }; + /** From the feed's own aggregate. Zero means there is nothing here worth a request. */ + commentCount: number; }) { const { claims, isLoading } = useDebateTranscriptClaims(debateId, spaceId); const { timings, isReady } = useClaimTimings(debateId, claims); + // The debate's own comments, on the same query key the comments panel uses — so opening the panel + // on this debate costs no extra request, and a comment posted there appears here too. + const { comments: debateComments } = useComments({ + entityId: debateId, + spaceId, + // Most debates have no comments, and this feed already knows which. Fetching them anyway would + // be a backlink walk per debate row to discover a list we were told is empty. + enabled: commentCount > 0, + }); const claimIds = React.useMemo(() => claims.all.map(claim => claim.id), [claims.all]); // One call for every extracted claim on screen. Without it each row's own vote control would read @@ -226,37 +320,28 @@ function ExtractedClaims({ const ordered = React.useMemo(() => orderExtractedClaims(claims.all, timings), [claims.all, timings]); - // The branch hangs where a reply to this row would: indented to the parent's avatar centre and - // then out to the thread's body inset, so an extracted claim and a comment reply share a left - // edge and the connectors reach back to the same point. - const branchStyle = { - marginLeft: density.avatarCenterPx, - paddingLeft: density.bodyInsetPx - density.avatarCenterPx, - }; - - // Measured rather than computed: the spine has to stop at the last row's arm, and the rows are - // variable height (a claim sentence wraps to one line or three). Same approach `CommentList` takes - // for exactly the same reason. - const containerRef = React.useRef(null); + // Measured rather than computed: the spine has to stop at the last row's elbow, and these rows are + // variable height — a claim sentence wraps to one line or three. + const listRef = React.useRef(null); const lastRowRef = React.useRef(null); const [spineHeightPx, setSpineHeightPx] = React.useState(null); const measureSpine = React.useCallback(() => { - const container = containerRef.current; + const list = listRef.current; const lastRow = lastRowRef.current; - if (!container || !lastRow) { + if (!list || !lastRow) { setSpineHeightPx(null); return; } - setSpineHeightPx(lastRow.getBoundingClientRect().top - container.getBoundingClientRect().top); + setSpineHeightPx(lastRow.getBoundingClientRect().top - list.getBoundingClientRect().top); }, []); React.useLayoutEffect(() => { measureSpine(); - const container = containerRef.current; - if (container == null || typeof ResizeObserver === 'undefined') return; + const list = listRef.current; + if (list == null || typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(() => measureSpine()); - observer.observe(container); + observer.observe(list); return () => observer.disconnect(); }); @@ -264,91 +349,49 @@ function ExtractedClaims({ // Held rather than painted unordered. `isReady` is false only while a timing source is still // arriving, and it reports ready on a failed transcript fetch — so this cannot hang forever on // a debate whose transcript is gone; that degrades to "all untimed, arrival order". - return ; + return ; } // Timed first, in order, then the ones nothing could place. The tail is not sorted among itself: // there is nothing to sort it by, and imposing an order would say there was. - const rows = [...ordered.timed, ...ordered.untimed]; + const claimsInOrder = [...ordered.timed, ...ordered.untimed]; + const rowCount = claimsInOrder.length + debateComments.length; // Silent rather than apologetic. Claim extraction postdates a chunk of the corpus, so a debate - // with none is ordinary — and a line saying so under every old debate is noise in a feed. - if (rows.length === 0) return null; - - // Every claim, not a page of them. These are what the debate produced; a reader who has scrolled - // to a debate has asked for them, and "Show 17 more claims" was asking a second time. The spine - // collapses the whole branch in one press, which is the control that actually shortens the page. - const collapseLabel = { - expand: `Expand ${rows.length} claims from this debate`, - collapse: `Collapse ${rows.length} claims from this debate`, - }; - - if (collapsed) { - return ( -
- -
- ); - } + // with nothing under it is ordinary — and a line saying so under every old debate is noise. + if (rowCount === 0) return null; + + const armCenterPx = threadArmCenterPx(PAGE_DENSITY); return ( -
- + - {rows.map((claim, index) => { + {claimsInOrder.map((claim, index) => { const speakerSpaceId = speakerBySourceBlockId.get(uuidToHex(claim.blockId)) ?? null; - const isLast = index === rows.length - 1; + const isLast = index === rowCount - 1; return ( -
-
- {isLast ? ( -
- -
- ) : ( - - )} -
- + -
+ + ); + })} + + {debateComments.map((comment, index) => { + const isLast = claimsInOrder.length + index === rowCount - 1; + return ( + + + ); })}
); } -/** The branch has no cross-row highlight of its own yet; the spine still collapses on press. */ -function noop() {} +/** One row in a branch, with the connector that ties it back to the spine. */ +function BranchRow({ + isLast, + armCenterPx, + rowRef, + children, +}: { + isLast: boolean; + armCenterPx: number; + rowRef?: React.Ref; + children: React.ReactNode; +}) { + return ( +
+
+ {isLast ? ( + + ) : ( + + )} +
+ {children} +
+ ); +} diff --git a/apps/web/core/claims/browse/debate-comment-row.tsx b/apps/web/core/claims/browse/debate-comment-row.tsx new file mode 100644 index 0000000000..44a6712e4d --- /dev/null +++ b/apps/web/core/claims/browse/debate-comment-row.tsx @@ -0,0 +1,101 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; + +import { useEntityCommentsPanel } from '~/core/hooks/use-entity-comments-panel'; +import { renderMarkdownDocument } from '~/core/state/editor/markdown-render'; +import { NavUtils } from '~/core/utils/utils'; + +import { Avatar } from '~/design-system/avatar'; + +import { PAGE_DENSITY } from '~/partials/comments/comment-density'; +import { getRelativeTime } from '~/partials/comments/comment-time'; +import type { CommentWithReplies } from '~/partials/comments/types'; +import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; + +/** + * A comment somebody left on a debate, shown in the claim's activity thread. + * + * These do not appear in the claim's own comment backlinks — they reply to the debate, not to the + * claim — so without this the reader saw a debate, the claims it produced, and no sign of the + * conversation happening on it one click away. + * + * Read-only apart from voting. Replying lands in the debate's comment panel, which is where the + * reply chain for that entity actually lives; threading a composer for a second entity into this + * one's list would put the reply somewhere the surrounding thread cannot show it. + */ +export function DebateCommentRow({ + comment, + debateId, + spaceId, +}: { + comment: CommentWithReplies; + debateId: string; + spaceId: string; +}) { + const { openComments } = useEntityCommentsPanel(); + const body = React.useMemo(() => renderMarkdownDocument(comment.markdownContent), [comment.markdownContent]); + const replyCount = comment.replies.length; + + return ( +
+ {/* `Avatar` fills its container whenever it has a real `avatarUrl` — `size` only sizes the + generated fallback — so the frame is the caller's job, exactly as in the comment rows. */} + + + + +
+
+ + {comment.author.name ?? 'Anonymous'} + + + {comment.isPublishing ? 'Publishing…' : getRelativeTime(comment.createdAt)} + +
+ +
+ {body} +
+ +
+ + + {replyCount > 0 && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/core/hooks/use-comments.ts b/apps/web/core/hooks/use-comments.ts index ff4ea3d378..9b7e129496 100644 --- a/apps/web/core/hooks/use-comments.ts +++ b/apps/web/core/hooks/use-comments.ts @@ -138,6 +138,12 @@ function buildCommentTree(comments: CommentEntity[]): CommentWithReplies[] { interface UseCommentsOptions { entityId: string; spaceId: string; + /** + * Skip the fetch entirely. For a caller that already knows the count is zero — the claim page's + * activity feed reads one aggregate for every debate on it — this is the difference between one + * request and one per row, for rows that would all come back empty. + */ + enabled?: boolean; } /** @@ -225,7 +231,7 @@ export function mergePendingWithServer(server: CommentEntity[], prev: CommentEnt return pendingOnly.length > 0 ? [...server, ...pendingOnly] : server; } -export function useComments({ entityId }: UseCommentsOptions) { +export function useComments({ entityId, enabled = true }: UseCommentsOptions) { const queryClient = useQueryClient(); const { @@ -246,7 +252,7 @@ export function useComments({ entityId }: UseCommentsOptions) { queryClient.setQueryData(commentsFetchedQueryKey(entityId), true); return mergePendingWithServer(server, prev); }, - enabled: !!entityId, + enabled: enabled && !!entityId, }); const comments = React.useMemo(() => { diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index 5eeb1f9b4b..36121c3f42 100644 --- a/apps/web/partials/comments/comments-section.tsx +++ b/apps/web/partials/comments/comments-section.tsx @@ -34,8 +34,6 @@ import { NavUtils } from '~/core/utils/utils'; import { Avatar } from '~/design-system/avatar'; import { Dropdown } from '~/design-system/dropdown'; -import { Minus } from '~/design-system/icons/minus'; -import { Plus } from '~/design-system/icons/plus'; import { RightArrowDiagonal } from '~/design-system/icons/right-arrow-diagonal'; import { Spacer } from '~/design-system/spacer'; import { Text } from '~/design-system/text'; @@ -55,7 +53,12 @@ import { threadArmCenterPx, threadSpineOffsetPx, } from './comment-density'; -import { THREAD_BRANCH_HIT_PX, ThreadSpine, branchPointerBlurProps } from './thread-branch'; +import { + ThreadCollapseToggle, + ThreadListSpine, + ThreadParentSpine, + branchPointerBlurProps, +} from './thread-branch'; import { getRelativeTime } from './comment-time'; import type { CommentActivityRow, CommentFilter, CommentSortOrder, CommentWithReplies } from './types'; @@ -914,8 +917,8 @@ function CommentList({
{/* Single continuous vertical line from top to just before the last reply's curve */} {parentCommentId != null && ( - (null); @@ -1278,24 +1280,15 @@ function CommentItem({ {!isEditing && (
{showThreadToggle && showBranchCollapseButton && ( - + toggleThreadCollapsed(comment.id)} + onFocusBranch={() => hi.setParentThreadFocus(comment.id)} + onPressBranch={() => hi.pressSpineForListParent(comment.id)} + onClearFocus={hi.clearFocus} + /> )} + toggleThreadCollapsed(comment.id)} + /> )}
) : hasReplies ? (
- {parentLineHeight != null && parentLineHeight > 0 && ( - - )} + toggleThreadCollapsed(comment.id)} + onFocusBranch={() => hi.setParentThreadFocus(comment.id)} + onPressBranch={() => hi.pressSpineForListParent(comment.id)} + onClearFocus={hi.clearFocus} + /> {expandedHeaderRow}
{expandedBodyMain} diff --git a/apps/web/partials/comments/thread-branch.tsx b/apps/web/partials/comments/thread-branch.tsx index 4f1f01a8cd..b587d4c885 100644 --- a/apps/web/partials/comments/thread-branch.tsx +++ b/apps/web/partials/comments/thread-branch.tsx @@ -4,15 +4,15 @@ import * as React from 'react'; import cx from 'classnames'; +import { Minus } from '~/design-system/icons/minus'; +import { Plus } from '~/design-system/icons/plus'; + import { - type CommentDensity, + THREAD_LEVEL_BRANCH_SEGMENT, THREAD_SEGMENT_DIM, THREAD_SEGMENT_DIM_STROKE, THREAD_SEGMENT_HI, THREAD_SEGMENT_HI_STROKE, - THREAD_LEVEL_BRANCH_SEGMENT, - threadArmCenterPx, - threadSpineOffsetPx, } from './comment-density'; /** @@ -22,14 +22,25 @@ import { * the claims that hang off a debate, because to a reader they are the same relationship — this row * belongs to that one — and drawing it two ways would say they were different. * - * Geometry comes from `comment-density.ts` rather than from constants here, so a branch follows - * whatever avatar size the surrounding thread is using. The segment classes are shared for the same - * reason: a hover that lights one branch and not another is the tell that there are two of these. + * Every piece takes pixels rather than a `CommentDensity`, because the two ends of a connector can + * belong to rows of different sizes. A claim hanging off a debate reaches back to a 44px-wide + * keyframe column and lands on a 32px avatar; a reply hanging off a comment does both at 32. Taking + * a single density would have quietly assumed those are the same number, which is exactly the class + * of bug the geometry helpers in `comment-density.ts` exist to prevent. */ /** Width of the invisible hit area over a connector, so a 1px line is actually pressable. */ export const THREAD_BRANCH_HIT_PX = 20; +/** + * Half a pixel, to put a control's centre on a 1px line rather than beside it. + * + * A 1px line drawn at x spans x..x+1, so its visual centre is x+0.5. Without this the toggle sits + * consistently half a pixel left of the line it is supposed to be threaded onto, which reads as a + * wobble on a retina display and as a clear miss on a 1x one. + */ +export const THREAD_LINE_CENTER_NUDGE_PX = 0.5; + /** * Clears a branch's highlight when the pointer or focus genuinely leaves it. * @@ -49,48 +60,90 @@ export function branchPointerBlurProps( return { onPointerLeave: leaveIfOutside, onBlur: leaveIfOutside }; } +/** What every connector needs to know about the branch it belongs to. */ +type BranchFocusHandlers = { + lit: boolean; + onFocusBranch?: () => void; + onPressBranch?: () => void; + onClearFocus?: () => void; +}; + /** - * The elbow the last row in a branch gets: down the spine, then a quarter-turn into the row. + * The vertical line descending from a row into the branch beneath it, which collapses that branch. * - * Drawn rather than composed from a border radius because it has to land on the row's avatar centre, - * which moves with density — at the panel's 20px avatar the arm sits 16px down a 32px row, not 10px. + * Positioned absolutely inside the parent row's own relative box: `leftPx` is the centre of the + * avatar (or thumbnail) it descends from, `topPx` is that element's bottom edge, and `heightPx` is + * measured down to wherever the branch actually begins. Measured rather than computed because the + * rows between are variable height — a body that wraps to three lines moves it. */ -export function threadElbowPath(density: CommentDensity): string { - const spineOffsetPx = threadSpineOffsetPx(density); - const armY = threadArmCenterPx(density) - 0.5; - const radius = Math.min(9.5, Math.max(0, armY)); - return `M 0.5 0 L 0.5 ${armY - radius} Q 0.5 ${armY}, ${0.5 + radius} ${armY} L ${spineOffsetPx} ${armY}`; +export function ThreadParentSpine({ + leftPx, + topPx, + heightPx, + lit, + label, + onToggle, + onFocusBranch, + onPressBranch, + onClearFocus, +}: BranchFocusHandlers & { + leftPx: number; + topPx: number; + /** Null or non-positive while unmeasured, or when the branch is collapsed: nothing is drawn. */ + heightPx: number | null; + label: string; + onToggle: () => void; +}) { + if (heightPx == null || heightPx <= 0) return null; + + return ( + + ); } /** - * The continuous vertical line down the left of a branch, which collapses it when pressed. + * The continuous line down the left of a branch, from its top to the last row's elbow. * * Stops at the last row rather than running the branch's full height: below that point the line * would be pointing at nothing, and the elbow is what carries the eye into the final row. */ -export function ThreadSpine({ - density, +export function ThreadListSpine({ + reachPx, heightPx, lit, collapsed, + label, onToggle, onFocusBranch, onPressBranch, onClearFocus, - label, -}: { - density: CommentDensity; - /** Distance to the last row's arm. Null while unmeasured — the spine simply isn't drawn yet. */ +}: BranchFocusHandlers & { + /** How far left of the branch's own edge the parent's spine sits. */ + reachPx: number; heightPx: number | null; - lit: boolean; collapsed: boolean; - onToggle: () => void; - onFocusBranch: () => void; - onPressBranch: () => void; - onClearFocus: () => void; label: { expand: string; collapse: string }; + onToggle: () => void; }) { - if (heightPx == null) return null; + if (heightPx == null || heightPx <= 0) return null; return ( + ); + } + + return ( + + ); +} + +function noop() {} From 61dd1574eb96605f84a1e538001c63273b948e86 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 23 Sep 2026 18:34:21 -0700 Subject: [PATCH 05/35] feat(claims): badge a debate commenter with their position on the claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Someone who comments under a debate and holds a position on the claim it argued is making the same commitment as someone who comments on the claim directly, so the thread now says so in both places rather than only where the comment happened to be filed. Same badge, same provider — the context already wrapped the whole thread, the row just was not asking for it. The debate row leads with the claim and follows with the debaters, the other way round from the first cut. A comment row's strong first line is what was said and its byline is who said it; a debate row reads as a peer of those rows only if it is built the same way. The byline no longer falls back to `debate.name` when no debater profile has resolved: published debates are named " | vs. " since #2554, so that fallback would have printed the claim underneath itself. And the page composer says "Join the conversation..." like the panel's, rather than "Start the discussion..." — which is the wrong offer on a thread that already holds debates and the claims pulled out of them. --- .../claims/browse/debate-activity-row.tsx | 36 ++++-- .../claims/browse/debate-comment-row.test.tsx | 115 ++++++++++++++++++ .../core/claims/browse/debate-comment-row.tsx | 9 ++ .../partials/comments/comments-section.tsx | 7 +- 4 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 apps/web/core/claims/browse/debate-comment-row.test.tsx diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index b7f5f49e01..f0c75b2fa2 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -113,7 +113,13 @@ export function DebateActivityRow({ () => new Map(sides.map(side => [uuidToHex(side.spaceId), side.position])), [sides] ); - const title = debaters.length > 0 ? debaters.join(' vs. ') : (debate.name ?? 'Debate'); + // Null when no debater profile has resolved yet. Deliberately *not* falling back to `debate.name`: + // a published debate is named " | vs. " (#2554), so using it as the byline would + // print the claim a second time under itself. + const debaterLine = debaters.length > 0 ? debaters.join(' vs. ') : null; + // What the debate argued, from its own `Claims` relation. `debate.name` is the last resort and + // already leads with the claim, so it degrades to roughly the right sentence. + const headline = claimText ?? debate.name ?? 'Debate'; const debateHref = NavUtils.toEntity(spaceId, debate.id); // Measured from this row's top down to where the branch begins, so the spine stops exactly there @@ -168,7 +174,7 @@ export function DebateActivityRow({ // 540 × 820, so a 16:9 tile would letterbox every still it ever showed. @@ -180,11 +186,27 @@ export function DebateActivityRow({
+ {/* + The claim leads and the debaters follow, the other way round from the first cut. + A comment row's strong first line is what was said, and its byline is who said it; a + debate row reads as a peer of those rows only if it is built the same way. What the + debate argued is the row's substance — "Ada vs. Tomas" is its byline. + */}
- {title} + + + {headline} + + Debate +
+ +
+ {debaterLine && ( + {debaterLine} + )} {publishedAt && ( // Same helper and classes the comment rows use, so a debate and a comment in one // thread age identically rather than reading as two lists side by side. @@ -194,14 +216,6 @@ export function DebateActivityRow({ )}
- {claimText && ( - - - {claimText} - - - )} -
{!collapsed && ( ({ + /** Responder space id → the side they hold on the claim, as the provider would resolve it. */ + directions: new Map(), +})); + +// The badge under test reads its own context, which the claim page supplies around the whole +// thread. Standing in for the provider keeps this about whether the row asks for the badge at all. +vi.mock('~/core/claims/browse/claim-comment-position', () => ({ + ClaimCommentPositionBadge: ({ authorSpaceId }: { authorSpaceId: string }) => { + const direction = mocks.directions.get(authorSpaceId); + if (!direction) return null; + return {direction === 'positive' ? 'Agree' : 'Disagree'}; + }, +})); + +vi.mock('~/core/hooks/use-entity-comments-panel', () => ({ + useEntityCommentsPanel: () => ({ commentsTarget: null, openComments: vi.fn() }), +})); +vi.mock('~/core/state/editor/markdown-render', () => ({ renderMarkdownDocument: (text: string) => text })); +vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ EntityVoteButtons: () => null })); + +import { DebateCommentRow } from './debate-comment-row'; + +function comment(overrides: Partial = {}): CommentWithReplies { + return { + id: 'comment-1', + name: 'comment', + markdownContent: 'The containment failure was a misconfiguration, not a capability problem.', + targetEntityId: 'debate-1', + targetSpaceId: 'space-1', + replyToCommentId: null, + replyToCommentSpaceId: null, + author: { spaceId: 'author-space', address: '0xabc', name: 'Preston Mantel', avatarUrl: null }, + createdAt: '2026-09-22T10:00:00Z', + spaceId: 'author-space', + resolved: false, + replies: [], + ...overrides, + }; +} + +function renderRow(overrides: Partial = {}) { + return render(); +} + +describe('DebateCommentRow', () => { + afterEach(() => { + cleanup(); + mocks.directions.clear(); + }); + + it('shows the author and what they said', () => { + renderRow(); + + expect(screen.getByText('Preston Mantel')).toBeInTheDocument(); + expect( + screen.getByText('The containment failure was a misconfiguration, not a capability problem.') + ).toBeInTheDocument(); + }); + + // The comment is filed against the debate, but the person holds a position on the claim this page + // is about — and that is the same commitment a commenter on the claim would be badged for. + it('badges the author with the position they hold on the claim', () => { + mocks.directions.set('author-space', 'positive'); + + renderRow(); + + expect(screen.getByTestId('position-badge')).toHaveTextContent('Agree'); + }); + + it('badges a disagreeing author too', () => { + mocks.directions.set('author-space', 'negative'); + + renderRow(); + + expect(screen.getByTestId('position-badge')).toHaveTextContent('Disagree'); + }); + + it('draws no badge for an author who holds no position on the claim', () => { + renderRow(); + + expect(screen.queryByTestId('position-badge')).not.toBeInTheDocument(); + }); + + it('ages the comment the way the surrounding thread does', () => { + renderRow({ createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString() }); + + expect(screen.getByText('2d ago')).toBeInTheDocument(); + }); + + it('says "Publishing…" instead of an age while the comment is still being written', () => { + renderRow({ isPublishing: true }); + + expect(screen.getByText('Publishing…')).toBeInTheDocument(); + }); + + it('offers the replies as a way into the debate’s own thread', () => { + renderRow({ replies: [comment({ id: 'reply-1' })] }); + + expect(screen.getByRole('button', { name: '1 reply' })).toBeInTheDocument(); + }); + + it('offers no reply count when nobody has replied', () => { + renderRow(); + + expect(screen.queryByRole('button', { name: /repl(y|ies)$/ })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/core/claims/browse/debate-comment-row.tsx b/apps/web/core/claims/browse/debate-comment-row.tsx index 44a6712e4d..2861182796 100644 --- a/apps/web/core/claims/browse/debate-comment-row.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import cx from 'classnames'; +import { ClaimCommentPositionBadge } from '~/core/claims/browse/claim-comment-position'; import { useEntityCommentsPanel } from '~/core/hooks/use-entity-comments-panel'; import { renderMarkdownDocument } from '~/core/state/editor/markdown-render'; import { NavUtils } from '~/core/utils/utils'; @@ -60,6 +61,14 @@ export function DebateCommentRow({ {comment.author.name ?? 'Anonymous'} + {/* + Where this person stands on the *claim*, not on the debate — the same badge, from the + same provider, that a commenter on the claim gets. Someone who argues under a debate and + holds a position on the claim it argued is the same person making the same commitment, + and the thread should say so in one place rather than only where the comment happened to + be filed. Renders nothing for a commenter who holds no position. + */} + {comment.isPublishing ? 'Publishing…' : getRelativeTime(comment.createdAt)} diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index 36121c3f42..8a6f3f34e7 100644 --- a/apps/web/partials/comments/comments-section.tsx +++ b/apps/web/partials/comments/comments-section.tsx @@ -581,7 +581,7 @@ function CommentFilters({ ); } -/** Top-level pill-style input matching the design ("Start the discussion...") */ +/** Top-level pill-style input matching the design ("Join the conversation...") */ function TopLevelCommentInput({ onSubmit, isLoggedIn, @@ -638,7 +638,10 @@ function TopLevelCommentInput({ onClick={openComposer} className="w-full rounded-lg border border-grey-02 px-4 py-3 text-left text-body text-grey-04 hover:border-text" > - Start the discussion... + {/* Same invitation the panel composer gives. The page variant said "Start the discussion", + which is the wrong offer on a thread that already holds debates and the claims from + them — there is a discussion, and the reader is being asked to join it. */} + Join the conversation... ); } From e72731bdd0b8a01920f22c10fa46f50bda962aa5 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 11:48:04 -0700 Subject: [PATCH 06/35] feat(claims): four levels of activity, and one count the card and page agree on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depth goes to four: a debate, the claims it produced, the comments on one of those, and the replies to those. Past that the row shows its count and a way into the debate's own thread, which is where a conversation that deep already lives. The plain comment thread is unchanged — it has always recursed as far as it goes. The Activity heading now counts everything the feed draws rather than only the rows it can see: debates, the claims extracted from them, and every comment anywhere in that tree. The claim's Explore card shows the same number, from the same definition, so opening a card can no longer contradict what it promised. Two measurements made that affordable rather than a per-card traversal: Comment depth is free. A reply carries `Reply to` to every ancestor, not just its parent, so one aggregate counts an entity's whole comment tree at any depth. Extracted claims can be counted from the debate — they are published with `Sources` pointing back at it, so it is one aggregate rather than the Debate → Transcripts → Blocks → Claims walk used to read them. Identical totals against testnet (177 across 30 debates), 0.52s against 3.48s. Added to the shared explore card selection the whole thing costs +0.02s and +1 KB over a 20-card page, because for anything that is not a claim those lookups return empty immediately — so there is no claim-only selection to keep in step. What the count means lives in `claim-activity-fields.ts`, deliberately not a client module: the card's selection is assembled on the server, and a `'use client'` import there becomes a client-reference proxy at build time, which interpolated the fields away and broke the query. The build caught it; a parse test now covers the assembled selection so the next one is caught in a suite. Debates keep their Activity gallery as well as their place in the thread. The gallery is the way through to the Debates tab — the complete, filterable index — where the thread carries the recent ones in the order they happened. --- .../browse/claim-activity-count.test.ts | 84 ++++++++++++ .../claims/browse/claim-activity-count.ts | 124 +++++++++++++++++ .../claims/browse/claim-activity-fields.ts | 112 ++++++++++++++++ .../claims/browse/claim-page-view.test.tsx | 55 ++++++-- .../core/claims/browse/claim-page-view.tsx | 22 +++ .../claims/browse/debate-activity-row.tsx | 118 +++++----------- .../claims/browse/debate-comment-row.test.tsx | 20 ++- .../core/claims/browse/debate-comment-row.tsx | 36 ++++- .../claims/browse/extracted-claim-row.tsx | 39 ++++++ apps/web/core/explore/explore-card-item.ts | 21 ++- .../explore/explore-card-selection.test.ts | 45 +++++++ .../core/explore/explore-card-selection.ts | 7 + .../partials/comments/comments-section.tsx | 11 +- .../partials/comments/thread-branch-list.tsx | 126 ++++++++++++++++++ .../explore/claim-explore-feed-card.tsx | 9 +- 15 files changed, 721 insertions(+), 108 deletions(-) create mode 100644 apps/web/core/claims/browse/claim-activity-count.test.ts create mode 100644 apps/web/core/claims/browse/claim-activity-count.ts create mode 100644 apps/web/core/claims/browse/claim-activity-fields.ts create mode 100644 apps/web/core/explore/explore-card-selection.test.ts create mode 100644 apps/web/partials/comments/thread-branch-list.tsx diff --git a/apps/web/core/claims/browse/claim-activity-count.test.ts b/apps/web/core/claims/browse/claim-activity-count.test.ts new file mode 100644 index 0000000000..12113495f7 --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-count.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeClaimActivityCounts } from './claim-activity-count'; + +function debate(comments: number, extracted: Array) { + return { + fromEntity: { + id: `debate${comments}${extracted.join('')}`, + comments: { totalCount: comments }, + extracted: extracted.map((n, index) => ({ + fromEntity: { id: `extracted${index}`, comments: { totalCount: n } }, + })), + }, + }; +} + +describe('decodeClaimActivityCounts', () => { + it('adds up everything the feed shows by default', () => { + const counts = decodeClaimActivityCounts({ + entities: [ + { + id: 'claim1', + comments: { totalCount: 3 }, + debates: [debate(2, [0, 1]), debate(0, [4])], + }, + ], + }); + + // 3 claim comments + 2 debates + 3 extracted claims + (2 + 0) debate comments + (0 + 1 + 4) + // comments on extracted claims. + expect(counts.get('claim1')).toEqual({ + comments: 3, + debates: 2, + extractedClaims: 3, + debateComments: 7, + total: 15, + }); + }); + + it('keys by canonical id so a hyphenated lookup finds it', () => { + const counts = decodeClaimActivityCounts({ + entities: [{ id: '3e4a0955-699a-4c8f-813f-cc803a3335ba', comments: { totalCount: 1 }, debates: [] }], + }); + + expect(counts.get('3e4a0955699a4c8f813fcc803a3335ba')?.total).toBe(1); + }); + + it('counts a claim nobody has touched as zero rather than leaving it out', () => { + const counts = decodeClaimActivityCounts({ entities: [{ id: 'claim1', comments: { totalCount: 0 }, debates: [] }] }); + + expect(counts.get('claim1')?.total).toBe(0); + }); + + // A debate with no transcript yet is still a debate — it counts, its absent claims do not. + it('counts a debate that produced no claims', () => { + const counts = decodeClaimActivityCounts({ + entities: [{ id: 'claim1', comments: { totalCount: 0 }, debates: [debate(0, [])] }], + }); + + expect(counts.get('claim1')).toMatchObject({ debates: 1, extractedClaims: 0, total: 1 }); + }); + + it('survives nulls anywhere the API may return them', () => { + const counts = decodeClaimActivityCounts({ + entities: [ + null, + { id: null, comments: null, debates: null }, + { + id: 'claim1', + comments: null, + debates: [null, { fromEntity: null }, { fromEntity: { id: 'd', comments: null, extracted: [null] } }], + }, + ], + }); + + expect([...counts.keys()]).toEqual(['claim1']); + expect(counts.get('claim1')).toMatchObject({ comments: 0, debates: 1, extractedClaims: 0, total: 1 }); + }); + + it('is empty when nothing came back', () => { + expect(decodeClaimActivityCounts({}).size).toBe(0); + expect(decodeClaimActivityCounts({ entities: null }).size).toBe(0); + }); +}); diff --git a/apps/web/core/claims/browse/claim-activity-count.ts b/apps/web/core/claims/browse/claim-activity-count.ts new file mode 100644 index 0000000000..224097d619 --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-count.ts @@ -0,0 +1,124 @@ +'use client'; + +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { useQuery } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { Effect } from 'effect'; +import { parse } from 'graphql'; + +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; +import { COMMENT_REPLY_TO_ID } from '~/core/comment-ids'; +import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_TYPE_ID, SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; +import { uuidToHex } from '~/core/id/normalize'; +import { graphql } from '~/core/io/graphql-client'; + +import { type ClaimActivityCount, countActivityForNode } from './claim-activity-fields'; + +export { CLAIM_ACTIVITY_COUNT_FIELDS, countActivityForNode } from './claim-activity-fields'; +export type { ClaimActivityCount } from './claim-activity-fields'; + +/** + * Activity totals for a set of claims, in one request. + * + * Explore cards get this for free — their own selection carries the fields, and what the number + * means lives in `claim-activity-fields.ts`. This is for surfaces holding a claim id with no card + * behind it, the claim page's own heading above all. + * + * Batched because the cost is per request rather than per claim, and a feed asking once per row is + * what makes a number on a row expensive: 20 active claims answer together in 0.69s and 17 KB. + */ +const CLAIM_ACTIVITY_COUNTS_SOURCE = /* GraphQL */ ` + query ClaimActivityCounts( + $ids: [UUID!] + $claimsPropertyId: UUID! + $sourcesPropertyId: UUID! + $replyToTypeId: UUID! + $debateTypeId: UUID! + $claimTypeId: UUID! + ) { + entities(filter: { id: { in: $ids } }) { + id + comments: backlinks(filter: { typeId: { is: $replyToTypeId } }) { + totalCount + } + debates: backlinksList( + filter: { typeId: { is: $claimsPropertyId }, fromEntity: { typeIds: { overlaps: [$debateTypeId] } } } + ) { + fromEntity { + id + comments: backlinks(filter: { typeId: { is: $replyToTypeId } }) { + totalCount + } + extracted: backlinksList( + filter: { typeId: { is: $sourcesPropertyId }, fromEntity: { typeIds: { overlaps: [$claimTypeId] } } } + ) { + fromEntity { + id + comments: backlinks(filter: { typeId: { is: $replyToTypeId } }) { + totalCount + } + } + } + } + } + } + } +`; + +const claimActivityCountsDocument = parse(CLAIM_ACTIVITY_COUNTS_SOURCE) as TypedDocumentNode; + +// Only `id` is read here by name; the counting fields are whatever `countActivityForNode` +// recognises, so the shape stays open rather than restating them in two places. +type CountsResponse = { entities?: Array<({ id?: string | null } & Record) | null> | null }; + +const EMPTY_COUNTS = new Map(); + +export function decodeClaimActivityCounts(data: CountsResponse): Map { + const counts = new Map(); + + for (const claim of data.entities ?? []) { + if (!claim?.id) continue; + counts.set(uuidToHex(claim.id), countActivityForNode(claim)); + } + + return counts; +} + +export const claimActivityCountsQueryKey = (ids: string[]) => ['claim-activity-counts', ids] as const; + +/** + * Activity totals keyed by canonical claim id. + * + * An absent entry means "not answered yet", not "nothing" — a caller showing a number should hold + * whatever it already had rather than flashing a zero it invented. + */ +export function useClaimActivityCounts(claimIds: string[], enabled = true): Map { + // Sorted and deduped, so the same claims in a different order are the same query. + const ids = React.useMemo(() => [...new Set(claimIds.filter(Boolean).map(uuidToHex))].sort(), [claimIds]); + + const { data } = useQuery({ + queryKey: claimActivityCountsQueryKey(ids), + queryFn: ({ signal }) => + Effect.runPromise( + graphql({ + query: claimActivityCountsDocument, + decoder: decodeClaimActivityCounts, + variables: { + ids, + claimsPropertyId: DEBATE_CLAIMS_PROPERTY_ID, + sourcesPropertyId: SOURCES_PROPERTY_ID, + replyToTypeId: COMMENT_REPLY_TO_ID, + debateTypeId: DEBATE_TYPE_ID, + claimTypeId: CLAIM_TYPE_ID, + }, + signal, + }) + ), + enabled: enabled && ids.length > 0, + staleTime: 60_000, + }); + + return data ?? EMPTY_COUNTS; +} diff --git a/apps/web/core/claims/browse/claim-activity-fields.ts b/apps/web/core/claims/browse/claim-activity-fields.ts new file mode 100644 index 0000000000..14199fbcad --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-fields.ts @@ -0,0 +1,112 @@ +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; +import { COMMENT_REPLY_TO_ID } from '~/core/comment-ids'; +import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_TYPE_ID, SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; + +/** + * What "how much has happened to this claim" means, and the fields it is read from. + * + * Deliberately **not** a client module. The explore card's selection is assembled on the server, and + * a `'use client'` module imported from there is replaced by a client-reference proxy at build time + * — the interpolated fields come out empty and the query fails to parse, which is how this split + * was found. The hook that runs these lives next door in `claim-activity-count.ts`. + * + * The number covers everything the claim page's feed draws by default: the debates held on the + * claim, the claims extracted from those debates, and every comment anywhere in that tree. One + * definition, so the claim card and the claim page cannot disagree about it. + * + * Two facts about the graph make it affordable, both measured rather than assumed: + * + * 1. **Comment depth is free.** A reply carries a `Reply to` relation to *every* ancestor, not just + * its parent (see `use-comments.ts`), so one `backlinks` aggregate counts an entity's whole + * comment tree at any depth. Nothing here walks replies. + * + * 2. **Extracted claims can be counted from the debate.** They are published with a `Sources` + * relation pointing back at it, so counting them is one aggregate rather than the + * Debate → Transcripts → Blocks → Claims traversal used to *read* them. Measured against testnet + * on 2026-09-24: identical totals (177 across 30 debates), 0.52s versus 3.48s. + * + * Adding these to the shared explore card selection costs +0.02s and +1 KB over a 20-card page, + * because for anything that is not a claim the relation lookups come back empty immediately — which + * is why there is no claim-only selection to maintain. + */ +export const CLAIM_ACTIVITY_COUNT_FIELDS = /* GraphQL */ ` + activityComments: backlinks(filter: { typeId: { is: "${COMMENT_REPLY_TO_ID}" } }) { + totalCount + } + activityDebates: backlinksList( + filter: { + typeId: { is: "${DEBATE_CLAIMS_PROPERTY_ID}" } + fromEntity: { typeIds: { overlaps: ["${DEBATE_TYPE_ID}"] } } + } + ) { + fromEntity { + id + comments: backlinks(filter: { typeId: { is: "${COMMENT_REPLY_TO_ID}" } }) { + totalCount + } + extracted: backlinksList( + filter: { + typeId: { is: "${SOURCES_PROPERTY_ID}" } + fromEntity: { typeIds: { overlaps: ["${CLAIM_TYPE_ID}"] } } + } + ) { + fromEntity { + id + comments: backlinks(filter: { typeId: { is: "${COMMENT_REPLY_TO_ID}" } }) { + totalCount + } + } + } + } + } +`; + +/** The parts, so a caller can explain the number rather than only print it. */ +export type ClaimActivityCount = { + /** Comments on the claim itself, replies included. */ + comments: number; + debates: number; + extractedClaims: number; + /** Comments anywhere under a debate or one of its extracted claims. */ + debateComments: number; + total: number; +}; + +function commentsOf(entity: { comments?: { totalCount?: number | null } | null } | null | undefined): number { + return entity?.comments?.totalCount ?? 0; +} + +/** + * The count for one entity's node, from either shape the fields arrive in. + * + * The batched query aliases them `comments`/`debates`; the explore card selection has to prefix its + * own (`activityComments`/`activityDebates`) because a card node already selects an unaliased + * `backlinks` for its comment pill. One function reads both, so the two cannot drift into counting + * different things. + */ +export function countActivityForNode(node: unknown): ClaimActivityCount { + const raw = (node ?? {}) as Record; + const claimComments = (raw.comments ?? raw.activityComments)?.totalCount ?? 0; + const debates = ((raw.debates ?? raw.activityDebates ?? []) as Array).flatMap(row => + row?.fromEntity ? [row.fromEntity] : [] + ); + + let extractedClaims = 0; + let debateComments = 0; + for (const debate of debates) { + debateComments += commentsOf(debate); + for (const row of (debate.extracted ?? []) as Array) { + if (!row?.fromEntity) continue; + extractedClaims += 1; + debateComments += commentsOf(row.fromEntity); + } + } + + return { + comments: claimComments, + debates: debates.length, + extractedClaims, + debateComments, + total: claimComments + debates.length + extractedClaims + debateComments, + }; +} diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index fea0c36f4c..63cb140317 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({ entity: null as Record | null, /** Non-comment rows the Overview orders into its activity thread — the debates on this claim. */ activityRows: [] as Array<{ id: string; createdAt: string; content: unknown }>, + /** What the shared activity count answers for this claim; null means it has not answered. */ + activityTotal: null as number | null, /** How many responses the claim has; zero means the hero draws no verdict column. */ responseTotal: 11, /** Whether the response counts are still out, which is what the hero reserves its column for. */ @@ -121,6 +123,12 @@ vi.mock('./use-claim-activity-rows', () => ({ useClaimActivityRows: () => ({ rows: mocks.activityRows, isLoading: false }), })); +// The heading's number, which is the same one the claim's Explore card shows. Its own query is +// covered by `claim-activity-count.test.ts`; here it only needs to reach the heading. +vi.mock('./claim-activity-count', () => ({ + useClaimActivityCounts: () => new Map(mocks.activityTotal == null ? [] : [['claim1', { total: mocks.activityTotal }]]), +})); + vi.mock('~/core/debates/hooks', () => ({ useDebateClaims: () => ({ data: { claims: [] } }), })); @@ -212,8 +220,21 @@ vi.mock('~/partials/profile/profile-activity-section', () => ({ })); vi.mock('~/partials/editor/editor', () => ({ Editor: () =>
})); vi.mock('~/partials/comments/comments-section', () => ({ - CommentSection: ({ title, activityRows }: { title?: string; activityRows?: Array<{ id: string }> }) => ( -
r.id).join(',')} /> + CommentSection: ({ + title, + activityRows, + totalOverride, + }: { + title?: string; + activityRows?: Array<{ id: string }>; + totalOverride?: number; + }) => ( +
r.id).join(',')} + /> ), })); @@ -229,6 +250,7 @@ function claimEntity(description: string | null) { beforeEach(() => { mocks.activityRows = []; + mocks.activityTotal = null; mocks.responseTotal = 11; mocks.summaryLoading = false; mocks.hasCounts = true; @@ -474,9 +496,11 @@ describe('ClaimPageView record', () => { render(); const kinds = mocks.activity?.kinds as Array<{ key: string; onSeeAll?: () => void }>; + kinds.find(kind => kind.key === 'debates')?.onSeeAll?.(); kinds.find(kind => kind.key === 'claims')?.onSeeAll?.(); - expect(setActiveSystemTab).toHaveBeenNthCalledWith(1, 'claims'); + expect(setActiveSystemTab).toHaveBeenNthCalledWith(1, 'debates'); + expect(setActiveSystemTab).toHaveBeenNthCalledWith(2, 'claims'); }); it('hands the debates on this claim to the thread, and names it Activity', () => { @@ -494,14 +518,28 @@ describe('ClaimPageView record', () => { expect(comments).toHaveAttribute('data-title', 'Activity'); }); - // GEO-3008: debates moved into the activity thread, where they are ordered among the comments. - // A gallery of them above that thread would have shown the same debates twice and left the - // reader working out whether they were the same ones. - it('leaves debates to the activity thread rather than giving them a gallery of their own', () => { + it('heads the thread with the same count the claim’s Explore card shows', () => { + mocks.activityTotal = 17; + + render(); + + expect(screen.getByTestId('comments')).toHaveAttribute('data-total', '17'); + }); + + it('lets the thread count for itself until that number answers', () => { + render(); + + expect(screen.getByTestId('comments')).toHaveAttribute('data-total', ''); + }); + + // GEO-3008: debates are in the activity thread *and* keep their gallery. The gallery is the way + // through to the Debates tab, which is the complete filterable index; the thread shows the recent + // ones in the order they happened. Two jobs rather than two copies. + it('keeps the debates gallery alongside the thread', () => { render(); const kinds = mocks.activity?.kinds as Array<{ key: string }>; - expect(kinds.map(kind => kind.key)).toEqual(['claims']); + expect(kinds.map(kind => kind.key)).toEqual(['debates', 'claims']); }); it('marks only failed record counts unavailable in Activity', () => { @@ -514,6 +552,7 @@ describe('ClaimPageView record', () => { const kinds = mocks.activity?.kinds as Array<{ key: string; isCountUnavailable?: boolean }>; expect(kinds.find(kind => kind.key === 'claims')?.isCountUnavailable).toBe(true); + expect(kinds.find(kind => kind.key === 'debates')?.isCountUnavailable).toBe(false); }); it('hands the full side-panel tab the claim record scope', () => { diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index e47e036b72..dc03218814 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -45,6 +45,7 @@ import { ClaimRecordTab } from './claim-record-tab'; import { getClaimSources } from './claim-sources'; import { ClaimSourcesTab } from './claim-sources-tab'; import { ClaimTopicsTab } from './claim-topics-tab'; +import { useClaimActivityCounts } from './claim-activity-count'; import { useClaimActivityRows } from './use-claim-activity-rows'; import { useClaimRecord } from './use-claim-record'; import { type ClaimResponseState, useClaimResponseState } from './use-claim-response-state'; @@ -454,6 +455,11 @@ function ClaimOverviewTab({ }) { // The claim's own vocabulary carries into the thread: a debater's side reads Agree/Disagree on // an opinion claim and Verify/Dispute on a factual one, the same as every commenter's badge. + // The same number the claim's card shows in Explore, from the same query — one definition of + // "how much has happened here", so the two surfaces cannot disagree. + const activityCounts = useClaimActivityCounts(React.useMemo(() => [entityId], [entityId])); + const activityTotal = activityCounts.get(ID.uuidToHex(entityId))?.total; + const activity = useClaimActivityRows({ claimId: entityId, spaceId, @@ -461,6 +467,21 @@ function ClaimOverviewTab({ }); const kinds: ActivityKind[] = [ + { + // Still here as well as in the thread below. The gallery is the way through to the Debates + // tab — the complete, filterable index — where the thread shows the few most recent in the + // order they happened. Two jobs, not two copies. + key: 'debates', + label: 'Debates', + rows: record.debateRows, + total: record.debatesTotal, + isLoading: record.debatesLoading, + isError: record.debatesError, + isCountUnavailable: record.debatesCountUnavailable, + href: hrefs.debates, + seeAllLabel: 'View all debates', + onSeeAll: onSelectSystemTab ? () => onSelectSystemTab('debates') : undefined, + }, { key: 'claims', label: 'Claims', @@ -514,6 +535,7 @@ function ClaimOverviewTab({ targetEntityType="claim" title="Activity" activityRows={activity.rows} + totalOverride={activityTotal} // Best rather than most-recent: this list is the record of an argument, not a running // conversation, and the thing worth reading first is what the thread rates highest. defaultSortOrder="best" diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index f0c75b2fa2..b075ac6af3 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -20,16 +20,11 @@ import { GeoImage } from '~/design-system/geo-image'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Skeleton } from '~/design-system/skeleton'; -import { type CommentDensity, PAGE_DENSITY, threadArmCenterPx } from '~/partials/comments/comment-density'; +import { type CommentDensity, PAGE_DENSITY } from '~/partials/comments/comment-density'; import { getRelativeTime } from '~/partials/comments/comment-time'; import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; -import { - ThreadArm, - ThreadCollapseToggle, - ThreadElbow, - ThreadListSpine, - ThreadParentSpine, -} from '~/partials/comments/thread-branch'; +import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; +import { ThreadCollapseToggle, ThreadParentSpine } from '~/partials/comments/thread-branch'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import { orderExtractedClaims } from './claim-activity-order'; @@ -60,6 +55,15 @@ const DEBATE_DENSITY: CommentDensity = { headerMinHeightPx: KEYFRAME_HEIGHT_PX, }; +/** + * How deep the feed draws before it stops and counts instead. + * + * Four: a debate, the claims it produced, the comments on one of those, and the replies to those. + * Past that the page is showing a conversation that has its own home, and the row's count plus a + * way in reads better than a fifth indent. + */ +export const ACTIVITY_MAX_DEPTH = 4; + /** How far the branch's connectors reach back to find this row's spine. */ const BRANCH_REACH_PX = DEBATE_DENSITY.bodyInsetPx - DEBATE_DENSITY.avatarCenterPx; @@ -334,31 +338,6 @@ function DebateBranch({ const ordered = React.useMemo(() => orderExtractedClaims(claims.all, timings), [claims.all, timings]); - // Measured rather than computed: the spine has to stop at the last row's elbow, and these rows are - // variable height — a claim sentence wraps to one line or three. - const listRef = React.useRef(null); - const lastRowRef = React.useRef(null); - const [spineHeightPx, setSpineHeightPx] = React.useState(null); - - const measureSpine = React.useCallback(() => { - const list = listRef.current; - const lastRow = lastRowRef.current; - if (!list || !lastRow) { - setSpineHeightPx(null); - return; - } - setSpineHeightPx(lastRow.getBoundingClientRect().top - list.getBoundingClientRect().top); - }, []); - - React.useLayoutEffect(() => { - measureSpine(); - const list = listRef.current; - if (list == null || typeof ResizeObserver === 'undefined') return; - const observer = new ResizeObserver(() => measureSpine()); - observer.observe(list); - return () => observer.disconnect(); - }); - if (isLoading || !isReady) { // Held rather than painted unordered. `isReady` is false only while a timing source is still // arriving, and it reports ready on a failed transcript fetch — so this cannot hang forever on @@ -375,29 +354,19 @@ function DebateBranch({ // with nothing under it is ordinary — and a line saying so under every old debate is noise. if (rowCount === 0) return null; - const armCenterPx = threadArmCenterPx(PAGE_DENSITY); - return ( -
- - + {claimsInOrder.map((claim, index) => { const speakerSpaceId = speakerBySourceBlockId.get(uuidToHex(claim.blockId)) ?? null; - const isLast = index === rowCount - 1; return ( - + - - ); - })} - - {debateComments.map((comment, index) => { - const isLast = claimsInOrder.length + index === rowCount - 1; - return ( - - - + ); })} -
- ); -} -/** One row in a branch, with the connector that ties it back to the spine. */ -function BranchRow({ - isLast, - armCenterPx, - rowRef, - children, -}: { - isLast: boolean; - armCenterPx: number; - rowRef?: React.Ref; - children: React.ReactNode; -}) { - return ( -
-
- {isLast ? ( - - ) : ( - - )} -
- {children} -
+ {debateComments.map((comment, index) => ( + + + + ))} + ); } diff --git a/apps/web/core/claims/browse/debate-comment-row.test.tsx b/apps/web/core/claims/browse/debate-comment-row.test.tsx index 72005d5923..2f83a70345 100644 --- a/apps/web/core/claims/browse/debate-comment-row.test.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.test.tsx @@ -46,8 +46,10 @@ function comment(overrides: Partial = {}): CommentWithReplie }; } -function renderRow(overrides: Partial = {}) { - return render(); +function renderRow(overrides: Partial = {}, maxDepth = 2) { + return render( + + ); } describe('DebateCommentRow', () => { @@ -101,9 +103,19 @@ describe('DebateCommentRow', () => { expect(screen.getByText('Publishing…')).toBeInTheDocument(); }); - it('offers the replies as a way into the debate’s own thread', () => { - renderRow({ replies: [comment({ id: 'reply-1' })] }); + it('draws its replies while there is depth budget left', () => { + renderRow({ replies: [comment({ id: 'reply-1', markdownContent: 'a reply' })] }); + expect(screen.getByText('a reply')).toBeInTheDocument(); + // Drawn, so the count would be saying the same thing twice. + expect(screen.queryByRole('button', { name: '1 reply' })).not.toBeInTheDocument(); + }); + + // Past the budget the thread has its own home, and a count plus a way in beats a fifth indent. + it('counts its replies instead of drawing them once the budget runs out', () => { + renderRow({ replies: [comment({ id: 'reply-1', markdownContent: 'a reply' })] }, 0); + + expect(screen.queryByText('a reply')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '1 reply' })).toBeInTheDocument(); }); diff --git a/apps/web/core/claims/browse/debate-comment-row.tsx b/apps/web/core/claims/browse/debate-comment-row.tsx index 2861182796..066530c665 100644 --- a/apps/web/core/claims/browse/debate-comment-row.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.tsx @@ -12,6 +12,7 @@ import { NavUtils } from '~/core/utils/utils'; import { Avatar } from '~/design-system/avatar'; import { PAGE_DENSITY } from '~/partials/comments/comment-density'; +import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; import { getRelativeTime } from '~/partials/comments/comment-time'; import type { CommentWithReplies } from '~/partials/comments/types'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; @@ -31,14 +32,22 @@ export function DebateCommentRow({ comment, debateId, spaceId, + depth = 0, + maxDepth, }: { comment: CommentWithReplies; debateId: string; spaceId: string; + /** How far below the branch's first row this one sits. */ + depth?: number; + /** Rows deeper than this are counted, not drawn — the reader opens the debate for the rest. */ + maxDepth: number; }) { const { openComments } = useEntityCommentsPanel(); const body = React.useMemo(() => renderMarkdownDocument(comment.markdownContent), [comment.markdownContent]); - const replyCount = comment.replies.length; + const replies = Array.isArray(comment.replies) ? comment.replies : []; + const replyCount = replies.length; + const showsReplies = replies.length > 0 && depth < maxDepth; return (
@@ -93,7 +102,7 @@ export function DebateCommentRow({ > Reply - {replyCount > 0 && ( + {replyCount > 0 && !showsReplies && (
+ + {/* + Replies come with the comment — they are already in the tree `useComments` built — so + drawing them costs nothing beyond the depth budget. Past that budget the count above takes + over and the debate's own panel is where the rest of the thread lives. + */} + {showsReplies && ( +
+ + {replies.map((reply, index) => ( + + + + ))} + +
+ )}
); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index 005ec87195..1176a7c9c5 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -6,6 +6,7 @@ import cx from 'classnames'; import type { DebateResponseKind } from '~/core/debates/api'; import { isAssertableMoment } from '~/core/debates/claim-timing'; +import { useComments } from '~/core/hooks/use-comments'; import { debateSeekSeconds, formatTimecode, withDebateTimecode } from '~/core/debates/debate-timecode'; import type { ResponseKind } from '~/core/responses/entity-response'; import { NavUtils } from '~/core/utils/utils'; @@ -14,10 +15,12 @@ import { Avatar } from '~/design-system/avatar'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { type CommentDensity, PAGE_DENSITY } from '~/partials/comments/comment-density'; +import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import type { OrderedTranscriptClaim } from './claim-activity-order'; +import { DebateCommentRow } from './debate-comment-row'; import { ResponsePositionTag } from './claim-comment-position'; export type SpeakerProfile = { name?: string | null; avatarUrl?: string | null }; @@ -45,6 +48,7 @@ export function ExtractedClaimRow({ speakerPosition, responseVocabulary, commentCount = 0, + maxDepth = 0, density = PAGE_DENSITY, className, }: { @@ -80,6 +84,8 @@ export function ExtractedClaimRow({ * comments — a number wrong in the one direction that tells the reader not to look. */ commentCount?: number; + /** Rows deeper than this are counted, not drawn. Zero means this row is already at the floor. */ + maxDepth?: number; /** The surrounding thread's metrics, so these rows sit on the same ramp as the comments. */ density?: CommentDensity; className?: string; @@ -170,11 +176,44 @@ export function ExtractedClaimRow({
) : null} + + {claimSpaceId && commentCount > 0 && maxDepth > 0 && ( + + )}
); } +/** + * The comments people have left on one extracted claim. + * + * Only mounted where the feed's own aggregate already said there are some, so a debate's ten silent + * claims cost ten fetches of nothing. That gate is why this can be eager at all: in the corpus today + * almost every extracted claim has no comments, and the few that do are worth a request. + */ +function ClaimComments({ claimId, spaceId, maxDepth }: { claimId: string; spaceId: string; maxDepth: number }) { + const { comments } = useComments({ entityId: claimId, spaceId }); + if (comments.length === 0) return null; + + return ( +
+ + {comments.map((comment, index) => ( + + + + ))} + +
+ ); +} + function PlayGlyph() { return (
- {title} ({totalCount + activityRows.length}) + {title} ({totalOverride ?? totalCount + activityRows.length})
diff --git a/apps/web/partials/comments/thread-branch-list.tsx b/apps/web/partials/comments/thread-branch-list.tsx new file mode 100644 index 0000000000..a7710e6561 --- /dev/null +++ b/apps/web/partials/comments/thread-branch-list.tsx @@ -0,0 +1,126 @@ +'use client'; + +import * as React from 'react'; + +import { type CommentDensity, threadArmCenterPx, threadSpineOffsetPx } from './comment-density'; +import { ThreadArm, ThreadElbow, ThreadListSpine } from './thread-branch'; + +/** + * A branch of rows hanging off the row above it, with its spine, arms and elbow. + * + * The claim page's activity feed grows several of these — claims under a debate, comments under a + * debate, replies under one of those comments — and each was otherwise re-deriving the same + * measurement and the same per-row connector choice. `CommentList` still draws its own, because its + * rows carry highlight state these do not; what is shared is the geometry and the pieces. + */ + +type BranchGeometry = { + /** How far left of this branch's edge the parent's spine sits. */ + reachPx: number; + /** Where a row's own avatar centre is, which is what an arm has to land on. */ + armCenterPx: number; + registerLastRow: React.Ref; +}; + +const ThreadBranchContext = React.createContext(null); + +export function ThreadBranch({ + density, + reachPx, + armCenterPx, + onCollapse, + label, + children, +}: { + /** + * The *parent* row's density, which is what the connectors reach back across. Defaults are taken + * from it; a parent whose left column is not an avatar — a debate's keyframe — passes its own. + */ + density: CommentDensity; + reachPx?: number; + armCenterPx?: number; + /** Omitted where the branch has no collapse of its own; the spine is then inert. */ + onCollapse?: () => void; + label?: { expand: string; collapse: string }; + children: React.ReactNode; +}) { + const reach = reachPx ?? threadSpineOffsetPx(density); + const arm = armCenterPx ?? threadArmCenterPx(density); + + // Measured rather than computed: the spine stops at the last row's elbow, and these rows are + // variable height — a claim sentence wraps to one line or three, a comment to more. + const listRef = React.useRef(null); + const lastRowRef = React.useRef(null); + const [spineHeightPx, setSpineHeightPx] = React.useState(null); + + const measure = React.useCallback(() => { + const list = listRef.current; + const lastRow = lastRowRef.current; + if (!list || !lastRow) { + setSpineHeightPx(null); + return; + } + setSpineHeightPx(lastRow.getBoundingClientRect().top - list.getBoundingClientRect().top); + }, []); + + React.useLayoutEffect(() => { + measure(); + const list = listRef.current; + if (list == null || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => measure()); + observer.observe(list); + return () => observer.disconnect(); + }); + + const geometry = React.useMemo( + () => ({ reachPx: reach, armCenterPx: arm, registerLastRow: lastRowRef }), + [arm, reach] + ); + + return ( + +
+ {onCollapse && label && ( + + )} + {children} +
+
+ ); +} + +/** One row in a branch, with the connector tying it back to the spine. */ +export function ThreadBranchRow({ + isLast, + density, + children, +}: { + isLast: boolean; + /** Only used when this row is rendered outside a {@link ThreadBranch}, which should not happen. */ + density?: CommentDensity; + children: React.ReactNode; +}) { + const branch = React.useContext(ThreadBranchContext); + const reachPx = branch?.reachPx ?? (density ? threadSpineOffsetPx(density) : 28); + const armCenterPx = branch?.armCenterPx ?? (density ? threadArmCenterPx(density) : 16); + + return ( +
+
+ {isLast ? ( + + ) : ( + + )} +
+ {children} +
+ ); +} diff --git a/apps/web/partials/explore/claim-explore-feed-card.tsx b/apps/web/partials/explore/claim-explore-feed-card.tsx index 83f6245bf7..13d34bdb46 100644 --- a/apps/web/partials/explore/claim-explore-feed-card.tsx +++ b/apps/web/partials/explore/claim-explore-feed-card.tsx @@ -158,7 +158,12 @@ export function ClaimExploreFeedCard({ // action at all. Checking only the server seed would keep the button hidden after this card's // optional composer publishes the first comment; rendering a button that returns null would leave // PositionRow in its three-column layout with an empty final column. - const liveCommentCount = useCommentCount(item.entityId, item.commentCount); + // What the claim page's Activity heading says: its debates, the claims extracted from them, and + // every comment in that tree — not only the comments filed directly on the claim. The two read + // from one query so a reader who opens the card is not told a different number. Falls back to the + // comment count for a row built somewhere that does not fetch it. + const activityCount = item.activityCount ?? item.commentCount; + const liveCommentCount = useCommentCount(item.entityId, activityCount); // Withheld while the counts are still out, so the column does not appear a beat after the card. // `hasCounts` as well as a non-zero total. The two are equivalent as the hook computes them — @@ -320,7 +325,7 @@ export function ClaimExploreFeedCard({ entityId={item.entityId} spaceId={item.spaceId} targetEntityType="claim" - count={item.commentCount} + count={activityCount} className="inline-flex h-7 shrink-0 items-center gap-2 rounded-full border border-grey-02 px-2.5 text-[14px] leading-[13px] font-normal text-text tabular-nums transition-colors hover:border-text" /> ) : undefined From 79469c99425ddc89659ee38fe182096a3d38f254 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 13:20:21 -0700 Subject: [PATCH 07/35] fix(claims): land branch connectors on the avatar they point at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every arm in a debate's branch was drawn 18px below its row's avatar. The cause is the distinction the module's own doc comment describes and its API then made impossible to honour: a connector has two ends, and they belong to rows of different sizes. A claim hanging off a debate reaches back across a 44px keyframe column but lands on a 32px avatar. `ThreadBranch` took one `density` for both, so the arm's height came out as half the *parent's* 67px keyframe — 33.5px — instead of half the row's, 16px. The prop is now `rowDensity`, which can only mean the rows in the branch, with `reachPx` required from the parent for the horizontal end. Nothing can supply one and silently get the other. Measured in a browser rather than reasoned about, because reasoning about it is what produced the bug: 17 branch rows, 0 misaligned, at 1280px and 480px. Each arm's 1px line now sits at the avatar's vertical centre and stops at its left edge, and each elbow's curve ends there. --- .../claims/browse/debate-activity-row.tsx | 2 +- .../core/claims/browse/debate-comment-row.tsx | 6 +-- .../claims/browse/extracted-claim-row.tsx | 4 +- .../partials/comments/thread-branch-list.tsx | 47 +++++++++---------- 4 files changed, 27 insertions(+), 32 deletions(-) diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index b075ac6af3..b324fb4d7c 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -355,7 +355,7 @@ function DebateBranch({ if (rowCount === 0) return null; return ( - + {claimsInOrder.map((claim, index) => { const speakerSpaceId = speakerBySourceBlockId.get(uuidToHex(claim.blockId)) ?? null; return ( diff --git a/apps/web/core/claims/browse/debate-comment-row.tsx b/apps/web/core/claims/browse/debate-comment-row.tsx index 066530c665..7bf8c21d7f 100644 --- a/apps/web/core/claims/browse/debate-comment-row.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.tsx @@ -11,7 +11,7 @@ import { NavUtils } from '~/core/utils/utils'; import { Avatar } from '~/design-system/avatar'; -import { PAGE_DENSITY } from '~/partials/comments/comment-density'; +import { PAGE_DENSITY, threadSpineOffsetPx } from '~/partials/comments/comment-density'; import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; import { getRelativeTime } from '~/partials/comments/comment-time'; import type { CommentWithReplies } from '~/partials/comments/types'; @@ -121,9 +121,9 @@ export function DebateCommentRow({ */} {showsReplies && (
- + {replies.map((reply, index) => ( - + - + {comments.map((comment, index) => ( (null); export function ThreadBranch({ - density, + rowDensity, reachPx, - armCenterPx, onCollapse, label, children, }: { /** - * The *parent* row's density, which is what the connectors reach back across. Defaults are taken - * from it; a parent whose left column is not an avatar — a debate's keyframe — passes its own. + * The density of the rows *in* this branch — never the parent's. + * + * A connector has two ends and they can belong to rows of different sizes: a claim hanging off a + * debate reaches back across a 44px keyframe column but lands on a 32px avatar. Taking one + * density for both is what put every arm in the debate branch 18px below the avatar it was + * pointing at — half the keyframe's height instead of half the row's. The horizontal end is + * {@link reachPx}, which the parent supplies; the vertical end is this. */ - density: CommentDensity; - reachPx?: number; - armCenterPx?: number; + rowDensity: CommentDensity; + /** How far left of this branch's edge the parent's spine sits — the parent's geometry, not ours. */ + reachPx: number; /** Omitted where the branch has no collapse of its own; the spine is then inert. */ onCollapse?: () => void; label?: { expand: string; collapse: string }; children: React.ReactNode; }) { - const reach = reachPx ?? threadSpineOffsetPx(density); - const arm = armCenterPx ?? threadArmCenterPx(density); + const arm = threadArmCenterPx(rowDensity); // Measured rather than computed: the spine stops at the last row's elbow, and these rows are // variable height — a claim sentence wraps to one line or three, a comment to more. @@ -73,8 +76,8 @@ export function ThreadBranch({ }); const geometry = React.useMemo( - () => ({ reachPx: reach, armCenterPx: arm, registerLastRow: lastRowRef }), - [arm, reach] + () => ({ reachPx, armCenterPx: arm, registerLastRow: lastRowRef }), + [arm, reachPx] ); return ( @@ -82,7 +85,7 @@ export function ThreadBranch({
{onCollapse && label && ( {children}; + const { reachPx, armCenterPx } = branch; return ( -
+
{isLast ? ( From 2e5d4e04567972222ad2f23fb84b7bedd0f36dd7 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 15:35:28 -0700 Subject: [PATCH 08/35] feat(claims): split thread overflow in two, and count depth from the root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a thread can say when it has more than it is drawing, and until now we said both with one control: "N replies", which opened the global comments panel. Reddit keeps them apart and it is worth copying, because they answer different questions. More of the same, here. Replies held back for length are already in the tree `useComments` built, so revealing them costs nothing and happens in place — `ThreadShowMore`, capped at three, a cap about legibility rather than fetching. Deeper than this page goes. `ThreadContinue` navigates to the entity whose own page roots that thread, rather than opening a panel. We are better placed for this than Reddit is: Reddit has to synthesise a permalink for a comment, where every node in our tree is already an entity with a page that renders its own thread. A link rather than a button, so middle-click and open-in-new-tab work. Half right for one case, and worth saying so: an extracted claim's page renders its full thread, so continuing lands exactly where it should. A published debate's page is the video feed, with the thread behind the comments button — better than the old flat panel, because the debate's context survives, but not the clean re-rooting. The fix is a deep link that opens the panel on arrival and there is no such link today; GEO-3033 is now that piece and nothing else. Depth is counted once, from the root. `claim-activity-depth.ts` owns the numbers and `canNestBelow(depth)` asks the only question. The arithmetic was previously spread across three files — `ACTIVITY_MAX_DEPTH - 2` twice, `maxDepth - 1`, and a `depth < maxDepth` comparison — which was correct but had to be hand-verified to stay that way, and a row now knows only its own depth. `DebateCommentRow`'s `debateId` becomes `targetEntityId`, because under an extracted claim it was being handed a claim id. --- .../browse/claim-activity-depth.test.ts | 25 +++++ .../claims/browse/claim-activity-depth.ts | 33 +++++++ .../claims/browse/debate-activity-row.tsx | 20 +--- .../claims/browse/debate-comment-row.test.tsx | 49 ++++++++-- .../core/claims/browse/debate-comment-row.tsx | 65 +++++++------ .../browse/extracted-claim-row.test.tsx | 27 ++++-- .../claims/browse/extracted-claim-row.tsx | 20 ++-- .../web/partials/comments/thread-overflow.tsx | 93 +++++++++++++++++++ 8 files changed, 257 insertions(+), 75 deletions(-) create mode 100644 apps/web/core/claims/browse/claim-activity-depth.test.ts create mode 100644 apps/web/core/claims/browse/claim-activity-depth.ts create mode 100644 apps/web/partials/comments/thread-overflow.tsx diff --git a/apps/web/core/claims/browse/claim-activity-depth.test.ts b/apps/web/core/claims/browse/claim-activity-depth.test.ts new file mode 100644 index 0000000000..fac7aa035c --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-depth.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { ACTIVITY_MAX_DEPTH, ACTIVITY_ROOT_DEPTH, canNestBelow } from './claim-activity-depth'; + +describe('activity depth', () => { + // The levels the feed actually draws, named, so a change to the constant has to answer for them. + it('draws a debate, its claims, the comments on those, and their replies', () => { + const debate = ACTIVITY_ROOT_DEPTH; + const extractedClaim = debate + 1; + const commentOnClaim = extractedClaim + 1; + const replyToThatComment = commentOnClaim + 1; + + expect(canNestBelow(debate)).toBe(true); + expect(canNestBelow(extractedClaim)).toBe(true); + expect(canNestBelow(commentOnClaim)).toBe(true); + // The floor: this row's own children would be a fifth level. + expect(canNestBelow(replyToThatComment)).toBe(false); + expect(replyToThatComment).toBe(ACTIVITY_MAX_DEPTH); + }); + + it('never nests below the floor, however far past it a caller gets', () => { + expect(canNestBelow(ACTIVITY_MAX_DEPTH + 1)).toBe(false); + expect(canNestBelow(ACTIVITY_MAX_DEPTH + 10)).toBe(false); + }); +}); diff --git a/apps/web/core/claims/browse/claim-activity-depth.ts b/apps/web/core/claims/browse/claim-activity-depth.ts new file mode 100644 index 0000000000..46d71648d9 --- /dev/null +++ b/apps/web/core/claims/browse/claim-activity-depth.ts @@ -0,0 +1,33 @@ +/** + * How far the claim page's activity feed draws before it stops and points elsewhere. + * + * Counted from the root, the way Reddit counts it, rather than as a budget each level subtracts + * from. The arithmetic used to be spread across three files — `ACTIVITY_MAX_DEPTH - 2` in two + * places, `maxDepth - 1` in a third, `depth < maxDepth` in a fourth — which was correct but had to + * be hand-verified to stay that way. A row now knows only its own depth and asks one question. + */ + +/** A debate or a top-level comment: the first thing under the claim. */ +export const ACTIVITY_ROOT_DEPTH = 1; + +/** + * The deepest level drawn. + * + * Four: a debate, the claims it produced, the comments on one of those, and the replies to those. + * + * Deliberately not Reddit's ~9–10. Reddit affords that because every node is the same cheap thing — + * a line of text at ~20px of indent. Ours are not: a claim row carries a vote control, responder + * faces and a comment count; a debate row carries a keyframe. At 44–56px of indent per level, four + * already spends ~180px, which at side-panel width is most of what there is. + */ +export const ACTIVITY_MAX_DEPTH = 4; + +/** + * Whether a row at this depth may draw its children. + * + * A row at the floor still *has* children — they are counted and offered, just not drawn. See + * `ThreadOverflow`, which is what the reader gets instead. + */ +export function canNestBelow(depth: number): boolean { + return depth < ACTIVITY_MAX_DEPTH; +} diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index b324fb4d7c..f937901a2f 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -27,6 +27,7 @@ import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch import { ThreadCollapseToggle, ThreadParentSpine } from '~/partials/comments/thread-branch'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; +import { ACTIVITY_ROOT_DEPTH } from './claim-activity-depth'; import { orderExtractedClaims } from './claim-activity-order'; import { DebateCommentRow } from './debate-comment-row'; import { ExtractedClaimRow, type SpeakerProfile } from './extracted-claim-row'; @@ -55,15 +56,6 @@ const DEBATE_DENSITY: CommentDensity = { headerMinHeightPx: KEYFRAME_HEIGHT_PX, }; -/** - * How deep the feed draws before it stops and counts instead. - * - * Four: a debate, the claims it produced, the comments on one of those, and the replies to those. - * Past that the page is showing a conversation that has its own home, and the row's count plus a - * way in reads better than a fifth indent. - */ -export const ACTIVITY_MAX_DEPTH = 4; - /** How far the branch's connectors reach back to find this row's spine. */ const BRANCH_REACH_PX = DEBATE_DENSITY.bodyInsetPx - DEBATE_DENSITY.avatarCenterPx; @@ -365,8 +357,8 @@ function DebateBranch({ debateId={debateId} debateSpaceId={spaceId} commentCount={commentCounts.get(uuidToHex(claim.id)) ?? 0} - // Level two, so its comments are level three and their replies level four. - maxDepth={ACTIVITY_MAX_DEPTH - 2} + // A debate is the root of this branch, so everything hanging off it is one below. + depth={ACTIVITY_ROOT_DEPTH + 1} // Stance unless geo-chat says otherwise. A missing row means the space is not indexed, // not that the claim is factual, and stance is what the graph defaults to as well. responseKind={responseKindByClaimId.get(uuidToHex(claim.id)) ?? 'stance'} @@ -393,11 +385,9 @@ function DebateBranch({ ))} diff --git a/apps/web/core/claims/browse/debate-comment-row.test.tsx b/apps/web/core/claims/browse/debate-comment-row.test.tsx index 2f83a70345..7c23b43b28 100644 --- a/apps/web/core/claims/browse/debate-comment-row.test.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.test.tsx @@ -1,10 +1,12 @@ import '@testing-library/jest-dom/vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CommentWithReplies } from '~/partials/comments/types'; +import { ACTIVITY_MAX_DEPTH } from './claim-activity-depth'; + const mocks = vi.hoisted(() => ({ /** Responder space id → the side they hold on the claim, as the provider would resolve it. */ directions: new Map(), @@ -25,6 +27,11 @@ vi.mock('~/core/hooks/use-entity-comments-panel', () => ({ })); vi.mock('~/core/state/editor/markdown-render', () => ({ renderMarkdownDocument: (text: string) => text })); vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ EntityVoteButtons: () => null })); +vi.mock('~/design-system/prefetch-link', () => ({ + PrefetchLink: ({ children, ...props }: { children?: React.ReactNode } & Record) => ( + )}>{children} + ), +})); import { DebateCommentRow } from './debate-comment-row'; @@ -46,9 +53,10 @@ function comment(overrides: Partial = {}): CommentWithReplie }; } -function renderRow(overrides: Partial = {}, maxDepth = 2) { +// Depth is counted from the claim: 1 is a debate or a top-level comment, 4 is the floor. +function renderRow(overrides: Partial = {}, depth = 2) { return render( - + ); } @@ -103,20 +111,41 @@ describe('DebateCommentRow', () => { expect(screen.getByText('Publishing…')).toBeInTheDocument(); }); - it('draws its replies while there is depth budget left', () => { + it('draws its replies while there is depth left', () => { renderRow({ replies: [comment({ id: 'reply-1', markdownContent: 'a reply' })] }); expect(screen.getByText('a reply')).toBeInTheDocument(); - // Drawn, so the count would be saying the same thing twice. - expect(screen.queryByRole('button', { name: '1 reply' })).not.toBeInTheDocument(); + // Drawn, so offering to continue elsewhere would be saying the same thing twice. + expect(screen.queryByRole('link', { name: /Continue this thread/ })).not.toBeInTheDocument(); }); - // Past the budget the thread has its own home, and a count plus a way in beats a fifth indent. - it('counts its replies instead of drawing them once the budget runs out', () => { - renderRow({ replies: [comment({ id: 'reply-1', markdownContent: 'a reply' })] }, 0); + // At the floor there is no room to draw them, so the reader is sent to the page where this thread + // is the whole page rather than a branch of one. A navigation, not a load — Reddit's distinction. + it('offers to continue the thread on its own entity once it hits the floor', () => { + renderRow({ replies: [comment({ id: 'reply-1', markdownContent: 'a reply' })] }, ACTIVITY_MAX_DEPTH); expect(screen.queryByText('a reply')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: '1 reply' })).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'Continue this thread — 1 more reply' }); + expect(link).toHaveAttribute('href', expect.stringContaining('debate-1')); + }); + + // The other overflow: siblings held back for length. Already loaded, so it reveals in place. + it('holds back a long reply list and reveals it in place', async () => { + const replies = Array.from({ length: 5 }, (_, i) => + comment({ id: `reply-${i}`, markdownContent: `reply body ${i}` }) + ); + renderRow({ replies }); + + expect(screen.getByText('reply body 0')).toBeInTheDocument(); + expect(screen.queryByText('reply body 4')).not.toBeInTheDocument(); + + const more = screen.getByRole('button', { name: 'Show 2 more replies' }); + expect(more).toBeInTheDocument(); + // In place — no navigation offered for siblings we already hold. + expect(screen.queryByRole('link', { name: /Continue this thread/ })).not.toBeInTheDocument(); + + fireEvent.click(more); + expect(screen.getByText('reply body 4')).toBeInTheDocument(); }); it('offers no reply count when nobody has replied', () => { diff --git a/apps/web/core/claims/browse/debate-comment-row.tsx b/apps/web/core/claims/browse/debate-comment-row.tsx index 7bf8c21d7f..3779d20c6e 100644 --- a/apps/web/core/claims/browse/debate-comment-row.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.tsx @@ -11,8 +11,11 @@ import { NavUtils } from '~/core/utils/utils'; import { Avatar } from '~/design-system/avatar'; +import { canNestBelow } from './claim-activity-depth'; + import { PAGE_DENSITY, threadSpineOffsetPx } from '~/partials/comments/comment-density'; import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; +import { ThreadContinue, ThreadShowMore } from '~/partials/comments/thread-overflow'; import { getRelativeTime } from '~/partials/comments/comment-time'; import type { CommentWithReplies } from '~/partials/comments/types'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; @@ -28,26 +31,35 @@ import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; * reply chain for that entity actually lives; threading a composer for a second entity into this * one's list would put the reply somewhere the surrounding thread cannot show it. */ +/** Replies drawn before the rest are offered in place. Beyond this a thread stops being scannable. */ +const REPLY_PAGE_SIZE = 3; + export function DebateCommentRow({ comment, - debateId, + targetEntityId, spaceId, - depth = 0, - maxDepth, + depth, }: { comment: CommentWithReplies; - debateId: string; + /** The entity this comment replies to — the debate, or the extracted claim. */ + targetEntityId: string; spaceId: string; - /** How far below the branch's first row this one sits. */ - depth?: number; - /** Rows deeper than this are counted, not drawn — the reader opens the debate for the rest. */ - maxDepth: number; + /** Counted from the claim, not from this branch. See `claim-activity-depth.ts`. */ + depth: number; }) { const { openComments } = useEntityCommentsPanel(); + const [visibleReplies, setVisibleReplies] = React.useState(REPLY_PAGE_SIZE); const body = React.useMemo(() => renderMarkdownDocument(comment.markdownContent), [comment.markdownContent]); const replies = Array.isArray(comment.replies) ? comment.replies : []; - const replyCount = replies.length; - const showsReplies = replies.length > 0 && depth < maxDepth; + + // Two different overflows, and they want different offers. Replies we are holding back for length + // are already loaded, so revealing them is free and happens here. Replies below the depth floor + // are a different matter: there is no room to draw them, so the reader is sent to the page where + // this thread is the whole page rather than a branch of one. + const canNest = canNestBelow(depth); + const shown = canNest ? replies.slice(0, visibleReplies) : []; + const hiddenHere = canNest ? replies.length - shown.length : 0; + const belowFloor = canNest ? 0 : replies.length; return (
@@ -97,42 +109,39 @@ export function DebateCommentRow({ - {replyCount > 0 && !showsReplies && ( - - )} +
{/* Replies come with the comment — they are already in the tree `useComments` built — so - drawing them costs nothing beyond the depth budget. Past that budget the count above takes - over and the debate's own panel is where the rest of the thread lives. + drawing them costs nothing but space, and the cap below is about legibility rather than + fetching. */} - {showsReplies && ( + {shown.length > 0 && (
- {replies.map((reply, index) => ( - + {shown.map((reply, index) => ( + ))} + {hiddenHere > 0 && ( + setVisibleReplies(count => count + REPLY_PAGE_SIZE)} + /> + )}
)} diff --git a/apps/web/core/claims/browse/extracted-claim-row.test.tsx b/apps/web/core/claims/browse/extracted-claim-row.test.tsx index 7690310cc5..ae31cb7658 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.test.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; import { cleanup, render, screen } from '@testing-library/react'; @@ -56,17 +57,23 @@ function timing(overrides: Partial = {}): ClaimTiming { } function renderRow(overrides: Partial> = {}) { + // A row with comments and depth left mounts the list that fetches them, which needs a client even + // though this file never lets it resolve. + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( - + + + ); } diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index ec08e76437..26eafa901c 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -19,6 +19,7 @@ import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; +import { canNestBelow } from './claim-activity-depth'; import type { OrderedTranscriptClaim } from './claim-activity-order'; import { DebateCommentRow } from './debate-comment-row'; import { ResponsePositionTag } from './claim-comment-position'; @@ -48,7 +49,7 @@ export function ExtractedClaimRow({ speakerPosition, responseVocabulary, commentCount = 0, - maxDepth = 0, + depth, density = PAGE_DENSITY, className, }: { @@ -84,8 +85,8 @@ export function ExtractedClaimRow({ * comments — a number wrong in the one direction that tells the reader not to look. */ commentCount?: number; - /** Rows deeper than this are counted, not drawn. Zero means this row is already at the floor. */ - maxDepth?: number; + /** This row's depth, counted from the claim. Its comments sit one below it. */ + depth: number; /** The surrounding thread's metrics, so these rows sit on the same ramp as the comments. */ density?: CommentDensity; className?: string; @@ -177,8 +178,8 @@ export function ExtractedClaimRow({
) : null} - {claimSpaceId && commentCount > 0 && maxDepth > 0 && ( - + {claimSpaceId && commentCount > 0 && canNestBelow(depth) && ( + )}
@@ -192,7 +193,7 @@ export function ExtractedClaimRow({ * claims cost ten fetches of nothing. That gate is why this can be eager at all: in the corpus today * almost every extracted claim has no comments, and the few that do are worth a request. */ -function ClaimComments({ claimId, spaceId, maxDepth }: { claimId: string; spaceId: string; maxDepth: number }) { +function ClaimComments({ claimId, spaceId, depth }: { claimId: string; spaceId: string; depth: number }) { const { comments } = useComments({ entityId: claimId, spaceId }); if (comments.length === 0) return null; @@ -201,12 +202,7 @@ function ClaimComments({ claimId, spaceId, maxDepth }: { claimId: string; spaceI {comments.map((comment, index) => ( - + ))} diff --git a/apps/web/partials/comments/thread-overflow.tsx b/apps/web/partials/comments/thread-overflow.tsx new file mode 100644 index 0000000000..646185bd07 --- /dev/null +++ b/apps/web/partials/comments/thread-overflow.tsx @@ -0,0 +1,93 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; + +import { PrefetchLink as Link } from '~/design-system/prefetch-link'; + +import { PAGE_DENSITY } from './comment-density'; + +/** + * The two things a thread can say when it has more than it is drawing. + * + * Reddit keeps these apart and it is worth copying, because they answer different questions: + * + * * **More of the same, here** — siblings the list is holding back for length. Reddit's "load + * more comments (N)". Pressing it reveals them in place; nothing about the reader's position + * changes. + * * **Deeper than this page goes** — Reddit's "continue this thread", which is *not* a load at + * all. It navigates to that comment's permalink, where it becomes the root and gets a fresh + * depth budget. In the API those placeholders come back with zero children, which is how you + * tell the two apart programmatically. + * + * We are better placed for the second than Reddit is. Reddit has to synthesise a permalink for a + * comment; every node in our tree is already an entity with a page that renders its own thread. So + * continuing a thread lands the reader somewhere with *more* context than they had, not less — + * where the previous behaviour, opening the global comments panel, gave them a flat list and lost + * the claim they were reading. + */ + +/** More siblings, revealed in place. The data is already loaded; this only lifts the cap. */ +export function ThreadShowMore({ + count, + noun, + onShowMore, + className, +}: { + count: number; + /** Singular form; pluralised here. "reply" → "Show 3 more replies". */ + noun: string; + onShowMore: () => void; + className?: string; +}) { + if (count <= 0) return null; + + return ( + + ); +} + +/** + * Deeper than this page draws: go to where the rest of it is the whole page. + * + * A link rather than a button, because it is a navigation and should behave like one — middle-click + * and open-in-new-tab both work, which a button swallowing the click would not. + */ +export function ThreadContinue({ + href, + count, + className, +}: { + /** The entity whose own page roots this thread. */ + href: string; + /** How many rows are down there, so the offer is specific rather than "there is more". */ + count: number; + className?: string; +}) { + if (count <= 0) return null; + + return ( + + Continue this thread ({count}) → + + ); +} From c32a3327f34eeb66b40d26397d9edef0110a2270 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 15:59:41 -0700 Subject: [PATCH 09/35] chore(comments): say why the batched count and the connection count both exist GEO-3030 landed a single-entity comment count over entitiesConnection while this branch was open. The two now look like duplicates and are not: one answers for a page with one count, the other for a screen of rows in a single round trip, which is what lets the activity feed put a count on every debate. Left unmarked, the next person deletes one of them. --- apps/web/core/comments/use-entity-comment-counts.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/core/comments/use-entity-comment-counts.ts b/apps/web/core/comments/use-entity-comment-counts.ts index 5316b126f0..12c6e48999 100644 --- a/apps/web/core/comments/use-entity-comment-counts.ts +++ b/apps/web/core/comments/use-entity-comment-counts.ts @@ -25,6 +25,13 @@ import { graphql } from '~/core/io/graphql-client'; * (see `useCommentCount`) — so a host with no count to give passes zero, and the button reads "0" * on an entity with comments until the reader opens the panel and finds them. That is a number * being wrong in the one direction that tells the reader not to look. + * + * `getEntityCommentCount` (GEO-3030) is the single-entity sibling of this, and the two are not + * duplicates: that one answers for one entity over `entitiesConnection` and is what a page with a + * single count wants; this one answers for a whole screen of rows in one round trip, which is the + * only reason the activity feed can put a count on every debate it draws. They count the same + * thing from opposite ends — Comment entities replying to the target, versus `Reply to` relations + * arriving at it — so if one ever changes what it counts, change both. */ const ENTITY_COMMENT_COUNTS_SOURCE = /* GraphQL */ ` query EntityCommentCounts($ids: [UUID!], $replyToTypeId: UUID!) { From 8feeb278104ab36eadb64d6c82169c6005df7349 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 16:38:02 -0700 Subject: [PATCH 10/35] fix(claims): count the whole activity tree, not the first ten of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nested backlinksList inside entities(filter: {id: {in: …}}) stops at ten rows without erroring, and that is how both the batched count and the explore card selection read. So the claim whose feed draws 52 rows printed 33, and every debate with more than ten extracted claims was cut off at ten — the number was wrong exactly on the claims busy enough for anyone to notice. Take the extracted total from an aggregate instead of a list length, give every nested list an explicit first, and dedupe debates by id: testnet has a debate carrying two Claims relations to the same claim, which counted its subtree twice while the feed drew it once. The two errors were cancelling unevenly. The hook now interpolates the shared selection rather than restating it with variables, which is how the two shapes could have drifted again. Verified against the API on the 20 busiest claims: every total now matches what the same fields read one claim at a time, in 0.57s and 28 KB. --- .../browse/claim-activity-count.test.ts | 52 +++++++++++++- .../claims/browse/claim-activity-count.ts | 51 +++----------- .../claims/browse/claim-activity-fields.ts | 70 +++++++++++++++---- 3 files changed, 116 insertions(+), 57 deletions(-) diff --git a/apps/web/core/claims/browse/claim-activity-count.test.ts b/apps/web/core/claims/browse/claim-activity-count.test.ts index 12113495f7..301b1ffd6a 100644 --- a/apps/web/core/claims/browse/claim-activity-count.test.ts +++ b/apps/web/core/claims/browse/claim-activity-count.test.ts @@ -46,7 +46,9 @@ describe('decodeClaimActivityCounts', () => { }); it('counts a claim nobody has touched as zero rather than leaving it out', () => { - const counts = decodeClaimActivityCounts({ entities: [{ id: 'claim1', comments: { totalCount: 0 }, debates: [] }] }); + const counts = decodeClaimActivityCounts({ + entities: [{ id: 'claim1', comments: { totalCount: 0 }, debates: [] }], + }); expect(counts.get('claim1')?.total).toBe(0); }); @@ -77,6 +79,54 @@ describe('decodeClaimActivityCounts', () => { expect(counts.get('claim1')).toMatchObject({ comments: 0, debates: 1, extractedClaims: 0, total: 1 }); }); + // A nested list inside a batched `entities(filter: { id: { in: … } })` query stops at ten rows + // without erroring, so the list is the wrong thing to measure. The aggregate beside it is not + // capped: a debate with 22 extracted claims returns `totalCount: 22` next to a ten-item list. + it('takes the extracted total from the aggregate, not the length of a truncated list', () => { + const counts = decodeClaimActivityCounts({ + entities: [ + { + id: 'claim1', + comments: { totalCount: 0 }, + debates: [ + { + fromEntity: { + id: 'debate1', + comments: { totalCount: 0 }, + extractedCount: { totalCount: 22 }, + extracted: Array.from({ length: 10 }, (_, index) => ({ + fromEntity: { id: `extracted${index}`, comments: { totalCount: 0 } }, + })), + }, + }, + ], + }, + ], + }); + + expect(counts.get('claim1')).toMatchObject({ debates: 1, extractedClaims: 22, total: 23 }); + }); + + // Testnet has a debate carrying two `Claims` relations to the same claim. Each is its own backlink + // row, but the feed reads debates as entities and draws one — so counting rows double-counts the + // whole subtree under it. + it('counts a debate once however many times it links the claim', () => { + const twice = { + fromEntity: { + id: 'debate1', + comments: { totalCount: 2 }, + extractedCount: { totalCount: 22 }, + extracted: [], + }, + }; + + const counts = decodeClaimActivityCounts({ + entities: [{ id: 'claim1', comments: { totalCount: 0 }, debates: [twice, twice] }], + }); + + expect(counts.get('claim1')).toMatchObject({ debates: 1, extractedClaims: 22, debateComments: 2, total: 25 }); + }); + it('is empty when nothing came back', () => { expect(decodeClaimActivityCounts({}).size).toBe(0); expect(decodeClaimActivityCounts({ entities: null }).size).toBe(0); diff --git a/apps/web/core/claims/browse/claim-activity-count.ts b/apps/web/core/claims/browse/claim-activity-count.ts index 224097d619..b9e15a3f6b 100644 --- a/apps/web/core/claims/browse/claim-activity-count.ts +++ b/apps/web/core/claims/browse/claim-activity-count.ts @@ -8,13 +8,10 @@ import * as React from 'react'; import { Effect } from 'effect'; import { parse } from 'graphql'; -import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; -import { COMMENT_REPLY_TO_ID } from '~/core/comment-ids'; -import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_TYPE_ID, SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; import { uuidToHex } from '~/core/id/normalize'; import { graphql } from '~/core/io/graphql-client'; -import { type ClaimActivityCount, countActivityForNode } from './claim-activity-fields'; +import { CLAIM_ACTIVITY_COUNT_FIELDS, type ClaimActivityCount, countActivityForNode } from './claim-activity-fields'; export { CLAIM_ACTIVITY_COUNT_FIELDS, countActivityForNode } from './claim-activity-fields'; export type { ClaimActivityCount } from './claim-activity-fields'; @@ -28,41 +25,16 @@ export type { ClaimActivityCount } from './claim-activity-fields'; * * Batched because the cost is per request rather than per claim, and a feed asking once per row is * what makes a number on a row expensive: 20 active claims answer together in 0.69s and 17 KB. + * + * The selection is the shared one rather than a second copy of it. It used to be written out again + * here with the ontology ids as variables, which is how the two drifted: the nested-list cap was + * fixed in one place and the card kept reading the truncated shape. One string, one set of limits. */ const CLAIM_ACTIVITY_COUNTS_SOURCE = /* GraphQL */ ` - query ClaimActivityCounts( - $ids: [UUID!] - $claimsPropertyId: UUID! - $sourcesPropertyId: UUID! - $replyToTypeId: UUID! - $debateTypeId: UUID! - $claimTypeId: UUID! - ) { + query ClaimActivityCounts($ids: [UUID!]) { entities(filter: { id: { in: $ids } }) { id - comments: backlinks(filter: { typeId: { is: $replyToTypeId } }) { - totalCount - } - debates: backlinksList( - filter: { typeId: { is: $claimsPropertyId }, fromEntity: { typeIds: { overlaps: [$debateTypeId] } } } - ) { - fromEntity { - id - comments: backlinks(filter: { typeId: { is: $replyToTypeId } }) { - totalCount - } - extracted: backlinksList( - filter: { typeId: { is: $sourcesPropertyId }, fromEntity: { typeIds: { overlaps: [$claimTypeId] } } } - ) { - fromEntity { - id - comments: backlinks(filter: { typeId: { is: $replyToTypeId } }) { - totalCount - } - } - } - } - } + ${CLAIM_ACTIVITY_COUNT_FIELDS} } } `; @@ -105,14 +77,7 @@ export function useClaimActivityCounts(claimIds: string[], enabled = true): Map< graphql({ query: claimActivityCountsDocument, decoder: decodeClaimActivityCounts, - variables: { - ids, - claimsPropertyId: DEBATE_CLAIMS_PROPERTY_ID, - sourcesPropertyId: SOURCES_PROPERTY_ID, - replyToTypeId: COMMENT_REPLY_TO_ID, - debateTypeId: DEBATE_TYPE_ID, - claimTypeId: CLAIM_TYPE_ID, - }, + variables: { ids }, signal, }) ), diff --git a/apps/web/core/claims/browse/claim-activity-fields.ts b/apps/web/core/claims/browse/claim-activity-fields.ts index 14199fbcad..19bee8b4f3 100644 --- a/apps/web/core/claims/browse/claim-activity-fields.ts +++ b/apps/web/core/claims/browse/claim-activity-fields.ts @@ -25,15 +25,40 @@ import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_TYPE_ID, SOURCES_PROPERTY_ID } from ' * Debate → Transcripts → Blocks → Claims traversal used to *read* them. Measured against testnet * on 2026-09-24: identical totals (177 across 30 debates), 0.52s versus 3.48s. * - * Adding these to the shared explore card selection costs +0.02s and +1 KB over a 20-card page, - * because for anything that is not a claim the relation lookups come back empty immediately — which - * is why there is no claim-only selection to maintain. + * Adding these to the shared explore card selection is cheap for anything that is not a claim — the + * relation lookups come back empty immediately — which is why there is no claim-only selection to + * maintain. Measured on the 20 busiest claims in the corpus, which is the worst case rather than a + * typical page: 0.57s and 28 KB, every total matching what the same fields read one claim at a time. + * + * ## Why every nested list carries an explicit `first` + * + * A nested `backlinksList` inside a *list* query (`entities(filter: { id: { in: … } })`, which is + * both how this is batched and how the explore feed selects its cards) is silently truncated to ten + * rows. The singular `entity(id:)` form is not. So the same fields read one number on a claim page + * and a smaller one on a card — measured on testnet 2026-09-24: a debate with 22 extracted claims + * returned `totalCount: 22` next to a ten-item list, and a claim whose feed draws 52 rows counted 33. + * Nothing errors; the list just stops. An explicit `first` lifts it. + * + * The extracted-claim total is therefore taken from an aggregate rather than from the list's length, + * so it stays exact whatever `first` is. The list survives only to reach the comment counts hanging + * off those claims, which no aggregate on the debate can see two hops away. */ + +/** + * Bounds for the nested lists. + * + * Generous against the corpus rather than tight: the most debates on one claim is five and the most + * claims from one debate is 22. These exist to defeat the implicit ten, not to ration. + */ +const DEBATES_PER_CLAIM = 50; +const EXTRACTED_PER_DEBATE = 200; + export const CLAIM_ACTIVITY_COUNT_FIELDS = /* GraphQL */ ` activityComments: backlinks(filter: { typeId: { is: "${COMMENT_REPLY_TO_ID}" } }) { totalCount } activityDebates: backlinksList( + first: ${DEBATES_PER_CLAIM} filter: { typeId: { is: "${DEBATE_CLAIMS_PROPERTY_ID}" } fromEntity: { typeIds: { overlaps: ["${DEBATE_TYPE_ID}"] } } @@ -44,14 +69,24 @@ export const CLAIM_ACTIVITY_COUNT_FIELDS = /* GraphQL */ ` comments: backlinks(filter: { typeId: { is: "${COMMENT_REPLY_TO_ID}" } }) { totalCount } + extractedCount: backlinks( + filter: { + typeId: { is: "${SOURCES_PROPERTY_ID}" } + fromEntity: { typeIds: { overlaps: ["${CLAIM_TYPE_ID}"] } } + } + ) { + totalCount + } extracted: backlinksList( + first: ${EXTRACTED_PER_DEBATE} filter: { typeId: { is: "${SOURCES_PROPERTY_ID}" } fromEntity: { typeIds: { overlaps: ["${CLAIM_TYPE_ID}"] } } } ) { + # No id: nothing downstream keys off an extracted claim, and leaving it out of a list this + # long is 19 KB off a 20-card page. fromEntity { - id comments: backlinks(filter: { typeId: { is: "${COMMENT_REPLY_TO_ID}" } }) { totalCount } @@ -87,26 +122,35 @@ function commentsOf(entity: { comments?: { totalCount?: number | null } | null } export function countActivityForNode(node: unknown): ClaimActivityCount { const raw = (node ?? {}) as Record; const claimComments = (raw.comments ?? raw.activityComments)?.totalCount ?? 0; - const debates = ((raw.debates ?? raw.activityDebates ?? []) as Array).flatMap(row => - row?.fromEntity ? [row.fromEntity] : [] - ); + + // Deduped by debate id, because a debate can carry more than one `Claims` relation to the same + // claim and each is a separate backlink row. Observed on testnet: one debate linked a claim twice, + // which counted its 22 extracted claims twice while the feed — which reads debates as entities — + // drew it once. + const debates = new Map(); + for (const row of (raw.debates ?? raw.activityDebates ?? []) as Array) { + const debate = row?.fromEntity; + if (debate?.id) debates.set(debate.id, debate); + } let extractedClaims = 0; let debateComments = 0; - for (const debate of debates) { + for (const debate of debates.values()) { debateComments += commentsOf(debate); - for (const row of (debate.extracted ?? []) as Array) { - if (!row?.fromEntity) continue; - extractedClaims += 1; + const listed = ((debate.extracted ?? []) as Array).filter(row => row?.fromEntity); + // The aggregate rather than the list's length: a truncated list still reports its true total, + // and this number is the one the claim card prints. + extractedClaims += debate.extractedCount?.totalCount ?? listed.length; + for (const row of listed) { debateComments += commentsOf(row.fromEntity); } } return { comments: claimComments, - debates: debates.length, + debates: debates.size, extractedClaims, debateComments, - total: claimComments + debates.length + extractedClaims + debateComments, + total: claimComments + debates.size + extractedClaims + debateComments, }; } From 55dfd2f842feac5a8ca4ebd892586684cd6bb74f Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 17:11:20 -0700 Subject: [PATCH 11/35] feat(claims): answer, and find out who, without leaving the thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things in the activity feed sent the reader somewhere else to finish a thought, and the somewhere else was always the side panel — which replaces the thread with a flat list of one entity's comments, losing the debate, the claim and the position badges at exactly the moment the reader wanted to respond to them. Comment and Reply on a debate row, an extracted claim and a comment now open a composer under that row. The write is unchanged: it still lands on the entity the row belongs to, with the full ancestor chain, so a reply made here and one made in the panel are the same reply. A row that gated its thread on a server count also lifts that gate once something has been posted into it, or the first comment on a silent claim would be written and never drawn. Names and faces open the person's profile beside the thread instead of navigating to their space. They stay anchors, so middle-click and copy-link still work. The responder faces open the list of who responded and which way. They were the only thing in a vote row that looks like people and answered nothing, while the claim cards in the debate side panel have opened that list from this same stack all along. Gated on a non-zero total: the avatars component renders nothing when nobody has responded but is still an element, so a truthiness check would have put an empty clickable button on every unanswered claim in the app. Rows say what they are. A claim with no assertable moment and a comment from someone holding no position were indistinguishable — same face, name, sentence, vote and count — because every difference between them is conditional. And the debate row counts its claims beside its comments, claims first, in the order the branch draws them. --- .../core/claims/browse/activity-row-tag.tsx | 28 +++ .../claims/browse/debate-activity-row.tsx | 60 +++++- .../claims/browse/debate-comment-row.test.tsx | 84 +++++++- .../core/claims/browse/debate-comment-row.tsx | 70 +++++-- .../browse/extracted-claim-row.test.tsx | 75 ++++++- .../claims/browse/extracted-claim-row.tsx | 98 +++++++-- .../browse/use-open-debater-profile.test.tsx | 5 +- .../browse/use-open-debater-profile.ts | 28 ++- .../comments/entity-comments-button.tsx | 25 ++- .../comments/inline-comment-composer.tsx | 116 +++++++++++ ...ntity-vote-buttons.selected-state.test.tsx | 11 +- .../entity-page/entity-vote-buttons.tsx | 196 ++++++++++++------ 12 files changed, 674 insertions(+), 122 deletions(-) create mode 100644 apps/web/core/claims/browse/activity-row-tag.tsx create mode 100644 apps/web/partials/comments/inline-comment-composer.tsx diff --git a/apps/web/core/claims/browse/activity-row-tag.tsx b/apps/web/core/claims/browse/activity-row-tag.tsx new file mode 100644 index 0000000000..8f0d57dbe1 --- /dev/null +++ b/apps/web/core/claims/browse/activity-row-tag.tsx @@ -0,0 +1,28 @@ +import cx from 'classnames'; + +/** + * What kind of thing a row is, said in a word. + * + * Under a debate the feed draws two rows that carry the same furniture — a face, a name, a sentence, + * a vote, a comment count — and mean different things. One is a claim the debater made, extracted + * from the transcript; the other is a comment somebody left afterwards. The differences that exist + * (a timecode, a position badge) are conditional, so a claim with no assertable moment and a comment + * from someone holding no position are indistinguishable at a glance. + * + * Neutral on purpose. The coloured chip beside it is a *claim* about a person's position; this one + * is a label for the row, and two coloured chips in one header would compete for the same meaning. + */ +export function ActivityRowTag({ kind }: { kind: 'claim' | 'comment' }) { + return ( + + {kind === 'claim' ? 'Claim' : 'Comment'} + + ); +} diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index f937901a2f..f7927c2584 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -10,21 +10,22 @@ import { useDebateClaims } from '~/core/debates/hooks'; import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; import { useComments } from '~/core/hooks/use-comments'; -import { useEntityCommentsPanel } from '~/core/hooks/use-entity-comments-panel'; import { uuidToHex } from '~/core/id/normalize'; import type { ResponseKind } from '~/core/responses/entity-response'; import type { Entity } from '~/core/types'; import { NavUtils } from '~/core/utils/utils'; import { GeoImage } from '~/design-system/geo-image'; +import { Warning } from '~/design-system/icons/warning'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Skeleton } from '~/design-system/skeleton'; import { type CommentDensity, PAGE_DENSITY } from '~/partials/comments/comment-density'; import { getRelativeTime } from '~/partials/comments/comment-time'; import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; -import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; +import { InlineCommentComposer, useInlineComposer } from '~/partials/comments/inline-comment-composer'; import { ThreadCollapseToggle, ThreadParentSpine } from '~/partials/comments/thread-branch'; +import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import { ACTIVITY_ROOT_DEPTH } from './claim-activity-depth'; @@ -97,7 +98,12 @@ export function DebateActivityRow({ publishedAt, }: DebateActivityRowProps) { const [collapsed, setCollapsed] = React.useState(false); - const { openComments } = useEntityCommentsPanel(); + const composer = useInlineComposer(); + // Same query key the branch below reads, so this costs nothing while the branch is open — which is + // its default — and keeps the number honest when it is closed. The transcript is also the only + // source that agrees with the rows: it is what the branch counts off, where the `Sources` backlink + // aggregate the feed's totals use is a different question asked from the other end. + const { claims: transcriptClaims } = useDebateTranscriptClaims(debate.id, spaceId); // One aggregate for this row's own comment count. Without it the button seeds itself at zero and // only ever corrects downward, so a debate with comments read "0" until the panel was opened. const debateCommentCount = useEntityCommentCounts(React.useMemo(() => [debate.id], [debate.id])); @@ -224,17 +230,34 @@ export function DebateActivityRow({ /> )} + {/* Claims before comments, because the claims are what the debate produced and the + comments are what happened afterwards — the same order the branch draws them in. Not + a control: the rows are already below, and the collapse toggle on the spine is how + you hide them. */} + + + {transcriptClaims.totalCount} + - {/* A reply to a debate is a comment on the debate, so it goes where that entity's - comments already live rather than into this claim's thread. */} + {/* A reply to a debate is a comment on the debate — it is filed against that entity, not + against this claim — but it is written and read right here, at the bottom of the + debate's own branch, rather than in a panel that hides the branch to collect it. */}
+ {composer.isComposing && ( +
+ +
+ )} + {!collapsed && (
setCollapsed(true)} branchLabel={branchLabel} commentCount={debateCommentCount.get(uuidToHex(debate.id)) ?? 0} + hasPostedHere={composer.hasPosted} />
)} @@ -283,6 +320,7 @@ function DebateBranch({ onCollapse, branchLabel, commentCount, + hasPostedHere, }: { debateId: string; spaceId: string; @@ -294,6 +332,8 @@ function DebateBranch({ branchLabel: { expand: string; collapse: string }; /** From the feed's own aggregate. Zero means there is nothing here worth a request. */ commentCount: number; + /** Someone has commented from this row since the page loaded, so the aggregate is behind. */ + hasPostedHere: boolean; }) { const { claims, isLoading } = useDebateTranscriptClaims(debateId, spaceId); const { timings, isReady } = useClaimTimings(debateId, claims); @@ -303,8 +343,10 @@ function DebateBranch({ entityId: debateId, spaceId, // Most debates have no comments, and this feed already knows which. Fetching them anyway would - // be a backlink walk per debate row to discover a list we were told is empty. - enabled: commentCount > 0, + // be a backlink walk per debate row to discover a list we were told is empty. A comment made + // from this row makes that aggregate stale, so it also lifts the gate — otherwise the first + // comment on a silent debate would be written and never read back. + enabled: commentCount > 0 || hasPostedHere, }); const claimIds = React.useMemo(() => claims.all.map(claim => claim.id), [claims.all]); diff --git a/apps/web/core/claims/browse/debate-comment-row.test.tsx b/apps/web/core/claims/browse/debate-comment-row.test.tsx index 7c23b43b28..1a1cc58204 100644 --- a/apps/web/core/claims/browse/debate-comment-row.test.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.test.tsx @@ -10,6 +10,8 @@ import { ACTIVITY_MAX_DEPTH } from './claim-activity-depth'; const mocks = vi.hoisted(() => ({ /** Responder space id → the side they hold on the claim, as the provider would resolve it. */ directions: new Map(), + /** Personal-space ids whose profile the row asked to open, in order. */ + openedProfiles: [] as string[], })); // The badge under test reads its own context, which the claim page supplies around the whole @@ -22,8 +24,29 @@ vi.mock('~/core/claims/browse/claim-comment-position', () => ({ }, })); -vi.mock('~/core/hooks/use-entity-comments-panel', () => ({ - useEntityCommentsPanel: () => ({ commentsTarget: null, openComments: vi.fn() }), +vi.mock('~/core/debates/browse/use-open-debater-profile', () => ({ + useOpenDebaterProfile: (spaceId: string) => (event: React.MouseEvent) => { + event.preventDefault(); + mocks.openedProfiles.push(spaceId); + }, +})); +// The composer itself is covered where it lives; here the question is only whether the row opens +// one, against the right entity, carrying the right ancestor chain. +vi.mock('~/partials/comments/inline-comment-composer', async importOriginal => ({ + ...((await importOriginal()) as Record), + InlineCommentComposer: ({ + targetEntityId, + ancestors, + }: { + targetEntityId: string; + ancestors?: Array<{ id: string }>; + }) => ( +
ancestor.id).join(',')} + /> + ), })); vi.mock('~/core/state/editor/markdown-render', () => ({ renderMarkdownDocument: (text: string) => text })); vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ EntityVoteButtons: () => null })); @@ -154,3 +177,60 @@ describe('DebateCommentRow', () => { expect(screen.queryByRole('button', { name: /repl(y|ies)$/ })).not.toBeInTheDocument(); }); }); + +describe('DebateCommentRow, replying and naming', () => { + afterEach(() => { + cleanup(); + mocks.openedProfiles.length = 0; + }); + + it('opens a composer against the entity the comment replies to, not against the comment', () => { + renderRow(); + + fireEvent.click(screen.getByRole('button', { name: 'Reply' })); + + const composer = screen.getByTestId('inline-composer'); + // The reply is filed against the debate — a reply to a comment on a debate is a comment on the + // debate — with the comment it answers as its nearest ancestor. + expect(composer).toHaveAttribute('data-target', 'debate-1'); + expect(composer).toHaveAttribute('data-ancestors', 'comment-1'); + }); + + it('carries the whole ancestor chain down a nested reply', () => { + renderRow({ replies: [comment({ id: 'reply-1', markdownContent: 'Second.' })] }); + + // The nested row's own Reply, not the parent's. + fireEvent.click(screen.getAllByRole('button', { name: 'Reply' })[1]!); + + expect(screen.getByTestId('inline-composer')).toHaveAttribute('data-ancestors', 'reply-1,comment-1'); + }); + + it('closes the composer when Reply is pressed again', () => { + renderRow(); + const reply = screen.getByRole('button', { name: 'Reply' }); + + fireEvent.click(reply); + expect(screen.queryByTestId('inline-composer')).toBeInTheDocument(); + + fireEvent.click(reply); + expect(screen.queryByTestId('inline-composer')).not.toBeInTheDocument(); + }); + + // A comment from someone holding no position and a claim with no assertable moment carry exactly + // the same furniture, so each row says which it is rather than leaving it to be inferred. + it('labels itself a comment', () => { + renderRow(); + + expect(screen.getByText('Comment')).toBeInTheDocument(); + }); + + // The name keeps its href so middle-click still reaches the person's space; the plain click opens + // the profile beside the thread instead of navigating away from it. + it('opens the author profile from the name without leaving the page', () => { + renderRow(); + + fireEvent.click(screen.getByText('Preston Mantel')); + + expect(mocks.openedProfiles).toEqual(['author-space']); + }); +}); diff --git a/apps/web/core/claims/browse/debate-comment-row.tsx b/apps/web/core/claims/browse/debate-comment-row.tsx index 3779d20c6e..b2918826fd 100644 --- a/apps/web/core/claims/browse/debate-comment-row.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.tsx @@ -5,21 +5,23 @@ import * as React from 'react'; import cx from 'classnames'; import { ClaimCommentPositionBadge } from '~/core/claims/browse/claim-comment-position'; -import { useEntityCommentsPanel } from '~/core/hooks/use-entity-comments-panel'; +import { useOpenDebaterProfile } from '~/core/debates/browse/use-open-debater-profile'; import { renderMarkdownDocument } from '~/core/state/editor/markdown-render'; import { NavUtils } from '~/core/utils/utils'; import { Avatar } from '~/design-system/avatar'; -import { canNestBelow } from './claim-activity-depth'; - import { PAGE_DENSITY, threadSpineOffsetPx } from '~/partials/comments/comment-density'; +import { getRelativeTime } from '~/partials/comments/comment-time'; +import { InlineCommentComposer, useInlineComposer } from '~/partials/comments/inline-comment-composer'; import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; import { ThreadContinue, ThreadShowMore } from '~/partials/comments/thread-overflow'; -import { getRelativeTime } from '~/partials/comments/comment-time'; import type { CommentWithReplies } from '~/partials/comments/types'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; +import { ActivityRowTag } from './activity-row-tag'; +import { canNestBelow } from './claim-activity-depth'; + /** * A comment somebody left on a debate, shown in the claim's activity thread. * @@ -27,18 +29,23 @@ import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; * claim — so without this the reader saw a debate, the claims it produced, and no sign of the * conversation happening on it one click away. * - * Read-only apart from voting. Replying lands in the debate's comment panel, which is where the - * reply chain for that entity actually lives; threading a composer for a second entity into this - * one's list would put the reply somewhere the surrounding thread cannot show it. + * Replying happens here rather than in the debate's comment panel. The reply still lands on that + * other entity — a reply to a comment on a debate is a comment on the debate — but the panel used to + * take the whole thread away to collect it, so the reader lost the claim, the debate and the badge + * they were replying to at the moment they wanted to answer them. */ /** Replies drawn before the rest are offered in place. Beyond this a thread stops being scannable. */ const REPLY_PAGE_SIZE = 3; +/** Stable identity for a row directly under the entity, so the default doesn't rebuild each render. */ +const NO_ANCESTORS: Array<{ id: string; spaceId: string }> = []; + export function DebateCommentRow({ comment, targetEntityId, spaceId, depth, + ancestors = NO_ANCESTORS, }: { comment: CommentWithReplies; /** The entity this comment replies to — the debate, or the extracted claim. */ @@ -46,8 +53,16 @@ export function DebateCommentRow({ spaceId: string; /** Counted from the claim, not from this branch. See `claim-activity-depth.ts`. */ depth: number; + /** + * The comments between this one and the entity, nearest first. + * + * A reply carries `Reply to` to every ancestor, so this is not decoration: an incomplete chain + * writes a reply that the counts and the collapse tree cannot see. + */ + ancestors?: Array<{ id: string; spaceId: string }>; }) { - const { openComments } = useEntityCommentsPanel(); + const composer = useInlineComposer(); + const openAuthorProfile = useOpenDebaterProfile(comment.author.spaceId, { interactionSurface: 'comment_author' }); const [visibleReplies, setVisibleReplies] = React.useState(REPLY_PAGE_SIZE); const body = React.useMemo(() => renderMarkdownDocument(comment.markdownContent), [comment.markdownContent]); const replies = Array.isArray(comment.replies) ? comment.replies : []; @@ -65,23 +80,27 @@ export function DebateCommentRow({
{/* `Avatar` fills its container whenever it has a real `avatarUrl` — `size` only sizes the generated fallback — so the frame is the caller's job, exactly as in the comment rows. */} + {/* A link, so middle-click and copy-address still reach the person's space, with the click + itself intercepted to open the profile beside the thread instead of replacing it. */} - +
- + {comment.author.name ?? 'Anonymous'} + {/* Where this person stands on the *claim*, not on the debate — the same badge, from the same provider, that a commenter on the claim gets. Someone who argues under a debate and @@ -96,10 +115,7 @@ export function DebateCommentRow({
{body}
@@ -108,8 +124,8 @@ export function DebateCommentRow({
+ {composer.isComposing && ( +
+ +
+ )} + {/* Replies come with the comment — they are already in the tree `useComments` built — so drawing them costs nothing but space, and the cap below is about legibility rather than @@ -132,6 +161,7 @@ export function DebateCommentRow({ targetEntityId={targetEntityId} spaceId={spaceId} depth={depth + 1} + ancestors={[{ id: comment.id, spaceId: comment.spaceId }, ...ancestors]} /> ))} diff --git a/apps/web/core/claims/browse/extracted-claim-row.test.tsx b/apps/web/core/claims/browse/extracted-claim-row.test.tsx index ae31cb7658..d7c6c5a026 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.test.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -33,8 +33,36 @@ vi.mock('~/design-system/prefetch-link', () => ({ })); vi.mock('~/partials/comments/entity-comments-button', () => ({ - EntityCommentsButton: ({ entityId, count }: { entityId: string; count: number }) => ( -
+ EntityCommentsButton: ({ + entityId, + count, + onActivate, + }: { + entityId: string; + count: number; + onActivate?: () => void; + }) => ( + + ), +})); + +const mocks = vi.hoisted(() => ({ openedProfiles: [] as string[] })); + +vi.mock('~/core/debates/browse/use-open-debater-profile', () => ({ + useOpenDebaterProfile: (spaceId: string | undefined) => (event: React.MouseEvent) => { + event.preventDefault(); + if (spaceId) mocks.openedProfiles.push(spaceId); + }, +})); + +// Covered where it lives; here the question is only whether pressing comment opens one in place +// rather than sending the reader to the panel. +vi.mock('~/partials/comments/inline-comment-composer', async importOriginal => ({ + ...((await importOriginal()) as Record), + InlineCommentComposer: ({ targetEntityId }: { targetEntityId: string }) => ( +
), })); @@ -187,3 +215,44 @@ describe('ExtractedClaimRow', () => { expect(link).toHaveAttribute('href', expect.stringContaining('claim-space')); }); }); + +describe('ExtractedClaimRow, saying what it is and answering in place', () => { + afterEach(() => { + cleanup(); + mocks.openedProfiles.length = 0; + }); + + // A claim with no assertable moment and a comment from someone holding no position carry the same + // furniture, so the row says which it is rather than leaving it to be inferred. + it('labels itself a claim', () => { + renderRow(); + + expect(screen.getByText('Claim')).toBeInTheDocument(); + }); + + it('opens the speaker profile beside the thread instead of navigating to their space', () => { + renderRow(); + + fireEvent.click(screen.getByText('Ada Reyes')); + + expect(mocks.openedProfiles).toEqual(['speaker-space']); + }); + + it('opens a composer against the claim rather than the comments panel', () => { + renderRow(); + + fireEvent.click(screen.getByTestId('comments-button')); + + expect(screen.getByTestId('inline-composer')).toHaveAttribute('data-target', 'claim-1'); + }); + + it('closes it again when the same control is pressed twice', () => { + renderRow(); + const button = screen.getByTestId('comments-button'); + + fireEvent.click(button); + fireEvent.click(button); + + expect(screen.queryByTestId('inline-composer')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index 26eafa901c..755d00e4e8 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -5,9 +5,10 @@ import * as React from 'react'; import cx from 'classnames'; import type { DebateResponseKind } from '~/core/debates/api'; +import { useOpenDebaterProfile } from '~/core/debates/browse/use-open-debater-profile'; import { isAssertableMoment } from '~/core/debates/claim-timing'; -import { useComments } from '~/core/hooks/use-comments'; import { debateSeekSeconds, formatTimecode, withDebateTimecode } from '~/core/debates/debate-timecode'; +import { useComments } from '~/core/hooks/use-comments'; import type { ResponseKind } from '~/core/responses/entity-response'; import { NavUtils } from '~/core/utils/utils'; @@ -15,14 +16,16 @@ import { Avatar } from '~/design-system/avatar'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { type CommentDensity, PAGE_DENSITY, threadSpineOffsetPx } from '~/partials/comments/comment-density'; -import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; import { EntityCommentsButton } from '~/partials/comments/entity-comments-button'; +import { InlineCommentComposer, useInlineComposer } from '~/partials/comments/inline-comment-composer'; +import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; +import { ActivityRowTag } from './activity-row-tag'; import { canNestBelow } from './claim-activity-depth'; import type { OrderedTranscriptClaim } from './claim-activity-order'; -import { DebateCommentRow } from './debate-comment-row'; import { ResponsePositionTag } from './claim-comment-position'; +import { DebateCommentRow } from './debate-comment-row'; export type SpeakerProfile = { name?: string | null; avatarUrl?: string | null }; @@ -91,6 +94,9 @@ export function ExtractedClaimRow({ density?: CommentDensity; className?: string; }) { + const composer = useInlineComposer(); + const openSpeakerProfile = useOpenDebaterProfile(speaker?.spaceId, { interactionSurface: 'extracted_claim_speaker' }); + // Null where the graph reports no home space. The claim is then unlinkable and unrespondable — // pointing a vote at this page's space instead would record it somewhere the claim does not live, // which reads back as a claim nobody answered. Read-only is the honest rendering. @@ -114,18 +120,24 @@ export function ExtractedClaimRow({ the generated fallback — so the frame is the caller's job. Same shape the comment rows in this thread use, off the same density, which is what keeps the two kinds of row aligned. */} - - - + + + + +
- - {speaker?.name?.trim() || 'Unnamed debater'} - + + + {speaker?.name?.trim() || 'Unnamed debater'} + + + + {speakerPosition !== null && ( @@ -174,11 +186,37 @@ export function ExtractedClaimRow({ // read as one group. claimResponderAvatarsPosition="trailing" /> - +
) : null} - {claimSpaceId && commentCount > 0 && canNestBelow(depth) && ( + {claimSpaceId && composer.isComposing && ( +
+ +
+ )} + + {/* `composer.hasPosted` as well as the server count: the aggregate that gates this is from + the page load, so a reader's first comment on a silent claim would otherwise be written + and then not drawn. */} + {claimSpaceId && (commentCount > 0 || composer.hasPosted) && canNestBelow(depth) && ( )}
@@ -210,6 +248,38 @@ function ClaimComments({ claimId, spaceId, depth }: { claimId: string; spaceId: ); } +/** + * The speaker's name and face, opening their profile beside the thread. + * + * An anchor rather than a button so middle-click and "copy link" still reach their space; the click + * itself is intercepted, because navigating away from a claim to read who said something is exactly + * the context loss the side panel exists to avoid. Unattributed turns get no link — there is no + * person to open. + */ +function SpeakerLink({ + speaker, + onOpenProfile, + className, + children, +}: { + speaker: (SpeakerProfile & { spaceId: string }) | null; + onOpenProfile: (event: React.MouseEvent) => void; + className?: string; + children: React.ReactNode; +}) { + if (!speaker) return <>{children}; + + return ( + + {children} + + ); +} + function PlayGlyph() { return (
- {claimResponderAvatarsPosition === 'leading' && claimResponderAvatars ? ( - {claimResponderAvatars} - ) : null} + {claimResponderAvatarsPosition === 'leading' ? respondersTrigger(claimResponderAvatars, 'mr-1') : null} - - - - - {/* Into the sheet's own container when one is open, so this list is exempt from the sheet's - scroll lock; the body otherwise, unchanged. */} - - - - - - + 0 ? responseCopy.viewResponders : undefined} + disabled={totalResponders === 0} + triggerClassName="min-w-[2ch] cursor-pointer text-center text-[16px]! leading-5 tabular-nums hover:text-grey-04" + > + {displayLabel} + - {claimResponderAvatarsPosition === 'trailing' && claimResponderAvatars ? ( - {claimResponderAvatars} - ) : null} + {claimResponderAvatarsPosition === 'trailing' ? respondersTrigger(claimResponderAvatars, 'ml-1') : null} {isResponseIndexingDelayed ? ( Response submitted. Waiting for confirmation. @@ -415,6 +420,83 @@ export function EntityVoteButtons({ ); } +/** + * A popover that lists who responded, hung off whatever is passed as its trigger. + * + * Two things in a vote row open this list — the score, and the responder faces — and they used to + * be one hand-written popover and nothing at all. The portal target, the collision padding and the + * detach behaviour below are load-bearing and were arrived at by fixing them once; a second copy is + * a second place for them to be wrong. + */ +function RespondersDisclosure({ + entityId, + spaceId, + responseKind, + container, + open, + onOpenChange, + title, + disabled, + triggerClassName, + children, +}: { + entityId: string; + spaceId: string; + responseKind: ResponseKind; + /** The slide-up sheet's own container when one is open, so the list escapes its scroll lock. */ + container: HTMLElement | null; + open?: boolean; + onOpenChange?: (open: boolean) => void; + title?: string; + disabled?: boolean; + triggerClassName: string; + children: React.ReactNode; +}) { + // Uncontrolled unless the caller asks to own it, so the faces need no state of their own. + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); + const isOpen = open ?? uncontrolledOpen; + const setOpen = onOpenChange ?? setUncontrolledOpen; + + return ( + + + + + {/* Into the sheet's own container when one is open, so this list is exempt from the sheet's + scroll lock; the body otherwise, unchanged. */} + + + {isOpen ? ( + + ) : null} + + + + ); +} + function DebateVotePill({ orientation, score, @@ -617,9 +699,7 @@ function VoterRow({ return ( - personProfileOpened(profile.spaceId, profile.id, { interaction_surface: interactionSurface }) - } + onClick={() => personProfileOpened(profile.spaceId, profile.id, { interaction_surface: interactionSurface })} > {content} From 4e4e88d1f7e13085b584bff47685b603e2d00bf5 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 17:26:47 -0700 Subject: [PATCH 12/35] fix(claims): stop the speaker avatar rendering at its natural size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapping the avatar in a profile link made the link the row's flex item and left the frame inside it display:inline, which ignores the inline width and height that size it. The image inside is h-full w-full, so it resolved against nothing and drew at its natural size — measured on the preview at 987px across, in a 32px row, on all 30 rows of a claim page. The link is a flex container now, so the frame is a flex item again and its dimensions apply. The test asserts that structurally, since jsdom computes no layout: it fails against the previous class list. --- .../claims/browse/extracted-claim-row.test.tsx | 18 ++++++++++++++++++ .../core/claims/browse/extracted-claim-row.tsx | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/web/core/claims/browse/extracted-claim-row.test.tsx b/apps/web/core/claims/browse/extracted-claim-row.test.tsx index d7c6c5a026..7797efa9df 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.test.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.test.tsx @@ -238,6 +238,24 @@ describe('ExtractedClaimRow, saying what it is and answering in place', () => { expect(mocks.openedProfiles).toEqual(['speaker-space']); }); + /** + * jsdom computes no layout, so this asserts the structural condition rather than the pixels. + * + * The avatar's frame is a span sized by inline width and height, which only apply because the span + * is blockified as a flex item. Wrapping it in an anchor made the *anchor* the flex item and left + * the span inline, so both dimensions were ignored and the `h-full w-full` image inside rendered + * at its natural size — measured on the preview at 987px across, in a 32px row. + */ + it('keeps the avatar frame a flex item of the link that wraps it', () => { + const { container } = renderRow(); + + const frame = container.querySelector('span[style*="width"]'); + expect(frame).not.toBeNull(); + const wrapper = frame!.parentElement!; + expect(wrapper.tagName).toBe('A'); + expect(wrapper.className).toMatch(/\b(inline-)?flex\b/); + }); + it('opens a composer against the claim rather than the comments panel', () => { renderRow(); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index 755d00e4e8..5df0dd7f29 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -255,6 +255,13 @@ function ClaimComments({ claimId, spaceId, depth }: { claimId: string; spaceId: * itself is intercepted, because navigating away from a claim to read who said something is exactly * the context loss the side panel exists to avoid. Unattributed turns get no link — there is no * person to open. + * + * `inline-flex`, and this is load-bearing rather than tidiness. The avatar's frame is a `span` sized + * by inline width and height, and it used to be a direct child of the row's flex container, where + * being a flex item blockified it and those dimensions applied. Wrapping it in an anchor made the + * anchor the flex item and left the span `display: inline`, which ignores both — so the `h-full + * w-full` image inside resolved against nothing and rendered at its natural size, a 987px face in a + * 32px row. Making this a flex container puts the span back to being a flex item. */ function SpeakerLink({ speaker, @@ -273,7 +280,7 @@ function SpeakerLink({ {children} From dd06cc6c3c23bb6cd96cbf9b0b7e3b09b869efc3 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 17:32:57 -0700 Subject: [PATCH 13/35] fix(claims): put the speaker's face back on the name line Making the profile link a flex container fixed the size but left it a stretched flex item of the row, so the 32px frame centred against the row's full height and the face dropped down beside the claim text instead of sitting on the name line. Alignment comes from the caller now: the face asks for self-start, the name sits in an already-centred header and asks for nothing. One wrapper cannot know which of its two uses it is. --- .../core/claims/browse/extracted-claim-row.test.tsx | 11 ++++++++++- apps/web/core/claims/browse/extracted-claim-row.tsx | 10 ++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/web/core/claims/browse/extracted-claim-row.test.tsx b/apps/web/core/claims/browse/extracted-claim-row.test.tsx index 7797efa9df..0963a85dff 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.test.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.test.tsx @@ -42,7 +42,13 @@ vi.mock('~/partials/comments/entity-comments-button', () => ({ count: number; onActivate?: () => void; }) => ( - ), @@ -254,6 +260,9 @@ describe('ExtractedClaimRow, saying what it is and answering in place', () => { const wrapper = frame!.parentElement!; expect(wrapper.tagName).toBe('A'); expect(wrapper.className).toMatch(/\b(inline-)?flex\b/); + // And it must not stretch: a stretched link centres the 32px frame against the row's whole + // height, which puts the face beside the claim text instead of on the name line. + expect(wrapper.className).toMatch(/\bself-start\b/); }); it('opens a composer against the claim rather than the comments panel', () => { diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index 5df0dd7f29..18c09949e6 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -120,7 +120,7 @@ export function ExtractedClaimRow({ the generated fallback — so the frame is the caller's job. Same shape the comment rows in this thread use, off the same density, which is what keeps the two kinds of row aligned. */} - + {children} From 1587730de7881ee598030318ad3daf117beabcaf Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 17:41:58 -0700 Subject: [PATCH 14/35] fix(claims): badge the claim the comment is under, and put the side first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with one badge. It sat after the row's Claim/Comment tag, which put a label about the row between a name and a fact about that name — the side belongs to the person, so it goes first. And it always reported the page's claim, wherever the comment was. A comment on an extracted claim sits directly under that claim's sentence, so a bare "Agree" beside the author reads as agreement with it. Observed on testnet: a comment arguing against an extracted claim, badged Agree, because its author agrees with the page claim the debate was about. Both facts were true and the badge showed the one the reader could not see. The rule is now one sentence: the nearest claim above the comment. Nesting the existing provider under an extracted claim is the whole change — the badge already reads whichever is closest, so a comment under a debate, or at the top of the thread, still reports the page claim. Nothing is borrowed from further away when the author holds no position on the nearer claim; an absent badge says less than a wrong one. The badge also names what it is about on hover, since the claim it reports can be several screens up by the time the reader reaches it. --- .../browse/claim-comment-position.test.tsx | 69 +++++++++++++++++++ .../claims/browse/claim-comment-position.tsx | 49 +++++++++++-- .../core/claims/browse/debate-comment-row.tsx | 14 ++-- .../claims/browse/extracted-claim-row.tsx | 36 +++++++--- 4 files changed, 146 insertions(+), 22 deletions(-) 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..9723708e88 100644 --- a/apps/web/core/claims/browse/claim-comment-position.test.tsx +++ b/apps/web/core/claims/browse/claim-comment-position.test.tsx @@ -87,6 +87,75 @@ describe('claim comment position badges', () => { expect(await screen.findByText('Disagree')).toBeInTheDocument(); }); + /** + * The bug this closes, seen on testnet: a comment arguing *against* an extracted claim wore an + * "Agree" badge, because the only provider on the page was the page claim's and its author agrees + * with that. The badge sits under the extracted claim's sentence, so that is what a reader takes + * it to be about. + */ + it('reports the nearest claim, not the page claim, when a provider is nested inside another', async () => { + mocks.getEntityResponders.mockImplementation((entityId: string) => + Effect.succeed([ + { userId: 'author-space', direction: entityId === 'extracted-claim' ? 'negative' : 'positive' }, + ]) + ); + + render( + wrapper( + client, + + + + + + ) + ); + + expect(await screen.findByText('Disagree')).toBeInTheDocument(); + expect(screen.queryByText('Agree')).not.toBeInTheDocument(); + }); + + // A word on its own does not say what it is a position on, and the claim it reports can be off + // screen by the time the reader reaches the badge. + it('names the claim the position is about', async () => { + mocks.getEntityResponders.mockReturnValue( + Effect.succeed([{ userId: 'author-space', direction: 'positive' as const }]) + ); + + render( + wrapper( + client, + + + + ) + ); + + expect(await screen.findByTitle('Agree: Remote work improves mental health')).toBeInTheDocument(); + }); + it('uses the viewer’s optimistic position and the claim’s factual vocabulary', async () => { mocks.getEntityResponders.mockReturnValue( Effect.succeed([{ userId: 'viewer-space', direction: 'negative' as const }]) diff --git a/apps/web/core/claims/browse/claim-comment-position.tsx b/apps/web/core/claims/browse/claim-comment-position.tsx index bdab9579a8..13099957b8 100644 --- a/apps/web/core/claims/browse/claim-comment-position.tsx +++ b/apps/web/core/claims/browse/claim-comment-position.tsx @@ -19,6 +19,13 @@ import { CLAIM_RESPONSE_OBJECT_TYPE, useClaimResponseSummary } from './claim-res type ClaimCommentPositionContextValue = { directions: Map; responseKind: DebateResponseKind; + /** + * What the badge is a position *on*, for the hover title. + * + * A bare "Agree" beside a name answers a question the reader has to guess at, and the guess is + * whatever claim is nearest on screen. Naming it removes the guess. + */ + claimName: string | null; }; const ClaimCommentPositionContext = React.createContext(null); @@ -52,6 +59,7 @@ export function ClaimCommentPositionBoundary({ entityId={entityId} spaceId={spaceId} responseKind={responseKind} + claimName={entity?.name ?? null} viewerDirection={summary.viewerDirection} viewerSpaceId={summary.viewerSpaceId} isViewerResponseLoading={summary.isViewerResponseLoading} @@ -72,6 +80,7 @@ export function ClaimCommentPositionProvider({ entityId, spaceId, responseKind, + claimName = null, viewerDirection, viewerSpaceId, isViewerResponseLoading, @@ -80,6 +89,8 @@ export function ClaimCommentPositionProvider({ entityId: string; spaceId: string; responseKind: DebateResponseKind; + /** The claim these positions are about, named for the badge's hover title. */ + claimName?: string | null; viewerDirection: ActiveResponseDirection | null; viewerSpaceId: string | null; /** True until the viewer read succeeds; failures stay unresolved rather than becoming a clear. */ @@ -90,8 +101,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, @@ -109,7 +119,7 @@ export function ClaimCommentPositionProvider({ return result; }, [responders]); - const value = React.useMemo(() => ({ directions, responseKind }), [directions, responseKind]); + const value = React.useMemo(() => ({ directions, responseKind, claimName }), [claimName, directions, responseKind]); return {children}; } @@ -125,12 +135,16 @@ export function ClaimCommentPositionProvider({ export function ResponsePositionTag({ responseKind, position, + title, }: { responseKind: DebateResponseKind; position: boolean; + /** Says what the position is *on*, which the word alone cannot. */ + title?: string; }) { return ( ; + const label = responsePositionLabel(context.responseKind, direction === 'positive'); + + return ( + + ); } diff --git a/apps/web/core/claims/browse/debate-comment-row.tsx b/apps/web/core/claims/browse/debate-comment-row.tsx index b2918826fd..3fbeef9fde 100644 --- a/apps/web/core/claims/browse/debate-comment-row.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.tsx @@ -100,15 +100,17 @@ export function DebateCommentRow({ > {comment.author.name ?? 'Anonymous'} - {/* - Where this person stands on the *claim*, not on the debate — the same badge, from the - same provider, that a commenter on the claim gets. Someone who argues under a debate and - holds a position on the claim it argued is the same person making the same commitment, - and the thread should say so in one place rather than only where the comment happened to - be filed. Renders nothing for a commenter who holds no position. + Where this person stands on the claim this comment hangs under — the extracted claim when + there is one, the page's claim otherwise. Same badge, same provider a commenter on the + claim gets; which claim it reports is decided by whichever provider is nearest, so a + comment under an extracted claim badges against that claim rather than against something + several screens up. Renders nothing for someone who holds no position on it. + + Before the row's own tag: the side is a fact about the person whose name it follows. */} + {comment.isPublishing ? 'Publishing…' : getRelativeTime(comment.createdAt)} diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index 18c09949e6..a2dc30f11d 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -24,7 +24,7 @@ import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import { ActivityRowTag } from './activity-row-tag'; import { canNestBelow } from './claim-activity-depth'; import type { OrderedTranscriptClaim } from './claim-activity-order'; -import { ResponsePositionTag } from './claim-comment-position'; +import { ClaimCommentPositionBoundary, ResponsePositionTag } from './claim-comment-position'; import { DebateCommentRow } from './debate-comment-row'; export type SpeakerProfile = { name?: string | null; avatarUrl?: string | null }; @@ -137,12 +137,19 @@ export function ExtractedClaimRow({ - - + {/* The side first, then what kind of row this is. The side belongs to the person whose + name it follows; the kind belongs to the row. Reading them the other way round put a + label about the row between a name and the fact about that name. */} {speakerPosition !== null && ( - + )} + + {timecodeHref ? ( - - {comments.map((comment, index) => ( - - - - ))} - + + + {comments.map((comment, index) => ( + + + + ))} + +
); } From 428e2b355273b07974024be39a89516570def928 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 17:54:41 -0700 Subject: [PATCH 15/35] fix(claims): draw the spine every branch was reaching for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three branches in the claim feed drew their arms and their elbow against a line that was never rendered — ThreadBranch gated the spine on being given something to collapse, and the comments under an extracted claim and the replies under a comment were given neither. On screen that is an arm pointing at nothing, which reads as a broken connector rather than as a missing control. Both now carry the full set the debate branch already had: a parent spine from the face down to where the branch starts, the branch's own spine, and the collapse toggle on the line in the action row. The gating is gone and the props are required, so a spineless branch can no longer be written. The debate row is also rebuilt to the shape every other row in the thread uses — who, then what, then what you can do about it. Debaters, kind and age on the header line; the claim it argued on its own line as the body. Reply is gone: the comment count opens the same composer, and two controls for one box was one too many. --- activity-1.png | Bin 0 -> 308698 bytes activity-2.png | Bin 0 -> 175075 bytes .../claims/browse/debate-activity-row.tsx | 47 ++++---- .../core/claims/browse/debate-comment-row.tsx | 74 +++++++++++-- .../claims/browse/extracted-claim-row.tsx | 103 +++++++++++++++++- .../partials/comments/thread-branch-list.tsx | 32 +++--- check-align.mjs | 40 +++++++ shot2.mjs | 15 +++ 8 files changed, 259 insertions(+), 52 deletions(-) create mode 100644 activity-1.png create mode 100644 activity-2.png create mode 100644 check-align.mjs create mode 100644 shot2.mjs diff --git a/activity-1.png b/activity-1.png new file mode 100644 index 0000000000000000000000000000000000000000..2e84e1dd0a695060a8b96f956a179d8d880ad879 GIT binary patch literal 308698 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 YpuGbWTSh8+S!uyWj*m@4D8-^dtJ{Qd> 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�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⁣<0-Pgv96JpH2%&9 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

}oT;$Bk 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 literal 0 HcmV?d00001 diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index f7927c2584..4745d7c9a8 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -189,26 +189,22 @@ export function DebateActivityRow({

{/* - The claim leads and the debaters follow, the other way round from the first cut. - A comment row's strong first line is what was said, and its byline is who said it; a - debate row reads as a peer of those rows only if it is built the same way. What the - debate argued is the row's substance — "Ada vs. Tomas" is its byline. + Built like every other row in the thread: who, then what, then what you can do about it. + The debaters are this row's byline the way a commenter's name is theirs, and the claim + the debate argued is its body — so the header carries the names, the kind tag and the + age, and the sentence sits underneath on its own line. + + An earlier cut led with the claim and dropped the debaters to a second line. It read as a + headline with a credit under it, which is a different shape from the rows above and below + it, and the eye had to find a new place to look for "who" on every other row. */}
- - - {headline} - - + {debaterLine && ( + {debaterLine} + )} Debate -
- -
- {debaterLine && ( - {debaterLine} - )} {publishedAt && ( // Same helper and classes the comment rows use, so a debate and a comment in one // thread age identically rather than reading as two lists side by side. @@ -218,6 +214,12 @@ export function DebateActivityRow({ )}
+ + + {headline} + + +
{!collapsed && ( - {/* A reply to a debate is a comment on the debate — it is filed against that entity, not - against this claim — but it is written and read right here, at the bottom of the - debate's own branch, rather than in a panel that hides the branch to collect it. */} - = []; +/** Gap between the avatar's bottom edge and where its spine starts, as on every other row here. */ +const SPINE_START_GAP_PX = 4; + export function DebateCommentRow({ comment, targetEntityId, @@ -64,6 +68,34 @@ export function DebateCommentRow({ const composer = useInlineComposer(); const openAuthorProfile = useOpenDebaterProfile(comment.author.spaceId, { interactionSurface: 'comment_author' }); const [visibleReplies, setVisibleReplies] = React.useState(REPLY_PAGE_SIZE); + const [repliesCollapsed, setRepliesCollapsed] = React.useState(false); + + // Measured rather than computed: a comment body wraps to any number of lines, and the spine has to + // stop at the first reply's elbow. + const rowRef = React.useRef(null); + const branchRef = React.useRef(null); + const [spineHeightPx, setSpineHeightPx] = React.useState(null); + + const measureSpine = React.useCallback(() => { + const row = rowRef.current; + const branch = branchRef.current; + if (!row || !branch) { + setSpineHeightPx(null); + return; + } + setSpineHeightPx( + branch.getBoundingClientRect().top - row.getBoundingClientRect().top - PAGE_DENSITY.avatarPx - SPINE_START_GAP_PX + ); + }, []); + + React.useLayoutEffect(() => { + measureSpine(); + const row = rowRef.current; + if (row == null || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => measureSpine()); + observer.observe(row); + return () => observer.disconnect(); + }); const body = React.useMemo(() => renderMarkdownDocument(comment.markdownContent), [comment.markdownContent]); const replies = Array.isArray(comment.replies) ? comment.replies : []; @@ -76,8 +108,23 @@ export function DebateCommentRow({ const hiddenHere = canNest ? replies.length - shown.length : 0; const belowFloor = canNest ? 0 : replies.length; + const branchLabel = { expand: 'Show replies', collapse: 'Hide replies' }; + const drawnReplies = repliesCollapsed ? [] : shown; + return ( -
+
+ {/* The line from this comment's face down to its replies. Without it the branch below reaches + an elbow back to a spine that was never drawn. */} + {drawnReplies.length > 0 && ( + setRepliesCollapsed(true)} + /> + )} {/* `Avatar` fills its container whenever it has a real `avatarUrl` — `size` only sizes the generated fallback — so the frame is the caller's job, exactly as in the comment rows. */} {/* A link, so middle-click and copy-address still reach the person's space, with the click @@ -122,7 +169,15 @@ export function DebateCommentRow({ {body}
-
+
+ {shown.length > 0 && ( + setRepliesCollapsed(collapsed => !collapsed)} + /> + )} - + {/* This comment's own page, not the debate's. A Comment is an entity like any other and its + page renders its own thread, so continuing lands on the branch the reader was reading + with a fresh depth budget — which is what the control promises. Pointing at the target + entity reopened the whole parent thread instead, and for a debate that is the video + page, where the thread is behind a button. Verified on the preview: a comment entity + page draws `Comments (11)` for a comment with eleven replies. */} +
0); const keyframeByDebateId = useDebateKeyframes(debates); + // Both counts for every debate on the page, in two requests rather than two per row. The rows used + // to ask for their own — the comment count through a batched hook handed a single id, which is a + // request each, and the claim count through the debate's transcript, which is the expensive read + // the collapse control exists to avoid and which ran whether or not the row was expanded. + const debateIds = React.useMemo(() => debates.map(debate => debate.id), [debates]); + const commentCountByDebateId = useEntityCommentCounts(debateIds); + const claimCountByDebateId = useDebateClaimCounts(debateIds); + const rows = React.useMemo(() => { if (debates.length === 0) return NO_ROWS; @@ -118,10 +128,21 @@ export function useClaimActivityRows({ responseVocabulary={responseVocabulary} keyframeUrl={keyframeByDebateId.get(debate.id) ?? null} publishedAt={debateDate(debate)} + commentCount={commentCountByDebateId.get(uuidToHex(debate.id)) ?? 0} + claimCount={claimCountByDebateId.get(uuidToHex(debate.id)) ?? 0} /> ), })); - }, [debates, keyframeByDebateId, profilesBySpaceId, responseVocabulary, sidesByDebateId, spaceId]); + }, [ + claimCountByDebateId, + commentCountByDebateId, + debates, + keyframeByDebateId, + profilesBySpaceId, + responseVocabulary, + sidesByDebateId, + spaceId, + ]); return { rows, isLoading }; } diff --git a/apps/web/core/debates/use-debate-claim-counts.test.ts b/apps/web/core/debates/use-debate-claim-counts.test.ts new file mode 100644 index 0000000000..7d3ddd168f --- /dev/null +++ b/apps/web/core/debates/use-debate-claim-counts.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeDebateClaimCounts } from './use-debate-claim-counts'; + +/** + * This aggregate replaced reading the count off each debate's transcript, which is the expensive + * fetch the collapse control exists to avoid and which ran whether or not the row was expanded. The + * two agree: measured against testnet on 2026-09-25, 27 of 27 debates across the fifteen busiest + * claims reported the same number from `Sources` as from the transcript. + */ +describe('decodeDebateClaimCounts', () => { + it('keys counts by canonical id, so a hyphenated lookup still finds them', () => { + const counts = decodeDebateClaimCounts({ + entities: [{ id: '3e4a0955-699a-4c8f-813f-cc803a3335ba', extracted: { totalCount: 22 } }], + }); + + expect(counts.get('3e4a0955699a4c8f813fcc803a3335ba')).toBe(22); + }); + + it('reads a debate that produced no claims as zero rather than leaving it out', () => { + const counts = decodeDebateClaimCounts({ entities: [{ id: 'aa', extracted: { totalCount: 0 } }] }); + + expect(counts.get('aa')).toBe(0); + }); + + // A missing aggregate is the server declining to count, not a claim that there are none — but a + // row has to render something, and zero is what it already shows while it waits. + it('falls back to zero when the aggregate is absent', () => { + const counts = decodeDebateClaimCounts({ entities: [{ id: 'aa', extracted: null }, { id: 'bb' }] }); + + expect(counts.get('aa')).toBe(0); + expect(counts.get('bb')).toBe(0); + }); + + it('skips rows the API returned as null', () => { + const counts = decodeDebateClaimCounts({ + entities: [null, { id: null }, { id: 'aa', extracted: { totalCount: 2 } }], + }); + + expect([...counts.keys()]).toEqual(['aa']); + }); + + it('is an empty map when nothing came back', () => { + expect(decodeDebateClaimCounts({}).size).toBe(0); + expect(decodeDebateClaimCounts({ entities: null }).size).toBe(0); + }); +}); diff --git a/apps/web/core/debates/use-debate-claim-counts.ts b/apps/web/core/debates/use-debate-claim-counts.ts new file mode 100644 index 0000000000..1f7ddf9d6f --- /dev/null +++ b/apps/web/core/debates/use-debate-claim-counts.ts @@ -0,0 +1,82 @@ +'use client'; + +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { useQuery } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { Effect } from 'effect'; +import { parse } from 'graphql'; + +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; +import { SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; +import { uuidToHex } from '~/core/id/normalize'; +import { graphql } from '~/core/io/graphql-client'; + +/** + * How many claims each of a set of debates produced, in one request. + * + * The claim page's activity feed wants this number beside each debate's comment count. Reading it + * from the transcript — which is what draws the claims themselves — costs a transcript fetch per + * debate row *whether or not the row is expanded*, so collapsing a debate stopped saving anything. + * The published `Sources` relation answers the same question as a server-side aggregate: measured + * against testnet on 2026-09-25, the two agree on 27 of 27 debates, which is every debate attached + * to the fifteen busiest claims. + * + * Batched for the same reason `useEntityCommentCounts` is: the cost is per request rather than per + * debate, and a feed asking once per row is what makes a number on a row expensive. + */ +const DEBATE_CLAIM_COUNTS_SOURCE = /* GraphQL */ ` + query DebateClaimCounts($ids: [UUID!], $sourcesPropertyId: UUID!, $claimTypeId: UUID!) { + entities(filter: { id: { in: $ids } }) { + id + extracted: backlinks( + filter: { typeId: { is: $sourcesPropertyId }, fromEntity: { typeIds: { overlaps: [$claimTypeId] } } } + ) { + totalCount + } + } + } +`; + +const debateClaimCountsDocument = parse(DEBATE_CLAIM_COUNTS_SOURCE) as TypedDocumentNode; + +type CountsResponse = { + entities?: Array<{ id?: string | null; extracted?: { totalCount?: number | null } | null } | null> | null; +}; + +const NO_COUNTS = new Map(); + +export function decodeDebateClaimCounts(data: CountsResponse): Map { + const counts = new Map(); + for (const entity of data.entities ?? []) { + if (!entity?.id) continue; + counts.set(uuidToHex(entity.id), entity.extracted?.totalCount ?? 0); + } + return counts; +} + +export const debateClaimCountsQueryKey = (ids: string[]) => ['debate-claim-counts', ids] as const; + +/** Extracted-claim counts keyed by canonical debate id. Absent means "not answered yet", not zero. */ +export function useDebateClaimCounts(debateIds: string[], enabled = true): Map { + // Sorted and deduped, so the same debates in a different order are the same query. + const ids = React.useMemo(() => [...new Set(debateIds.filter(Boolean).map(uuidToHex))].sort(), [debateIds]); + + const { data } = useQuery({ + queryKey: debateClaimCountsQueryKey(ids), + queryFn: ({ signal }) => + Effect.runPromise( + graphql({ + query: debateClaimCountsDocument, + decoder: decodeDebateClaimCounts, + variables: { ids, sourcesPropertyId: SOURCES_PROPERTY_ID, claimTypeId: CLAIM_TYPE_ID }, + signal, + }) + ), + enabled: enabled && ids.length > 0, + staleTime: 60_000, + }); + + return data ?? NO_COUNTS; +} diff --git a/apps/web/core/hooks/use-comment-count.test.tsx b/apps/web/core/hooks/use-comment-count.test.tsx index 9469f366fc..4f39e6c62e 100644 --- a/apps/web/core/hooks/use-comment-count.test.tsx +++ b/apps/web/core/hooks/use-comment-count.test.tsx @@ -66,6 +66,46 @@ describe('useCommentCount', () => { expect(result.current).toBe(5); }); + /** + * The claim card's pill is an activity total — debates, extracted claims, and every comment under + * them. The comment list for that claim is a strict subset, and the claim page fetches it on every + * visit, so without this a reader who opened a claim and went back to Explore found the card's 33 + * had become 1. + */ + it('keeps a broader seed when the comment list answers with its own smaller measurement', async () => { + // Both readings of the same cache, because asserting the broader one alone cannot fail: 33 is + // also its answer before the list arrives, so `waitFor` would pass on the first tick whatever + // the list went on to say. The plain count is the control — when it drops to 1 the list has + // definitely replaced, and only then does the broader one holding 33 mean anything. + const { result } = renderHook( + () => ({ + broader: useCommentCount(ENTITY_ID, 33, { broaderThanComments: true }), + plain: useCommentCount(ENTITY_ID, 33), + }), + { wrapper } + ); + + vi.setSystemTime(2_000); + act(() => { + writeFetchedList([comment('c1')]); + }); + + await waitFor(() => expect(result.current.plain).toBe(1)); + expect(result.current.broader).toBe(33); + }); + + it('still adds a row the viewer has only just posted to a broader seed', async () => { + const { result } = renderHook(() => useCommentCount(ENTITY_ID, 33, { broaderThanComments: true }), { wrapper }); + + // The list answers after the count was rendered, and the reader has a row in flight. + vi.setSystemTime(2_000); + act(() => { + writeFetchedList([comment('c1'), comment('c2', { isPendingPublish: true } as Partial)]); + }); + + await waitFor(() => expect(result.current).toBe(34)); + }); + it('follows the list once something has read it', async () => { const { result } = renderHook(() => useCommentCount(ENTITY_ID, 5), { wrapper }); diff --git a/apps/web/core/hooks/use-comment-count.ts b/apps/web/core/hooks/use-comment-count.ts index f113da9922..bb3eb2f3c3 100644 --- a/apps/web/core/hooks/use-comment-count.ts +++ b/apps/web/core/hooks/use-comment-count.ts @@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query'; - import { commentsFetchedQueryKey } from '~/core/io/query-keys'; import type { CommentEntity } from '~/partials/comments/types'; @@ -50,7 +49,25 @@ export function resetCommentCountSeeds() { seeds.clear(); } -export function useCommentCount(entityId: string, serverCount: number): number { +export function useCommentCount( + entityId: string, + serverCount: number, + { + broaderThanComments = false, + }: { + /** + * The seed counts something the comment list does not, so the list may not replace it. + * + * The claim card's pill shows an *activity* total — the claim's debates, the claims extracted + * from them, and every comment in that tree. The comment list for that claim is a strict subset + * of it, so the rule below ("once the list has answered it *is* the count") turns a pill reading + * 33 into one reading 1 the moment anything fetches that list — which the claim page itself does + * on every visit, so a reader who opens a claim and goes back to Explore finds the card's number + * collapsed. Optimistic rows still add to the seed; only the replacement is switched off. + */ + broaderThanComments?: boolean; + } = {} +): number { // Whether the list has ever been fetched, rather than merely written to. Same disabled-subscription // shape as the list below, for the same reason: this reads what another hook owns. const { data: hasFetchedList } = useQuery({ @@ -82,7 +99,10 @@ export function useCommentCount(entityId: string, serverCount: number): number { // what keeps those two apart: a publish that fails before the list loads filters its own optimistic // row back out and leaves `[]` behind, which is not the list speaking — and a post on top of an // authoritatively empty list leaves only a pending row, which is. - if (hasFetchedList === true) return data.length; + if (hasFetchedList === true && !broaderThanComments) return data.length; + // A broader seed keeps counting from itself, with anything still only local added on: the list + // that just answered measured a subset, so its length cannot stand in for the whole. + if (hasFetchedList === true) return serverCount + data.filter(comment => comment.isPendingPublish === true).length; // Never fetched, so this entry holds only what was written into it: `useCreateComment` seeds it with // `(old = [])`. Its length would read 1 where the server knows 5, so the count is the base those diff --git a/apps/web/partials/comments/activity-posts.tsx b/apps/web/partials/comments/activity-posts.tsx new file mode 100644 index 0000000000..af11f6d878 --- /dev/null +++ b/apps/web/partials/comments/activity-posts.tsx @@ -0,0 +1,39 @@ +'use client'; + +import * as React from 'react'; + +/** + * How many comments the reader has added to this thread since it loaded. + * + * The Activity heading's number is a server aggregate — the claim's debates, the claims extracted + * from them, and every comment anywhere in that tree. It is the only way to count things the section + * does not hold, and it is also why publishing used to leave the heading standing still: the + * aggregate answered before the comment existed, and nothing in the comment caches can move a number + * that counts debates and extracted claims too. + * + * So the aggregate is a baseline and this is the delta. Every composer inside the section reports + * here — the top-level one and the inline ones on debates, extracted claims and replies — because a + * reader who has just written something expects the count above to have noticed. + * + * The baseline has to hold still for that to be right: `useClaimActivityCounts` does not refetch on + * window focus, so a refetch that already included these posts cannot land underneath and make the + * delta count them twice. Navigating away and back re-reads the aggregate and resets the delta, + * which is the same answer arrived at from the other side. + */ +const ActivityPostsContext = React.createContext<(() => void) | null>(null); + +export function ActivityPostsProvider({ onPost, children }: { onPost: () => void; children: React.ReactNode }) { + return {children}; +} + +/** + * Reports a comment published here, if anything is counting. + * + * Returns a no-op outside a provider rather than throwing: the same composer runs in the entity + * comments panel and on proposal threads, neither of which has a heading with an aggregate behind + * it. + */ +export function useReportActivityPost(): () => void { + const report = React.useContext(ActivityPostsContext); + return React.useCallback(() => report?.(), [report]); +} diff --git a/apps/web/partials/comments/comment-activity-rows.test.tsx b/apps/web/partials/comments/comment-activity-rows.test.tsx index 620a0c57ae..08faecc285 100644 --- a/apps/web/partials/comments/comment-activity-rows.test.tsx +++ b/apps/web/partials/comments/comment-activity-rows.test.tsx @@ -1,6 +1,6 @@ import '@testing-library/jest-dom/vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -8,16 +8,22 @@ import type { CommentActivityRow, CommentWithReplies } from './types'; const mocks = vi.hoisted(() => ({ comments: [] as CommentWithReplies[], + smartAccount: null as unknown, + /** Stands in for the optimistic row a real publish writes, which is what tells the section. */ + publishComment: vi.fn((input: { onOptimistic?: (id: string) => void }) => { + input.onOptimistic?.('new-comment'); + return Promise.resolve(undefined); + }), })); vi.mock('~/core/hooks/use-comments', () => ({ useComments: () => ({ comments: mocks.comments, totalCount: mocks.comments.length, isLoading: false }), })); vi.mock('~/core/hooks/use-publish-comment', () => ({ - usePublishComment: () => ({ publishComment: vi.fn(), editComment: vi.fn() }), + usePublishComment: () => ({ publishComment: mocks.publishComment, editComment: vi.fn() }), })); vi.mock('~/core/hooks/use-personal-space-id', () => ({ usePersonalSpaceId: () => ({ personalSpaceId: null }) })); -vi.mock('~/core/hooks/use-smart-account', () => ({ useSmartAccount: () => ({ smartAccount: null }) })); +vi.mock('~/core/hooks/use-smart-account', () => ({ useSmartAccount: () => ({ smartAccount: mocks.smartAccount }) })); vi.mock('~/core/hooks/use-geo-profile', () => ({ useGeoProfile: () => ({ profile: null }) })); vi.mock('~/core/hooks/use-space-editor-ids', () => ({ useSpaceRoles: () => ({ @@ -80,6 +86,8 @@ describe('CommentSection activity rows', () => { afterEach(() => { cleanup(); mocks.comments = []; + mocks.smartAccount = null; + mocks.publishComment.mockClear(); }); it('orders non-comment rows in among the comments rather than stacking them above', () => { @@ -120,6 +128,30 @@ describe('CommentSection activity rows', () => { expect(screen.getByText('Activity (2)')).toBeInTheDocument(); }); + /** + * The heading's number is a server aggregate over debates, extracted claims and every comment + * under them, so nothing in the comment caches can move it — publishing used to leave it standing + * still, including for the comment the reader had just written. + */ + it('counts a comment published here on top of the aggregate it was given', async () => { + // Signed in, because the composer asks for a sign-in rather than opening otherwise. + mocks.smartAccount = { account: { address: '0xabc' } }; + render( + withClient( + + ) + ); + + expect(screen.getByText('Activity (33)')).toBeInTheDocument(); + + // What a composer does on publish: the section is told, whichever entity the comment landed on. + fireEvent.click(screen.getByText('Join the conversation...')); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'A new comment' } }); + fireEvent.click(screen.getByRole('button', { name: 'Comment' })); + + await waitFor(() => expect(screen.getByText('Activity (34)')).toBeInTheDocument()); + }); + it('renders the rows on a claim nobody has commented on yet', () => { renderSection([{ id: 'd-1', entityId: 'd-1', spaceId: 'space-1', createdAt: '2026-09-21T10:00:00Z', content: debate }]); diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index 589d837ca0..7e4547ae8f 100644 --- a/apps/web/partials/comments/comments-section.tsx +++ b/apps/web/partials/comments/comments-section.tsx @@ -39,6 +39,7 @@ import { Text } from '~/design-system/text'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; +import { ActivityPostsProvider } from './activity-posts'; import { type CommentDensity, PAGE_DENSITY, @@ -369,6 +370,12 @@ export function CommentSection({ setSessionNewIds((prev: string[]) => (prev.includes(id) ? prev : [id, ...prev])); }, []); + // Comments published from anywhere in this section since it loaded. Only meaningful beside + // `totalOverride`: that number is a server aggregate over debates and extracted claims as well as + // comments, so nothing in the comment caches can move it. See `activity-posts.tsx`. + const [activityPosts, setActivityPosts] = useState(0); + const reportActivityPost = React.useCallback(() => setActivityPosts(posts => posts + 1), []); + // Fire-and-forget: the input boxes close/clear synchronously. The optimistic row appears // in the cache immediately (via usePublishComment) with a "Publishing…" tag; sessionNewIds // is updated via the onOptimistic callback so the row pins to the top right away. @@ -379,10 +386,13 @@ export function CommentSection({ void publishComment({ text, ancestorComments, - onOptimistic: markSessionNew, + onOptimistic: commentId => { + markSessionNew(commentId); + reportActivityPost(); + }, }); }, - [markSessionNew, publishComment, smartAccount] + [markSessionNew, publishComment, reportActivityPost, smartAccount] ); const handleEditComment = React.useCallback( @@ -432,75 +442,79 @@ export function CommentSection({ return ( - -
- {!isPanel && ( - <> -
- {title} ({totalOverride ?? totalCount + activityRows.length}) -
- - - )} - - {totalCount + activityRows.length > 0 && ( - <> - - - - )} - {isLoading ? ( -
- - Loading comments... - -
- ) : ( - (filteredComments.length > 0 || activityRows.length > 0) && ( + {/* Every composer inside — the one below and the inline ones on the activity rows — reports a + publish here, because the heading's number is an aggregate none of them can otherwise move. */} + + +
+ {!isPanel && ( <> +
+ {title} ({totalOverride == null ? totalCount + activityRows.length : totalOverride + activityPosts}) +
- - - setPendingReplyToId(null)} - /> - - - ) - )} -
-
+ )} + + {totalCount + activityRows.length > 0 && ( + <> + + + + )} + {isLoading ? ( +
+ + Loading comments... + +
+ ) : ( + (filteredComments.length > 0 || activityRows.length > 0) && ( + <> + + + + setPendingReplyToId(null)} + /> + + + + ) + )} +
+
+
); } diff --git a/apps/web/partials/comments/entity-comments-button.tsx b/apps/web/partials/comments/entity-comments-button.tsx index 0087847e3c..05d41ff8f2 100644 --- a/apps/web/partials/comments/entity-comments-button.tsx +++ b/apps/web/partials/comments/entity-comments-button.tsx @@ -29,6 +29,7 @@ export function EntityCommentsButton({ className, onActivate, isActive, + broaderThanComments = false, }: { entityId: string; spaceId: string; @@ -40,11 +41,18 @@ export function EntityCommentsButton({ onActivate?: () => void; /** What the button reports as expanded when the caller owns the disclosure. */ isActive?: boolean; + /** + * `count` measures more than this entity's comments — an activity total, say. + * + * Without it the live comment list replaces the number as soon as anything fetches that list, and + * a pill showing a whole claim's activity collapses to its direct comments. See `useCommentCount`. + */ + broaderThanComments?: boolean; }) { const { commentsTarget, openComments } = useEntityCommentsPanel(); const isOpen = onActivate ? (isActive ?? false) : commentsTarget?.entityId === entityId; // Commenting from the panel this button opens used to leave the number beside it behind. - const liveCount = useCommentCount(entityId, count); + const liveCount = useCommentCount(entityId, count, { broaderThanComments }); return ( + ), +})); + +vi.mock('~/design-system/prefetch-link', () => ({ + PrefetchLink: ({ children, ...props }: { children?: React.ReactNode } & Record) => ( +
)}>{children} + ), +})); + +vi.mock('~/design-system/geo-image', () => ({ GeoImage: () => })); + +vi.mock('~/partials/comments/inline-comment-composer', async importOriginal => ({ + ...((await importOriginal()) as Record), + InlineCommentComposer: ({ composer }: { composer: { isComposing: boolean } }) => + composer.isComposing ?
: null, +})); + +// The branch's own reads. Empty, so what the tests observe is whether the row *offered* a branch — +// not what came back inside it. +vi.mock('~/core/debates/use-debate-transcript-claims', () => ({ + useDebateTranscriptClaims: () => ({ claims: { all: [], blocks: [] }, isLoading: false }), +})); + +vi.mock('~/core/debates/use-claim-timings', () => ({ + useClaimTimings: () => ({ timings: new Map(), isReady: true }), +})); + +vi.mock('~/core/debates/hooks', () => ({ useDebateClaims: () => ({ data: null }) })); + +vi.mock('~/core/hooks/use-comments', () => ({ + useComments: ({ entityId, enabled }: { entityId: string; enabled?: boolean }) => { + mocks.commentQueries.push({ entityId, enabled: enabled !== false }); + return { comments: [] }; + }, +})); + +function debate(): Entity { + // Only the id and the name are read here; the rest of an `Entity` never reaches this component. + return { id: 'debate-1', name: 'Practical effects | Ada vs. Ben', relations: [] } as unknown as Entity; +} + +const COLLAPSE_LABEL = 'Collapse this debate'; + +/** Every distinct gate the branch asked its comments with, since a row renders more than once. */ +function gatesOfCommentQueries(): boolean[] { + expect(mocks.commentQueries.map(query => query.entityId)).not.toHaveLength(0); + expect(new Set(mocks.commentQueries.map(query => query.entityId))).toEqual(new Set(['debate-1'])); + return [...new Set(mocks.commentQueries.map(query => query.enabled))]; +} + +function renderRow(overrides: Partial> = {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +describe('DebateActivityRow', () => { + beforeEach(() => { + mocks.commentQueries.length = 0; + }); + afterEach(cleanup); + + it('draws no collapse control on a debate that produced nothing and has no comments', () => { + renderRow({ claimCount: 0, commentCount: 0 }); + + expect(screen.queryByLabelText(COLLAPSE_LABEL)).not.toBeInTheDocument(); + }); + + it('draws one when the debate has claims', () => { + renderRow({ claimCount: 18, commentCount: 0 }); + + expect(screen.getAllByLabelText(COLLAPSE_LABEL).length).toBeGreaterThan(0); + }); + + /** + * The reason the counts are nullable. Both aggregates are separate requests from the debates + * themselves, and when one fails every row on the page reported zero claims and zero comments — + * so the branch, the spine and both collapse controls all went away, and a debate with 26 + * extracted claims became a dead end with nothing left to press. + */ + it('keeps the branch when the claim aggregate could not answer', () => { + renderRow({ claimCount: null, commentCount: 0 }); + + expect(screen.getAllByLabelText(COLLAPSE_LABEL).length).toBeGreaterThan(0); + }); + + it('keeps the branch when the comment aggregate could not answer', () => { + renderRow({ claimCount: 0, commentCount: null }); + + expect(screen.getAllByLabelText(COLLAPSE_LABEL).length).toBeGreaterThan(0); + }); + + // The gate that saves a backlink walk per row when the aggregate says a debate is silent. An + // aggregate that *failed* has not said that, so it must not keep the comments unreachable. + it('fetches the debate comments when the count is unknown, and not when it is a real zero', () => { + renderRow({ claimCount: null, commentCount: null }); + + expect(gatesOfCommentQueries()).toEqual([true]); + + cleanup(); + mocks.commentQueries.length = 0; + renderRow({ claimCount: 4, commentCount: 0 }); + + expect(gatesOfCommentQueries()).toEqual([false]); + }); + + it('prints the claim count it was given, and prints nothing when it has none', () => { + const { unmount } = renderRow({ claimCount: 18 }); + expect(screen.getByLabelText('18 extracted claims')).toBeInTheDocument(); + unmount(); + + renderRow({ claimCount: null }); + expect(screen.queryByLabelText(/extracted claim/)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index 8105dfb90b..dd4800e03a 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -9,6 +9,7 @@ import type { DebateResponseKind } from '~/core/debates/api'; import { useDebateClaims } from '~/core/debates/hooks'; import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; +import { countFor } from '~/core/hooks/batched-counts'; import { useComments } from '~/core/hooks/use-comments'; import { uuidToHex } from '~/core/id/normalize'; import type { ResponseKind } from '~/core/responses/entity-response'; @@ -81,9 +82,12 @@ export type DebateActivityRowProps = { * Both used to be read here: the comments through a batched hook handed one id, which is a request * each, and the claims through this debate's transcript — the expensive read the collapse control * exists to avoid, running whether or not the row was expanded. + * + * `null` means the aggregate could not answer, and the row then assumes there is something here — + * otherwise one failed request turns every debate on the page into a dead end. */ - commentCount: number; - claimCount: number; + commentCount: number | null; + claimCount: number | null; }; /** @@ -140,7 +144,12 @@ export function DebateActivityRow({ // collapse control was drawn anyway, so pressing it on a transcript-less debate collapsed nothing // and merely swapped the keyframe for a plus. `composer.hasPosted` as well as the two counts, // because a comment written here is a branch the server aggregate has not heard about yet. - const hasBranch = claimCount + commentCount > 0 || composer.hasPosted; + // + // A count of `null` is an aggregate that failed, not an empty debate, so it opens the branch: the + // worst case is that toggle with nothing under it again, against losing every extracted claim and + // every comment on the page with no way to ask for them. + const countsUnknown = claimCount == null || commentCount == null; + const hasBranch = countsUnknown || claimCount + commentCount > 0 || composer.hasPosted; return (
@@ -228,18 +237,25 @@ export function DebateActivityRow({ comments are what happened afterwards — the same order the branch draws them in. Not a control: the rows are already below, and the collapse toggle on the spine is how you hide them. */} - - - {claimCount} - + {/* Omitted rather than shown as "0" when the aggregate could not answer: the branch below + is drawing whatever is actually there, and a zero beside it would contradict it. */} + {claimCount != null && ( + + + {claimCount} + + )} void; branchLabel: { expand: string; collapse: string }; - /** From the feed's own aggregate. Zero means there is nothing here worth a request. */ - commentCount: number; + /** From the feed's own aggregate. Zero means nothing here is worth a request; `null` means it could not say. */ + commentCount: number | null; /** Someone has commented from this row since the page loaded, so the aggregate is behind. */ hasPostedHere: boolean; }) { @@ -325,8 +341,9 @@ function DebateBranch({ // Most debates have no comments, and this feed already knows which. Fetching them anyway would // be a backlink walk per debate row to discover a list we were told is empty. A comment made // from this row makes that aggregate stale, so it also lifts the gate — otherwise the first - // comment on a silent debate would be written and never read back. - enabled: commentCount > 0 || hasPostedHere, + // comment on a silent debate would be written and never read back. So does an aggregate that + // failed (`null`): "we could not ask" is not "there is nothing to fetch". + enabled: commentCount == null || commentCount > 0 || hasPostedHere, }); const claimIds = React.useMemo(() => claims.all.map(claim => claim.id), [claims.all]); @@ -378,7 +395,9 @@ function DebateBranch({ claim={claim} debateId={debateId} debateSpaceId={spaceId} - commentCount={commentCounts.get(uuidToHex(claim.id)) ?? 0} + // Null when this aggregate failed, so the claim still offers its comments — the same + // rule the debate row above follows for its own counts. + commentCount={countFor(commentCounts, claim.id)} // A debate is the root of this branch, so everything hanging off it is one below. depth={ACTIVITY_ROOT_DEPTH + 1} // Stance unless geo-chat says otherwise. A missing row means the space is not indexed, diff --git a/apps/web/core/claims/browse/extracted-claim-row.test.tsx b/apps/web/core/claims/browse/extracted-claim-row.test.tsx index d305484fe3..ce03b3e57b 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.test.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.test.tsx @@ -219,6 +219,21 @@ describe('ExtractedClaimRow', () => { expect(screen.getByTestId('comments-button')).toHaveAttribute('data-count', '4'); }); + /** + * The count is a separate aggregate from the claims themselves, so it can fail on its own. When it + * did, a zero meant "no comments" and the row folded its branch away — the replies unreachable for + * as long as the page stayed open. `null` says the request could not answer, and the row keeps the + * thread open on it. + */ + it('keeps its comments reachable when the count aggregate could not answer', () => { + const { unmount } = renderRow({ commentCount: 0 }); + expect(screen.queryByLabelText('Hide comments on this claim')).not.toBeInTheDocument(); + unmount(); + + renderRow({ commentCount: null }); + expect(screen.getAllByLabelText('Hide comments on this claim').length).toBeGreaterThan(0); + }); + it('links the sentence to the claim in its own space', () => { renderRow(); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index ad2c9c6573..1e1c4ff4bc 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -92,8 +92,11 @@ export function ExtractedClaimRow({ * Passed rather than left to the button's own seed: that seed only ever corrects *downward*, from * a list somebody has opened, so a row with no count to give reads "0" on a claim that has * comments — a number wrong in the one direction that tells the reader not to look. + * + * `null` means the aggregate could not answer. The row then keeps its comments reachable instead + * of reading a failed request as silence. */ - commentCount?: number; + commentCount?: number | null; /** This row's depth, counted from the claim. Its comments sit one below it. */ depth: number; /** The surrounding thread's metrics, so these rows sit on the same ramp as the comments. */ @@ -122,7 +125,10 @@ export function ExtractedClaimRow({ ? withDebateTimecode(NavUtils.toEntity(debateSpaceId, debateId), debateSeekSeconds(moment.startMs)) : null; - const hasComments = Boolean(claimSpaceId) && (commentCount > 0 || composer.hasPosted) && canNestBelow(depth); + // `commentCount == null` is an aggregate that could not answer, not a claim with no comments: the + // branch opens so the replies stay reachable. See `batched-counts.ts`. + const hasComments = + Boolean(claimSpaceId) && (commentCount == null || commentCount > 0 || composer.hasPosted) && canNestBelow(depth); const branchLabel = { expand: 'Show comments on this claim', collapse: 'Hide comments on this claim' }; return ( @@ -233,7 +239,9 @@ export function ExtractedClaimRow({ entityId={claim.id} spaceId={claimSpaceId} targetEntityType="claim" - count={commentCount} + // Understated rather than absent when unknown: the button corrects upward from the + // list it opens. + count={commentCount ?? 0} // In place rather than in the panel: the claim's comments are already drawn below this // row, so the panel would replace a thread the reader can see with the same rows minus // the debate they came out of. diff --git a/apps/web/core/claims/browse/use-claim-activity-rows.tsx b/apps/web/core/claims/browse/use-claim-activity-rows.tsx index 815147801d..264640b903 100644 --- a/apps/web/core/claims/browse/use-claim-activity-rows.tsx +++ b/apps/web/core/claims/browse/use-claim-activity-rows.tsx @@ -12,9 +12,9 @@ import { } from '~/core/debates/ontology'; import { useDebateClaimCounts } from '~/core/debates/use-debate-claim-counts'; import { EntitiesOrderBy } from '~/core/gql/graphql'; +import { countFor } from '~/core/hooks/batched-counts'; import { useProfilesBySpaceIds } from '~/core/hooks/use-profiles-by-space-ids'; import { ID } from '~/core/id'; -import { uuidToHex } from '~/core/id/normalize'; import { useQueryEntities } from '~/core/sync/use-store'; import type { Entity } from '~/core/types'; @@ -135,8 +135,10 @@ export function useClaimActivityRows({ responseVocabulary={responseVocabulary} keyframeUrl={keyframeByDebateId.get(debate.id) ?? null} publishedAt={debateDate(debate)} - commentCount={commentCountByDebateId.get(uuidToHex(debate.id)) ?? 0} - claimCount={claimCountByDebateId.get(uuidToHex(debate.id)) ?? 0} + // `countFor`, not `?? 0`: a failed aggregate answers `null`, and the row draws its branch + // on a null rather than reporting the debate as empty. See `batched-counts.ts`. + commentCount={countFor(commentCountByDebateId, debate.id)} + claimCount={countFor(claimCountByDebateId, debate.id)} /> ), })); diff --git a/apps/web/core/comments/use-entity-comment-counts.ts b/apps/web/core/comments/use-entity-comment-counts.ts index 12c6e48999..e0a998e5c1 100644 --- a/apps/web/core/comments/use-entity-comment-counts.ts +++ b/apps/web/core/comments/use-entity-comment-counts.ts @@ -9,6 +9,8 @@ import { Effect } from 'effect'; import { parse } from 'graphql'; import { COMMENT_REPLY_TO_ID } from '~/core/comment-ids'; +import type { BatchedCounts } from '~/core/hooks/batched-counts'; +import { batchedCounts } from '~/core/hooks/batched-counts'; import { uuidToHex } from '~/core/id/normalize'; import { graphql } from '~/core/io/graphql-client'; @@ -50,8 +52,6 @@ type CountsResponse = { entities?: Array<{ id?: string | null; backlinks?: { totalCount?: number | null } | null } | null> | null; }; -const NO_COUNTS = new Map(); - export function decodeEntityCommentCounts(data: CountsResponse): Map { const counts = new Map(); for (const entity of data.entities ?? []) { @@ -64,17 +64,17 @@ export function decodeEntityCommentCounts(data: CountsResponse): Map ['entity-comment-counts', ids] as const; /** - * Comment counts keyed by canonical entity id. Absent means "not answered yet", not "none" — - * callers should hold the button's own live count rather than rendering a zero they invented. + * Comment counts for a set of entities, read through {@link countFor}. + * + * Absent means "not answered yet", not "none", and a failed request says so rather than reporting + * every row as silent — see {@link BatchedCounts}, which exists because reading this map with + * `?? 0` hid real comments behind a permanent zero. */ -export function useEntityCommentCounts(entityIds: string[], enabled = true): Map { +export function useEntityCommentCounts(entityIds: string[], enabled = true): BatchedCounts { // Sorted and deduped so the same set of rows in a different order is the same query. - const ids = React.useMemo( - () => [...new Set(entityIds.filter(Boolean).map(uuidToHex))].sort(), - [entityIds] - ); + const ids = React.useMemo(() => [...new Set(entityIds.filter(Boolean).map(uuidToHex))].sort(), [entityIds]); - const { data } = useQuery({ + const { data, isError } = useQuery({ queryKey: entityCommentCountsQueryKey(ids), queryFn: ({ signal }) => Effect.runPromise( @@ -89,5 +89,7 @@ export function useEntityCommentCounts(entityIds: string[], enabled = true): Map staleTime: 60_000, }); - return data ?? NO_COUNTS; + // Memoized because callers hold this in `useMemo` dependency lists to build their rows; a fresh + // object every render would rebuild every row every render. + return React.useMemo(() => batchedCounts(data, isError), [data, isError]); } diff --git a/apps/web/core/debates/use-debate-claim-counts.ts b/apps/web/core/debates/use-debate-claim-counts.ts index 1f7ddf9d6f..613e5c951f 100644 --- a/apps/web/core/debates/use-debate-claim-counts.ts +++ b/apps/web/core/debates/use-debate-claim-counts.ts @@ -10,6 +10,8 @@ import { parse } from 'graphql'; import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; import { SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; +import type { BatchedCounts } from '~/core/hooks/batched-counts'; +import { batchedCounts } from '~/core/hooks/batched-counts'; import { uuidToHex } from '~/core/id/normalize'; import { graphql } from '~/core/io/graphql-client'; @@ -45,8 +47,6 @@ type CountsResponse = { entities?: Array<{ id?: string | null; extracted?: { totalCount?: number | null } | null } | null> | null; }; -const NO_COUNTS = new Map(); - export function decodeDebateClaimCounts(data: CountsResponse): Map { const counts = new Map(); for (const entity of data.entities ?? []) { @@ -58,12 +58,18 @@ export function decodeDebateClaimCounts(data: CountsResponse): Map ['debate-claim-counts', ids] as const; -/** Extracted-claim counts keyed by canonical debate id. Absent means "not answered yet", not zero. */ -export function useDebateClaimCounts(debateIds: string[], enabled = true): Map { +/** + * Extracted-claim counts for a set of debates, read through {@link countFor}. + * + * Absent means "not answered yet", not zero, and a failed request says so — see + * {@link BatchedCounts}. A row that hides its branch on a zero would otherwise lose every claim the + * debate produced whenever this one request failed. + */ +export function useDebateClaimCounts(debateIds: string[], enabled = true): BatchedCounts { // Sorted and deduped, so the same debates in a different order are the same query. const ids = React.useMemo(() => [...new Set(debateIds.filter(Boolean).map(uuidToHex))].sort(), [debateIds]); - const { data } = useQuery({ + const { data, isError } = useQuery({ queryKey: debateClaimCountsQueryKey(ids), queryFn: ({ signal }) => Effect.runPromise( @@ -78,5 +84,6 @@ export function useDebateClaimCounts(debateIds: string[], enabled = true): Map batchedCounts(data, isError), [data, isError]); } diff --git a/apps/web/core/hooks/batched-counts.test.ts b/apps/web/core/hooks/batched-counts.test.ts new file mode 100644 index 0000000000..2046019cb8 --- /dev/null +++ b/apps/web/core/hooks/batched-counts.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { batchedCounts, countFor } from './batched-counts'; + +/** + * The three states a batched aggregate can be in, and why the third one is not zero. + * + * The rows that read these counts hide their branch when the number is zero. Reading an absent entry + * as zero therefore hid a debate's extracted claims and its whole discussion — a flash while the + * request was in flight, and permanently once it failed, with no control left to expand them. + */ +describe('countFor', () => { + const answered = batchedCounts(new Map([['aa', 18]]), false); + + it('answers with the number the aggregate reported', () => { + expect(countFor(answered, 'aa')).toBe(18); + }); + + it('normalizes the id, so a hyphenated caller finds a canonical entry', () => { + const counts = batchedCounts(new Map([['3e4a0955699a4c8f813fcc803a3335ba', 4]]), false); + + expect(countFor(counts, '3e4a0955-699a-4c8f-813f-cc803a3335ba')).toBe(4); + }); + + // Bounded by one request, and it only ever corrects upward. Failing open here instead would mount + // every debate's branch on every page load, fetching the transcript of a claimless debate — the + // work this aggregate was added to avoid. + it('reads as zero while the request is still in flight', () => { + expect(countFor(batchedCounts(undefined, false), 'aa')).toBe(0); + }); + + // The one that matters: nothing is coming, so the caller must not conclude "empty". + it('is null when the request failed, not zero', () => { + expect(countFor(batchedCounts(undefined, true), 'aa')).toBeNull(); + }); + + it('is null for a row the failed request never answered for, and the number for one it did', () => { + const stale = batchedCounts(undefined, true); + + expect(countFor(stale, 'bb')).toBeNull(); + }); + + // React Query keeps the last payload through a failed *refetch*. Numbers a minute old beat + // "unknown", and treating them as unknown would pop every branch open on a background failure. + it('prefers data it already has over a refetch error', () => { + const refetchFailed = batchedCounts(new Map([['aa', 18]]), true); + + expect(countFor(refetchFailed, 'aa')).toBe(18); + expect(refetchFailed.isUnavailable).toBe(false); + }); + + it('answers zero for an id a successful response simply did not include', () => { + expect(countFor(answered, 'bb')).toBe(0); + }); +}); diff --git a/apps/web/core/hooks/batched-counts.ts b/apps/web/core/hooks/batched-counts.ts new file mode 100644 index 0000000000..33b8fce45a --- /dev/null +++ b/apps/web/core/hooks/batched-counts.ts @@ -0,0 +1,57 @@ +import { uuidToHex } from '~/core/id/normalize'; + +/** + * What a batched aggregate knows about a screenful of rows. + * + * Two hooks count things for a whole feed in one request rather than one per row — + * `useEntityCommentCounts` and `useDebateClaimCounts`. Both key their map by canonical id, and both + * documented that an absent entry means "not answered yet, not zero". The call sites then read them + * with `?? 0`, which says the opposite, and the rows hide a branch when a count is zero. + * + * While the request is in flight that is a flash. When it *fails* it is permanent: a debate's + * extracted claims and its entire discussion become unreachable, with no control to expand them, + * because one aggregate returned an error. So the failure travels with the map and {@link countFor} + * is the only way to read an entry, because the policy is the part that was getting lost. + */ +export type BatchedCounts = { + /** Canonical entity id → count. An absent entry means the request has not answered for that id. */ + counts: Map; + /** The request failed, so no answer is coming for anything in this set. */ + isUnavailable: boolean; +}; + +const NO_COUNTS = new Map(); + +/** Nothing answered yet. Stable, so a caller holding it in a dependency list does not rebuild. */ +const UNANSWERED: BatchedCounts = { counts: NO_COUNTS, isUnavailable: false }; +/** Nothing answered, and nothing will. */ +const UNAVAILABLE: BatchedCounts = { counts: NO_COUNTS, isUnavailable: true }; + +/** + * A query's state as counts, shared by both batched hooks so the three cases are decided once. + * + * Data wins over the error: React Query keeps the last payload through a failed *refetch*, and + * numbers a minute old are a better answer than "unknown". + */ +export function batchedCounts(counts: Map | undefined, isError: boolean): BatchedCounts { + if (counts != null) return { counts, isUnavailable: false }; + return isError ? UNAVAILABLE : UNANSWERED; +} + +/** + * One row's count: a number, or `null` when there is no answer and none is coming. + * + * `null` means "there may be something here" and callers have to treat it that way — draw the expand + * control, mount the branch, let the list fetch. It is the choice the claim page's Activity heading + * already makes with its own aggregate: unavailable falls through to the live count rather than + * printing a zero. The cost on that path is a toggle that may turn out to have nothing under it, + * which is the cheaper of the two ways to be wrong. + * + * Loading still reads as `0`, deliberately. It is bounded by one request and only ever corrects + * upward, whereas failing open until the aggregate answers would mount every debate's branch on + * every page load — fetching the transcript of a claimless debate, which is the exact work these + * aggregates were added to avoid. + */ +export function countFor({ counts, isUnavailable }: BatchedCounts, entityId: string): number | null { + return counts.get(uuidToHex(entityId)) ?? (isUnavailable ? null : 0); +} diff --git a/apps/web/partials/comments/comment-time.test.ts b/apps/web/partials/comments/comment-time.test.ts new file mode 100644 index 0000000000..14a254f979 --- /dev/null +++ b/apps/web/partials/comments/comment-time.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getRelativeTime } from './comment-time'; + +const NOW = new Date('2026-09-25T12:00:00.000Z'); + +function ago(ms: number): string { + return getRelativeTime(new Date(NOW.getTime() - ms).toISOString()); +} + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** + * Every row in the claim's activity feed ages through here — debates, extracted claims and comments + * alike — so a plural that does not agree with its number shows up on all three. + */ +describe('getRelativeTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => vi.useRealTimers()); + + it('agrees with its number on the singular, the way the hours already did', () => { + expect(ago(MINUTE)).toBe('1 min'); + expect(ago(2 * MINUTE)).toBe('2 mins'); + expect(ago(HOUR)).toBe('1 hour'); + expect(ago(2 * HOUR)).toBe('2 hours'); + }); + + it('is coarse on purpose: the nearest unit, not a duration', () => { + expect(ago(30 * SECOND)).toBe('just now'); + expect(ago(59 * MINUTE + 59 * SECOND)).toBe('59 mins'); + expect(ago(23 * HOUR)).toBe('23 hours'); + expect(ago(3 * DAY)).toBe('3d ago'); + expect(ago(2 * 7 * DAY)).toBe('2w ago'); + }); + + it('prints a date once a row is older than a month, rather than counting weeks forever', () => { + expect(ago(40 * DAY)).toMatch(/\d/); + expect(ago(40 * DAY)).not.toContain('w ago'); + }); + + // A row whose timestamp the graph never recorded. The feed sorts these last; the age has nothing + // to say about them, and an empty string is what the row renders as no age at all. + it('says nothing about an unparseable date', () => { + expect(getRelativeTime('')).toBe(''); + expect(getRelativeTime('not a date')).toBe(''); + }); +}); diff --git a/apps/web/partials/comments/comment-time.ts b/apps/web/partials/comments/comment-time.ts index 3a18d59e7b..5384ecd6a8 100644 --- a/apps/web/partials/comments/comment-time.ts +++ b/apps/web/partials/comments/comment-time.ts @@ -20,7 +20,7 @@ export function getRelativeTime(dateString: string): string { const diffDays = Math.floor(diffHours / 24); if (diffSeconds < 60) return 'just now'; - if (diffMinutes < 60) return `${diffMinutes} mins`; + if (diffMinutes < 60) return `${diffMinutes} min${diffMinutes > 1 ? 's' : ''}`; if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''}`; if (diffDays < 7) return `${diffDays}d ago`; if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; From 31a300a00fd4bd9ab61f41403a9ee09e6c455b19 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Fri, 25 Sep 2026 15:45:27 -0700 Subject: [PATCH 24/35] fix(claims): count the reader's comment where the count lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all of them about the fix that made the Activity heading notice a comment the reader had just written, and two of them the same mistake: the correction lived somewhere shorter-lived and differently keyed than the number it was correcting. `CommentSection` held the delta in its own state. That state is lost when the section unmounts, and this aggregate has `staleTime: 60_000` — so posting, leaving and coming back inside the minute produced the pre-publish number with an empty delta, and the heading dropped a comment still sitting in the list below it. The query's own doc comment asserted the opposite. The state also outlived the claim, because `EntityPageBody` is reused between records rather than remounted — `ProfileActivitySection` six lines above is keyed for exactly that reason — so walking to the next claim carried the delta onto a number that had never heard of it, and a late rollback could take one off the wrong claim. The adjustment now goes into the query cache the baseline comes out of, keyed by claim, lasting as long as the value it corrects. The section reports a signed delta to whoever supplied `totalOverride` and counts nothing itself. Not keying the section instead: that would also throw away the reader's chosen sort and unpin a comment they posted when they walked back to it, neither of which is a defect. `refetchOnWindowFocus` stays off, now for the honest reason — a refetch landing before the indexer replaces the reader's own comment with a number that does not include it. Separately: the merge re-sorted a list the thread had already pinned. A comment you have just written is meant to stay at the top of its group whatever the sort says, and this is the one place a pinned list gets re-sorted — so on the claim page, which opens on Best, a brand-new comment scoring zero went below anything with a single upvote, and under Old it went to the bottom of the page. Pinned comments are held out of the sort and put back in front of it. And the seek latch behind the claims' timecode links had no coverage: the suite defined a `seekBoth` mock and never asserted it. Four tests, one per guarantee, each proved by breaking that guarantee on its own. Co-Authored-By: Claude Opus 5 (1M context) --- .../claims/browse/claim-activity-count.ts | 54 +++- .../browse/claim-activity-order.test.ts | 90 +++++- .../claims/browse/claim-activity-order.ts | 29 +- .../claims/browse/claim-page-view.test.tsx | 24 +- .../core/claims/browse/claim-page-view.tsx | 15 +- .../browse/debate-feed-player.test.tsx | 94 +++++- apps/web/partials/comments/activity-posts.tsx | 23 +- .../comments/comment-activity-rows.test.tsx | 299 ++++++++++++++++-- .../partials/comments/comments-section.tsx | 44 ++- 9 files changed, 613 insertions(+), 59 deletions(-) diff --git a/apps/web/core/claims/browse/claim-activity-count.ts b/apps/web/core/claims/browse/claim-activity-count.ts index 3ec81e4723..43226d442a 100644 --- a/apps/web/core/claims/browse/claim-activity-count.ts +++ b/apps/web/core/claims/browse/claim-activity-count.ts @@ -1,7 +1,7 @@ 'use client'; import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; -import { useQuery } from '@tanstack/react-query'; +import { type QueryClient, useQuery } from '@tanstack/react-query'; import * as React from 'react'; @@ -87,12 +87,56 @@ export function useClaimActivityCounts(claimIds: string[], enabled = true): Map< ), enabled: enabled && ids.length > 0, staleTime: 60_000, - // The Activity heading treats this as a baseline and adds the comments published since it - // loaded (see `activity-posts.tsx`). A refetch on focus could land with those comments already - // indexed, and the delta would then count them a second time. Navigating away and back re-reads - // it and resets the delta together, which is the same answer without the window in between. + // Not on focus. A reader's own comment is counted by adjusting this cache + // ({@link adjustClaimActivityTotal}), and a refetch replaces that adjustment with whatever the + // server says — which, if the indexer has not caught up, is a number without their comment in + // it. Returning to a tab is not a moment to take someone's comment back out of the count. A + // remount past `staleTime` does re-read, and by then the indexer has had a minute. refetchOnWindowFocus: false, }); return data ?? EMPTY_COUNTS; } + +/** + * Count a comment the reader just published, or take one back that failed. + * + * The heading over the claim's activity is this aggregate: the claim's debates, the claims extracted + * from them, and every comment anywhere in that tree. Nothing in the comment caches can move it, so + * publishing used to leave the number standing still — the aggregate answered before the comment + * existed. + * + * The adjustment goes into the query cache rather than into the section's own state, which is where + * the previous version of this kept it. Two things followed from that and both were wrong. React + * Query holds this answer for `staleTime`, so a reader who posted, navigated away and came back + * inside the minute got the pre-publish aggregate with a delta that had reset to zero — the heading + * dropped their comment while the comment sat in the list below it. And the state outlived the + * claim: `EntityPageBody` is reused between records, so walking to another claim carried the delta + * onto a number it had nothing to do with. The cache is keyed by claim, and it is the same thing + * whose lifetime the baseline already has. + * + * Signed, because a publish can fail: `useCreateComment` drops the optimistic row when the + * transaction is rejected, and the count has to give back what it counted. A publish merely + * *retained* for retry keeps its row, so it keeps its count. + * + * A claim with no entry yet is left alone rather than invented: the aggregate is still in flight and + * will answer for itself. That loses a comment published in the few hundred milliseconds before the + * first response lands, which is not long enough to write one. + */ +export function adjustClaimActivityTotal(queryClient: QueryClient, claimId: string, delta: number): void { + if (delta === 0) return; + const id = uuidToHex(claimId); + + // Every cached set that holds this claim, not one key: the page asks for one claim and other + // surfaces ask for a screenful, and a number that moves on one of them has moved on all of them. + queryClient.setQueriesData>({ queryKey: ['claim-activity-counts'] }, counts => { + const current = counts?.get(id); + // `undefined` bails out of the update rather than writing anything, so a cached set that does + // not hold this claim — or one that has not answered yet — is left untouched instead of being + // re-set to itself and waking its subscribers. + if (current == null) return undefined; + const next = new Map(counts); + next.set(id, { ...current, total: Math.max(0, current.total + delta) }); + return next; + }); +} diff --git a/apps/web/core/claims/browse/claim-activity-order.test.ts b/apps/web/core/claims/browse/claim-activity-order.test.ts index 8582eff096..9ec3ea40a7 100644 --- a/apps/web/core/claims/browse/claim-activity-order.test.ts +++ b/apps/web/core/claims/browse/claim-activity-order.test.ts @@ -74,7 +74,10 @@ describe('orderExtractedClaims', () => { it('matches timings keyed by canonical id regardless of the id format on the claim', () => { const hyphenated = '3e4a0955-699a-4c8f-813f-cc803a3335ba'; - const { timed } = orderExtractedClaims([claim(hyphenated)], new Map([['3e4a0955699a4c8f813fcc803a3335ba', timing(7_000)]])); + const { timed } = orderExtractedClaims( + [claim(hyphenated)], + new Map([['3e4a0955699a4c8f813fcc803a3335ba', timing(7_000)]]) + ); expect(timed).toHaveLength(1); expect(timed[0]!.timing?.startMs).toBe(7_000); @@ -130,6 +133,73 @@ describe('mergeActivityRows', () => { expect(merged.map(entry => entry.row.id)).toEqual(['c1', 'd1']); }); + /** + * The thread pins a comment the reader wrote in this session to the top whatever the sort says, so + * that it does not scroll away from the person who just wrote it — `sortWithSessionPinned` does it + * at every nesting level. This merge re-sorts a list that has already been pinned, so it is the one + * place that can undo it, and it did: under Best, which is what the claim page opens on, any row + * with a single upvote went above a brand-new comment scoring zero. + */ + describe('comments the reader wrote in this session', () => { + const pinned = new Set(['c1']); + + it('stay in front under Best, however the rest of the page ranks', () => { + const scoreFor = (row: { id: string }) => (row.id === 'd1' ? { positive: 6, negative: 0 } : null); + + expect(mergeActivityRows(comments, debates, 'best', scoreFor, pinned).map(entry => entry.row.id)).toEqual([ + 'c1', + 'd1', + 'd2', + 'c2', + ]); + }); + + // The other end of the same failure: oldest-first sent the newest thing on the page — the + // comment just written — to the very bottom of it. `c2` is the newest comment here, so this only + // says something if the pin is what moves it. + it('stay in front under Oldest, which otherwise sends them to the bottom', () => { + const justWritten = new Set(['c2']); + + expect(mergeActivityRows(comments, debates, 'oldest', undefined, justWritten).map(e => e.row.id)).toEqual([ + 'c2', + 'c1', + 'd1', + 'd2', + ]); + }); + + it('keep the order they were pinned in, which is the order the list arrived in', () => { + const both = new Set(['c2', 'c1']); + // `sortWithSessionPinned` has already put them most-recent-first at the head of the list, so + // this holds them in that order rather than inventing one of its own. + expect(mergeActivityRows(comments, debates, 'best', undefined, both).map(entry => entry.row.id)).toEqual([ + 'c1', + 'c2', + 'd2', + 'd1', + ]); + }); + + it('changes nothing when the reader has not written anything', () => { + expect(mergeActivityRows(comments, debates, 'oldest', undefined, new Set()).map(entry => entry.row.id)).toEqual([ + 'c1', + 'd1', + 'c2', + 'd2', + ]); + }); + + // A debate is not something the reader typed, so an id that only matches an extra is ignored. + it('does not lift a non-comment row that happens to share an id', () => { + expect(mergeActivityRows(comments, debates, 'oldest', undefined, new Set(['d2'])).map(e => e.row.id)).toEqual([ + 'c1', + 'd1', + 'c2', + 'd2', + ]); + }); + }); + describe('ranked orders', () => { const scores: Record = { c1: { positive: 1, negative: 0 }, @@ -211,20 +281,32 @@ describe('rows nothing can place in time', () => { // Old that puts the undated row *first*. A row nothing can place is not the oldest thing that // ever happened any more than it is the newest. it('keeps an undated row last under Old, not first', () => { - const merged = mergeActivityRows([], [undated('no-date'), dated('older', '2026-01-01T00:00:00Z'), dated('newer', '2026-06-01T00:00:00Z')], 'oldest'); + const merged = mergeActivityRows( + [], + [undated('no-date'), dated('older', '2026-01-01T00:00:00Z'), dated('newer', '2026-06-01T00:00:00Z')], + 'oldest' + ); expect(merged.map(entry => entry.row.id)).toEqual(['older', 'newer', 'no-date']); }); it('keeps it last under New as well', () => { - const merged = mergeActivityRows([], [undated('no-date'), dated('older', '2026-01-01T00:00:00Z'), dated('newer', '2026-06-01T00:00:00Z')], 'newest'); + const merged = mergeActivityRows( + [], + [undated('no-date'), dated('older', '2026-01-01T00:00:00Z'), dated('newer', '2026-06-01T00:00:00Z')], + 'newest' + ); expect(merged.map(entry => entry.row.id)).toEqual(['newer', 'older', 'no-date']); }); // `-Infinity - -Infinity` is NaN, which is the unstable comparator the sentinel existed to avoid. it('does not compare two undated rows by arithmetic', () => { - const merged = mergeActivityRows([], [undated('a'), undated('b'), dated('dated', '2026-01-01T00:00:00Z')], 'oldest'); + const merged = mergeActivityRows( + [], + [undated('a'), undated('b'), dated('dated', '2026-01-01T00:00:00Z')], + 'oldest' + ); expect(merged.map(entry => entry.row.id)).toEqual(['dated', 'a', 'b']); }); diff --git a/apps/web/core/claims/browse/claim-activity-order.ts b/apps/web/core/claims/browse/claim-activity-order.ts index e867ba66b8..4ce544e800 100644 --- a/apps/web/core/claims/browse/claim-activity-order.ts +++ b/apps/web/core/claims/browse/claim-activity-order.ts @@ -93,23 +93,36 @@ export function activityTime(row: ActivityOrderable): number { * Stable within a timestamp: rows that arrive at the same instant keep the order their own lists * gave them, and comments come first. A debate and a comment published in the same second is not a * tie worth breaking on identity, and reordering on every render is worse than either choice. + * + * `pinnedIds` are comments the reader wrote in this session, which the thread already keeps at the + * top of their group whatever the sort says, so that a comment does not scroll away from the person + * who has just written it. The comment list arrives here already ordered that way and this sort ran + * straight over it — under Best, which is what the claim page opens on, any row with a single upvote + * went above a brand-new comment scoring zero, and under Oldest it went to the bottom of the page. + * So they are held out of the sort and put back in front of it, in the order the thread pinned them. */ -export function mergeActivityRows( +export function mergeActivityRows( comments: C[], extras: E[], order: 'best' | 'top' | 'newest' | 'oldest', - scoreFor: ActivityScoreLookup = NO_SCORES + scoreFor: ActivityScoreLookup = NO_SCORES, + pinnedIds?: ReadonlySet ): Array<{ kind: 'comment'; row: C } | { kind: 'extra'; row: E }> { - const merged: Array<{ kind: 'comment'; row: C } | { kind: 'extra'; row: E }> = [ - ...comments.map(row => ({ kind: 'comment' as const, row })), - ...extras.map(row => ({ kind: 'extra' as const, row })), - ]; + const pinned: Array<{ kind: 'comment'; row: C }> = []; + const sortable: Array<{ kind: 'comment'; row: C } | { kind: 'extra'; row: E }> = []; + + for (const row of comments) { + (pinnedIds?.has(row.id) ? pinned : sortable).push({ kind: 'comment' as const, row }); + } + for (const row of extras) sortable.push({ kind: 'extra' as const, row }); + + const merged = sortable; // `sort` is stable in every engine we target, so rows that compare equal keep insertion order — // which above is "comments, then extras". Said out loud because the tie-break is a decision. if (order === 'newest' || order === 'oldest') { merged.sort((a, b) => compareByTime(a.row, b.row, order === 'newest' ? -1 : 1)); - return merged; + return [...pinned, ...merged]; } // Newest breaks a score tie, so a thread where nothing has been voted on yet still reads as a @@ -118,7 +131,7 @@ export function mergeActivityRows rank(scoreFor(b.row)) - rank(scoreFor(a.row)) || compareByTime(a.row, b.row, -1)); - return merged; + return [...pinned, ...merged]; } /** diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index 63cb140317..58c8b88388 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -1,5 +1,6 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, render as renderBare, screen } from '@testing-library/react'; import type React from 'react'; @@ -11,6 +12,24 @@ import { SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; import { ClaimPageView, resolveClaimTab } from './claim-page-view'; +/** + * The page inside the provider it actually runs inside. + * + * It reads the activity aggregate out of the query cache and writes a reader's own comment back into + * it, so a bare render throws "No QueryClient set" — the client is not optional context here. One + * client per render, kept across `rerender` so that a re-render with different props stays a + * re-render rather than becoming a fresh cache. + */ +function render(ui: React.ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = renderBare({ui}); + return { + ...view, + rerender: (next: React.ReactElement) => + view.rerender({next}), + }; +} + const mocks = vi.hoisted(() => ({ entity: null as Record | null, /** Non-comment rows the Overview orders into its activity thread — the debates on this claim. */ @@ -126,7 +145,8 @@ vi.mock('./use-claim-activity-rows', () => ({ // The heading's number, which is the same one the claim's Explore card shows. Its own query is // covered by `claim-activity-count.test.ts`; here it only needs to reach the heading. vi.mock('./claim-activity-count', () => ({ - useClaimActivityCounts: () => new Map(mocks.activityTotal == null ? [] : [['claim1', { total: mocks.activityTotal }]]), + useClaimActivityCounts: () => + new Map(mocks.activityTotal == null ? [] : [['claim1', { total: mocks.activityTotal }]]), })); vi.mock('~/core/debates/hooks', () => ({ diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 6fc4b96093..e93c7c34b3 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; + import * as React from 'react'; import cx from 'classnames'; @@ -40,7 +42,7 @@ import { ClaimVerdictColumn } from '~/partials/explore/claim-explore-feed-card'; import { type ActivityKind, ProfileActivitySection } from '~/partials/profile/profile-activity-section'; import { SPACE_TABS_ANCHOR } from '~/partials/space-page/space-tabs-anchor'; -import { useClaimActivityCounts } from './claim-activity-count'; +import { adjustClaimActivityTotal, useClaimActivityCounts } from './claim-activity-count'; import { ClaimEndSlot } from './claim-end-slot'; import { ClaimRecordTab } from './claim-record-tab'; import { getClaimSources } from './claim-sources'; @@ -461,6 +463,16 @@ function ClaimOverviewTab({ const activityCounts = useClaimActivityCounts(React.useMemo(() => [entityId], [entityId])); const activityTotal = activityCounts.get(ID.uuidToHex(entityId))?.total; + // The thread can add to that number but cannot compute it, so the page that owns the aggregate + // owns the adjustment too. It lands in the query cache rather than in the section's state, which + // is what makes it survive the section remounting and stay behind when the reader walks to the + // next claim — see `adjustClaimActivityTotal`. + const queryClient = useQueryClient(); + const adjustActivityTotal = React.useCallback( + (delta: number) => adjustClaimActivityTotal(queryClient, entityId, delta), + [entityId, queryClient] + ); + const activity = useClaimActivityRows({ claimId: entityId, spaceId, @@ -537,6 +549,7 @@ function ClaimOverviewTab({ title="Activity" activityRows={activity.rows} totalOverride={activityTotal} + onActivityPublish={adjustActivityTotal} // Best rather than most-recent: this list is the record of an argument, not a running // conversation, and the thing worth reading first is what the thread rates highest. defaultSortOrder="best" 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..bcc174b979 100644 --- a/apps/web/core/debates/browse/debate-feed-player.test.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.test.tsx @@ -94,6 +94,10 @@ function controllerFixture(overrides: { /** A freshly signed recording, as `refreshSlotUrl` produces — or no recording yet, as the * blanking pass that precedes a different pair leaves behind. */ urls?: { slot1: string | null; slot2: string | null }; + /** False before the media can be seeked. A seek issued then is dropped by the element. */ + ready?: boolean; + /** Shared across re-renders where a test needs to count the calls the component made. */ + seekBoth?: ReturnType; }) { return { slot1VideoRef: { current: null }, @@ -101,7 +105,7 @@ function controllerFixture(overrides: { slot1Participant: participant(1), slot2Participant: participant(2), urls: overrides.urls ?? { slot1: 'https://cdn.test/slot1.webm', slot2: 'https://cdn.test/slot2.webm' }, - ready: true, + ready: overrides.ready ?? true, error: null, playing: overrides.playing ?? true, autoplayBlocked: overrides.autoplayBlocked ?? false, @@ -123,7 +127,7 @@ function controllerFixture(overrides: { playFromStart: vi.fn(), resumeBoth: vi.fn(), suspend: vi.fn(), - seekBoth: vi.fn(), + seekBoth: overrides.seekBoth ?? vi.fn(), beginScrub: vi.fn(), endScrub: vi.fn(), }; @@ -209,7 +213,6 @@ describe('player layout', () => { expect(nameRow?.nextElementSibling).toBe(getByText(affiliation)); expect(nameNode.closest('button')?.className).toContain('items-center'); } - }); it('keeps the position chip in the video playback surface', () => { @@ -804,3 +807,88 @@ describe('a recording whose pipeline dies is rebuilt (GEO-2985)', () => { expect(controller().resyncSlot).not.toHaveBeenCalled(); }); }); + +/** + * A link that named a moment — the timecode on a claim extracted from this debate — and the one + * chance the player gets to honour it. + * + * Three things have to hold at once, and they pull against each other. The seek cannot fire on mount, + * because before the media is seekable the element drops it and the reader lands at zero. It cannot + * fire more than once, because the feed re-renders and re-activates cards as the viewer scrolls, and + * a second seek would yank the playhead back to where the link pointed after they had moved it + * themselves. And it has to fire again for a different debate, because the same mounted card is + * reused as the feed scrolls from one to the next. + */ +describe('a seek asked for by the link that brought the reader here', () => { + function renderWithSeek(initialSeekSeconds: number | null, { ready = true }: { ready?: boolean } = {}) { + // One recorder, but a *fresh* function identity on every render — which is what the controller + // hook hands back, and what makes the effect re-run. An identity held stable across renders + // would keep the effect from running a second time at all, so the latch under test would never + // be asked to do anything and the test would pass against no latch whatsoever. + const seeks: number[] = []; + const makeSeek = () => vi.fn((seconds: number) => void seeks.push(seconds)); + + mocks.controller = controllerFixture({ mutedByUser: true, turnSlot: 1, ready, seekBoth: makeSeek() }); + const view = render(); + + return { + seeks, + /** Re-render as the feed does, optionally with the media now seekable or a different debate. */ + update(next: { ready?: boolean; active?: boolean; debateId?: string } = {}) { + mocks.controller = controllerFixture({ + mutedByUser: true, + turnSlot: 1, + ready: next.ready ?? ready, + seekBoth: makeSeek(), + }); + view.rerender( + + ); + }, + }; + } + + it('waits for the media to be seekable rather than firing on mount', () => { + const player = renderWithSeek(124, { ready: false }); + + expect(player.seeks).toEqual([]); + + player.update({ ready: true }); + + expect(player.seeks).toEqual([124]); + }); + + it('fires once, and not again when the card re-renders or is scrolled back to', () => { + const player = renderWithSeek(124); + + expect(player.seeks).toEqual([124]); + + player.update(); + // Out of view and back again, which is what scrolling past a card and returning to it looks like. + player.update({ active: false }); + player.update({ active: true }); + + expect(player.seeks).toEqual([124]); + }); + + it('fires again for a different debate, because the same card is reused down the feed', () => { + const player = renderWithSeek(124); + expect(player.seeks).toEqual([124]); + + player.update({ debateId: 'debate-2' }); + + expect(player.seeks).toEqual([124, 124]); + }); + + it('does nothing at all when the link named no moment', () => { + const player = renderWithSeek(null); + + player.update(); + + expect(player.seeks).toEqual([]); + }); +}); diff --git a/apps/web/partials/comments/activity-posts.tsx b/apps/web/partials/comments/activity-posts.tsx index 5c816b8d56..c895868c1c 100644 --- a/apps/web/partials/comments/activity-posts.tsx +++ b/apps/web/partials/comments/activity-posts.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; /** - * How many comments the reader has added to this thread since it loaded. + * A comment the reader just published, reported to whoever owns the heading's number. * * The Activity heading's number is a server aggregate — the claim's debates, the claims extracted * from them, and every comment anywhere in that tree. It is the only way to count things the section @@ -11,14 +11,17 @@ import * as React from 'react'; * aggregate answered before the comment existed, and nothing in the comment caches can move a number * that counts debates and extracted claims too. * - * So the aggregate is a baseline and this is the delta. Every composer inside the section reports - * here — the top-level one and the inline ones on debates, extracted claims and replies — because a - * reader who has just written something expects the count above to have noticed. + * So every composer inside the section reports here — the top-level one and the inline ones on + * debates, extracted claims and replies — because a reader who has just written something expects + * the count above to have noticed. * - * The baseline has to hold still for that to be right: `useClaimActivityCounts` does not refetch on - * window focus, so a refetch that already included these posts cannot land underneath and make the - * delta count them twice. Navigating away and back re-reads the aggregate and resets the delta, - * which is the same answer arrived at from the other side. + * What it reaches is the host's adjustment, not a counter kept here. That matters, and it is the + * whole history of this file: the count used to live in `CommentSection`'s own state, which meant it + * was lost every time the section unmounted — a reader who posted, left and came back inside the + * aggregate's `staleTime` got the pre-publish number with an empty delta — and kept every time the + * section was reused for a different record, since `EntityPageBody` is not remounted between claims. + * The claim page now moves the aggregate in the query cache instead, which is keyed by claim and + * lives exactly as long as the number it corrects. */ const ActivityPostsContext = React.createContext<((delta: number) => void) | null>(null); @@ -37,8 +40,8 @@ export function ActivityPostsProvider({ * * A signed delta rather than a bare "posted", because a publish can fail: `useCreateComment` removes * the optimistic row when the transaction is rejected, and without the matching `-1` the heading - * stayed one above the truth for as long as the page was open. A publish that is merely *retained* - * for retry keeps its row, so it keeps its `+1`. + * stayed one above the truth. A publish that is merely *retained* for retry keeps its row, so it + * keeps its `+1`. * * Returns a no-op outside a provider rather than throwing: the same composer runs in the entity * comments panel and on proposal threads, neither of which has a heading with an aggregate behind diff --git a/apps/web/partials/comments/comment-activity-rows.test.tsx b/apps/web/partials/comments/comment-activity-rows.test.tsx index 08faecc285..e2037c350e 100644 --- a/apps/web/partials/comments/comment-activity-rows.test.tsx +++ b/apps/web/partials/comments/comment-activity-rows.test.tsx @@ -1,18 +1,35 @@ -import '@testing-library/jest-dom/vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; +import '@testing-library/jest-dom/vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import * as React from 'react'; + import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + adjustClaimActivityTotal, + claimActivityCountsQueryKey, + useClaimActivityCounts, +} from '~/core/claims/browse/claim-activity-count'; +import type { ClaimActivityCount } from '~/core/claims/browse/claim-activity-fields'; +import { uuidToHex } from '~/core/id/normalize'; + +import { CommentSection } from './comments-section'; import type { CommentActivityRow, CommentWithReplies } from './types'; const mocks = vi.hoisted(() => ({ comments: [] as CommentWithReplies[], smartAccount: null as unknown, + /** + * What the publish resolves to. `useCreateComment` answers falsy only when the transaction was + * rejected and it has taken the optimistic row back out, which is the rollback signal. + */ + publishResult: { id: 'new-comment' } as unknown, /** Stands in for the optimistic row a real publish writes, which is what tells the section. */ publishComment: vi.fn((input: { onOptimistic?: (id: string) => void }) => { input.onOptimistic?.('new-comment'); - return Promise.resolve(undefined); + return Promise.resolve(mocks.publishResult); }), })); @@ -41,8 +58,13 @@ vi.mock('~/core/state/sign-in-prompt-store', () => ({ useSignInPrompt: () => ({ vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ EntityVoteButtons: () => null })); vi.mock('~/core/claims/browse/claim-comment-position', () => ({ ClaimCommentPositionBadge: () => null })); vi.mock('~/core/state/editor/markdown-render', () => ({ renderMarkdownDocument: (text: string) => text })); - -import { CommentSection } from './comments-section'; +// The aggregate is seeded straight into the query cache, so nothing here should ever ask the server +// for it. Loudly rather than silently, so a test that accidentally leaves the cache empty fails. +vi.mock('~/core/io/graphql-client', () => ({ + graphql: () => { + throw new Error('the activity aggregate should be read from the cache in this suite'); + }, +})); function comment(id: string, createdAt: string): CommentWithReplies { return { @@ -82,11 +104,19 @@ function renderSection(activityRows: CommentActivityRow[], sort?: 'best' | 'top' ); } +/** Drive the top-level composer the way a reader does. */ +async function publishAComment(text = 'A new comment') { + fireEvent.click(screen.getByText('Join the conversation...')); + fireEvent.change(screen.getByRole('textbox'), { target: { value: text } }); + fireEvent.click(screen.getByRole('button', { name: 'Comment' })); +} + describe('CommentSection activity rows', () => { afterEach(() => { cleanup(); mocks.comments = []; mocks.smartAccount = null; + mocks.publishResult = { id: 'new-comment' }; mocks.publishComment.mockClear(); }); @@ -94,13 +124,23 @@ describe('CommentSection activity rows', () => { mocks.comments = [comment('c-old', '2026-09-20T10:00:00Z'), comment('c-new', '2026-09-22T10:00:00Z')]; renderSection([ - { id: 'd-mid', entityId: 'd-mid', spaceId: 'space-1', createdAt: '2026-09-21T10:00:00Z', content: debate mid }, - { id: 'd-newest', entityId: 'd-newest', spaceId: 'space-1', createdAt: '2026-09-23T10:00:00Z', content: debate newest }, + { + id: 'd-mid', + entityId: 'd-mid', + spaceId: 'space-1', + createdAt: '2026-09-21T10:00:00Z', + content: debate mid, + }, + { + id: 'd-newest', + entityId: 'd-newest', + spaceId: 'space-1', + createdAt: '2026-09-23T10:00:00Z', + content: debate newest, + }, ]); - const order = screen - .getAllByText(/body of c-|debate /) - .map(node => node.textContent?.trim()); + const order = screen.getAllByText(/body of c-|debate /).map(node => node.textContent?.trim()); // Newest first, which is the section's default sort — and the two kinds interleave. expect(order).toEqual(['debate newest', 'body of c-new', 'debate mid', 'body of c-old']); @@ -112,7 +152,15 @@ describe('CommentSection activity rows', () => { mocks.comments = [comment('c-old', '2026-09-20T10:00:00Z'), comment('c-new', '2026-09-22T10:00:00Z')]; renderSection( - [{ id: 'd-mid', entityId: 'd-mid', spaceId: 'space-1', createdAt: '2026-09-21T10:00:00Z', content: debate mid }], + [ + { + id: 'd-mid', + entityId: 'd-mid', + spaceId: 'space-1', + createdAt: '2026-09-21T10:00:00Z', + content: debate mid, + }, + ], 'best' ); @@ -123,7 +171,15 @@ describe('CommentSection activity rows', () => { it('counts activity rows in the heading alongside the comments', () => { mocks.comments = [comment('c-1', '2026-09-20T10:00:00Z')]; - renderSection([{ id: 'd-1', entityId: 'd-1', spaceId: 'space-1', createdAt: '2026-09-21T10:00:00Z', content: debate }]); + renderSection([ + { + id: 'd-1', + entityId: 'd-1', + spaceId: 'space-1', + createdAt: '2026-09-21T10:00:00Z', + content: debate, + }, + ]); expect(screen.getByText('Activity (2)')).toBeInTheDocument(); }); @@ -133,27 +189,67 @@ describe('CommentSection activity rows', () => { * under them, so nothing in the comment caches can move it — publishing used to leave it standing * still, including for the comment the reader had just written. */ - it('counts a comment published here on top of the aggregate it was given', async () => { + it('reports a comment published here to whoever owns the number', async () => { // Signed in, because the composer asks for a sign-in rather than opening otherwise. mocks.smartAccount = { account: { address: '0xabc' } }; + const reported: number[] = []; render( withClient( - + reported.push(delta)} + activityRows={[]} + /> ) ); expect(screen.getByText('Activity (33)')).toBeInTheDocument(); - // What a composer does on publish: the section is told, whichever entity the comment landed on. - fireEvent.click(screen.getByText('Join the conversation...')); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'A new comment' } }); - fireEvent.click(screen.getByRole('button', { name: 'Comment' })); + await publishAComment(); - await waitFor(() => expect(screen.getByText('Activity (34)')).toBeInTheDocument()); + // The section does not move the number itself — it cannot, the aggregate counts things it never + // sees — so what it owes the host is the fact that a comment appeared. + await waitFor(() => expect(reported).toEqual([1])); + }); + + // The other half of the same contract. `useCreateComment` resolves falsy when the transaction is + // rejected and removes the optimistic row, so the count has to be given back. + it('reports the comment going away again when the publish is rejected', async () => { + mocks.smartAccount = { account: { address: '0xabc' } }; + // Rejected: `useCreateComment` resolves falsy and has already removed the optimistic row. + mocks.publishResult = undefined; + const reported: number[] = []; + render( + withClient( + reported.push(delta)} + activityRows={[]} + /> + ) + ); + + await publishAComment(); + + await waitFor(() => expect(reported).toEqual([1, -1])); }); it('renders the rows on a claim nobody has commented on yet', () => { - renderSection([{ id: 'd-1', entityId: 'd-1', spaceId: 'space-1', createdAt: '2026-09-21T10:00:00Z', content: debate }]); + renderSection([ + { + id: 'd-1', + entityId: 'd-1', + spaceId: 'space-1', + createdAt: '2026-09-21T10:00:00Z', + content: debate, + }, + ]); expect(screen.getByText('debate')).toBeInTheDocument(); expect(screen.getByText('Activity (1)')).toBeInTheDocument(); @@ -168,3 +264,168 @@ describe('CommentSection activity rows', () => { expect(screen.getByText('body of c-1')).toBeInTheDocument(); }); }); + +/** + * The claim page's wiring, in miniature: it reads the aggregate, hands the number down, and puts the + * reader's own comment back into the same cache the number came out of. + */ +function ActivityHost({ claimId }: { claimId: string }) { + const counts = useClaimActivityCounts(React.useMemo(() => [claimId], [claimId])); + const queryClient = useQueryClient(); + + return ( + adjustClaimActivityTotal(queryClient, claimId, delta)} + /> + ); +} + +function seedAggregate(client: QueryClient, claimId: string, total: number) { + const counts = new Map([ + [uuidToHex(claimId), { total, comments: 1, debates: 0, extractedClaims: 0 } as ClaimActivityCount], + ]); + client.setQueryData(claimActivityCountsQueryKey([uuidToHex(claimId)]), counts); +} + +/** + * Where the reader's own comment is counted, and why it is not counted in the section. + * + * It used to be: `CommentSection` held the delta in `useState`. Both ends of that state's life were + * wrong. React Query holds the aggregate for a minute, so leaving the page and coming back inside + * that minute produced the pre-publish number with a delta that had reset — the heading dropped a + * comment that was still sitting in the list underneath it. And `EntityPageBody` is reused between + * records rather than remounted, so walking to the next claim carried the delta onto a number that + * had never heard of it. + */ +describe('a comment the reader just published', () => { + const CLAIM_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const CLAIM_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + afterEach(() => { + cleanup(); + mocks.comments = []; + mocks.smartAccount = null; + mocks.publishResult = { id: 'new-comment' }; + mocks.publishComment.mockClear(); + }); + + it('is still counted after the section is unmounted and mounted again', async () => { + mocks.smartAccount = { account: { address: '0xabc' } }; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + seedAggregate(client, CLAIM_A, 33); + + const first = render( + + + + ); + + expect(screen.getByText('Activity (33)')).toBeInTheDocument(); + await publishAComment(); + await waitFor(() => expect(screen.getByText('Activity (34)')).toBeInTheDocument()); + + // Away and back inside the aggregate's `staleTime`, which is the case that was broken: nothing + // refetches, so the only thing that can still know about the comment is the cache. + first.unmount(); + render( + + + + ); + + expect(screen.getByText('Activity (34)')).toBeInTheDocument(); + }); + + it('is not counted on the next claim the reader walks to', async () => { + mocks.smartAccount = { account: { address: '0xabc' } }; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + seedAggregate(client, CLAIM_A, 33); + seedAggregate(client, CLAIM_B, 7); + + const onA = render( + + + + ); + + await publishAComment(); + await waitFor(() => expect(screen.getByText('Activity (34)')).toBeInTheDocument()); + + // The route does not remount this page between records, so this is a prop change rather than a + // fresh mount — which is exactly how the old delta leaked across. + onA.rerender( + + + + ); + + expect(screen.getByText('Activity (7)')).toBeInTheDocument(); + }); +}); + +/** + * Where a comment lands on the page the instant it is written. + * + * The thread already promises this and does it in `sortWithSessionPinned`, at every nesting level. + * The activity merge then re-sorted the list it had just been handed, so on the claim page — the one + * surface with both activity rows and a ranked default — the promise held everywhere except the top + * level, which is the only level a new comment is written at. + */ +describe('a comment just written, in the merged list', () => { + afterEach(() => { + cleanup(); + mocks.comments = []; + mocks.smartAccount = null; + mocks.publishResult = { id: 'new-comment' }; + mocks.publishComment.mockClear(); + }); + + it('stays at the top under Oldest, which would otherwise put it last', async () => { + mocks.smartAccount = { account: { address: '0xabc' } }; + // The comment the composer is about to publish is the newest thing on the page, so Oldest sends + // it to the bottom — behind a debate and a comment from three weeks earlier. + mocks.comments = [comment('c-old', '2026-09-01T10:00:00Z'), comment('new-comment', '2026-09-24T10:00:00Z')]; + + render( + withClient( + a debate, + }, + ]} + /> + ) + ); + + // Before it is the reader's own: ordinary oldest-first order, and it is last. + expect(screen.getAllByText(/body of |a debate/).map(node => node.textContent?.trim())).toEqual([ + 'body of c-old', + 'a debate', + 'body of new-comment', + ]); + + await publishAComment(); + + await waitFor(() => + expect(screen.getAllByText(/body of |a debate/).map(node => node.textContent?.trim())).toEqual([ + 'body of new-comment', + 'body of c-old', + 'a debate', + ]) + ); + }); +}); diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index 78cd90fcb1..86ac141a55 100644 --- a/apps/web/partials/comments/comments-section.tsx +++ b/apps/web/partials/comments/comments-section.tsx @@ -239,6 +239,20 @@ interface CommentSectionProps { * the rendered rows speak for themselves. */ totalOverride?: number; + /** + * A comment appearing or disappearing inside this section, for the host that owns + * {@link totalOverride}. + * + * Paired with it deliberately: the host is the only thing that can move a number it computed, and + * this component is the only thing that knows a comment was written. The delta is signed — `-1` + * when a publish is rejected and its optimistic row goes away. + * + * This used to be counted here instead, in component state, which broke in both directions: the + * count was lost whenever the section remounted (leaving the heading behind the list it was + * drawing), and kept when the section was reused for another record (leaving the heading ahead of + * one it had never seen). + */ + onActivityPublish?: (delta: number) => void; /** * Where the sort starts. Threads default to most-recent; the claim page's activity feed opens on * Best, because there the list is a record of an argument rather than a running conversation. @@ -254,6 +268,7 @@ export function CommentSection({ activityRows = NO_ACTIVITY_ROWS, title = 'Comments', totalOverride, + onActivityPublish, defaultSortOrder = 'newest', }: CommentSectionProps) { const { comments, totalCount, isLoading } = useComments({ entityId, spaceId }); @@ -370,11 +385,12 @@ export function CommentSection({ setSessionNewIds((prev: string[]) => (prev.includes(id) ? prev : [id, ...prev])); }, []); - // Comments published from anywhere in this section since it loaded. Only meaningful beside - // `totalOverride`: that number is a server aggregate over debates and extracted claims as well as - // comments, so nothing in the comment caches can move it. See `activity-posts.tsx`. - const [activityPosts, setActivityPosts] = useState(0); - const adjustActivityPosts = React.useCallback((delta: number) => setActivityPosts(posts => posts + delta), []); + // Comments published from anywhere in this section are reported to whoever supplied + // `totalOverride`, because that number is a server aggregate over debates and extracted claims as + // well as comments and nothing in the comment caches can move it. Deliberately not counted here: + // this component is remounted and reused across records, and a count kept in its own state was + // lost on the first and carried onto the second. See `activity-posts.tsx`. + const adjustActivityPosts = React.useCallback((delta: number) => onActivityPublish?.(delta), [onActivityPublish]); // Fire-and-forget: the input boxes close/clear synchronously. The optimistic row appears // in the cache immediately (via usePublishComment) with a "Publishing…" tag; sessionNewIds @@ -436,6 +452,10 @@ export function CommentSection({ [sortOrder, sessionNewIds] ); + // As a set, because the activity merge tests every comment against it. Same source as the pin + // inside `sortWithSessionPinned` — one idea, read two ways, rather than two lists to keep in step. + const sessionNewIdSet = React.useMemo(() => new Set(sessionNewIds), [sessionNewIds]); + const filteredComments = React.useMemo(() => { let result = comments; if (filter === 'editors') { @@ -454,7 +474,7 @@ export function CommentSection({ {!isPanel && ( <>
- {title} ({totalOverride == null ? totalCount + activityRows.length : totalOverride + activityPosts}) + {title} ({totalOverride == null ? totalCount + activityRows.length : totalOverride})
@@ -496,6 +516,7 @@ export function CommentSection({ comments={filteredComments} activityRows={activityRows} activityOrder={sortOrder} + activityPinnedIds={sessionNewIdSet} activityScoreFor={scoreFor} entityId={entityId} spaceId={spaceId} @@ -786,6 +807,7 @@ function CommentList({ activityRows = NO_ACTIVITY_ROWS, activityOrder = 'newest', activityScoreFor, + activityPinnedIds, entityId, spaceId, onReply, @@ -809,6 +831,14 @@ function CommentList({ activityOrder?: CommentSortOrder; /** Looks up a top-level row's votes, for the ranked orders. */ activityScoreFor?: (row: { id: string }) => { positive: number; negative: number } | null; + /** + * Comments written in this session, which stay at the top of the merged list however it is sorted. + * + * Passed in rather than re-derived, because the pin at every other nesting level comes from the + * same state inside `sortWithSessionPinned`; this merge is the one place that re-sorts a list + * which has already been pinned, so it is the one place that has to know. + */ + activityPinnedIds?: ReadonlySet; entityId: string; spaceId: string; onReply: (text: string, ancestorComments?: Array<{ id: string; spaceId: string }>) => void; @@ -875,7 +905,7 @@ function CommentList({ if (depth === 0) { // Activity rows only exist at the top level — a debate is not a reply to a comment — so the // merge lives inside this branch and the recursive one below is untouched. - const merged = mergeActivityRows(comments, activityRows, activityOrder, activityScoreFor); + const merged = mergeActivityRows(comments, activityRows, activityOrder, activityScoreFor, activityPinnedIds); return (
From 494516fb60a808d34f3785345aff2b185c94bb73 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Fri, 25 Sep 2026 16:20:40 -0700 Subject: [PATCH 25/35] fix(claims): give the thread's new links back to the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all in the surfaces added for the review's own requests. A name in the thread is a real link to that person's space, and this PR routed every one of them through a handler that calls `preventDefault()` on every click — so Cmd-click, shift-click and middle click on a speaker, a comment author or either of their faces opened nothing at all. `ProfileEntityLink` had already decided this rule and says why: that is how people read a graph, and "copy link address" goes with it. The guard is scoped to an anchor that has an href, because the debate surfaces hang the same hook on a button laid over the video, where deferring to the browser would toggle playback and open nothing. "Continue this thread (N)" promised the number of rows hidden below and counted only the comment's immediate replies. A reply carrying ten of its own was offered as one, and that undercount is worst on exactly the threads deep enough to reach the floor. It counts the subtree now, which at the floor is already loaded. The side badge's hover title names the claim it is about — the fix for a badge reading "Agree" beside a comment that disagrees with the claim above it. The page-level provider was silently leaving the name out, so the badges on the claim's own comments, the case it was added for, explained nothing. `claimName` is required now rather than defaulted, because a default is what let one of two call sites forget. Not changed, both reported on their threads: `CommentList`'s unbounded recursion predates this PR and giving the shared list a floor changes every comment thread in the app; and the Explore pill missing a comment filed from a debate's composer, where both available fixes cost a request per feed page or cache surgery across a feature boundary. `ACTIVITY_MAX_DEPTH`'s doc comment claimed to govern the whole feed, which is how the first of those was read as a contract; it now says what it actually governs. Co-Authored-By: Claude Opus 5 (1M context) --- .../claims/browse/claim-activity-depth.ts | 10 ++- .../browse/claim-comment-position.test.tsx | 12 ++- .../claims/browse/claim-comment-position.tsx | 13 ++- .../claims/browse/claim-page-view.test.tsx | 13 +++ .../core/claims/browse/claim-page-view.tsx | 12 +++ .../claims/browse/debate-comment-row.test.tsx | 42 ++++++++- .../core/claims/browse/debate-comment-row.tsx | 21 ++++- .../browse/use-open-debater-profile.test.tsx | 87 +++++++++++++++++++ .../browse/use-open-debater-profile.ts | 13 +++ 9 files changed, 212 insertions(+), 11 deletions(-) diff --git a/apps/web/core/claims/browse/claim-activity-depth.ts b/apps/web/core/claims/browse/claim-activity-depth.ts index 46d71648d9..209f746bc9 100644 --- a/apps/web/core/claims/browse/claim-activity-depth.ts +++ b/apps/web/core/claims/browse/claim-activity-depth.ts @@ -1,10 +1,18 @@ /** - * How far the claim page's activity feed draws before it stops and points elsewhere. + * How far the branches this feature draws go before they stop and point elsewhere. * * Counted from the root, the way Reddit counts it, rather than as a budget each level subtracts * from. The arithmetic used to be spread across three files — `ACTIVITY_MAX_DEPTH - 2` in two * places, `maxDepth - 1` in a third, `depth < maxDepth` in a fourth — which was correct but had to * be hand-verified to stay that way. A row now knows only its own depth and asks one question. + * + * Scope, because this used to say "the claim page's activity feed" and that reads as more than it is: + * it governs the rows drawn *under a debate* — the claims extracted from it, the comments on those, + * and the replies to those (`extracted-claim-row`, `debate-comment-row`). The claim's ordinary comment + * thread is `CommentList`, which predates this and recurses without a floor, on this page and on every + * other page that has comments. So a long enough reply chain on the claim itself still draws past four + * while a debate's branch does not. Giving the shared list a floor is a change to every comment thread + * in the app and belongs in its own PR, not in this constant's contract. */ /** A debate or a top-level comment: the first thing under the claim. */ 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 9723708e88..ea140199a2 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'; @@ -78,6 +78,7 @@ describe('claim comment position badges', () => { viewerDirection={null} viewerSpaceId={null} isViewerResponseLoading={false} + claimName={null} > @@ -95,9 +96,7 @@ describe('claim comment position badges', () => { */ it('reports the nearest claim, not the page claim, when a provider is nested inside another', async () => { mocks.getEntityResponders.mockImplementation((entityId: string) => - Effect.succeed([ - { userId: 'author-space', direction: entityId === 'extracted-claim' ? 'negative' : 'positive' }, - ]) + Effect.succeed([{ userId: 'author-space', direction: entityId === 'extracted-claim' ? 'negative' : 'positive' }]) ); render( @@ -110,6 +109,7 @@ describe('claim comment position badges', () => { viewerDirection={null} viewerSpaceId={null} isViewerResponseLoading={false} + claimName={null} > { viewerDirection={null} viewerSpaceId={null} isViewerResponseLoading={false} + claimName={null} > @@ -171,6 +172,7 @@ describe('claim comment position badges', () => { viewerDirection="positive" viewerSpaceId="viewer-space" isViewerResponseLoading + claimName={null} > @@ -196,6 +198,7 @@ describe('claim comment position badges', () => { viewerDirection={null} viewerSpaceId="viewer-space" isViewerResponseLoading + claimName={null} > @@ -220,6 +223,7 @@ describe('claim comment position badges', () => { viewerDirection={null} viewerSpaceId="viewer-space" isViewerResponseLoading={false} + claimName={null} > diff --git a/apps/web/core/claims/browse/claim-comment-position.tsx b/apps/web/core/claims/browse/claim-comment-position.tsx index 13099957b8..180998089f 100644 --- a/apps/web/core/claims/browse/claim-comment-position.tsx +++ b/apps/web/core/claims/browse/claim-comment-position.tsx @@ -80,7 +80,7 @@ export function ClaimCommentPositionProvider({ entityId, spaceId, responseKind, - claimName = null, + claimName, viewerDirection, viewerSpaceId, isViewerResponseLoading, @@ -89,8 +89,15 @@ export function ClaimCommentPositionProvider({ entityId: string; spaceId: string; responseKind: DebateResponseKind; - /** The claim these positions are about, named for the badge's hover title. */ - claimName?: string | null; + /** + * The claim these positions are about, for the badge's hover title. + * + * Required rather than defaulted, and `null` only where there is genuinely no name to give. It was + * optional, and the page-level provider then silently left it out — so the badge explained itself on + * comments under an extracted claim and said nothing on the claim's own top-level comments, which is + * the case the title was added for. A default is what let one of two call sites forget. + */ + claimName: string | null; viewerDirection: ActiveResponseDirection | null; viewerSpaceId: string | null; /** True until the viewer read succeeds; failures stay unresolved rather than becoming a clear. */ diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index 58c8b88388..2fb7ea57d3 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -653,6 +653,19 @@ describe('ClaimPageView comments', () => { isViewerResponseLoading: true, }); }); + + /** + * Which claim an Agree is about. By the time a reader reaches the thread the title is off screen, + * and a badge on a comment under an extracted claim answers for a different claim than the page — + * so each badge names its own in its hover title. The page-level provider left the name out while + * the prop was optional, which meant the badges on the claim's *own* comments, the common case and + * the one this was added for, explained nothing. + */ + it('names the claim its side badges are about', () => { + render(); + + expect(mocks.commentPosition).toMatchObject({ claimName: 'Pineapple belongs on pizza' }); + }); }); // Topics live on their own tab for now; the hero's row is switched off (`SHOW_HERO_TOPICS`). The diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index e93c7c34b3..52a10ae5ec 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -337,6 +337,7 @@ export function ClaimPageView({ activeTab={requestedTab} entityId={entityId} spaceId={spaceId} + claimName={entity.name ?? null} entityRelations={entity.relations} responseKind={responseKind} summary={summary} @@ -356,6 +357,7 @@ function ClaimTabPanel({ activeTab, entityId, spaceId, + claimName, entityRelations, responseKind, summary, @@ -368,6 +370,8 @@ function ClaimTabPanel({ activeTab: ClaimTab; entityId: string; spaceId: string; + /** Passed down to the thread, where every side badge names the claim it is about. */ + claimName: string | null; entityRelations: Relation[]; responseKind: ClaimResponseState['responseKind']; summary: ClaimResponseState['summary']; @@ -417,6 +421,7 @@ function ClaimTabPanel({ ; @@ -536,6 +544,10 @@ function ClaimOverviewTab({ entityId={entityId} spaceId={spaceId} responseKind={responseKind} + // Which claim an Agree or a Disagree is about. By the time a reader is this far down the page + // the title above it is out of sight, and a badge on a comment under an extracted claim is + // answering for a different claim than the one the page is about — so each badge names its own. + claimName={claimName} viewerDirection={summary.viewerDirection} viewerSpaceId={summary.viewerSpaceId} isViewerResponseLoading={summary.isViewerResponseLoading} diff --git a/apps/web/core/claims/browse/debate-comment-row.test.tsx b/apps/web/core/claims/browse/debate-comment-row.test.tsx index 4fe7f0c1f6..1dc07067b5 100644 --- a/apps/web/core/claims/browse/debate-comment-row.test.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.test.tsx @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CommentWithReplies } from '~/partials/comments/types'; import { ACTIVITY_MAX_DEPTH } from './claim-activity-depth'; +import { DebateCommentRow } from './debate-comment-row'; const mocks = vi.hoisted(() => ({ /** Responder space id → the side they hold on the claim, as the provider would resolve it. */ @@ -60,8 +61,6 @@ vi.mock('~/design-system/prefetch-link', () => ({ ), })); -import { DebateCommentRow } from './debate-comment-row'; - function comment(overrides: Partial = {}): CommentWithReplies { return { id: 'comment-1', @@ -160,6 +159,45 @@ describe('DebateCommentRow', () => { expect(link).not.toHaveAttribute('href', expect.stringContaining('debate-1')); }); + /** + * How many rows are down there, which is what the control promises and what makes the offer worth + * taking. It counted this comment's own replies, so a single reply carrying ten of its own read as + * "Continue this thread (1)" — undersold by an order of magnitude on exactly the threads deep enough + * to reach the floor. + */ + it('counts every reply below it, not just the ones hanging off it directly', () => { + renderRow( + { + replies: [ + comment({ + id: 'reply-1', + markdownContent: 'a reply', + replies: [ + comment({ id: 'reply-1-1', replies: [comment({ id: 'reply-1-1-1' })] }), + comment({ id: 'reply-1-2' }), + ], + }), + comment({ id: 'reply-2' }), + ], + }, + ACTIVITY_MAX_DEPTH + ); + + // Five: two replies, two under the first of those, and one under the first of *those*. + expect(screen.getByRole('link', { name: 'Continue this thread — 5 more replies' })).toBeInTheDocument(); + }); + + // An optimistic reply is inserted before its `replies` array exists, and a count that walks the + // tree must not throw on it — the row guards its own `replies` read for the same reason. + it('survives a reply with no replies array at all', () => { + renderRow( + { replies: [comment({ id: 'reply-1', replies: undefined as unknown as CommentWithReplies[] })] }, + ACTIVITY_MAX_DEPTH + ); + + expect(screen.getByRole('link', { name: 'Continue this thread — 1 more reply' })).toBeInTheDocument(); + }); + // The other overflow: siblings held back for length. Already loaded, so it reveals in place. it('holds back a long reply list and reveals it in place', async () => { const replies = Array.from({ length: 5 }, (_, i) => diff --git a/apps/web/core/claims/browse/debate-comment-row.tsx b/apps/web/core/claims/browse/debate-comment-row.tsx index 1cbb3a1ff7..521ea52a88 100644 --- a/apps/web/core/claims/browse/debate-comment-row.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.tsx @@ -40,6 +40,21 @@ const REPLY_PAGE_SIZE = 3; /** Stable identity for a row directly under the entity, so the default doesn't rebuild each render. */ const NO_ANCESTORS: Array<{ id: string; spaceId: string }> = []; +/** + * Every reply in a subtree, at every depth. + * + * Only asked at the depth floor, where the tree is already loaded and none of it is being drawn — so + * this walks what the comment query returned rather than counting what is on screen. A reply with no + * `replies` array is one row and nothing under it; the guard is the same one the row itself uses, + * because an optimistic comment is inserted before that array exists. + */ +function countRepliesDeep(replies: CommentWithReplies[]): number { + return replies.reduce( + (total, reply) => total + 1 + countRepliesDeep(Array.isArray(reply.replies) ? reply.replies : []), + 0 + ); +} + export function DebateCommentRow({ comment, targetEntityId, @@ -77,7 +92,11 @@ export function DebateCommentRow({ const canNest = canNestBelow(depth); const shown = canNest ? replies.slice(0, visibleReplies) : []; const hiddenHere = canNest ? replies.length - shown.length : 0; - const belowFloor = canNest ? 0 : replies.length; + // The whole subtree, not this comment's own replies. `ThreadContinue` promises "how many rows are + // down there", and one reply carrying ten of its own is eleven rows the reader is not being shown — + // offering that as "Continue this thread (1)" undersells it by an order of magnitude on exactly the + // threads deep enough to reach the floor in the first place. + const belowFloor = canNest ? 0 : countRepliesDeep(replies); const branchLabel = { expand: 'Show replies', collapse: 'Hide replies' }; const drawnReplies = repliesCollapsed ? [] : shown; diff --git a/apps/web/core/debates/browse/use-open-debater-profile.test.tsx b/apps/web/core/debates/browse/use-open-debater-profile.test.tsx index 943968792a..2a9165aa6e 100644 --- a/apps/web/core/debates/browse/use-open-debater-profile.test.tsx +++ b/apps/web/core/debates/browse/use-open-debater-profile.test.tsx @@ -37,6 +37,29 @@ function click(result: { current: (event: React.MouseEvent) => void }) { return stopPropagation; } +/** + * A click with the element it was handled on, which is what decides whether the browser has anything + * to do instead of us. + */ +function clickOn( + result: { current: (event: React.MouseEvent) => void }, + currentTarget: Element, + modifiers: { metaKey?: boolean; shiftKey?: boolean; button?: number } = {} +) { + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + act(() => + result.current({ currentTarget, preventDefault, stopPropagation, ...modifiers } as unknown as React.MouseEvent) + ); + return { preventDefault, stopPropagation }; +} + +function anchor(href: string | null): HTMLAnchorElement { + const element = document.createElement('a'); + if (href != null) element.setAttribute('href', href); + return element; +} + beforeEach(() => { mocks.openSidePanel.mockReset(); mocks.sidePanelTarget = null; @@ -133,3 +156,67 @@ describe('useOpenDebaterProfile', () => { expect(mocks.openSidePanel).not.toHaveBeenCalled(); }); }); + +/** + * A name in the claim's thread is a real link to the person's space, and the reader may want it in a + * new tab rather than in the panel. `ProfileEntityLink` and `ExploreCardEntityLink` both already keep + * that rule — "Cmd-click, shift-click and middle click must still open the entity page in a new tab, + * which is how people read a graph" — and this hook was preventing the default on every click, so the + * anchors it was newly attached to could not be opened any other way. + */ +describe("useOpenDebaterProfile and the browser's own click", () => { + beforeEach(() => { + mocks.spaces.set(PERSONAL_SPACE_ID, { topicId: TOPIC_ENTITY_ID, entity: { id: PAGE_ENTITY_ID } }); + }); + + it('leaves a modified click on a real link to the browser', () => { + const { result } = renderHook(() => useOpenDebaterProfile(PERSONAL_SPACE_ID)); + + const cmd = clickOn(result, anchor('/space/' + PERSONAL_SPACE_ID), { metaKey: true }); + + expect(cmd.preventDefault).not.toHaveBeenCalled(); + expect(mocks.openSidePanel).not.toHaveBeenCalled(); + + // Middle click is the other half of the same gesture and reaches here as `button: 1`. + const middle = clickOn(result, anchor('/space/' + PERSONAL_SPACE_ID), { button: 1 }); + + expect(middle.preventDefault).not.toHaveBeenCalled(); + expect(mocks.openSidePanel).not.toHaveBeenCalled(); + }); + + it('still opens the panel on a plain click on that same link', () => { + const { result } = renderHook(() => useOpenDebaterProfile(PERSONAL_SPACE_ID)); + + const plain = clickOn(result, anchor('/space/' + PERSONAL_SPACE_ID)); + + expect(plain.preventDefault).toHaveBeenCalledOnce(); + expect(mocks.openSidePanel).toHaveBeenCalledWith(TOPIC_ENTITY_ID, PERSONAL_SPACE_ID, false, { + forceRequestedSpace: true, + }); + }); + + /** + * The debate surfaces hang this on a button laid over the video, which is one large play/pause + * control. There is no href for the browser to honour there, so a modified click still has to open + * the profile — deferring to the browser would toggle playback and open nothing. + */ + it('keeps opening the panel for a modified click on a button', () => { + const { result } = renderHook(() => useOpenDebaterProfile(PERSONAL_SPACE_ID)); + + const cmd = clickOn(result, document.createElement('button'), { metaKey: true }); + + expect(cmd.preventDefault).toHaveBeenCalledOnce(); + expect(mocks.openSidePanel).toHaveBeenCalledWith(TOPIC_ENTITY_ID, PERSONAL_SPACE_ID, false, { + forceRequestedSpace: true, + }); + }); + + // An anchor with no href navigates nowhere, so there is nothing to defer to. + it('keeps opening the panel for a modified click on a link with no destination', () => { + const { result } = renderHook(() => useOpenDebaterProfile(PERSONAL_SPACE_ID)); + + clickOn(result, anchor(null), { shiftKey: true }); + + expect(mocks.openSidePanel).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/core/debates/browse/use-open-debater-profile.ts b/apps/web/core/debates/browse/use-open-debater-profile.ts index accde4ddf4..a0e43d98fe 100644 --- a/apps/web/core/debates/browse/use-open-debater-profile.ts +++ b/apps/web/core/debates/browse/use-open-debater-profile.ts @@ -6,6 +6,7 @@ import { personProfileOpened } from '~/core/analytics'; import type { DebateParticipant } from '~/core/debates/api'; import { useEntitySidePanel } from '~/core/hooks/use-entity-side-panel'; import { useSpace } from '~/core/hooks/use-space'; +import { isModifiedClick } from '~/core/utils/is-modified-click'; import { getSpaceSubtopicRootEntityId } from '~/core/utils/space/spaces'; import type { EntitySidePanelTarget } from '~/atoms'; @@ -77,6 +78,18 @@ export function useOpenDebaterProfile( return React.useCallback( (event: React.MouseEvent) => { + // Cmd-click, shift-click and middle click on a real link belong to the browser: that is how + // people read a graph, and it is the contract `ProfileEntityLink` and `ExploreCardEntityLink` + // already keep — "a button with an onClick would look identical and break all of them, along + // with copy link address". The thread's speaker names, comment authors and faces are anchors + // with hrefs to the person's space, and this handler was swallowing every one of those clicks. + // + // Only for an anchor, and only one that has somewhere to go. The debate surfaces hang this on a + // button over the video, where there is no href to honour and swallowing the click is the + // entire point — otherwise a Cmd-click on a name there would toggle playback and open nothing. + const anchor = event.currentTarget instanceof HTMLAnchorElement ? event.currentTarget : null; + if (anchor?.getAttribute('href') && isModifiedClick(event)) return; + // The video behind is one large play/pause button, and a name in a thread is commonly inside // a link to somewhere else entirely. event.preventDefault(); From 993fb982236de5f8afeb7ba48157e5702d341098 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Fri, 25 Sep 2026 17:37:57 -0700 Subject: [PATCH 26/35] fix(claims): never guess how a claim is answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vote is published *as* a kind — thumbs write a stance response, chevrons a veracity one — so a control drawn on a guessed kind writes the wrong kind of answer into the graph and nothing later undoes it. The row defaulted to `stance` whenever the batch that resolves the kind had no entry, which collapsed three different unknowns into one confident answer: the batch in flight, the batch failed, and geo-chat holding no row for the claim. It now holds while the batch is in flight — the same skeleton the control uses for its own unresolved kind, rather than the per-entity read the batch exists to replace — and once the batch has settled without an answer it asks the control to resolve the kind from the entity itself. That is a real second source: the graph knows a claim's response kind independently of geo-chat. The hold cannot become permanent, because a failed space read resolves to `not-indexed` rather than staying unknown, which disables the claims query and falls through. The third instance of one mistake in this feature, and worth naming: a failed read rendered as a confirmed value. It was `?? 0` on the count aggregates, `?? 'stance'` here, and a failed transcript read rendered as a debate that produced nothing — that last one now says so and offers to try again, because the hook returns the same empty grouping for a failure as for a debate recorded before claim extraction existed. Two accessibility defects in the controls this PR added. A linked face had no accessible name, the image carrying empty `alt` and the fallback being an unlabelled SVG, so it announced nothing and gave a keyboard user no way to tell where it went; it takes the name the row is already printing. And the responder faces became a disclosure trigger this PR added, wrapped on the response *count* — a different query from the responder *list* — so a claim with five responses whose list had not answered was an invisible, focusable button. The trigger is handed into the component that knows whether it drew any faces. Not changed, reported on its thread: resolving response kinds per claim space. Measured 0 of 562 extracted claims named outside their debate's space, and the suggested `useDebateClaimsBySpaces` gates on `authenticated` where the current hook does not — it would stop resolving kinds for every signed-out reader. Co-Authored-By: Claude Opus 5 (1M context) --- .../browse/debate-activity-row.test.tsx | 53 +++++++++++++++++-- .../claims/browse/debate-activity-row.tsx | 39 ++++++++++++-- .../core/claims/browse/debate-comment-row.tsx | 2 + .../browse/extracted-claim-row.test.tsx | 50 +++++++++++++++++ .../claims/browse/extracted-claim-row.tsx | 46 ++++++++++++---- .../debates/use-debate-transcript-claims.ts | 10 +++- .../partials/comments/comments-section.tsx | 2 + apps/web/partials/comments/thread-avatar.tsx | 13 ++++- .../entity-page/claim-voter-avatars.test.tsx | 46 ++++++++++++++++ .../entity-page/claim-voter-avatars.tsx | 14 ++++- .../entity-page/entity-vote-buttons.tsx | 14 +++-- 11 files changed, 264 insertions(+), 25 deletions(-) diff --git a/apps/web/core/claims/browse/debate-activity-row.test.tsx b/apps/web/core/claims/browse/debate-activity-row.test.tsx index 755c269435..8d4eaa4443 100644 --- a/apps/web/core/claims/browse/debate-activity-row.test.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -8,7 +8,11 @@ import type { Entity } from '~/core/types'; import { DebateActivityRow } from './debate-activity-row'; -const mocks = vi.hoisted(() => ({ commentQueries: [] as Array<{ entityId: string; enabled: boolean }> })); +const mocks = vi.hoisted(() => ({ + commentQueries: [] as Array<{ entityId: string; enabled: boolean }>, + transcriptError: null as Error | null, + retryTranscript: vi.fn(), +})); // The controls have their own suites and both reach for wallet and query context. What this file is // about is what the row decides from its two counts: whether there is a branch under it at all. @@ -41,7 +45,12 @@ vi.mock('~/partials/comments/inline-comment-composer', async importOriginal => ( // The branch's own reads. Empty, so what the tests observe is whether the row *offered* a branch — // not what came back inside it. vi.mock('~/core/debates/use-debate-transcript-claims', () => ({ - useDebateTranscriptClaims: () => ({ claims: { all: [], blocks: [] }, isLoading: false }), + useDebateTranscriptClaims: () => ({ + claims: { all: [], blocks: [] }, + isLoading: false, + error: mocks.transcriptError, + retry: mocks.retryTranscript, + }), })); vi.mock('~/core/debates/use-claim-timings', () => ({ @@ -95,6 +104,8 @@ function renderRow(overrides: Partial { beforeEach(() => { mocks.commentQueries.length = 0; + mocks.transcriptError = null; + mocks.retryTranscript.mockClear(); }); afterEach(cleanup); @@ -151,3 +162,39 @@ describe('DebateActivityRow', () => { expect(screen.queryByLabelText(/extracted claim/)).not.toBeInTheDocument(); }); }); + +/** + * A debate whose transcript could not be read is not a debate that produced nothing. + * + * The hook answers with the same empty grouping either way — deliberately, because "no claims yet" is + * a real state for a chunk of the corpus that predates claim extraction. So the branch stayed silent + * on a failure, which put "nothing here" under a row that was simultaneously advertising eighteen + * extracted claims, with nothing to press and no way to tell which had happened. + */ +describe('DebateActivityRow when the transcript will not load', () => { + beforeEach(() => { + mocks.commentQueries.length = 0; + mocks.transcriptError = null; + mocks.retryTranscript.mockClear(); + }); + afterEach(cleanup); + + it('says so, and offers to try again', () => { + mocks.transcriptError = new Error('transcript unavailable'); + renderRow({ claimCount: 18, commentCount: 0 }); + + expect(screen.getByText(/Couldn’t load the claims from this debate/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(mocks.retryTranscript).toHaveBeenCalledOnce(); + }); + + // The ordinary empty debate still says nothing: a line under every old debate is noise, which is + // why silence was chosen in the first place. + it('stays silent when the transcript loaded and there was simply nothing in it', () => { + renderRow({ claimCount: 18, commentCount: 0 }); + + expect(screen.queryByText(/Couldn’t load the claims/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index dd4800e03a..58d3c54ad1 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -331,7 +331,7 @@ function DebateBranch({ /** Someone has commented from this row since the page loaded, so the aggregate is behind. */ hasPostedHere: boolean; }) { - const { claims, isLoading } = useDebateTranscriptClaims(debateId, spaceId); + const { claims, isLoading, error: claimsError, retry: retryClaims } = useDebateTranscriptClaims(debateId, spaceId); const { timings, isReady } = useClaimTimings(debateId, claims); // The debate's own comments, on the same query key the comments panel uses — so opening the panel // on this debate costs no extra request, and a comment posted there appears here too. @@ -383,7 +383,26 @@ function DebateBranch({ // Silent rather than apologetic. Claim extraction postdates a chunk of the corpus, so a debate // with nothing under it is ordinary — and a line saying so under every old debate is noise. - if (rowCount === 0) return null; + // + // A *failed* read is not that, and the hook reports both as the same empty grouping. Staying silent + // here said "this debate produced nothing" on a row whose own indicator was advertising eighteen + // extracted claims, with nothing to press and no way to tell the difference. + if (rowCount === 0) { + if (claimsError == null) return null; + + return ( + + + + Couldn’t load the claims from this debate.{' '} + + + + + ); + } return ( @@ -400,9 +419,19 @@ function DebateBranch({ commentCount={countFor(commentCounts, claim.id)} // A debate is the root of this branch, so everything hanging off it is one below. depth={ACTIVITY_ROOT_DEPTH + 1} - // Stance unless geo-chat says otherwise. A missing row means the space is not indexed, - // not that the claim is factual, and stance is what the graph defaults to as well. - responseKind={responseKindByClaimId.get(uuidToHex(claim.id)) ?? 'stance'} + // Never guessed. A vote is published *as* a kind — thumbs write a stance response and + // chevrons write a veracity one — so a control drawn on a guess can write the wrong kind + // of answer into the graph, which no later correction undoes. This used to default to + // `stance` on the grounds that it is the graph's own default, which is true of a claim + // with nothing recorded and not true of one whose row simply has not arrived yet. + // + // `null` here means this lookup has no answer, and the row then asks the control to + // resolve the kind from the entity itself rather than assuming one — see + // `ExtractedClaimRow`. + responseKind={responseKindByClaimId.get(uuidToHex(claim.id)) ?? null} + // Still in flight. The row holds rather than drawing a control it cannot yet label, and + // rather than falling back to the per-entity read this batch exists to replace. + isResponseKindPending={claimRows.isLoading} responseVocabulary={responseVocabulary} // A debater who is somehow not recorded on either side gets no tag rather than a // guessed one — the same rule the speaker name follows below. diff --git a/apps/web/core/claims/browse/debate-comment-row.tsx b/apps/web/core/claims/browse/debate-comment-row.tsx index 521ea52a88..29991d8427 100644 --- a/apps/web/core/claims/browse/debate-comment-row.tsx +++ b/apps/web/core/claims/browse/debate-comment-row.tsx @@ -121,6 +121,8 @@ export function DebateCommentRow({ itself intercepted to open the profile beside the thread instead of replacing it. */} { expect(screen.getByText('Practical effects age better than CGI.')).toBeInTheDocument(); }); + /** + * A vote is published *as* a kind — thumbs write a stance response, chevrons a veracity one — so a + * control drawn on a guessed kind can write the wrong kind of answer into the graph, which nothing + * later undoes. The batch that resolves the kind is a separate request from the claims themselves, + * and this row used to default to `stance` for the whole time it was in flight. + */ + describe('the response kind it has not been told yet', () => { + it('holds the control while the batch is still resolving', () => { + renderRow({ isResponseKindPending: true }); + + expect(screen.queryByTestId('vote-buttons')).not.toBeInTheDocument(); + }); + + it('asks the control to resolve the kind itself once the batch has settled without an answer', () => { + renderRow({ responseKind: null }); + + // `undefined` is what tells `EntityVoteButtons` to read the kind off the entity. Anything else + // here — `stance` above all — is this row inventing an answer it was not given. + expect(screen.getByTestId('vote-buttons')).toHaveAttribute('data-kind', 'undefined'); + }); + + it('passes the kind straight through when the batch did answer', () => { + renderRow({ responseKind: 'veracity' }); + + expect(screen.getByTestId('vote-buttons')).toHaveAttribute('data-kind', 'veracity'); + }); + }); + it('shows the server-counted comments rather than seeding the button at zero', () => { renderRow({ commentCount: 4 }); @@ -234,6 +262,28 @@ describe('ExtractedClaimRow', () => { expect(screen.getAllByLabelText('Hide comments on this claim').length).toBeGreaterThan(0); }); + /** + * A face is the one link with nothing readable inside it: the image carries empty `alt` because the + * name is right beside it, and the generated fallback is an unlabelled SVG. Linked and unnamed, it + * announced nothing at all and gave a keyboard user no way to tell where it went. + */ + it('names the face it links, including when the speaker has no name', () => { + renderRow(); + + // Two links to the same person — the face and the name — and both have to say whose they are. + // Two adjacent links with one name is the cost of that, and the alternative is worse: a single + // anchor around both is the nesting that put a 987px face in a 32px row. + const named = screen.getAllByRole('link', { name: 'Ada Reyes' }); + expect(named).toHaveLength(2); + for (const link of named) expect(link).toHaveAttribute('href', expect.stringContaining('speaker-space')); + + cleanup(); + renderRow({ speaker: { spaceId: 'speaker-space', name: null } }); + + // The same fallback the row prints, rather than a link announced as nothing at all. + expect(screen.getAllByRole('link', { name: 'Unnamed debater' })).toHaveLength(2); + }); + it('links the sentence to the claim in its own space', () => { renderRow(); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index 1e1c4ff4bc..beb86222be 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -13,6 +13,7 @@ import type { ResponseKind } from '~/core/responses/entity-response'; import { NavUtils } from '~/core/utils/utils'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; +import { Skeleton } from '~/design-system/skeleton'; import { type CommentDensity, @@ -54,6 +55,7 @@ export function ExtractedClaimRow({ debateId, debateSpaceId, responseKind, + isResponseKindPending = false, speaker, speakerPosition, responseVocabulary, @@ -73,7 +75,16 @@ export function ExtractedClaimRow({ * per instance — twenty extracted claims would mean twenty of them. Passing it down is the * difference between one lookup and one per row. */ - responseKind: ResponseKind; + /** + * How this claim is answered — thumbs or chevrons — resolved in a batch by the caller. + * + * `null` means that batch has no answer for this claim, and the control is then asked to resolve + * the kind from the entity itself. Deliberately not defaulted to `stance`: a vote is published *as* + * a kind, so a control drawn on a guess writes the wrong kind of answer into the graph. + */ + responseKind: ResponseKind | null; + /** The caller's batch is still in flight, so the vote control holds rather than guessing. */ + isResponseKindPending?: boolean; /** The debater this turn is attributed to, or null on a block with no `Authors` relation. */ speaker: (SpeakerProfile & { spaceId: string }) | null; /** @@ -154,6 +165,9 @@ export function ExtractedClaimRow({ height includes everything nested under it. */} setCommentsCollapsed(collapsed => !collapsed)} /> )} - + {isResponseKindPending ? ( + // The same shape the control holds for its own unresolved kind, so the row does not + // reflow when the real one arrives. + + ) : ( + + )} void; }; /** @@ -27,6 +29,11 @@ export type DebateTranscriptClaimsResult = { * * Returns an empty grouping rather than throwing for debates with no transcript: recording * predates claim extraction for a chunk of the corpus, and "no claims yet" is a real state. + * + * Which is exactly why `error` has to be read separately. A failed read and a debate with nothing in + * it arrive here as the same empty grouping, and a caller that renders emptiness silently — as the + * activity feed's debate branch did — turns a transient failure into "this debate produced nothing" + * on a row that is simultaneously advertising eighteen extracted claims. */ export function useDebateTranscriptClaims( debateId: string | null, @@ -38,7 +45,7 @@ export function useDebateTranscriptClaims( const debateEntityId = debateId ? ID.uuidToHex(debateId) : ''; const scopedSpaceId = spaceId ? ID.uuidToHex(spaceId) : ''; - const { data, isLoading, error } = useQuery({ + const { data, isLoading, error, refetch } = useQuery({ queryKey: debateTranscriptClaimsQueryKey(debateEntityId, scopedSpaceId), queryFn: ({ signal }) => Effect.runPromise(getDebateTranscriptClaims(debateEntityId, scopedSpaceId, signal)), enabled: enabled && debateEntityId !== '' && scopedSpaceId !== '', @@ -48,5 +55,6 @@ export function useDebateTranscriptClaims( claims: data ?? EMPTY_TRANSCRIPT_CLAIMS, isLoading, error: (error as Error | null) ?? null, + retry: () => void refetch(), }; } diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index 86ac141a55..a211e5de55 100644 --- a/apps/web/partials/comments/comments-section.tsx +++ b/apps/web/partials/comments/comments-section.tsx @@ -1230,6 +1230,8 @@ function CommentItem({ > void; className?: string; }) { @@ -51,7 +62,7 @@ export function ThreadAvatar({ } return ( - + {face} ); diff --git a/apps/web/partials/entity-page/claim-voter-avatars.test.tsx b/apps/web/partials/entity-page/claim-voter-avatars.test.tsx index b41a3a4873..c5391b3f53 100644 --- a/apps/web/partials/entity-page/claim-voter-avatars.test.tsx +++ b/apps/web/partials/entity-page/claim-voter-avatars.test.tsx @@ -73,3 +73,49 @@ describe('ClaimResponderAvatars', () => { expect(screen.getByTestId('responder-count')).toHaveTextContent('1'); }); }); + +/** + * The wrapper exists so a disclosure trigger only ever exists around faces that exist. + * + * The responder *count* and the responder *list* are two separate queries. A claim the count says has + * five responses draws nothing here while the list is unresolved, and nothing at all if it failed — so + * a trigger applied from outside on the count alone was an invisible, focusable button for exactly that + * state, offering to list people it could not name. + */ +describe('ClaimResponderAvatars and the trigger around it', () => { + function renderWithWrap(seeded: Array<{ userId: string; direction: string }> | undefined) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (seeded) queryClient.setQueryData(entityRespondersQueryKey('claim-1', 'space-1', 0, 'stance'), seeded); + + return render( + + ( + + )} + /> + + ); + } + + it('wraps the faces once there are faces', () => { + renderWithWrap([{ userId: 'profile-other', direction: 'negative' }]); + + expect(screen.getByTestId('responders-trigger')).toBeInTheDocument(); + expect(screen.getByTestId('responder-ids')).toHaveTextContent('profile-other'); + }); + + it('builds no trigger while the list has not answered, even though the count says five', () => { + renderWithWrap(undefined); + + expect(screen.queryByTestId('responders-trigger')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/partials/entity-page/claim-voter-avatars.tsx b/apps/web/partials/entity-page/claim-voter-avatars.tsx index 4cff239caa..eb6144dc87 100644 --- a/apps/web/partials/entity-page/claim-voter-avatars.tsx +++ b/apps/web/partials/entity-page/claim-voter-avatars.tsx @@ -20,6 +20,7 @@ export function ClaimResponderAvatars({ totalResponders, viewerSpaceId, optimisticViewerResponse, + wrap, }: { entityId: string; spaceId: string; @@ -28,6 +29,15 @@ export function ClaimResponderAvatars({ totalResponders: number; viewerSpaceId?: string | null; optimisticViewerResponse?: ActiveResponseDirection | null; + /** + * Wraps the faces, once there are faces — a disclosure trigger, typically. + * + * Passed in rather than applied around this component, because only this component knows whether it + * is going to draw anything: the responder *count* and the responder *list* are two queries, so a + * claim with five responses still renders nothing here while the list is unresolved or has failed. + * Wrapped from outside on the count alone, that state is an invisible, focusable button. + */ + wrap?: (faces: React.ReactNode) => React.ReactNode; }) { const { responders, queriesEnabled } = useEntityResponders({ entityId, @@ -51,7 +61,7 @@ export function ClaimResponderAvatars({ if (responderSpaceIds.length === 0) return null; - return ( + const faces = ( ); + + return wrap ? <>{wrap(faces)} : faces; } diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index 3f059f73a5..a2f791f9e7 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -289,7 +289,14 @@ export function EntityVoteButtons({ // `effectiveTotal` as well as the kind: `ClaimResponderAvatars` renders nothing when nobody has // responded, but it is still an element, so a truthiness check on it put an empty clickable button // on every unanswered claim in the app — offering to list nobody. - const claimResponderAvatars = + // + // The count is not the whole answer, though, and that is why the trigger is handed *inwards* as + // `wrap` rather than wrapped around this from outside. The count and the list of responders are two + // queries: a claim the count says has five responses still draws no faces while the list is + // unresolved, or for good if it failed. Deciding out here on the count alone left exactly that state + // as an invisible, focusable button — so the component that knows whether it drew anything is the one + // that puts the trigger on. + const claimResponderAvatars = (positionClassName: string) => isClaimResponse && effectiveTotal > 0 ? ( respondersTrigger(faces, positionClassName)} /> ) : null; @@ -372,7 +380,7 @@ export function EntityVoteButtons({ return (
- {claimResponderAvatarsPosition === 'leading' ? respondersTrigger(claimResponderAvatars, 'mr-1') : null} + {claimResponderAvatarsPosition === 'leading' ? claimResponderAvatars('mr-1') : null} - {claimResponderAvatarsPosition === 'trailing' ? respondersTrigger(claimResponderAvatars, 'ml-1') : null} + {claimResponderAvatarsPosition === 'trailing' ? claimResponderAvatars('ml-1') : null} {isResponseIndexingDelayed ? ( Response submitted. Waiting for confirmation. From 7c6e5ceda711ffb5b8d3d038154e1855767104a8 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 26 Sep 2026 11:28:05 -0700 Subject: [PATCH 27/35] fix(claims): meet master's one vocabulary, and three optimistic holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes landed on master that this feature was built against. #2541 answered every claim with Agree/Disagree and removed the `veracity` response kind, its vote kind and its SDK methods. So `responseVocabulary` — threaded from the page through four components to pick Agree/Disagree or Verify/Dispute — had no consumer left, and the geo-chat lookup that resolved each extracted claim's kind could only ever answer `stance`. Both are gone, and the row states `stance` outright, which saves the control a read of the entity. Last round's guard against publishing a guessed kind goes with them: there is one kind now, so there is nothing to guess. That removes a request per page. #2583 deleted the sign-up interstitial and its store; the inline composer opens Privy directly like every other gate. Then the three findings, all of them optimistic state that was not taken back: A failed transcript only said so when the branch was otherwise empty, so a debate with comments drew them and dropped its failed claims silently. The retry row is a row of the branch now, at the head, with the last-row arithmetic counting from it. The optimistic write on the activity aggregate could be overwritten by a refetch already in flight — the one a mount past `staleTime` starts. It answers with a number from before the comment existed and lands after the write. Timestamps cannot separate that from a response that includes the comment, because it was requested first and arrived second, so the write cancels matching queries before applying the delta, scoped by a predicate to the sets this claim is in. `hasPosted` latched true, so a rejected comment left its row holding a branch with nothing in it and a comments fetch looking for a row that had been removed. It is a count now, decremented on the same falsy result that gives the heading its count back, so a reader who loses one of two comments keeps the branch for the one that stood. Co-Authored-By: Claude Opus 5 (1M context) --- .../browse/claim-activity-count.test.ts | 92 ++++++++++++++++++- .../claims/browse/claim-activity-count.ts | 28 +++++- .../claims/browse/claim-comment-position.tsx | 2 +- .../core/claims/browse/claim-page-view.tsx | 8 +- .../browse/debate-activity-row.test.tsx | 29 +++++- .../claims/browse/debate-activity-row.tsx | 79 ++++++---------- .../browse/extracted-claim-row.test.tsx | 41 +-------- .../claims/browse/extracted-claim-row.tsx | 53 ++++------- .../claims/browse/use-claim-activity-rows.tsx | 6 -- .../browse/debate-feed-player.test.tsx | 3 + .../comments/comment-activity-rows.test.tsx | 3 +- .../comments/inline-comment-composer.test.tsx | 70 ++++++++++++++ .../comments/inline-comment-composer.tsx | 42 ++++++--- 13 files changed, 299 insertions(+), 157 deletions(-) create mode 100644 apps/web/partials/comments/inline-comment-composer.test.tsx diff --git a/apps/web/core/claims/browse/claim-activity-count.test.ts b/apps/web/core/claims/browse/claim-activity-count.test.ts index 301b1ffd6a..b2bd56e0ab 100644 --- a/apps/web/core/claims/browse/claim-activity-count.test.ts +++ b/apps/web/core/claims/browse/claim-activity-count.test.ts @@ -1,6 +1,13 @@ +import { QueryClient } from '@tanstack/react-query'; + import { describe, expect, it } from 'vitest'; -import { decodeClaimActivityCounts } from './claim-activity-count'; +import { + adjustClaimActivityTotal, + claimActivityCountsQueryKey, + decodeClaimActivityCounts, +} from './claim-activity-count'; +import type { ClaimActivityCount } from './claim-activity-fields'; function debate(comments: number, extracted: Array) { return { @@ -132,3 +139,86 @@ describe('decodeClaimActivityCounts', () => { expect(decodeClaimActivityCounts({ entities: null }).size).toBe(0); }); }); + +/** + * The reader's own comment, against a refetch that was already on its way. + * + * This aggregate is held for a minute, so a mount past that — or a reconnect — starts a background + * fetch. That request answers with a number from before the comment existed, and if it lands after the + * optimistic write it puts that number back: the heading drops a comment still sitting in the list + * underneath it. Timestamps cannot separate the two, because the response was asked for before the + * publish and arrives after it, so the write cancels first instead. + */ +describe('adjustClaimActivityTotal', () => { + const CLAIM = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const key = claimActivityCountsQueryKey([CLAIM]); + + function seeded(total: number) { + return new Map([[CLAIM, { total, comments: 1, debates: 0, extractedClaims: 0 } as ClaimActivityCount]]); + } + + function totalIn(client: QueryClient) { + return client.getQueryData>(key)?.get(CLAIM)?.total; + } + + it('counts the comment', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(key, seeded(33)); + + await adjustClaimActivityTotal(client, CLAIM, 1); + + expect(totalIn(client)).toBe(34); + }); + + it('keeps it when a refetch that started earlier lands afterwards', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(key, seeded(33)); + + // A fetch already in flight, holding the pre-publish answer. + let landPreIndexAnswer = (_value: Map) => {}; + const inFlight = new Promise>(resolve => { + landPreIndexAnswer = resolve; + }); + const fetching = client.fetchQuery({ queryKey: key, queryFn: () => inFlight, staleTime: 0 }).catch(() => {}); + + await adjustClaimActivityTotal(client, CLAIM, 1); + expect(totalIn(client)).toBe(34); + + // The indexer has not caught up, so what was already on its way still says 33. + landPreIndexAnswer(seeded(33)); + await fetching; + await Promise.resolve(); + + expect(totalIn(client)).toBe(34); + }); + + it('gives the count back when the publish is rejected', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(key, seeded(33)); + + await adjustClaimActivityTotal(client, CLAIM, 1); + await adjustClaimActivityTotal(client, CLAIM, -1); + + expect(totalIn(client)).toBe(33); + }); + + it('leaves a claim it has no answer for alone rather than inventing one', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await adjustClaimActivityTotal(client, CLAIM, 1); + + expect(client.getQueryData(key)).toBeUndefined(); + }); + + // Another claim's request is not this claim's business: abandoning it would cost it a refetch for + // nothing. + it('does not touch a cached set this claim is not in', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const otherKey = claimActivityCountsQueryKey(['bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb']); + client.setQueryData(otherKey, seeded(7)); + + await adjustClaimActivityTotal(client, CLAIM, 1); + + expect(client.getQueryData>(otherKey)?.get(CLAIM)?.total).toBe(7); + }); +}); diff --git a/apps/web/core/claims/browse/claim-activity-count.ts b/apps/web/core/claims/browse/claim-activity-count.ts index 43226d442a..d4afe5a4fb 100644 --- a/apps/web/core/claims/browse/claim-activity-count.ts +++ b/apps/web/core/claims/browse/claim-activity-count.ts @@ -122,14 +122,38 @@ export function useClaimActivityCounts(claimIds: string[], enabled = true): Map< * A claim with no entry yet is left alone rather than invented: the aggregate is still in flight and * will answer for itself. That loses a comment published in the few hundred milliseconds before the * first response lands, which is not long enough to write one. + * + * Asynchronous because it cancels before it writes, which is React Query's own recipe for an + * optimistic update and the only thing that closes this race: a refetch already on its way — the one + * a mount past `staleTime` starts, or a reconnect — answers with a number from before the comment + * existed, and if it lands *after* the write it puts that number back. Timestamps cannot tell the two + * apart, because the response was requested before the publish and arrives after it. Cancelling makes + * it never arrive. Callers do not await this; the write lands a microtask later, which is not a + * perceptible delay in a heading. */ -export function adjustClaimActivityTotal(queryClient: QueryClient, claimId: string, delta: number): void { +export async function adjustClaimActivityTotal( + queryClient: QueryClient, + claimId: string, + delta: number +): Promise { if (delta === 0) return; const id = uuidToHex(claimId); // Every cached set that holds this claim, not one key: the page asks for one claim and other // surfaces ask for a screenful, and a number that moves on one of them has moved on all of them. - queryClient.setQueriesData>({ queryKey: ['claim-activity-counts'] }, counts => { + // The predicate keeps that from reaching sets this claim is not in, which matters more for the + // cancel than for the write — abandoning an unrelated claim's request would cost it a refetch. + const heldByThisClaim = { + queryKey: ['claim-activity-counts'] as const, + predicate: (query: { queryKey: readonly unknown[] }) => { + const ids = query.queryKey[1]; + return Array.isArray(ids) && ids.includes(id); + }, + }; + + await queryClient.cancelQueries(heldByThisClaim); + + queryClient.setQueriesData>(heldByThisClaim, counts => { const current = counts?.get(id); // `undefined` bails out of the update rather than writing anything, so a cached set that does // not hold this claim — or one that has not answered yet — is left untouched instead of being diff --git a/apps/web/core/claims/browse/claim-comment-position.tsx b/apps/web/core/claims/browse/claim-comment-position.tsx index 3002f306d4..19af192345 100644 --- a/apps/web/core/claims/browse/claim-comment-position.tsx +++ b/apps/web/core/claims/browse/claim-comment-position.tsx @@ -187,7 +187,7 @@ export function ClaimCommentPositionBadge({ authorSpaceId }: { authorSpaceId: st const direction = context?.directions.get(uuidToHex(authorSpaceId)); if (!context || !direction) return null; - const label = responsePositionLabel(context.responseKind, direction === 'positive'); + const label = responsePositionLabel(direction === 'positive'); return ( adjustClaimActivityTotal(queryClient, entityId, delta), + (delta: number) => void adjustClaimActivityTotal(queryClient, entityId, delta), [entityId, queryClient] ); - const activity = useClaimActivityRows({ - claimId: entityId, - spaceId, - responseVocabulary: responseKind === 'veracity' ? 'veracity' : 'stance', - }); + const activity = useClaimActivityRows({ claimId: entityId, spaceId }); const kinds: ActivityKind[] = [ { diff --git a/apps/web/core/claims/browse/debate-activity-row.test.tsx b/apps/web/core/claims/browse/debate-activity-row.test.tsx index 8d4eaa4443..a06ff75a21 100644 --- a/apps/web/core/claims/browse/debate-activity-row.test.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.test.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ commentQueries: [] as Array<{ entityId: string; enabled: boolean }>, transcriptError: null as Error | null, retryTranscript: vi.fn(), + debateComments: [] as unknown[], })); // The controls have their own suites and both reach for wallet and query context. What this file is @@ -62,7 +63,7 @@ vi.mock('~/core/debates/hooks', () => ({ useDebateClaims: () => ({ data: null }) vi.mock('~/core/hooks/use-comments', () => ({ useComments: ({ entityId, enabled }: { entityId: string; enabled?: boolean }) => { mocks.commentQueries.push({ entityId, enabled: enabled !== false }); - return { comments: [] }; + return { comments: mocks.debateComments }; }, })); @@ -89,7 +90,6 @@ function renderRow(overrides: Partial { mocks.commentQueries.length = 0; mocks.transcriptError = null; mocks.retryTranscript.mockClear(); + mocks.debateComments = []; }); afterEach(cleanup); @@ -189,6 +190,30 @@ describe('DebateActivityRow when the transcript will not load', () => { expect(mocks.retryTranscript).toHaveBeenCalledOnce(); }); + /** + * The first version of this only spoke up when the whole branch was empty, so a debate that also had + * comments drew them and dropped its failed claims without a word — under a row still advertising + * eighteen of them. The comments are not a substitute for the claims. + */ + it('says so alongside the comments it did manage to draw', () => { + mocks.transcriptError = new Error('transcript unavailable'); + mocks.debateComments = [ + { + id: 'comment-1', + markdownContent: 'A comment that loaded fine.', + author: { spaceId: 'author-space', address: '0xabc', name: 'Ada', avatarUrl: null }, + createdAt: '2026-09-20T10:00:00Z', + spaceId: 'author-space', + replies: [], + }, + ]; + + renderRow({ claimCount: 18, commentCount: 1 }); + + expect(screen.getByText(/Couldn’t load the claims from this debate/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); + // The ordinary empty debate still says nothing: a line under every old debate is noise, which is // why silence was chosen in the first place. it('stays silent when the transcript loaded and there was simply nothing in it', () => { diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index 58d3c54ad1..d45aa316d4 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -5,14 +5,11 @@ import * as React from 'react'; import cx from 'classnames'; import { useEntityCommentCounts } from '~/core/comments/use-entity-comment-counts'; -import type { DebateResponseKind } from '~/core/debates/api'; -import { useDebateClaims } from '~/core/debates/hooks'; import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; import { countFor } from '~/core/hooks/batched-counts'; import { useComments } from '~/core/hooks/use-comments'; import { uuidToHex } from '~/core/id/normalize'; -import type { ResponseKind } from '~/core/responses/entity-response'; import type { Entity } from '~/core/types'; import { NavUtils } from '~/core/utils/utils'; @@ -70,8 +67,6 @@ export type DebateActivityRowProps = { profilesBySpaceId: Map; /** Each debater and the side they argued, from `Supported by` / `Opposed by`. */ sides: Array<{ spaceId: string; position: boolean }>; - /** The claim page's vocabulary, so a debater's side reads in the claim's own terms. */ - responseVocabulary: DebateResponseKind; /** What the debate argued. The row's body — without it the row is a byline and a control strip. */ claimText: string | null; keyframeUrl: string | null; @@ -106,7 +101,6 @@ export function DebateActivityRow({ spaceId, profilesBySpaceId, sides, - responseVocabulary, claimText, keyframeUrl, publishedAt, @@ -286,7 +280,6 @@ export function DebateActivityRow({ spaceId={spaceId} profilesBySpaceId={profilesBySpaceId} positionBySpaceId={positionBySpaceId} - responseVocabulary={responseVocabulary} onCollapse={() => setCollapsed(true)} branchLabel={branchLabel} commentCount={commentCount} @@ -312,7 +305,6 @@ function DebateBranch({ spaceId, profilesBySpaceId, positionBySpaceId, - responseVocabulary, onCollapse, branchLabel, commentCount, @@ -323,7 +315,6 @@ function DebateBranch({ profilesBySpaceId: Map; /** Debater space id (canonical) → the side they argued in this debate. */ positionBySpaceId: Map; - responseVocabulary: DebateResponseKind; onCollapse: () => void; branchLabel: { expand: string; collapse: string }; /** From the feed's own aggregate. Zero means nothing here is worth a request; `null` means it could not say. */ @@ -347,17 +338,7 @@ function DebateBranch({ }); const claimIds = React.useMemo(() => claims.all.map(claim => claim.id), [claims.all]); - // One call for every extracted claim on screen. Without it each row's own vote control would read - // the entity to work out whether it takes thumbs or chevrons — one request per row. - const claimRows = useDebateClaims(spaceId, claimIds, claimIds.length > 0); const commentCounts = useEntityCommentCounts(claimIds); - const responseKindByClaimId = React.useMemo(() => { - const map = new Map(); - for (const row of claimRows.data?.claims ?? []) { - map.set(uuidToHex(row.claim_entity_id), row.response_kind === 'veracity' ? 'veracity' : 'stance'); - } - return map; - }, [claimRows.data?.claims]); const speakerBySourceBlockId = React.useMemo(() => { const map = new Map(); @@ -379,20 +360,25 @@ function DebateBranch({ // Timed first, in order, then the ones nothing could place. The tail is not sorted among itself: // there is nothing to sort it by, and imposing an order would say there was. const claimsInOrder = [...ordered.timed, ...ordered.untimed]; - const rowCount = claimsInOrder.length + debateComments.length; - // Silent rather than apologetic. Claim extraction postdates a chunk of the corpus, so a debate - // with nothing under it is ordinary — and a line saying so under every old debate is noise. + // The claims could not be read, so the branch owes the reader an account of what it is not showing. // - // A *failed* read is not that, and the hook reports both as the same empty grouping. Staying silent - // here said "this debate produced nothing" on a row whose own indicator was advertising eighteen - // extracted claims, with nothing to press and no way to tell the difference. - if (rowCount === 0) { - if (claimsError == null) return null; - - return ( - - + // Silence is right for a debate that simply has nothing under it — claim extraction postdates a + // chunk of the corpus, and a line saying so beneath every old debate is noise — and the hook reports + // a failure as the same empty grouping, so the two have to be told apart here. The first version of + // this only spoke up when the *whole* branch was empty, which meant a debate that also had comments + // drew them and dropped its failed claims without a word, under a row still advertising eighteen. + const claimsFailed = claimsError != null && claimsInOrder.length === 0; + // One row, at the head: it is about the claims, which is what the rest of the branch leads with. + const leadingRows = claimsFailed ? 1 : 0; + const rowCount = leadingRows + claimsInOrder.length + debateComments.length; + + if (rowCount === 0) return null; + + return ( + + {claimsFailed && ( + Couldn’t load the claims from this debate.{' '} - - ); - } + )} - return ( - {claimsInOrder.map((claim, index) => { const speakerSpaceId = speakerBySourceBlockId.get(uuidToHex(claim.blockId)) ?? null; return ( - + ( - + { expect(screen.getByText('Disagree')).toBeInTheDocument(); }); - it('uses the claim’s own vocabulary for that tag', () => { - renderRow({ speakerPosition: false, responseVocabulary: 'veracity' }); - - expect(screen.getByText('Dispute')).toBeInTheDocument(); - }); - // A debater the debate does not record on either side gets no tag rather than a guessed one. it('draws no side tag when the debate records no side for the speaker', () => { renderRow({ speakerPosition: null }); @@ -188,11 +181,11 @@ describe('ExtractedClaimRow', () => { expect(screen.queryByText('Disagree')).not.toBeInTheDocument(); }); - it('passes the resolved response kind and trailing avatars to the vote control', () => { - renderRow({ responseKind: 'veracity' }); + it('passes the response kind and trailing avatars to the vote control', () => { + renderRow({ responseKind: 'curation' }); const votes = screen.getByTestId('vote-buttons'); - expect(votes).toHaveAttribute('data-kind', 'veracity'); + expect(votes).toHaveAttribute('data-kind', 'curation'); expect(votes).toHaveAttribute('data-avatars', 'trailing'); }); @@ -213,34 +206,6 @@ describe('ExtractedClaimRow', () => { expect(screen.getByText('Practical effects age better than CGI.')).toBeInTheDocument(); }); - /** - * A vote is published *as* a kind — thumbs write a stance response, chevrons a veracity one — so a - * control drawn on a guessed kind can write the wrong kind of answer into the graph, which nothing - * later undoes. The batch that resolves the kind is a separate request from the claims themselves, - * and this row used to default to `stance` for the whole time it was in flight. - */ - describe('the response kind it has not been told yet', () => { - it('holds the control while the batch is still resolving', () => { - renderRow({ isResponseKindPending: true }); - - expect(screen.queryByTestId('vote-buttons')).not.toBeInTheDocument(); - }); - - it('asks the control to resolve the kind itself once the batch has settled without an answer', () => { - renderRow({ responseKind: null }); - - // `undefined` is what tells `EntityVoteButtons` to read the kind off the entity. Anything else - // here — `stance` above all — is this row inventing an answer it was not given. - expect(screen.getByTestId('vote-buttons')).toHaveAttribute('data-kind', 'undefined'); - }); - - it('passes the kind straight through when the batch did answer', () => { - renderRow({ responseKind: 'veracity' }); - - expect(screen.getByTestId('vote-buttons')).toHaveAttribute('data-kind', 'veracity'); - }); - }); - it('shows the server-counted comments rather than seeding the button at zero', () => { renderRow({ commentCount: 4 }); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index beb86222be..310233a89c 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -4,7 +4,6 @@ import * as React from 'react'; import cx from 'classnames'; -import type { DebateResponseKind } from '~/core/debates/api'; import { useOpenDebaterProfile } from '~/core/debates/browse/use-open-debater-profile'; import { formatTimecode, isAssertableMoment } from '~/core/debates/claim-timing'; import { debateSeekSeconds, withDebateTimecode } from '~/core/debates/debate-timecode'; @@ -13,7 +12,6 @@ import type { ResponseKind } from '~/core/responses/entity-response'; import { NavUtils } from '~/core/utils/utils'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; -import { Skeleton } from '~/design-system/skeleton'; import { type CommentDensity, @@ -55,10 +53,8 @@ export function ExtractedClaimRow({ debateId, debateSpaceId, responseKind, - isResponseKindPending = false, speaker, speakerPosition, - responseVocabulary, commentCount = 0, depth, density = PAGE_DENSITY, @@ -76,15 +72,13 @@ export function ExtractedClaimRow({ * difference between one lookup and one per row. */ /** - * How this claim is answered — thumbs or chevrons — resolved in a batch by the caller. + * How this claim is answered. * - * `null` means that batch has no answer for this claim, and the control is then asked to resolve - * the kind from the entity itself. Deliberately not defaulted to `stance`: a vote is published *as* - * a kind, so a control drawn on a guess writes the wrong kind of answer into the graph. + * Stated by the caller rather than looked up, which saves the control a read of the entity. Every + * claim is `stance` since #2541; it stays a parameter because the control's own domain still has + * `curation` in it and a row that knows better should be able to say so. */ - responseKind: ResponseKind | null; - /** The caller's batch is still in flight, so the vote control holds rather than guessing. */ - isResponseKindPending?: boolean; + responseKind: ResponseKind; /** The debater this turn is attributed to, or null on a block with no `Authors` relation. */ speaker: (SpeakerProfile & { spaceId: string }) | null; /** @@ -96,7 +90,6 @@ export function ExtractedClaimRow({ */ speakerPosition: boolean | null; /** The claim page's vocabulary, so the tag reads Agree/Disagree or Verify/Dispute to match. */ - responseVocabulary: DebateResponseKind; /** * How many comments the claim has, counted by the server. * @@ -187,11 +180,7 @@ export function ExtractedClaimRow({ name it follows; the kind belongs to the row. Reading them the other way round put a label about the row between a name and the fact about that name. */} {speakerPosition !== null && ( - + )} @@ -239,26 +228,16 @@ export function ExtractedClaimRow({ onToggle={() => setCommentsCollapsed(collapsed => !collapsed)} /> )} - {isResponseKindPending ? ( - // The same shape the control holds for its own unresolved kind, so the row does not - // reflow when the real one arrives. - - ) : ( - - )} + { player.update(); expect(player.seeks).toEqual([]); + }); +}); + describe('the round it is playing', () => { const at = (playheadSeconds: number, extra: { playing?: boolean } = {}) => diff --git a/apps/web/partials/comments/comment-activity-rows.test.tsx b/apps/web/partials/comments/comment-activity-rows.test.tsx index e2037c350e..5e2ef1c757 100644 --- a/apps/web/partials/comments/comment-activity-rows.test.tsx +++ b/apps/web/partials/comments/comment-activity-rows.test.tsx @@ -54,7 +54,8 @@ vi.mock('~/core/debates/use-debate-votes', () => ({ useDebateVotesByVoter: () => vi.mock('~/core/governance/use-proposal-comment-attribution', () => ({ useProposalCommentAttribution: () => new Map(), })); -vi.mock('~/core/state/sign-in-prompt-store', () => ({ useSignInPrompt: () => ({ open: vi.fn() }) })); +// #2583 deleted the sign-up interstitial and its store; every gate now opens Privy directly. +vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => vi.fn() })); vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ EntityVoteButtons: () => null })); vi.mock('~/core/claims/browse/claim-comment-position', () => ({ ClaimCommentPositionBadge: () => null })); vi.mock('~/core/state/editor/markdown-render', () => ({ renderMarkdownDocument: (text: string) => text })); diff --git a/apps/web/partials/comments/inline-comment-composer.test.tsx b/apps/web/partials/comments/inline-comment-composer.test.tsx new file mode 100644 index 0000000000..75018ea99d --- /dev/null +++ b/apps/web/partials/comments/inline-comment-composer.test.tsx @@ -0,0 +1,70 @@ +import '@testing-library/jest-dom/vitest'; +import { act, renderHook } from '@testing-library/react'; + +import { describe, expect, it } from 'vitest'; + +import { useInlineComposer } from './inline-comment-composer'; + +/** + * Whether a row should be holding its branch open for something the reader wrote. + * + * The rows gate real work on this: `DebateActivityRow` draws its spine and both collapse controls on + * it, `ExtractedClaimRow` mounts its replies on it, and `DebateBranch` lifts the gate on its comments + * query with it — because a comment written a second ago is a branch the server aggregate has not + * heard about yet. + * + * As a latching flag that meant a *rejected* publish left all three enabled for a comment that had + * been taken back out: a collapse control that collapsed nothing, and a fetch looking for a row that + * no longer existed. + */ +describe('useInlineComposer', () => { + it('holds nothing open until something is posted', () => { + const { result } = renderHook(() => useInlineComposer()); + + expect(result.current.hasPosted).toBe(false); + }); + + it('holds the branch open once a comment is posted, and closes the box', () => { + const { result } = renderHook(() => useInlineComposer()); + + act(() => result.current.open()); + expect(result.current.isComposing).toBe(true); + + act(() => result.current.markPosted('comment-1')); + + expect(result.current.hasPosted).toBe(true); + expect(result.current.isComposing).toBe(false); + }); + + it('stops holding it when that publish is rejected', () => { + const { result } = renderHook(() => useInlineComposer()); + + act(() => result.current.markPosted('comment-1')); + act(() => result.current.markPostRejected()); + + expect(result.current.hasPosted).toBe(false); + }); + + // A count rather than a flag, so one rejection does not take the branch away from a comment that + // stood. This is the case a boolean cannot represent in either direction. + it('keeps holding it for an earlier comment that stood', () => { + const { result } = renderHook(() => useInlineComposer()); + + act(() => result.current.markPosted('comment-1')); + act(() => result.current.markPosted('comment-2')); + act(() => result.current.markPostRejected()); + + expect(result.current.hasPosted).toBe(true); + }); + + it('never goes below nothing, however many rejections arrive', () => { + const { result } = renderHook(() => useInlineComposer()); + + act(() => result.current.markPosted('comment-1')); + act(() => result.current.markPostRejected()); + act(() => result.current.markPostRejected()); + act(() => result.current.markPosted('comment-2')); + + expect(result.current.hasPosted).toBe(true); + }); +}); diff --git a/apps/web/partials/comments/inline-comment-composer.tsx b/apps/web/partials/comments/inline-comment-composer.tsx index 9b5ca2c19a..0c4e63d3c6 100644 --- a/apps/web/partials/comments/inline-comment-composer.tsx +++ b/apps/web/partials/comments/inline-comment-composer.tsx @@ -2,9 +2,9 @@ import * as React from 'react'; +import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { usePublishComment } from '~/core/hooks/use-publish-comment'; import { useSmartAccount } from '~/core/hooks/use-smart-account'; -import { useSignInPrompt } from '~/core/state/sign-in-prompt-store'; import { useAdjustActivityPosts } from './activity-posts'; import { CommentInput } from './comments-section'; @@ -59,9 +59,9 @@ export function InlineCommentComposer({ interactionSurface: 'activity_feed', }); const { smartAccount } = useSmartAccount(); - const { open: openSignInPrompt } = useSignInPrompt(); + const promptSignIn = usePrivySignIn(); const isSignedIn = !!smartAccount; - const { isComposing, close, markPosted } = composer; + const { isComposing, close, markPosted, markPostRejected } = composer; const adjustActivityPosts = useAdjustActivityPosts(); // Asked before the box opens, not after a draft is typed into it. The thread's own composer @@ -69,7 +69,7 @@ export function InlineCommentComposer({ // types a paragraph and is then asked to sign in loses the paragraph. React.useEffect(() => { if (!isComposing || isSignedIn) return; - openSignInPrompt('comment'); + promptSignIn(); close(); // Only on the transition into a signed-out open composer; `close` is a fresh closure each render. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -98,8 +98,13 @@ export function InlineCommentComposer({ }, }).then(result => { // A rejected transaction takes the optimistic row back out, so the heading has to give - // back the one it just counted. A retained publish returns a result and keeps its row. - if (!result) adjustActivityPosts(-1); + // back the one it just counted — and this row has to stop holding a branch open for a + // comment that is no longer there. A retained publish returns a result and keeps its row, + // so it keeps both. + if (!result) { + markPostRejected(); + adjustActivityPosts(-1); + } }); close(); }} @@ -119,22 +124,35 @@ export type InlineComposerState = ReturnType; export function useInlineComposer() { const [isComposing, setIsComposing] = React.useState(false); - // Sticky: once something has been posted here the row keeps showing its thread, even while the - // server count that gated it is still zero. - const [hasPosted, setHasPosted] = React.useState(false); + /** + * How many comments written here are still standing. + * + * Sticky while it is above zero: the row keeps showing its thread even while the server count that + * gated it is still reporting nothing, because the aggregate has not heard about a comment written + * a second ago. + * + * A count rather than a flag, because a publish can be rejected. As a flag it latched true and + * stayed there, so a rejected first comment left the row holding a branch with nothing in it — a + * collapse control that collapsed nothing, and a comments fetch enabled to look for a comment that + * had been taken back out. Counting means a rejection can undo exactly its own post and a reader + * who published two comments and lost one keeps the branch for the one that stood. + */ + const [postedCount, setPostedCount] = React.useState(0); return React.useMemo( () => ({ isComposing, - hasPosted, + hasPosted: postedCount > 0, toggle: () => setIsComposing(open => !open), open: () => setIsComposing(true), close: () => setIsComposing(false), markPosted: (_commentId?: string) => { - setHasPosted(true); + setPostedCount(posted => posted + 1); setIsComposing(false); }, + /** The transaction was rejected and the optimistic row is gone, so this post no longer counts. */ + markPostRejected: () => setPostedCount(posted => Math.max(0, posted - 1)), }), - [hasPosted, isComposing] + [isComposing, postedCount] ); } From cce8597e8d7d134b540a546e1aa531a562549278 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 26 Sep 2026 11:48:12 -0700 Subject: [PATCH 28/35] fix(claims): a cancel as narrow as the write, and a debates read that can fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both a read whose failure is indistinguishable from an answer. The cancel added last round matched queries on the key while the write matched on having an entry, so the two disagreed about what they were talking about. A comment published while the very first aggregate request was still out aborted that request, and then the write bailed for want of a baseline — leaving nothing to answer and the heading on its incomplete fallback until something else refetched. Worse than the race it closed. One predicate now, used for both: the key holds this claim *and* the cached data already has an entry for it. A query with no baseline has nothing to overwrite, so there is no race to cancel and it is left alone to answer for itself, which is what its doc comment always claimed. `useQueryEntities` returns `error` and `refetch` so that a caller drawing an empty state can tell "nothing matched" from "the query never came back" — its own words. The activity rows hook dropped both, so a cold-load failure emptied the feed of every debate and every claim extracted from one, under a heading that is a separate query and went on counting them. The hook returns them and the page draws a retry line. Above the section rather than as a row in it: a synthetic row has no `createdAt`, so it would sort either by lying or into the one place a notice about a missing list is useless. Third instance of that shape, after the count aggregates and the transcript read. Co-Authored-By: Claude Opus 5 (1M context) --- .../browse/claim-activity-count.test.ts | 27 ++++++++++++++ .../claims/browse/claim-activity-count.ts | 31 +++++++++------- .../claims/browse/claim-page-view.test.tsx | 36 +++++++++++++++++-- .../core/claims/browse/claim-page-view.tsx | 14 ++++++++ .../claims/browse/use-claim-activity-rows.tsx | 27 ++++++++++++-- 5 files changed, 118 insertions(+), 17 deletions(-) diff --git a/apps/web/core/claims/browse/claim-activity-count.test.ts b/apps/web/core/claims/browse/claim-activity-count.test.ts index b2bd56e0ab..f80962b1d9 100644 --- a/apps/web/core/claims/browse/claim-activity-count.test.ts +++ b/apps/web/core/claims/browse/claim-activity-count.test.ts @@ -202,6 +202,33 @@ describe('adjustClaimActivityTotal', () => { expect(totalIn(client)).toBe(33); }); + /** + * The cancel has to be as narrow as the write, and it was not. + * + * Matched on the key alone, a comment published while the very first request was still out aborted + * that request — and then the write bailed, because there was no entry to adjust. Nothing was left to + * answer, so the heading stayed on its incomplete fallback until something else happened to refetch. + * A query with no baseline has nothing to overwrite, so there is no race worth cancelling. + */ + it('does not abort the first request, which is the only thing that can answer', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + let landFirstAnswer = (_value: Map) => {}; + const firstRequest = new Promise>(resolve => { + landFirstAnswer = resolve; + }); + const fetching = client.fetchQuery({ queryKey: key, queryFn: () => firstRequest, staleTime: 0 }); + + await adjustClaimActivityTotal(client, CLAIM, 1); + + landFirstAnswer(seeded(33)); + await fetching; + + // The aggregate answered for itself, which is what the delta gives way to when there is no + // baseline to adjust. Aborted, this would be `undefined` and the heading would have nothing. + expect(totalIn(client)).toBe(33); + }); + it('leaves a claim it has no answer for alone rather than inventing one', async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/apps/web/core/claims/browse/claim-activity-count.ts b/apps/web/core/claims/browse/claim-activity-count.ts index d4afe5a4fb..c0c2004ae0 100644 --- a/apps/web/core/claims/browse/claim-activity-count.ts +++ b/apps/web/core/claims/browse/claim-activity-count.ts @@ -119,9 +119,10 @@ export function useClaimActivityCounts(claimIds: string[], enabled = true): Map< * transaction is rejected, and the count has to give back what it counted. A publish merely * *retained* for retry keeps its row, so it keeps its count. * - * A claim with no entry yet is left alone rather than invented: the aggregate is still in flight and - * will answer for itself. That loses a comment published in the few hundred milliseconds before the - * first response lands, which is not long enough to write one. + * A claim with no entry yet is left alone rather than invented — neither written nor cancelled, so the + * request that is still out answers for itself. That loses a comment published in the few hundred + * milliseconds before the first response lands, which is not long enough to write one, and is a far + * better trade than aborting the only request that was going to produce a number at all. * * Asynchronous because it cancels before it writes, which is React Query's own recipe for an * optimistic update and the only thing that closes this race: a refetch already on its way — the one @@ -139,21 +140,27 @@ export async function adjustClaimActivityTotal( if (delta === 0) return; const id = uuidToHex(claimId); - // Every cached set that holds this claim, not one key: the page asks for one claim and other - // surfaces ask for a screenful, and a number that moves on one of them has moved on all of them. - // The predicate keeps that from reaching sets this claim is not in, which matters more for the - // cancel than for the write — abandoning an unrelated claim's request would cost it a refetch. - const heldByThisClaim = { + // Every cached set that *already answered* for this claim — which is both what the write can adjust + // and, exactly, what the cancel is allowed to touch. + // + // Matching on the key alone was wrong in a way that mattered more than the race it was fixing: a + // comment published while the very first request was still out aborted that request, and then the + // write bailed because there was no entry to adjust. Nothing was left to answer, so the heading sat + // on its incomplete fallback until something else happened to refetch. A query with no baseline has + // nothing to overwrite, so there is no race to cancel — it is left alone to answer for itself, which + // is what this comment used to claim happened. + const holdsABaseline = { queryKey: ['claim-activity-counts'] as const, - predicate: (query: { queryKey: readonly unknown[] }) => { + predicate: (query: { queryKey: readonly unknown[]; state: { data?: unknown } }) => { const ids = query.queryKey[1]; - return Array.isArray(ids) && ids.includes(id); + if (!Array.isArray(ids) || !ids.includes(id)) return false; + return (query.state.data as Map | undefined)?.get(id) != null; }, }; - await queryClient.cancelQueries(heldByThisClaim); + await queryClient.cancelQueries(holdsABaseline); - queryClient.setQueriesData>(heldByThisClaim, counts => { + queryClient.setQueriesData>(holdsABaseline, counts => { const current = counts?.get(id); // `undefined` bails out of the update rather than writing anything, so a cached set that does // not hold this claim — or one that has not answered yet — is left untouched instead of being diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index 8a221e67db..6c5f7543bf 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; -import { cleanup, render as renderBare, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render as renderBare, screen } from '@testing-library/react'; import type React from 'react'; @@ -31,6 +31,8 @@ function render(ui: React.ReactElement) { } const mocks = vi.hoisted(() => ({ + activityError: null as Error | null, + retryActivity: vi.fn(), entity: null as Record | null, /** Non-comment rows the Overview orders into its activity thread — the debates on this claim. */ activityRows: [] as Array<{ id: string; createdAt: string; content: unknown }>, @@ -143,7 +145,12 @@ vi.mock('~/core/sync/use-store', () => ({ // What the Overview puts in its activity thread besides comments. Its own fetching — debates, // profiles, keyframes — is covered by the rows' suites; this file is about page composition. vi.mock('./use-claim-activity-rows', () => ({ - useClaimActivityRows: () => ({ rows: mocks.activityRows, isLoading: false }), + useClaimActivityRows: () => ({ + rows: mocks.activityRows, + isLoading: false, + error: mocks.activityError, + retry: mocks.retryActivity, + }), })); // The heading's number, which is the same one the claim's Explore card shows. Its own query is @@ -279,6 +286,8 @@ function claimEntity(description: string | null) { beforeEach(() => { mocks.activityRows = []; + mocks.activityError = null; + mocks.retryActivity.mockClear(); mocks.activityTotal = null; mocks.responseTotal = 11; mocks.summaryLoading = false; @@ -691,6 +700,29 @@ describe('ClaimPageView comments', () => { * the prop was optional, which meant the badges on the claim's *own* comments, the common case and * the one this was added for, explained nothing. */ + /** + * `useQueryEntities` hands back `error` precisely so a caller drawing an empty state can tell + * "nothing matched" from "the query never came back". This hook dropped it, so a cold-load failure + * made the feed omit every debate — and every claim extracted from one — in silence, while the + * heading, which is a separate query, went on counting them. + */ + it('says the debates could not be read rather than drawing a feed without them', () => { + mocks.activityError = new Error('kg timeout'); + + render(); + + expect(screen.getByText(/Couldn’t load the debates on this claim/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(mocks.retryActivity).toHaveBeenCalledOnce(); + }); + + it('says nothing when the debates read simply found none', () => { + render(); + + expect(screen.queryByText(/Couldn’t load the debates/)).not.toBeInTheDocument(); + }); + it('names the claim its side badges are about', () => { render(); diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 65eef6becb..9946ed82c6 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -552,6 +552,20 @@ function ClaimOverviewTab({ viewerSpaceId={summary.viewerSpaceId} isViewerResponseLoading={summary.isViewerResponseLoading} > + {/* + The debates could not be read, so the feed below is missing every debate and every claim + extracted from one — while the heading above it, which is a different query, goes on counting + them. Said here rather than as a row inside the section: it is about the whole list, not a + place in it, and a synthetic row would have to claim a timestamp to sort anywhere sensible. + */} + {activity.error != null && ( +
+ Couldn’t load the debates on this claim.{' '} + +
+ )} {/* "Activity", because the list now holds debates as well as comments — and the count says how much has happened to this claim rather than how many people typed. */} void; +} { + const { + entities: debates, + isLoading, + error, + refetch, + } = useQueryEntities({ where: { types: [{ id: { equals: DEBATE_TYPE_ID } }], spaces: [{ equals: spaceId }], @@ -147,7 +166,9 @@ export function useClaimActivityRows({ spaceId, ]); - return { rows, isLoading }; + const retry = React.useCallback(() => void refetch(), [refetch]); + + return { rows, isLoading, error: (error as Error | null) ?? null, retry }; } /** From 7062ad2178eb362aed995610923d992e01dcb219 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 26 Sep 2026 12:21:58 -0700 Subject: [PATCH 29/35] fix(claims): say when a read failed, everywhere the feed draws emptiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, one shape between them: a request that failed reported as a fact. Three reads answered a failure with the same empty value they answer "nothing here" with, and every caller drew the emptiness silently. The debate's comments became an empty discussion under a row still showing its comment count and collapse control. A claim's replies became an expanded branch with nothing in it, under a count that said there were replies. And the heading's own aggregate, on failure, returned an empty map indistinguishable from "not answered yet" — so the number fell back to this claim's comments plus the rows on screen, which leaves out every extracted claim and every comment nested under a debate, and presented that permanently as the Activity total. Each says so now and offers to retry. `ThreadRetry` is the third member of the family `ThreadShowMore` and `ThreadContinue` already formed — the three answers to "this branch is not showing you rows" — because this markup had been written three times in two rounds. The fourth is the other direction: an optimistic write nobody took back. A publish retained for a personal space that does not exist yet resolves *truthy* and is retried later, so a caller reading the returned value never learns that the retry failed — while `useCreateComment` removes the row. The Activity total stayed one too high and the row kept a branch open for a comment that was gone. Failure is reported through `onFailed` now, from both paths, and neither composer reads the resolved value for it; the policy lives with the publish rather than in each caller. Also swept the rest of that direction across the feature, which was owed: five optimistic writes, two needed a rollback and have one, two are derived from the comment list and correct themselves, and `sessionNewIds` needs none because a rejected comment's id then matches nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .../claims/browse/claim-activity-count.ts | 26 ++++++- .../claims/browse/claim-page-view.test.tsx | 39 +++++++++- .../core/claims/browse/claim-page-view.tsx | 27 ++++--- .../browse/debate-activity-row.test.tsx | 20 +++++- .../claims/browse/debate-activity-row.tsx | 25 ++++--- .../browse/extracted-claim-row.test.tsx | 52 +++++++++++++- .../claims/browse/extracted-claim-row.tsx | 13 +++- .../core/hooks/use-publish-comment.test.ts | 72 +++++++++++++++++++ apps/web/core/hooks/use-publish-comment.ts | 32 +++++++-- .../comments/comment-activity-rows.test.tsx | 14 +++- .../partials/comments/comments-section.tsx | 8 +-- .../comments/inline-comment-composer.tsx | 14 ++-- .../web/partials/comments/thread-overflow.tsx | 36 +++++++++- 13 files changed, 328 insertions(+), 50 deletions(-) diff --git a/apps/web/core/claims/browse/claim-activity-count.ts b/apps/web/core/claims/browse/claim-activity-count.ts index c0c2004ae0..7bda8d09b2 100644 --- a/apps/web/core/claims/browse/claim-activity-count.ts +++ b/apps/web/core/claims/browse/claim-activity-count.ts @@ -70,11 +70,26 @@ export const claimActivityCountsQueryKey = (ids: string[]) => ['claim-activity-c * An absent entry means "not answered yet", not "nothing" — a caller showing a number should hold * whatever it already had rather than flashing a zero it invented. */ -export function useClaimActivityCounts(claimIds: string[], enabled = true): Map { +export function useClaimActivityCounts( + claimIds: string[], + enabled = true +): { + counts: Map; + /** + * The read failed, so an absent entry is not "not answered yet" — nothing is coming. + * + * It used to return the map alone, and a caller could not tell the two apart. The claim page reads + * `?.total` and falls back to its own comments plus debate rows when that is undefined, which is a + * number that leaves out every extracted claim and every comment nested under a debate — presented, + * silently and permanently, as the Activity total. + */ + error: Error | null; + retry: () => void; +} { // Sorted and deduped, so the same claims in a different order are the same query. const ids = React.useMemo(() => [...new Set(claimIds.filter(Boolean).map(uuidToHex))].sort(), [claimIds]); - const { data } = useQuery({ + const { data, error, refetch } = useQuery({ queryKey: claimActivityCountsQueryKey(ids), queryFn: ({ signal }) => Effect.runPromise( @@ -95,7 +110,12 @@ export function useClaimActivityCounts(claimIds: string[], enabled = true): Map< refetchOnWindowFocus: false, }); - return data ?? EMPTY_COUNTS; + const retry = React.useCallback(() => void refetch(), [refetch]); + + return React.useMemo( + () => ({ counts: data ?? EMPTY_COUNTS, error: (error as Error | null) ?? null, retry }), + [data, error, retry] + ); } /** diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index 6c5f7543bf..097bb9e052 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -33,6 +33,8 @@ function render(ui: React.ReactElement) { const mocks = vi.hoisted(() => ({ activityError: null as Error | null, retryActivity: vi.fn(), + activityCountError: null as Error | null, + retryActivityCount: vi.fn(), entity: null as Record | null, /** Non-comment rows the Overview orders into its activity thread — the debates on this claim. */ activityRows: [] as Array<{ id: string; createdAt: string; content: unknown }>, @@ -156,8 +158,12 @@ vi.mock('./use-claim-activity-rows', () => ({ // The heading's number, which is the same one the claim's Explore card shows. Its own query is // covered by `claim-activity-count.test.ts`; here it only needs to reach the heading. vi.mock('./claim-activity-count', () => ({ - useClaimActivityCounts: () => - new Map(mocks.activityTotal == null ? [] : [['claim1', { total: mocks.activityTotal }]]), + useClaimActivityCounts: () => ({ + counts: new Map(mocks.activityTotal == null ? [] : [['claim1', { total: mocks.activityTotal }]]), + error: mocks.activityCountError, + retry: mocks.retryActivityCount, + }), + adjustClaimActivityTotal: vi.fn(), })); vi.mock('~/core/debates/hooks', () => ({ @@ -288,6 +294,8 @@ beforeEach(() => { mocks.activityRows = []; mocks.activityError = null; mocks.retryActivity.mockClear(); + mocks.activityCountError = null; + mocks.retryActivityCount.mockClear(); mocks.activityTotal = null; mocks.responseTotal = 11; mocks.summaryLoading = false; @@ -717,6 +725,33 @@ describe('ClaimPageView comments', () => { expect(mocks.retryActivity).toHaveBeenCalledOnce(); }); + /** + * The heading's aggregate can fail on its own, and its failure was indistinguishable from "not + * answered yet": `totalOverride` came back undefined either way, so the heading fell back to this + * claim's own comments plus the rows it drew — a number that leaves out every extracted claim and + * every comment nested under a debate, presented as the Activity total and never corrected. + */ + it('says when the activity total could not be read', () => { + mocks.activityCountError = new Error('aggregate unavailable'); + + render(); + + expect(screen.getByText(/Couldn’t load this claim’s activity total/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(mocks.retryActivityCount).toHaveBeenCalledOnce(); + }); + + // Each read speaks for itself: one failing says nothing about the other. + it('says only what failed when just the debates read did', () => { + mocks.activityError = new Error('kg timeout'); + + render(); + + expect(screen.getByText(/Couldn’t load the debates on this claim/)).toBeInTheDocument(); + expect(screen.queryByText(/Couldn’t load this claim’s activity total/)).not.toBeInTheDocument(); + }); + it('says nothing when the debates read simply found none', () => { render(); diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 9946ed82c6..7666806241 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -34,6 +34,7 @@ import { Skeleton } from '~/design-system/skeleton'; import { Text } from '~/design-system/text'; import { CommentSection } from '~/partials/comments/comments-section'; +import { ThreadRetry } from '~/partials/comments/thread-overflow'; import { Editor } from '~/partials/editor/editor'; import { EditableHeading } from '~/partials/entity-page/editable-entity-header'; import { @@ -473,7 +474,7 @@ function ClaimOverviewTab({ // The same number the claim's card shows in Explore, from the same query — one definition of // "how much has happened here", so the two surfaces cannot disagree. const activityCounts = useClaimActivityCounts(React.useMemo(() => [entityId], [entityId])); - const activityTotal = activityCounts.get(ID.uuidToHex(entityId))?.total; + const activityTotal = activityCounts.counts.get(ID.uuidToHex(entityId))?.total; // The thread can add to that number but cannot compute it, so the page that owns the aggregate // owns the adjustment too. It lands in the query cache rather than in the section's state, which @@ -553,17 +554,21 @@ function ClaimOverviewTab({ isViewerResponseLoading={summary.isViewerResponseLoading} > {/* - The debates could not be read, so the feed below is missing every debate and every claim - extracted from one — while the heading above it, which is a different query, goes on counting - them. Said here rather than as a row inside the section: it is about the whole list, not a - place in it, and a synthetic row would have to claim a timestamp to sort anywhere sensible. + Two reads feed this section and either can fail on its own, so each says so for itself. + Without the debates the feed is missing every debate and every claim extracted from one; + without the total the heading falls back to this claim's own comments plus the rows it drew, + which leaves out everything nested under a debate and is not the number it appears to be. + Said here rather than as rows inside the section: each is about a whole list rather than a + place in one, and a synthetic row would have to claim a timestamp to sort anywhere sensible. */} - {activity.error != null && ( -
- Couldn’t load the debates on this claim.{' '} - + {(activity.error != null || activityCounts.error != null) && ( +
+ {activity.error != null && ( + Couldn’t load the debates on this claim. + )} + {activityCounts.error != null && ( + Couldn’t load this claim’s activity total. + )}
)} {/* "Activity", because the list now holds debates as well as comments — and the count says diff --git a/apps/web/core/claims/browse/debate-activity-row.test.tsx b/apps/web/core/claims/browse/debate-activity-row.test.tsx index a06ff75a21..7ddef8625a 100644 --- a/apps/web/core/claims/browse/debate-activity-row.test.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.test.tsx @@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({ transcriptError: null as Error | null, retryTranscript: vi.fn(), debateComments: [] as unknown[], + commentsError: null as Error | null, + refetchComments: vi.fn(), })); // The controls have their own suites and both reach for wallet and query context. What this file is @@ -63,7 +65,7 @@ vi.mock('~/core/debates/hooks', () => ({ useDebateClaims: () => ({ data: null }) vi.mock('~/core/hooks/use-comments', () => ({ useComments: ({ entityId, enabled }: { entityId: string; enabled?: boolean }) => { mocks.commentQueries.push({ entityId, enabled: enabled !== false }); - return { comments: mocks.debateComments }; + return { comments: mocks.debateComments, error: mocks.commentsError, refetch: mocks.refetchComments }; }, })); @@ -177,6 +179,8 @@ describe('DebateActivityRow when the transcript will not load', () => { mocks.transcriptError = null; mocks.retryTranscript.mockClear(); mocks.debateComments = []; + mocks.commentsError = null; + mocks.refetchComments.mockClear(); }); afterEach(cleanup); @@ -214,6 +218,20 @@ describe('DebateActivityRow when the transcript will not load', () => { expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); }); + /** + * The comments are their own read, so they fail on their own — and a row whose count says there are + * comments kept that count, and its collapse control, while drawing none of them. + */ + it('says when the comments could not be read, separately from the claims', () => { + mocks.commentsError = new Error('comments unavailable'); + + renderRow({ claimCount: 0, commentCount: 3 }); + + expect(screen.getByText(/Couldn’t load the comments on this debate/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(mocks.refetchComments).toHaveBeenCalledOnce(); + }); + // The ordinary empty debate still says nothing: a line under every old debate is noise, which is // why silence was chosen in the first place. it('stays silent when the transcript loaded and there was simply nothing in it', () => { diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index d45aa316d4..4b332db039 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -24,6 +24,7 @@ import { EntityCommentsButton } from '~/partials/comments/entity-comments-button import { InlineCommentComposer, useInlineComposer } from '~/partials/comments/inline-comment-composer'; import { ThreadCollapseToggle, ThreadParentSpine, useThreadParentSpine } from '~/partials/comments/thread-branch'; import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; +import { ThreadRetry } from '~/partials/comments/thread-overflow'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import { ActivityRowTag } from './activity-row-tag'; @@ -326,7 +327,11 @@ function DebateBranch({ const { timings, isReady } = useClaimTimings(debateId, claims); // The debate's own comments, on the same query key the comments panel uses — so opening the panel // on this debate costs no extra request, and a comment posted there appears here too. - const { comments: debateComments } = useComments({ + const { + comments: debateComments, + error: commentsError, + refetch: refetchComments, + } = useComments({ entityId: debateId, spaceId, // Most debates have no comments, and this feed already knows which. Fetching them anyway would @@ -369,9 +374,12 @@ function DebateBranch({ // this only spoke up when the *whole* branch was empty, which meant a debate that also had comments // drew them and dropped its failed claims without a word, under a row still advertising eighteen. const claimsFailed = claimsError != null && claimsInOrder.length === 0; + // The comments are their own read and fail on their own. Same rule, same sentence, at the end of the + // branch rather than the head — the comments sit after the claims, so that is where their absence is. + const commentsFailed = commentsError != null && debateComments.length === 0; // One row, at the head: it is about the claims, which is what the rest of the branch leads with. const leadingRows = claimsFailed ? 1 : 0; - const rowCount = leadingRows + claimsInOrder.length + debateComments.length; + const rowCount = leadingRows + claimsInOrder.length + debateComments.length + (commentsFailed ? 1 : 0); if (rowCount === 0) return null; @@ -379,12 +387,7 @@ function DebateBranch({ {claimsFailed && ( - - Couldn’t load the claims from this debate.{' '} - - + Couldn’t load the claims from this debate. )} @@ -428,6 +431,12 @@ function DebateBranch({ ); })} + {commentsFailed && ( + + void refetchComments()}>Couldn’t load the comments on this debate. + + )} + {debateComments.map((comment, index) => ( ({ ), })); -const mocks = vi.hoisted(() => ({ openedProfiles: [] as string[] })); +const mocks = vi.hoisted(() => ({ + openedProfiles: [] as string[], + comments: [] as unknown[], + commentsError: null as Error | null, + refetchComments: vi.fn(), +})); + +// The replies branch wraps itself in the position boundary, which reads its claim through the sync +// engine. The side tag from this same module is asserted below, so only the boundary is stood in for. +vi.mock('~/core/claims/browse/claim-comment-position', async importOriginal => ({ + ...((await importOriginal()) as Record), + ClaimCommentPositionBoundary: ({ children }: { children?: React.ReactNode }) => <>{children}, +})); + +vi.mock('~/core/hooks/use-comments', () => ({ + useComments: () => ({ + comments: mocks.comments, + totalCount: mocks.comments.length, + isLoading: false, + error: mocks.commentsError, + refetch: mocks.refetchComments, + }), +})); vi.mock('~/core/debates/browse/use-open-debater-profile', () => ({ useOpenDebaterProfile: (spaceId: string | undefined) => (event: React.MouseEvent) => { @@ -77,6 +99,13 @@ vi.mock('~/partials/comments/inline-comment-composer', async importOriginal => ( }) => (composer.isComposing ?
: null), })); +// Both describes below reset what they use; these are shared, so they are reset once for the file. +afterEach(() => { + mocks.comments = []; + mocks.commentsError = null; + mocks.refetchComments.mockClear(); +}); + function claim(overrides: Partial = {}): OrderedTranscriptClaim { return { id: 'claim-1', @@ -218,6 +247,27 @@ describe('ExtractedClaimRow', () => { * as long as the page stayed open. `null` says the request could not answer, and the row keeps the * thread open on it. */ + /** + * A failed read of the replies reports the same empty list as a claim that has none, so returning + * null on it drew an expanded branch with nothing in it — under a count saying there were replies, + * and with nothing to press. + */ + it('says when the replies could not be read rather than drawing an empty branch', () => { + mocks.commentsError = new Error('replies unavailable'); + + renderRow({ commentCount: 4 }); + + expect(screen.getByText(/Couldn’t load the replies to this claim/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(mocks.refetchComments).toHaveBeenCalledOnce(); + }); + + it('draws nothing extra when the replies loaded and there are none', () => { + renderRow({ commentCount: 4 }); + + expect(screen.queryByText(/Couldn’t load the replies/)).not.toBeInTheDocument(); + }); + it('keeps its comments reachable when the count aggregate could not answer', () => { const { unmount } = renderRow({ commentCount: 0 }); expect(screen.queryByLabelText('Hide comments on this claim')).not.toBeInTheDocument(); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index 310233a89c..a80d31e580 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -24,6 +24,7 @@ import { InlineCommentComposer, useInlineComposer } from '~/partials/comments/in import { ThreadAvatar } from '~/partials/comments/thread-avatar'; import { ThreadCollapseToggle, ThreadParentSpine, useThreadParentSpine } from '~/partials/comments/thread-branch'; import { ThreadBranch, ThreadBranchRow } from '~/partials/comments/thread-branch-list'; +import { ThreadRetry } from '~/partials/comments/thread-overflow'; import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import { ActivityRowTag } from './activity-row-tag'; @@ -314,8 +315,10 @@ function ClaimComments({ onCollapse: () => void; label: { expand: string; collapse: string }; }) { - const { comments } = useComments({ entityId: claimId, spaceId }); - if (comments.length === 0) return null; + const { comments, error, refetch } = useComments({ entityId: claimId, spaceId }); + // Only after a *successful* empty response. A failed read reports the same empty list, so returning + // null on it drew an expanded branch with nothing in it under a count that said there were replies. + if (comments.length === 0 && error == null) return null; return ( // No wrapper of its own. The caller measures where this branch begins in order to stop the spine @@ -329,6 +332,12 @@ function ClaimComments({ onCollapse={onCollapse} label={label} > + {comments.length === 0 ? ( + + void refetch()}>Couldn’t load the replies to this claim. + + ) : null} + {comments.map((comment, index) => ( diff --git a/apps/web/core/hooks/use-publish-comment.test.ts b/apps/web/core/hooks/use-publish-comment.test.ts index 37c07d9b8a..418fcfe12d 100644 --- a/apps/web/core/hooks/use-publish-comment.test.ts +++ b/apps/web/core/hooks/use-publish-comment.test.ts @@ -144,3 +144,75 @@ describe('usePublishComment', () => { expect(mocks.commentEdited).toHaveBeenCalledTimes(1); }); }); + +/** + * Who hears that the comment did not make it. + * + * Callers used to read the returned value, and that only ever sees the first attempt. A publish + * retained for a personal space that does not exist yet resolves *truthy* and is retried later — so + * when that retry failed, `useCreateComment` removed the optimistic row while every count and flag the + * caller had set for it stayed behind: the Activity total one too high, and a row holding a branch open + * for a comment that no longer existed. + */ +describe('usePublishComment reporting a failure', () => { + beforeEach(() => { + mocks.commentCreated.mockReset(); + mocks.createComment.mockReset(); + mocks.enqueuePendingAction.mockReset(); + }); + + function publish(onFailed: () => void) { + const { result } = renderHook(() => usePublishComment('claim-1', 'space-1')); + return act(() => result.current.publishComment({ text: 'A comment', onFailed }) as Promise); + } + + it('reports a publish that failed immediately', async () => { + mocks.createComment.mockResolvedValue(null); + const onFailed = vi.fn(); + + await publish(onFailed); + + expect(onFailed).toHaveBeenCalledOnce(); + }); + + it('does not report one that succeeded', async () => { + mocks.createComment.mockResolvedValue({ id: 'comment-1', published: true }); + const onFailed = vi.fn(); + + await publish(onFailed); + + expect(onFailed).not.toHaveBeenCalled(); + }); + + // The case the returned value cannot express: retained now, failed later. + it('reports a retained publish whose retry fails, and not before', async () => { + mocks.createComment.mockResolvedValue({ id: 'comment-1', published: false }); + const onFailed = vi.fn(); + + await publish(onFailed); + + // Retained, so nothing has failed yet: the row and its counts are still right. + expect(onFailed).not.toHaveBeenCalled(); + expect(mocks.enqueuePendingAction).toHaveBeenCalledOnce(); + + const queued = mocks.enqueuePendingAction.mock.calls[0]![0] as { run: () => Promise }; + mocks.createComment.mockResolvedValue(null); + + await expect(queued.run()).rejects.toThrow('Comment could not be published'); + expect(onFailed).toHaveBeenCalledOnce(); + }); + + it('says nothing when the retry succeeds', async () => { + mocks.createComment.mockResolvedValue({ id: 'comment-1', published: false }); + const onFailed = vi.fn(); + + await publish(onFailed); + + const queued = mocks.enqueuePendingAction.mock.calls[0]![0] as { run: () => Promise }; + mocks.createComment.mockResolvedValue({ id: 'comment-1', published: true }); + + await queued.run(); + + expect(onFailed).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/core/hooks/use-publish-comment.ts b/apps/web/core/hooks/use-publish-comment.ts index 01774335f7..f25715b613 100644 --- a/apps/web/core/hooks/use-publish-comment.ts +++ b/apps/web/core/hooks/use-publish-comment.ts @@ -11,6 +11,16 @@ import { useCreateComment } from './use-create-comment'; type PublishCommentInput = Pick & { onOptimistic?: (commentId: string) => void; + /** + * The comment did not make it, and `useCreateComment` has taken its optimistic row back out. + * + * Both ways that can happen report here, which is the point: callers were checking the returned + * value, and that only sees the *first* attempt. A publish retained for a personal space that does + * not exist yet returns truthy and is retried later — and when that retry failed, the row vanished + * while every count and flag the caller had set for it stayed behind. There is no way for a caller + * to observe that from the promise it was handed, so the policy belongs on this side of the call. + */ + onFailed?: () => void; }; type CommentAnalyticsContext = { @@ -35,7 +45,7 @@ export function usePublishComment( const enqueuePendingAction = useEnqueuePendingAction(); const publishComment = React.useCallback( - async ({ text, ancestorComments, onOptimistic }: PublishCommentInput) => { + async ({ text, ancestorComments, onOptimistic, onFailed }: PublishCommentInput) => { const recordIfPublished = (result: Awaited>) => { if (!result?.published) return false; @@ -60,22 +70,30 @@ export function usePublishComment( onOptimistic, }); - if (!result) return result; + if (!result) { + onFailed?.(); + return result; + } if (recordIfPublished(result)) return result; enqueuePendingAction({ id: `comment:${targetEntityId}:${result.id}`, label: 'your comment', requires: 'personalSpace', - run: () => - createComment({ + run: async () => { + const published = await createComment({ text, targetSpaceId, ancestorComments, commentId: result.id, - }).then(published => { - if (!recordIfPublished(published)) throw new Error('Comment could not be published'); - }), + }); + if (recordIfPublished(published)) return; + + // The row is already gone — `useCreateComment` removes it on a failed publish — so anything + // the caller counted for this comment has to come back before the error is surfaced. + onFailed?.(); + throw new Error('Comment could not be published'); + }, }); return result; diff --git a/apps/web/partials/comments/comment-activity-rows.test.tsx b/apps/web/partials/comments/comment-activity-rows.test.tsx index 5e2ef1c757..75020646c2 100644 --- a/apps/web/partials/comments/comment-activity-rows.test.tsx +++ b/apps/web/partials/comments/comment-activity-rows.test.tsx @@ -26,9 +26,17 @@ const mocks = vi.hoisted(() => ({ * rejected and it has taken the optimistic row back out, which is the rollback signal. */ publishResult: { id: 'new-comment' } as unknown, - /** Stands in for the optimistic row a real publish writes, which is what tells the section. */ - publishComment: vi.fn((input: { onOptimistic?: (id: string) => void }) => { + /** + * Stands in for the optimistic row a real publish writes, which is what tells the section. + * + * It reports a failure through `onFailed` because that is what the real `usePublishComment` does — + * and it has to, since a publish can also fail *after* resolving, when one retained for a personal + * space that does not exist yet loses its retry. A stub that only resolved falsy would let the + * section stop listening to the callback without any test noticing. + */ + publishComment: vi.fn((input: { onOptimistic?: (id: string) => void; onFailed?: () => void }) => { input.onOptimistic?.('new-comment'); + if (!mocks.publishResult) input.onFailed?.(); return Promise.resolve(mocks.publishResult); }), })); @@ -280,7 +288,7 @@ function ActivityHost({ claimId }: { claimId: string }) { spaceId="space-1" title="Activity" activityRows={[]} - totalOverride={counts.get(uuidToHex(claimId))?.total} + totalOverride={counts.counts.get(uuidToHex(claimId))?.total} onActivityPublish={delta => adjustClaimActivityTotal(queryClient, claimId, delta)} /> ); diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index c2fe4989e3..b842c04804 100644 --- a/apps/web/partials/comments/comments-section.tsx +++ b/apps/web/partials/comments/comments-section.tsx @@ -406,10 +406,10 @@ export function CommentSection({ markSessionNew(commentId); adjustActivityPosts(1); }, - }).then(result => { - // Rolled back by `useCreateComment` when the transaction is rejected, so the heading gives - // back what it counted. A publish retained for retry keeps both its row and its count. - if (!result) adjustActivityPosts(-1); + // `useCreateComment` takes the optimistic row back out on a failed publish, so the heading + // gives back what it counted. Reported rather than read off the returned value, because a + // publish retained for a personal space that does not exist yet fails later than that value. + onFailed: () => adjustActivityPosts(-1), }); }, [adjustActivityPosts, markSessionNew, publishComment, smartAccount] diff --git a/apps/web/partials/comments/inline-comment-composer.tsx b/apps/web/partials/comments/inline-comment-composer.tsx index 0c4e63d3c6..25cf5ea04e 100644 --- a/apps/web/partials/comments/inline-comment-composer.tsx +++ b/apps/web/partials/comments/inline-comment-composer.tsx @@ -96,15 +96,15 @@ export function InlineCommentComposer({ // its own. adjustActivityPosts(1); }, - }).then(result => { - // A rejected transaction takes the optimistic row back out, so the heading has to give - // back the one it just counted — and this row has to stop holding a branch open for a - // comment that is no longer there. A retained publish returns a result and keeps its row, - // so it keeps both. - if (!result) { + // Whichever way it fails, and there are two: the transaction rejected now, or a publish + // retained for a personal space that does not exist yet failing its retry later. Both take + // the optimistic row back out, so the heading gives back the one it counted and this row + // stops holding a branch open for a comment that is no longer there. Reading the returned + // value only ever saw the first of the two. + onFailed: () => { markPostRejected(); adjustActivityPosts(-1); - } + }, }); close(); }} diff --git a/apps/web/partials/comments/thread-overflow.tsx b/apps/web/partials/comments/thread-overflow.tsx index 646185bd07..3f02ef3d28 100644 --- a/apps/web/partials/comments/thread-overflow.tsx +++ b/apps/web/partials/comments/thread-overflow.tsx @@ -9,7 +9,7 @@ import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { PAGE_DENSITY } from './comment-density'; /** - * The two things a thread can say when it has more than it is drawing. + * The three things a thread can say when it is not drawing rows. * * Reddit keeps these apart and it is worth copying, because they answer different questions: * @@ -91,3 +91,37 @@ export function ThreadContinue({ ); } + +/** + * A read that failed where rows were expected. + * + * Third member of this family, and the reason it lives here: {@link ThreadShowMore} and + * {@link ThreadContinue} are the other two answers to "this branch is not showing you rows", and a + * failure is the third. It arrived three times in two review rounds — the debate's transcript, the + * debate's comments, a claim's replies — because every one of those reads reports a failure as an + * empty list, and a branch that draws emptiness silently says the discussion is over when it could not + * be read at all. + * + * The retry is the point. Without one a failed fetch reads as "fetched, nothing there" for as long as + * the page stays open, which is the same sentence `useQueryEntities` uses to explain why it hands its + * callers a `refetch`. + */ +export function ThreadRetry({ + children, + onRetry, + className, +}: { + /** What could not be read, as a sentence. */ + children: React.ReactNode; + onRetry: () => void; + className?: string; +}) { + return ( + + {children}{' '} + + + ); +} From 56751ea68f5516e427f0be3bc574d094826535fe Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 26 Sep 2026 12:52:57 -0700 Subject: [PATCH 30/35] fix(claims): count a comment once, wherever it was written from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings. The first is a defect in last round's fix and the worst of the three, because its error grows. `PendingActionsRunner` keeps a failed action queued and its error toast offers a retry that runs it again, so the queued publish is not once-only. Reporting the failure per attempt took the Activity total down once per attempt — unbounded drift from a single comment — and a retry that finally succeeded never gave it back. The two callbacks are a transition on one flag now: the caller is told at most once that the row is gone, and told again when a retry puts it back, so the net is always either zero or one. The claim page has two surfaces that publish an ordinary comment on the claim and only one of them was counted. The hero's position explanation goes through `usePublishComment` like the thread's composers, but the adjustment was written inline beside the thread — so explaining a position left the heading one behind, which is exactly the bug the adjustment exists to fix, on the other composer. It is a hook both surfaces call now, rather than a block one of them happened to have. And a signed-out press on an inline composer was discarded: it opened Privy, closed the box and forgot, so a reader who signed in came back to a shut box and had to find the control again. `CommentSection` already keeps that intent in `pendingCommentComposerAtom`; this uses the same atom, keyed by the reply as well as the entity so one press cannot open both a debate's composer and a reply composer under one of its comments. Two existing tests needed updating rather than the code: the hero composer's asserted exactly the publish input, and the section's stub of `usePublishComment` resolved falsy without reporting it. Both now state the contract they stand in for. Co-Authored-By: Claude Opus 5 (1M context) --- .../claims/browse/claim-page-view.test.tsx | 20 +++- .../core/claims/browse/claim-page-view.tsx | 36 +++++-- .../browse/claim-position-comment.test.tsx | 31 +++++- .../claims/browse/claim-position-comment.tsx | 17 +++- .../core/hooks/use-publish-comment.test.ts | 46 +++++++++ apps/web/core/hooks/use-publish-comment.ts | 35 ++++++- .../comments/inline-comment-composer.test.tsx | 97 ++++++++++++++++++- .../comments/inline-comment-composer.tsx | 30 +++++- 8 files changed, 288 insertions(+), 24 deletions(-) diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index 097bb9e052..67d18a00d9 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -35,6 +35,7 @@ const mocks = vi.hoisted(() => ({ retryActivity: vi.fn(), activityCountError: null as Error | null, retryActivityCount: vi.fn(), + adjustActivityTotal: vi.fn(), entity: null as Record | null, /** Non-comment rows the Overview orders into its activity thread — the debates on this claim. */ activityRows: [] as Array<{ id: string; createdAt: string; content: unknown }>, @@ -163,7 +164,7 @@ vi.mock('./claim-activity-count', () => ({ error: mocks.activityCountError, retry: mocks.retryActivityCount, }), - adjustClaimActivityTotal: vi.fn(), + adjustClaimActivityTotal: mocks.adjustActivityTotal, })); vi.mock('~/core/debates/hooks', () => ({ @@ -296,6 +297,7 @@ beforeEach(() => { mocks.retryActivity.mockClear(); mocks.activityCountError = null; mocks.retryActivityCount.mockClear(); + mocks.adjustActivityTotal.mockClear(); mocks.activityTotal = null; mocks.responseTotal = 11; mocks.summaryLoading = false; @@ -731,6 +733,22 @@ describe('ClaimPageView comments', () => { * claim's own comments plus the rows it drew — a number that leaves out every extracted claim and * every comment nested under a debate, presented as the Activity total and never corrected. */ + /** + * The claim page has two surfaces that publish an ordinary comment on the claim — the thread's own + * composers and the hero's position explanation — and only the first was wired to the heading's + * aggregate, which the heading prefers unconditionally. So explaining a position left the number one + * behind. + */ + it('routes the hero explanation into the same activity count the thread uses', () => { + render(); + + const report = mocks.positionControl?.onActivityPublish as ((delta: number) => void) | undefined; + report?.(1); + + // The same cache the thread's composers move, keyed by this claim — not a second counter. + expect(mocks.adjustActivityTotal).toHaveBeenCalledWith(expect.anything(), 'claim-1', 1); + }); + it('says when the activity total could not be read', () => { mocks.activityCountError = new Error('aggregate unavailable'); diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 7666806241..85f150df43 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -449,6 +449,26 @@ function ClaimTabPanel({ * Its own component because the panel above returns early for every other tab, and a hook cannot * live behind an early return. */ +/** + * Reports a comment this page published into the Activity heading's number. + * + * Every composer on the page needs it and none of them can compute the number: it is a server + * aggregate over the claim's debates, the claims extracted from them and every comment in that tree. + * Two surfaces publish an ordinary comment on the claim — the thread's own composers and the hero's + * position explanation — and the second was missed while this was written inline beside the first. + * + * The adjustment lands in the query cache rather than in a component's state, which is what makes it + * survive a remount and stay behind when the reader walks to the next claim. See + * {@link adjustClaimActivityTotal}. + */ +function useAdjustActivityTotal(claimId: string): (delta: number) => void { + const queryClient = useQueryClient(); + return React.useCallback( + (delta: number) => void adjustClaimActivityTotal(queryClient, claimId, delta), + [claimId, queryClient] + ); +} + function ClaimOverviewTab({ entityId, spaceId, @@ -476,15 +496,7 @@ function ClaimOverviewTab({ const activityCounts = useClaimActivityCounts(React.useMemo(() => [entityId], [entityId])); const activityTotal = activityCounts.counts.get(ID.uuidToHex(entityId))?.total; - // The thread can add to that number but cannot compute it, so the page that owns the aggregate - // owns the adjustment too. It lands in the query cache rather than in the section's state, which - // is what makes it survive the section remounting and stay behind when the reader walks to the - // next claim — see `adjustClaimActivityTotal`. - const queryClient = useQueryClient(); - const adjustActivityTotal = React.useCallback( - (delta: number) => void adjustClaimActivityTotal(queryClient, entityId, delta), - [entityId, queryClient] - ); + const adjustActivityTotal = useAdjustActivityTotal(entityId); const activity = useClaimActivityRows({ claimId: entityId, spaceId }); @@ -641,6 +653,9 @@ function ClaimPositionSection({ }); const indexedPosition = trustedIndexedPosition(state.summary, control.isResponsePending); useBackfillReadinessForHeldPosition({ readiness: row, entityId, spaceId, indexedPosition }); + // The same number the thread's composers move: an explanation published here is a comment on this + // claim, and the heading's aggregate counts it. + const adjustActivityTotal = useAdjustActivityTotal(entityId); return ( // No card of its own: it renders in the hero's left column, under the claim. @@ -652,6 +667,9 @@ function ClaimPositionSection({ responseKind={CLAIM_RESPONSE_KIND} viewerPosition={control.viewerPosition} onRespond={control.respond} + // The explanation published here is a comment on this claim, so it belongs in the same number + // the thread's own composers move — see `adjustClaimActivityTotal`. + onActivityPublish={adjustActivityTotal} promptForComment={control.isConnected} disabled={!control.canRespond} pending={control.isResponsePending} 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 0500d73351..3dedb785a1 100644 --- a/apps/web/core/claims/browse/claim-position-comment.test.tsx +++ b/apps/web/core/claims/browse/claim-position-comment.test.tsx @@ -40,12 +40,14 @@ describe('ClaimPositionCommentControl', () => { onRespond = vi.fn(), responseKind = 'stance', positionRowEndSlot, + onActivityPublish, }: { viewerPosition?: boolean | null; promptForComment?: boolean; onRespond?: (position: boolean) => void; responseKind?: 'stance'; positionRowEndSlot?: ReactNode; + onActivityPublish?: (delta: number) => void; } = {}) { render( { viewerPosition={viewerPosition} onRespond={onRespond} promptForComment={promptForComment} + onActivityPublish={onActivityPublish} positionRowEndSlot={positionRowEndSlot} /> ); @@ -238,13 +241,35 @@ describe('ClaimPositionCommentControl', () => { fireEvent.click(screen.getByRole('button', { name: 'Comment' })); await waitFor(() => expect(mocks.createComment).toHaveBeenCalledTimes(1)); - expect(mocks.createComment).toHaveBeenCalledWith({ - text: 'Because the evidence supports it.', - }); + expect(mocks.createComment).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Because the evidence supports it.' }) + ); expect(onRespond).toHaveBeenCalledTimes(1); expect(order).toEqual(['response', 'comment']); }); + /** + * On the claim page this comment lands inside the Activity heading's server aggregate, which the + * heading prefers over anything the comment caches can say — so a composer that does not report + * leaves the number one behind. Only the claim page passes a reporter; the Explore card's pill is + * derived from the comment list and corrects itself. + */ + it('reports the explanation to a host that is counting, and takes it back if it fails', async () => { + const reported: number[] = []; + mocks.createComment.mockImplementation(async (input: { onOptimistic?: () => void; onFailed?: () => void }) => { + input.onOptimistic?.(); + input.onFailed?.(); + return { id: 'comment-1', published: true }; + }); + renderControl({ onActivityPublish: (delta: number) => void reported.push(delta) }); + + fireEvent.click(screen.getByRole('button', { name: 'Agree' })); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Because the evidence supports it.' } }); + fireEvent.click(screen.getByRole('button', { name: 'Comment' })); + + await waitFor(() => expect(reported).toEqual([1, -1])); + }); + it('keeps a failed comment draft open so it can be retried', async () => { mocks.createComment.mockResolvedValue(null); renderControl(); diff --git a/apps/web/core/claims/browse/claim-position-comment.tsx b/apps/web/core/claims/browse/claim-position-comment.tsx index ef028c05b6..94652fef5f 100644 --- a/apps/web/core/claims/browse/claim-position-comment.tsx +++ b/apps/web/core/claims/browse/claim-position-comment.tsx @@ -45,6 +45,7 @@ export function ClaimPositionCommentControl({ noteFor, positionRowClassName, positionRowEndSlot, + onActivityPublish, }: { entityId: string; spaceId: string; @@ -54,6 +55,16 @@ export function ClaimPositionCommentControl({ onRespond: (position: boolean) => void; /** False while signed out; the first click should open sign-in rather than an unusable composer. */ promptForComment: boolean; + /** + * A comment appearing or disappearing here, for a host whose own count cannot see it. + * + * This composer publishes an ordinary top-level comment on the claim, and on the claim page that + * comment is inside the Activity heading's server aggregate — which nothing in the comment caches + * can move. The heading prefers that aggregate unconditionally, so without this it stayed one + * behind after a reader explained their position. Omitted on the Explore card, whose pill is + * derived from the comment list and corrects itself. + */ + onActivityPublish?: (delta: number) => void; disabled?: boolean; /** The viewer's response is still confirming; presses are dropped. See `PositionRow`. */ pending?: boolean; @@ -130,7 +141,11 @@ export function ClaimPositionCommentControl({ const text = comment.trim(); if (!text) return; setIsSubmitting(true); - const result = await submitComment({ text }); + const result = await submitComment({ + text, + onOptimistic: () => onActivityPublish?.(1), + onFailed: () => onActivityPublish?.(-1), + }); // A failed publish leaves the draft available to retry. Successful and queued comments already // have an optimistic row in the thread; closing here hands the reader from the composer to it. diff --git a/apps/web/core/hooks/use-publish-comment.test.ts b/apps/web/core/hooks/use-publish-comment.test.ts index 418fcfe12d..b8c7c99ceb 100644 --- a/apps/web/core/hooks/use-publish-comment.test.ts +++ b/apps/web/core/hooks/use-publish-comment.test.ts @@ -202,6 +202,52 @@ describe('usePublishComment reporting a failure', () => { expect(onFailed).toHaveBeenCalledOnce(); }); + /** + * `PendingActionsRunner` keeps a failed action queued and its error toast offers a retry that runs it + * again, so this is not once-only. Reported per attempt, one comment took the count down twice, three + * times, as many times as the reader pressed retry — and a retry that finally succeeded never gave it + * back. The two callbacks are a transition, not an event. + */ + it('reports the loss once however many retries fail, and reports it back when one succeeds', async () => { + // `useCreateComment` inserts the optimistic row and calls `onOptimistic` when it is handed one, + // which is what the first attempt passes and the retries do not. + let nextResult: unknown = { id: 'comment-1', published: false }; + mocks.createComment.mockImplementation(async (input: { onOptimistic?: (id: string) => void }) => { + input.onOptimistic?.('comment-1'); + return nextResult; + }); + + const counted: number[] = []; + const { result } = renderHook(() => usePublishComment('claim-1', 'space-1')); + + await act(() => + result.current.publishComment({ + text: 'A comment', + onOptimistic: () => void counted.push(1), + onFailed: () => void counted.push(-1), + }) + ); + + expect(counted).toEqual([1]); + + const queued = mocks.enqueuePendingAction.mock.calls[0]![0] as { run: () => Promise }; + nextResult = null; + + await expect(queued.run()).rejects.toThrow(); + await expect(queued.run()).rejects.toThrow(); + await expect(queued.run()).rejects.toThrow(); + + // Three failures, one row: one rollback. + expect(counted).toEqual([1, -1]); + + nextResult = { id: 'comment-1', published: true }; + await queued.run(); + + // The comment exists again, so it counts again — and the net is back to one. + expect(counted).toEqual([1, -1, 1]); + expect(counted.reduce((a, b) => a + b, 0)).toBe(1); + }); + it('says nothing when the retry succeeds', async () => { mocks.createComment.mockResolvedValue({ id: 'comment-1', published: false }); const onFailed = vi.fn(); diff --git a/apps/web/core/hooks/use-publish-comment.ts b/apps/web/core/hooks/use-publish-comment.ts index f25715b613..438ef9fd26 100644 --- a/apps/web/core/hooks/use-publish-comment.ts +++ b/apps/web/core/hooks/use-publish-comment.ts @@ -19,6 +19,10 @@ type PublishCommentInput = Pick void; }; @@ -76,6 +80,18 @@ export function usePublishComment( } if (recordIfPublished(result)) return result; + /** + * Whether the caller is currently counting this comment. + * + * `PendingActionsRunner` keeps a failed action queued and its error toast offers a retry that + * runs this again, so `run` is not once-only. Reporting a failure every time it ran took the + * count down once per attempt — unbounded drift from a single comment — and a retry that + * finally succeeded never gave it back. So the two callbacks are a transition on this flag + * rather than an event per attempt: the caller is told at most once that the row is gone, and + * told again when a retry puts it back. + */ + let countedByCaller = true; + enqueuePendingAction({ id: `comment:${targetEntityId}:${result.id}`, label: 'your comment', @@ -87,11 +103,22 @@ export function usePublishComment( ancestorComments, commentId: result.id, }); - if (recordIfPublished(published)) return; - // The row is already gone — `useCreateComment` removes it on a failed publish — so anything - // the caller counted for this comment has to come back before the error is surfaced. - onFailed?.(); + if (recordIfPublished(published)) { + // A retry after a rollback: the comment exists again, so it counts again. + if (!countedByCaller) { + countedByCaller = true; + onOptimistic?.(result.id); + } + return; + } + + // The row is gone — `useCreateComment` removes it on a failed publish — so anything the + // caller counted for this comment comes back before the error is surfaced. + if (countedByCaller) { + countedByCaller = false; + onFailed?.(); + } throw new Error('Comment could not be published'); }, }); diff --git a/apps/web/partials/comments/inline-comment-composer.test.tsx b/apps/web/partials/comments/inline-comment-composer.test.tsx index 75018ea99d..ebf1d63bb1 100644 --- a/apps/web/partials/comments/inline-comment-composer.test.tsx +++ b/apps/web/partials/comments/inline-comment-composer.test.tsx @@ -1,9 +1,20 @@ import '@testing-library/jest-dom/vitest'; -import { act, renderHook } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { Provider } from 'jotai'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useInlineComposer } from './inline-comment-composer'; +import { InlineCommentComposer, useInlineComposer } from './inline-comment-composer'; + +const mocks = vi.hoisted(() => ({ signedIn: false, promptSignIn: vi.fn() })); + +vi.mock('~/core/hooks/use-smart-account', () => ({ + useSmartAccount: () => ({ smartAccount: mocks.signedIn ? { account: { address: '0xabc' } } : null }), +})); +vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => mocks.promptSignIn })); +vi.mock('~/core/hooks/use-publish-comment', () => ({ + usePublishComment: () => ({ publishComment: vi.fn(), editComment: vi.fn() }), +})); /** * Whether a row should be holding its branch open for something the reader wrote. @@ -68,3 +79,83 @@ describe('useInlineComposer', () => { expect(result.current.hasPosted).toBe(true); }); }); + +/** + * A signed-out press, and what happens to it. + * + * The box asks for sign-in before it opens rather than after a draft is typed into it, which is right — + * but it used to open Privy, close itself, and forget. A reader who signed in came back to a shut box + * and had to find the control again, which is not what pressing it said would happen. `CommentSection` + * already keeps that intent in `pendingCommentComposerAtom`; this uses the same atom so the two cannot + * disagree about it. + */ +describe('InlineCommentComposer across sign-in', () => { + beforeEach(() => { + mocks.signedIn = false; + mocks.promptSignIn.mockClear(); + }); + afterEach(cleanup); + + function Host({ ancestors }: { ancestors?: Array<{ id: string; spaceId: string }> } = {}) { + const composer = useInlineComposer(); + return ( + <> + + {String(composer.isComposing)} + + + ); + } + + it('remembers the press and opens the box once the reader is signed in', () => { + const view = render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'comment' })); + + // Privy was asked, and the box closed rather than sitting there unusable. + expect(mocks.promptSignIn).toHaveBeenCalledOnce(); + expect(screen.getByTestId('composing')).toHaveTextContent('false'); + + mocks.signedIn = true; + view.rerender( + + + + ); + + expect(screen.getByTestId('composing')).toHaveTextContent('true'); + }); + + // Two composers can target the same debate — its own, and a reply under one of its comments — so the + // intent is keyed by the reply as well, or one press would open both. + it('does not open a reply composer for a press on the row itself', () => { + const view = render( + + + + ); + fireEvent.click(screen.getByRole('button', { name: 'comment' })); + + mocks.signedIn = true; + view.rerender( + + + + ); + + expect(screen.getByTestId('composing')).toHaveTextContent('false'); + }); +}); diff --git a/apps/web/partials/comments/inline-comment-composer.tsx b/apps/web/partials/comments/inline-comment-composer.tsx index 25cf5ea04e..c67049fb84 100644 --- a/apps/web/partials/comments/inline-comment-composer.tsx +++ b/apps/web/partials/comments/inline-comment-composer.tsx @@ -2,9 +2,12 @@ import * as React from 'react'; +import { useAtom } from 'jotai'; + import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { usePublishComment } from '~/core/hooks/use-publish-comment'; import { useSmartAccount } from '~/core/hooks/use-smart-account'; +import { pendingCommentComposerAtom } from '~/core/state/pending-comment-intents'; import { useAdjustActivityPosts } from './activity-posts'; import { CommentInput } from './comments-section'; @@ -64,17 +67,38 @@ export function InlineCommentComposer({ const { isComposing, close, markPosted, markPostRejected } = composer; const adjustActivityPosts = useAdjustActivityPosts(); - // Asked before the box opens, not after a draft is typed into it. The thread's own composer - // checks at the same moment — on the press, not on the submit — because a signed-out reader who - // types a paragraph and is then asked to sign in loses the paragraph. + /** + * Sign-in is asked for before the box opens, not after a draft is typed into it — the thread's own + * composer checks at the same moment, because a signed-out reader who types a paragraph and is then + * asked to sign in loses the paragraph. + * + * The press is remembered across it. This used to open Privy and close the box, and nothing brought + * it back: a reader who signed in landed on the page with the box shut and had to find the control + * again, which is not what pressing it said would happen. `CommentSection` already keeps that intent + * in `pendingCommentComposerAtom`; this is the same atom, keyed the same way, so the two cannot + * disagree — and keying on the reply as well as the entity is what stops a debate's own composer and + * a reply composer under one of its comments from both opening on one press. + */ + const replyToCommentId = ancestors?.[0]?.id ?? null; + const [pendingComposer, setPendingComposer] = useAtom(pendingCommentComposerAtom); + React.useEffect(() => { if (!isComposing || isSignedIn) return; + setPendingComposer({ entityId: targetEntityId, replyToCommentId }); promptSignIn(); close(); // Only on the transition into a signed-out open composer; `close` is a fresh closure each render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isComposing, isSignedIn]); + React.useEffect(() => { + if (!isSignedIn || pendingComposer == null) return; + if (pendingComposer.entityId !== targetEntityId || pendingComposer.replyToCommentId !== replyToCommentId) return; + setPendingComposer(null); + composer.open(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSignedIn, pendingComposer, targetEntityId, replyToCommentId]); + if (!isComposing || !isSignedIn) return null; return ( From 97c8009b40e6593ca57dfe097d082ee64214dc7b Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 26 Sep 2026 13:31:36 -0700 Subject: [PATCH 31/35] fix(claims): draw a new comment first, and keep the number it moves mounted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings. Three are one class nobody had named yet: the cache appends, the query orders newest-first, and every branch in this feed rendered the cache order straight — so the one row that is certainly the newest, the comment the reader had just written, was drawn last, under comments from weeks earlier, until the indexer replaced the list. `orderNewestFirst` is a no-op on a settled list and a correction for exactly that row, applied at all three sites: a debate's comments, a claim's replies, and the replies under a debate comment — the third being one the review did not flag but which is the same list from the same cache, sorted before the slice so "show more" reveals the next-newest. The fourth is last round's hook, one level out. `useAdjustActivityTotal` corrects a cached answer, so it does nothing when that answer was never fetched — and the hero that holds it renders on every tab while the heading, the aggregate's only other caller, lives inside the Overview. An explanation published from the Debates tab therefore moved nothing. The hook subscribes to the aggregate itself now, so a caller cannot hold the adjuster without the baseline being on its way. It costs one batched request on tabs that do not display the number; the Overview is the default tab, so nearly every visit was fetching it anyway. And the timecode latch recorded only the debate id, so following a second extracted claim's timecode in the same debate — a client-side navigation that does not remount the card — was swallowed. It latches the debate and the moment. The test for the aggregate mount was vacuous when written: it set a tab mock the page does not read, so the Overview rendered anyway and the assertion held with the fix removed. The active tab comes from the route, and it now sets that. Co-Authored-By: Claude Opus 5 (1M context) --- .../browse/claim-activity-order.test.ts | 45 ++++++++++++++++++- .../claims/browse/claim-activity-order.ts | 20 +++++++++ .../claims/browse/claim-page-view.test.tsx | 37 ++++++++++++--- .../core/claims/browse/claim-page-view.tsx | 14 ++++++ .../browse/debate-activity-row.test.tsx | 33 ++++++++++++++ .../claims/browse/debate-activity-row.tsx | 9 ++-- .../core/claims/browse/debate-comment-row.tsx | 9 +++- .../claims/browse/extracted-claim-row.tsx | 6 ++- .../browse/debate-feed-player.test.tsx | 29 ++++++++++-- .../debates/browse/debate-feed-player.tsx | 18 +++++--- 10 files changed, 198 insertions(+), 22 deletions(-) diff --git a/apps/web/core/claims/browse/claim-activity-order.test.ts b/apps/web/core/claims/browse/claim-activity-order.test.ts index 9ec3ea40a7..aad8b5be88 100644 --- a/apps/web/core/claims/browse/claim-activity-order.test.ts +++ b/apps/web/core/claims/browse/claim-activity-order.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { ClaimTiming } from '~/core/debates/claim-timing'; import type { TranscriptClaim } from '~/core/debates/transcript-claims'; -import { activityTime, mergeActivityRows, orderExtractedClaims } from './claim-activity-order'; +import { activityTime, mergeActivityRows, orderExtractedClaims, orderNewestFirst } from './claim-activity-order'; function claim(id: string, overrides: Partial = {}): TranscriptClaim { return { @@ -317,3 +317,46 @@ describe('rows nothing can place in time', () => { expect(merged.map(entry => entry.row.id)).toEqual(['dated', 'no-date']); }); }); + +/** + * The one row that is certainly the newest was the one drawn last. + * + * `useCreateComment` appends its optimistic row, while the query asks for `[CREATED_AT_DESC, ID_ASC]`. + * So every branch in this feed put a comment the reader had just written at the bottom, under comments + * from weeks earlier, until the indexer replaced the list. + */ +describe('orderNewestFirst', () => { + const older = { id: 'c-old', createdAt: '2026-09-01T10:00:00Z' }; + const newer = { id: 'c-new', createdAt: '2026-09-20T10:00:00Z' }; + + it('lifts a just-appended comment to the front', () => { + expect(orderNewestFirst([newer, older, { id: 'c-just-now', createdAt: '2026-09-26T10:00:00Z' }])).toEqual([ + { id: 'c-just-now', createdAt: '2026-09-26T10:00:00Z' }, + newer, + older, + ]); + }); + + it('leaves a settled list alone, because the query already ordered it', () => { + expect(orderNewestFirst([newer, older]).map(c => c.id)).toEqual(['c-new', 'c-old']); + }); + + it('keeps the query order within one timestamp, which is its id order', () => { + const a = { id: 'a', createdAt: '2026-09-20T10:00:00Z' }; + const b = { id: 'b', createdAt: '2026-09-20T10:00:00Z' }; + + expect(orderNewestFirst([a, b]).map(c => c.id)).toEqual(['a', 'b']); + }); + + it('does not mutate what it was given', () => { + const list = [older, newer]; + orderNewestFirst(list); + + expect(list.map(c => c.id)).toEqual(['c-old', 'c-new']); + }); + + // Undated rows go last rather than to 1970, the same rule the merged feed follows. + it('puts a row nothing can place at the tail', () => { + expect(orderNewestFirst([{ id: 'undated', createdAt: '' }, older]).map(c => c.id)).toEqual(['c-old', 'undated']); + }); +}); diff --git a/apps/web/core/claims/browse/claim-activity-order.ts b/apps/web/core/claims/browse/claim-activity-order.ts index 4ce544e800..7581c8822e 100644 --- a/apps/web/core/claims/browse/claim-activity-order.ts +++ b/apps/web/core/claims/browse/claim-activity-order.ts @@ -166,3 +166,23 @@ function netScoreOf(counts: { positive: number; negative: number } | null): numb function upvotesOf(counts: { positive: number; negative: number } | null): number { return counts?.positive ?? 0; } + +/** + * A comment list in the order it claims to be in: newest first. + * + * The fetched list already is — `comment-entities-connection` asks for `[CREATED_AT_DESC, ID_ASC]` — + * but `useCreateComment` *appends* its optimistic row, so a comment the reader has just written landed + * at the bottom of every branch in this feed until the indexer replaced the list. The one row that is + * certainly the newest was the one drawn last. + * + * So this is a no-op on a settled list and a correction for exactly the optimistic case. `sort` is + * stable in every engine we target, so rows sharing a timestamp keep the order the query gave them, + * which is `ID_ASC`. + * + * Not the same thing as the top-level thread's ordering: that has a sort control and pins the + * reader's own comments through `sortWithSessionPinned`. A branch has neither, and newest-first is + * what it says it draws. + */ +export function orderNewestFirst(comments: T[]): T[] { + return [...comments].sort((a, b) => compareByTime(a, b, -1)); +} diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index 67d18a00d9..fd6d78db64 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -36,6 +36,9 @@ const mocks = vi.hoisted(() => ({ activityCountError: null as Error | null, retryActivityCount: vi.fn(), adjustActivityTotal: vi.fn(), + countsMountedFor: [] as string[], + // The active tab comes from the route, so a test that needs another tab sets this. + pathname: '/space/space-1/claim-1', entity: null as Record | null, /** Non-comment rows the Overview orders into its activity thread — the debates on this claim. */ activityRows: [] as Array<{ id: string; createdAt: string; content: unknown }>, @@ -100,7 +103,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('next/navigation', () => ({ - usePathname: () => '/space/space-1/claim-1', + usePathname: () => mocks.pathname, })); vi.mock('~/core/state/editor/editor-provider', () => ({ useActiveTabIdForEditor: () => null })); @@ -159,11 +162,14 @@ vi.mock('./use-claim-activity-rows', () => ({ // The heading's number, which is the same one the claim's Explore card shows. Its own query is // covered by `claim-activity-count.test.ts`; here it only needs to reach the heading. vi.mock('./claim-activity-count', () => ({ - useClaimActivityCounts: () => ({ - counts: new Map(mocks.activityTotal == null ? [] : [['claim1', { total: mocks.activityTotal }]]), - error: mocks.activityCountError, - retry: mocks.retryActivityCount, - }), + useClaimActivityCounts: (ids: string[]) => { + mocks.countsMountedFor.push(...ids); + return { + counts: new Map(mocks.activityTotal == null ? [] : [['claim1', { total: mocks.activityTotal }]]), + error: mocks.activityCountError, + retry: mocks.retryActivityCount, + }; + }, adjustClaimActivityTotal: mocks.adjustActivityTotal, })); @@ -298,6 +304,8 @@ beforeEach(() => { mocks.activityCountError = null; mocks.retryActivityCount.mockClear(); mocks.adjustActivityTotal.mockClear(); + mocks.countsMountedFor.length = 0; + mocks.pathname = '/space/space-1/claim-1'; mocks.activityTotal = null; mocks.responseTotal = 11; mocks.summaryLoading = false; @@ -739,6 +747,23 @@ describe('ClaimPageView comments', () => { * aggregate, which the heading prefers unconditionally. So explaining a position left the number one * behind. */ + /** + * The adjustment corrects a cached answer, so it does nothing when that answer was never fetched. The + * hero renders on every tab; the heading — the only other caller of the aggregate — is inside the + * Overview. So an explanation published from the Debates tab moved nothing at all, and switching to + * Overview before the indexer caught up showed the pre-comment number. + */ + it('keeps the activity aggregate mounted on a tab that does not display it', () => { + mocks.pathname = '/space/space-1/claim-1/debates'; + + render(); + + // The Overview — and so the heading, the aggregate's only other caller — is not rendered here. + expect(screen.queryByText('Activity (34)')).not.toBeInTheDocument(); + // The hero is, so the number it adjusts has to be on its way regardless. + expect(mocks.countsMountedFor).toContain('claim-1'); + }); + it('routes the hero explanation into the same activity count the thread uses', () => { render(); diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 85f150df43..15723173cc 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -460,9 +460,23 @@ function ClaimTabPanel({ * The adjustment lands in the query cache rather than in a component's state, which is what makes it * survive a remount and stay behind when the reader walks to the next claim. See * {@link adjustClaimActivityTotal}. + * + * Which is why this subscribes to the aggregate as well as returning the adjuster: the adjustment is a + * correction to a cached answer, and it does nothing at all when that answer was never fetched. The + * hero renders on every tab while the heading — and so the only other caller of this query — lives + * inside the Overview, so an explanation published from the Debates tab moved nothing, and switching + * to Overview before the indexer caught up showed the pre-comment number. Now a caller cannot hold the + * adjuster without the baseline being on its way, which is the kind of thing that should not depend on + * remembering. + * + * It costs one batched aggregate request on the tabs that do not display the number. The Overview is + * the default tab, so nearly every visit was fetching it anyway, and `staleTime` is a minute, so + * moving between tabs does not refetch. */ function useAdjustActivityTotal(claimId: string): (delta: number) => void { const queryClient = useQueryClient(); + useClaimActivityCounts(React.useMemo(() => [claimId], [claimId])); + return React.useCallback( (delta: number) => void adjustClaimActivityTotal(queryClient, claimId, delta), [claimId, queryClient] diff --git a/apps/web/core/claims/browse/debate-activity-row.test.tsx b/apps/web/core/claims/browse/debate-activity-row.test.tsx index 7ddef8625a..1604757bd3 100644 --- a/apps/web/core/claims/browse/debate-activity-row.test.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.test.tsx @@ -218,6 +218,39 @@ describe('DebateActivityRow when the transcript will not load', () => { expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); }); + /** + * Where a comment the reader just wrote appears. `useCreateComment` appends its optimistic row, so + * without an order of its own this branch drew it beneath comments from weeks earlier. + */ + it('draws a just-written comment first, not last', () => { + mocks.debateComments = [ + { + id: 'comment-old', + markdownContent: 'From three weeks ago.', + author: { spaceId: 'author-space', address: '0xabc', name: 'Ada', avatarUrl: null }, + createdAt: '2026-09-01T10:00:00Z', + spaceId: 'author-space', + replies: [], + }, + // Appended, as the cache appends it. + { + id: 'comment-new', + markdownContent: 'Just now.', + author: { spaceId: 'author-space', address: '0xabc', name: 'Ada', avatarUrl: null }, + createdAt: '2026-09-26T10:00:00Z', + spaceId: 'author-space', + replies: [], + }, + ]; + + renderRow({ claimCount: 0, commentCount: 2 }); + + expect(screen.getAllByText(/From three weeks ago\.|Just now\./).map(n => n.textContent?.trim())).toEqual([ + 'Just now.', + 'From three weeks ago.', + ]); + }); + /** * The comments are their own read, so they fail on their own — and a row whose count says there are * comments kept that count, and its collapse control, while drawing none of them. diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index 4b332db039..702dd4327a 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -29,7 +29,7 @@ import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import { ActivityRowTag } from './activity-row-tag'; import { ACTIVITY_ROOT_DEPTH } from './claim-activity-depth'; -import { orderExtractedClaims } from './claim-activity-order'; +import { orderExtractedClaims, orderNewestFirst } from './claim-activity-order'; import { DebateCommentRow } from './debate-comment-row'; import { ExtractedClaimRow, type SpeakerProfile } from './extracted-claim-row'; @@ -354,6 +354,9 @@ function DebateBranch({ }, [claims.blocks]); const ordered = React.useMemo(() => orderExtractedClaims(claims.all, timings), [claims.all, timings]); + // Newest first, which is what this branch says it draws — see `orderNewestFirst`. The fetched list + // already is; the reader's own comment is the one row that is not, because it is appended. + const orderedComments = React.useMemo(() => orderNewestFirst(debateComments), [debateComments]); if (isLoading || !isReady) { // Held rather than painted unordered. `isReady` is false only while a timing source is still @@ -379,7 +382,7 @@ function DebateBranch({ const commentsFailed = commentsError != null && debateComments.length === 0; // One row, at the head: it is about the claims, which is what the rest of the branch leads with. const leadingRows = claimsFailed ? 1 : 0; - const rowCount = leadingRows + claimsInOrder.length + debateComments.length + (commentsFailed ? 1 : 0); + const rowCount = leadingRows + claimsInOrder.length + orderedComments.length + (commentsFailed ? 1 : 0); if (rowCount === 0) return null; @@ -437,7 +440,7 @@ function DebateBranch({ )} - {debateComments.map((comment, index) => ( + {orderedComments.map((comment, index) => ( renderMarkdownDocument(comment.markdownContent), [comment.markdownContent]); - const replies = Array.isArray(comment.replies) ? comment.replies : []; + // Same order and the same reason as the branches above: `buildCommentTree` groups these out of the + // newest-first list the query returns, and an optimistic reply is appended to it. Sorted before the + // slice below, so "show more" reveals the next-newest rather than whatever the cache appended. + const replies = React.useMemo( + () => orderNewestFirst(Array.isArray(comment.replies) ? comment.replies : []), + [comment.replies] + ); // Two different overflows, and they want different offers. Replies we are holding back for length // are already loaded, so revealing them is free and happens here. Replies below the depth floor diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index a80d31e580..ba3fd3888b 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -29,6 +29,7 @@ import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import { ActivityRowTag } from './activity-row-tag'; import { canNestBelow } from './claim-activity-depth'; +import { orderNewestFirst } from './claim-activity-order'; import type { OrderedTranscriptClaim } from './claim-activity-order'; import { ClaimCommentPositionBoundary, ResponsePositionTag } from './claim-comment-position'; import { DebateCommentRow } from './debate-comment-row'; @@ -315,7 +316,10 @@ function ClaimComments({ onCollapse: () => void; label: { expand: string; collapse: string }; }) { - const { comments, error, refetch } = useComments({ entityId: claimId, spaceId }); + const { comments: unordered, error, refetch } = useComments({ entityId: claimId, spaceId }); + // Newest first, as the branch above draws its own: the reader's own comment is appended to the cache + // and would otherwise be the last row rather than the first. See `orderNewestFirst`. + const comments = React.useMemo(() => orderNewestFirst(unordered), [unordered]); // Only after a *successful* empty response. A failed read reports the same empty list, so returning // null on it drew an expanded branch with nothing in it under a count that said there were replies. if (comments.length === 0 && error == null) return null; 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 3419eb92b8..d90ab2c214 100644 --- a/apps/web/core/debates/browse/debate-feed-player.test.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.test.tsx @@ -867,7 +867,7 @@ describe('a seek asked for by the link that brought the reader here', () => { return { seeks, /** Re-render as the feed does, optionally with the media now seekable or a different debate. */ - update(next: { ready?: boolean; active?: boolean; debateId?: string } = {}) { + update(next: { ready?: boolean; active?: boolean; debateId?: string; seekSeconds?: number } = {}) { mocks.controller = controllerFixture({ mutedByUser: true, turnSlot: 1, @@ -878,7 +878,7 @@ describe('a seek asked for by the link that brought the reader here', () => { ); }, @@ -917,6 +917,29 @@ describe('a seek asked for by the link that brought the reader here', () => { expect(player.seeks).toEqual([124, 124]); }); + /** + * Two claims extracted from one debate carry two timecodes, and following one after the other is a + * client-side navigation: `initialSeekSeconds` changes and this card is not remounted. Latched on the + * debate alone, the second timecode did nothing at all. + */ + it('fires again for a different moment in the same debate', () => { + const player = renderWithSeek(124); + expect(player.seeks).toEqual([124]); + + player.update({ seekSeconds: 500 }); + + expect(player.seeks).toEqual([124, 500]); + }); + + it('still does not re-seek when the same moment is asked for again', () => { + const player = renderWithSeek(124); + + player.update({ seekSeconds: 124 }); + player.update(); + + expect(player.seeks).toEqual([124]); + }); + it('does nothing at all when the link named no moment', () => { const player = renderWithSeek(null); @@ -926,7 +949,6 @@ describe('a seek asked for by the link that brought the reader here', () => { }); }); - describe('the round it is playing', () => { const at = (playheadSeconds: number, extra: { playing?: boolean } = {}) => controllerFixture({ mutedByUser: false, turnSlot: 1, playheadSeconds, ...extra }); @@ -1090,6 +1112,5 @@ describe('the round it is playing', () => { const { container } = render(); expect(container.querySelector('[data-round-card]')).toBeNull(); expect(container.querySelector('[data-round-badge]')).toBeNull(); - }); }); diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 85b0c4721b..e6742f7a66 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -143,12 +143,17 @@ export function DebateFeedPlayer({ }; /** - * Honour a link that named a moment, once. + * Honour a link that named a moment, once per moment. * * Waits for `ready` rather than firing on mount: before the media is seekable the request would - * be dropped, and this is the arriving reader's one chance to land where they were sent. Latched - * by debate id so re-renders, a re-activation, or the viewer scrolling back to this card cannot - * yank the playhead back to where the link pointed after they have moved it themselves. + * be dropped, and this is the arriving reader's one chance to land where they were sent. Latched so + * that re-renders, a re-activation, or the viewer scrolling back to this card cannot yank the + * playhead back to where the link pointed after they have moved it themselves. + * + * Latched on the debate *and* the moment, not the debate alone. Two claims extracted from the same + * debate carry two timecodes, and following one after the other is a client-side navigation that + * changes `initialSeekSeconds` without remounting this card — which the id-only latch swallowed, so + * the second timecode did nothing at all. * * Not routed through the measured `seekBoth` above: this is a seek the reader asked for on the * previous page, not a scrub performed on this player, and counting it as one would report a @@ -157,8 +162,9 @@ export function DebateFeedPlayer({ const appliedInitialSeekRef = React.useRef(null); React.useEffect(() => { if (initialSeekSeconds == null || !ready) return; - if (appliedInitialSeekRef.current === debate.id) return; - appliedInitialSeekRef.current = debate.id; + const request = `${debate.id}@${initialSeekSeconds}`; + if (appliedInitialSeekRef.current === request) return; + appliedInitialSeekRef.current = request; seekBothRaw(initialSeekSeconds); }, [debate.id, initialSeekSeconds, ready, seekBothRaw]); From 7d4dcd0562a89225009adda5ef5cfed91455d3c8 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 26 Sep 2026 14:17:30 -0700 Subject: [PATCH 32/35] fix(claims): gate branches on the live comment count, and balance every rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings. A comment published on a debate or an extracted claim was out of reach after the reader navigated away and back. The branch gates read the batched comment-count aggregate, which is held for a minute and never written on publish, plus a flag the row's own composer set — and that flag reset on the remount while the aggregate still said none. The comment button beside the row reads the live list, so it reported the comment while the branch that would draw it was absent. All three gates — the debate's branch, the debate's comments query, the extracted claim's replies — read `useCommentCount` now, the hook the button already uses, with the same arguments. The button and the gate cannot disagree because they are one derivation. It survives a remount because its seeds are module-level by design, sees a comment from any surface because every composer writes the same cache, and drops a rejected comment on its own because `useCreateComment` removes the row. So the composer's posted-count state had no reader left, and it goes; what remains is whether the box is open. A publish that failed before inserting anything — `createComment` giving up with no cached account — still reported a failure, so callers treating the callbacks as a balanced +1/-1 took back a count never taken. Last round's flag covered only the queued retries; it covers the whole lifecycle now, first attempt included. And the claim's own comments could fail silently under an Activity heading that is an independent aggregate and kept counting them. `CommentSection` keeps that error to itself, which is right where its heading counts the same list. The page reads it too — same key, so no request — and says so beside the other two Activity reads. --- .../claims/browse/claim-page-view.test.tsx | 29 ++++++++ .../core/claims/browse/claim-page-view.tsx | 23 +++++-- .../browse/debate-activity-row.test.tsx | 57 ++++++++++++++++ .../claims/browse/debate-activity-row.tsx | 33 +++++++--- .../browse/extracted-claim-row.test.tsx | 38 +++++++++++ .../claims/browse/extracted-claim-row.tsx | 15 +++-- .../core/hooks/use-publish-comment.test.ts | 42 ++++++++++-- apps/web/core/hooks/use-publish-comment.ts | 48 +++++++------- .../comments/inline-comment-composer.test.tsx | 66 +------------------ .../comments/inline-comment-composer.tsx | 52 +++++---------- 10 files changed, 254 insertions(+), 149 deletions(-) diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index fd6d78db64..d4623d082c 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -37,6 +37,8 @@ const mocks = vi.hoisted(() => ({ retryActivityCount: vi.fn(), adjustActivityTotal: vi.fn(), countsMountedFor: [] as string[], + claimCommentsError: null as Error | null, + refetchClaimComments: vi.fn(), // The active tab comes from the route, so a test that needs another tab sets this. pathname: '/space/space-1/claim-1', entity: null as Record | null, @@ -161,6 +163,16 @@ vi.mock('./use-claim-activity-rows', () => ({ // The heading's number, which is the same one the claim's Explore card shows. Its own query is // covered by `claim-activity-count.test.ts`; here it only needs to reach the heading. +vi.mock('~/core/hooks/use-comments', () => ({ + useComments: () => ({ + comments: [], + totalCount: 0, + isLoading: false, + error: mocks.claimCommentsError, + refetch: mocks.refetchClaimComments, + }), +})); + vi.mock('./claim-activity-count', () => ({ useClaimActivityCounts: (ids: string[]) => { mocks.countsMountedFor.push(...ids); @@ -305,6 +317,8 @@ beforeEach(() => { mocks.retryActivityCount.mockClear(); mocks.adjustActivityTotal.mockClear(); mocks.countsMountedFor.length = 0; + mocks.claimCommentsError = null; + mocks.refetchClaimComments.mockClear(); mocks.pathname = '/space/space-1/claim-1'; mocks.activityTotal = null; mocks.responseTotal = 11; @@ -785,6 +799,21 @@ describe('ClaimPageView comments', () => { expect(mocks.retryActivityCount).toHaveBeenCalledOnce(); }); + /** + * The claim's own comments are the third read, and `CommentSection` keeps its failure to itself — + * reasonable where the heading counts that same list, wrong here, where the heading is an independent + * aggregate that goes on counting the comments the list could not load. + */ + it('says when the claim’s own comments could not be read', () => { + mocks.claimCommentsError = new Error('comments unavailable'); + + render(); + + expect(screen.getByText(/Couldn’t load the comments on this claim/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(mocks.refetchClaimComments).toHaveBeenCalledOnce(); + }); + // Each read speaks for itself: one failing says nothing about the other. it('says only what failed when just the debates read did', () => { mocks.activityError = new Error('kg timeout'); diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index dec997e38b..67b3da9c80 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -17,6 +17,7 @@ import { } from '~/core/debates/backfill-readiness-for-held-position'; import { useDebateClaims } from '~/core/debates/hooks'; import { useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; +import { useComments } from '~/core/hooks/use-comments'; import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { ID } from '~/core/id'; import { hasRecordToShow } from '~/core/profile/profile-proposer'; @@ -518,6 +519,12 @@ function ClaimOverviewTab({ const adjustActivityTotal = useAdjustActivityTotal(entityId); const activity = useClaimActivityRows({ claimId: entityId, spaceId }); + // The claim's own comments, for their error alone. `CommentSection` fetches them and keeps its error + // to itself, which is right for a thread whose heading counts the same list — there a failure reads + // as "Comments (0)" over nothing, consistent if unhelpful. Here the heading is an independent + // aggregate that goes on counting them, so a failed read drew a total over a list with none of the + // claim's comments in it. Same key as the section's, so this is a second subscription, not a request. + const claimComments = useComments({ entityId, spaceId }); const kinds: ActivityKind[] = [ { @@ -585,18 +592,24 @@ function ClaimOverviewTab({ isViewerResponseLoading={summary.isViewerResponseLoading} > {/* - Two reads feed this section and either can fail on its own, so each says so for itself. - Without the debates the feed is missing every debate and every claim extracted from one; - without the total the heading falls back to this claim's own comments plus the rows it drew, - which leaves out everything nested under a debate and is not the number it appears to be. + Three reads feed this section and any of them can fail on its own, so each says so for + itself. Without the debates the feed is missing every debate and every claim extracted from + one; without the claim's own comments it is missing those, under a heading that still counts + them; without the total the heading falls back to this claim's own comments plus the rows it + drew, which leaves out everything nested under a debate and is not the number it appears to be. Said here rather than as rows inside the section: each is about a whole list rather than a place in one, and a synthetic row would have to claim a timestamp to sort anywhere sensible. */} - {(activity.error != null || activityCounts.error != null) && ( + {(activity.error != null || claimComments.error != null || activityCounts.error != null) && (
{activity.error != null && ( Couldn’t load the debates on this claim. )} + {claimComments.error != null && ( + void claimComments.refetch()}> + Couldn’t load the comments on this claim. + + )} {activityCounts.error != null && ( Couldn’t load this claim’s activity total. )} diff --git a/apps/web/core/claims/browse/debate-activity-row.test.tsx b/apps/web/core/claims/browse/debate-activity-row.test.tsx index 1604757bd3..e2700db443 100644 --- a/apps/web/core/claims/browse/debate-activity-row.test.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.test.tsx @@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetCommentCountSeeds } from '~/core/hooks/use-comment-count'; import type { Entity } from '~/core/types'; import { DebateActivityRow } from './debate-activity-row'; @@ -274,3 +275,59 @@ describe('DebateActivityRow when the transcript will not load', () => { expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); }); }); + +/** + * A comment written on this debate, then the reader navigates away and back. + * + * The comment-count aggregate is batched for the page and held for a minute, and nothing writes to it + * on publish, so it still says none. The row used to lean on a flag its own composer set, which reset + * on the remount — so the comment button beside the row, reading the live list, said 1 while the + * branch that would show that comment was not drawn at all. The gate reads the same live count the + * button does now, so the two cannot disagree. + */ +describe('DebateActivityRow after a comment it has not heard about from the server', () => { + beforeEach(() => { + resetCommentCountSeeds(); + mocks.commentQueries.length = 0; + mocks.transcriptError = null; + mocks.debateComments = []; + mocks.commentsError = null; + }); + afterEach(cleanup); + + it('keeps the comment reachable across a remount while the aggregate still says none', () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const row = () => ( + + + + ); + + const first = render(row()); + expect(screen.queryByLabelText(COLLAPSE_LABEL)).not.toBeInTheDocument(); + + // What `useCreateComment` leaves in the cache: the reader's comment, still publishing. + client.setQueryData( + ['comments', 'debate-1'], + [{ id: 'comment-new', createdAt: '2026-09-26T10:00:00Z', isPendingPublish: true }], + { updatedAt: Date.now() + 1_000 } + ); + + // Away and back. + first.unmount(); + render(row()); + + expect(screen.getAllByLabelText(COLLAPSE_LABEL).length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/core/claims/browse/debate-activity-row.tsx b/apps/web/core/claims/browse/debate-activity-row.tsx index 702dd4327a..725c784811 100644 --- a/apps/web/core/claims/browse/debate-activity-row.tsx +++ b/apps/web/core/claims/browse/debate-activity-row.tsx @@ -8,6 +8,7 @@ import { useEntityCommentCounts } from '~/core/comments/use-entity-comment-count import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; import { countFor } from '~/core/hooks/batched-counts'; +import { useCommentCount } from '~/core/hooks/use-comment-count'; import { useComments } from '~/core/hooks/use-comments'; import { uuidToHex } from '~/core/id/normalize'; import type { Entity } from '~/core/types'; @@ -137,14 +138,23 @@ export function DebateActivityRow({ // A debate with no claims and no comments has no branch: `DebateBranch` returns null for it. The // collapse control was drawn anyway, so pressing it on a transcript-less debate collapsed nothing - // and merely swapped the keyframe for a plus. `composer.hasPosted` as well as the two counts, - // because a comment written here is a branch the server aggregate has not heard about yet. + // and merely swapped the keyframe for a plus. // // A count of `null` is an aggregate that failed, not an empty debate, so it opens the branch: the // worst case is that toggle with nothing under it again, against losing every extracted claim and // every comment on the page with no way to ask for them. + // + // The comments are read *live* — the same number the comment button beside this row shows, from the + // same hook. The aggregate is batched for the page and held for a minute, and nothing writes to it + // when a comment is published, so on its own it answered "no comments" for a debate that had just + // gained one. This used to lean on a flag set by this row's own composer, which only knew about + // comments written here, since this mount: navigate away and back and it had reset while the + // aggregate had not, so the button said 1 and the branch that would show that comment was absent. + // The live count survives a remount and sees a comment from any surface, because it reads the + // comments cache every composer writes to. + const liveCommentCount = useCommentCount(debate.id, commentCount ?? 0); const countsUnknown = claimCount == null || commentCount == null; - const hasBranch = countsUnknown || claimCount + commentCount > 0 || composer.hasPosted; + const hasBranch = countsUnknown || claimCount + liveCommentCount > 0; return (
@@ -284,7 +294,7 @@ export function DebateActivityRow({ onCollapse={() => setCollapsed(true)} branchLabel={branchLabel} commentCount={commentCount} - hasPostedHere={composer.hasPosted} + liveCommentCount={liveCommentCount} />
)} @@ -309,7 +319,7 @@ function DebateBranch({ onCollapse, branchLabel, commentCount, - hasPostedHere, + liveCommentCount, }: { debateId: string; spaceId: string; @@ -320,8 +330,11 @@ function DebateBranch({ branchLabel: { expand: string; collapse: string }; /** From the feed's own aggregate. Zero means nothing here is worth a request; `null` means it could not say. */ commentCount: number | null; - /** Someone has commented from this row since the page loaded, so the aggregate is behind. */ - hasPostedHere: boolean; + /** + * The comments as they stand now, which the aggregate lags. See the row above: this is what makes a + * comment written here, or anywhere else, reachable before the aggregate catches up. + */ + liveCommentCount: number; }) { const { claims, isLoading, error: claimsError, retry: retryClaims } = useDebateTranscriptClaims(debateId, spaceId); const { timings, isReady } = useClaimTimings(debateId, claims); @@ -335,11 +348,11 @@ function DebateBranch({ entityId: debateId, spaceId, // Most debates have no comments, and this feed already knows which. Fetching them anyway would - // be a backlink walk per debate row to discover a list we were told is empty. A comment made - // from this row makes that aggregate stale, so it also lifts the gate — otherwise the first + // be a backlink walk per debate row to discover a list we were told is empty. The live count lifts + // the gate as soon as a comment exists anywhere, which the aggregate does not — otherwise the first // comment on a silent debate would be written and never read back. So does an aggregate that // failed (`null`): "we could not ask" is not "there is nothing to fetch". - enabled: commentCount == null || commentCount > 0 || hasPostedHere, + enabled: commentCount == null || liveCommentCount > 0, }); const claimIds = React.useMemo(() => claims.all.map(claim => claim.id), [claims.all]); diff --git a/apps/web/core/claims/browse/extracted-claim-row.test.tsx b/apps/web/core/claims/browse/extracted-claim-row.test.tsx index 49d33c556b..95ebe9636c 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.test.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.test.tsx @@ -5,6 +5,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ClaimTiming } from '~/core/debates/claim-timing'; +import { resetCommentCountSeeds } from '~/core/hooks/use-comment-count'; import type { OrderedTranscriptClaim } from './claim-activity-order'; import { ExtractedClaimRow } from './extracted-claim-row'; @@ -268,6 +269,43 @@ describe('ExtractedClaimRow', () => { expect(screen.queryByText(/Couldn’t load the replies/)).not.toBeInTheDocument(); }); + /** + * The same gate as the debate row's, for the same reason: the aggregate is a minute stale and nothing + * writes to it on publish, so on its own it kept a comment written a moment ago out of reach while + * the comment button beside this row — reading the live list — reported it. + */ + it('opens its replies for a comment the aggregate has not heard about yet', () => { + resetCommentCountSeeds(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const row = () => ( + + + + ); + + const first = render(row()); + expect(screen.queryByLabelText('Hide comments on this claim')).not.toBeInTheDocument(); + + client.setQueryData( + ['comments', 'claim-1'], + [{ id: 'reply-new', createdAt: '2026-09-26T10:00:00Z', isPendingPublish: true }], + { updatedAt: Date.now() + 1_000 } + ); + first.unmount(); + render(row()); + + expect(screen.getAllByLabelText('Hide comments on this claim').length).toBeGreaterThan(0); + }); + it('keeps its comments reachable when the count aggregate could not answer', () => { const { unmount } = renderRow({ commentCount: 0 }); expect(screen.queryByLabelText('Hide comments on this claim')).not.toBeInTheDocument(); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index ba3fd3888b..41f1f1fee2 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -7,6 +7,7 @@ import cx from 'classnames'; import { useOpenDebaterProfile } from '~/core/debates/browse/use-open-debater-profile'; import { formatTimecode, isAssertableMoment } from '~/core/debates/claim-timing'; import { debateSeekSeconds, withDebateTimecode } from '~/core/debates/debate-timecode'; +import { useCommentCount } from '~/core/hooks/use-comment-count'; import { useComments } from '~/core/hooks/use-comments'; import type { ResponseKind } from '~/core/responses/entity-response'; import { NavUtils } from '~/core/utils/utils'; @@ -133,8 +134,13 @@ export function ExtractedClaimRow({ // `commentCount == null` is an aggregate that could not answer, not a claim with no comments: the // branch opens so the replies stay reachable. See `batched-counts.ts`. - const hasComments = - Boolean(claimSpaceId) && (commentCount == null || commentCount > 0 || composer.hasPosted) && canNestBelow(depth); + // + // Otherwise the *live* count, which is the number the comment button beside this row shows: the + // aggregate is held for a minute and nothing writes to it on publish, so on its own it kept a + // comment written a moment ago — here or on any other surface — out of reach, with the button + // reporting it. See `DebateActivityRow`, which gates the same way for the same reason. + const liveCommentCount = useCommentCount(claim.id, commentCount ?? 0); + const hasComments = Boolean(claimSpaceId) && (commentCount == null || liveCommentCount > 0) && canNestBelow(depth); const branchLabel = { expand: 'Show comments on this claim', collapse: 'Hide comments on this claim' }; return ( @@ -266,9 +272,8 @@ export function ExtractedClaimRow({ /> )} - {/* `composer.hasPosted` as well as the server count: the aggregate that gates this is from - the page load, so a reader's first comment on a silent claim would otherwise be written - and then not drawn. */} + {/* Gated on the live count, not the aggregate alone: the aggregate is from the page load, so a + reader's first comment on a silent claim would otherwise be written and then not drawn. */} {hasComments && !commentsCollapsed && (
{ await act(() => result.current.publishComment({ text: 'A reason' })); + // `onOptimistic` is the hook's own wrapper — it records that the row was counted, so a later + // failure knows whether a `-1` is owed — rather than the caller's callback passed straight through. expect(mocks.createComment).toHaveBeenCalledWith({ text: 'A reason', targetSpaceId: 'space-1', ancestorComments: undefined, - onOptimistic: undefined, + onOptimistic: expect.any(Function), }); expect(mocks.enqueuePendingAction).not.toHaveBeenCalled(); expect(mocks.commentCreated).toHaveBeenCalledWith('comment-1', 'claim-1', { @@ -166,8 +168,22 @@ describe('usePublishComment reporting a failure', () => { return act(() => result.current.publishComment({ text: 'A comment', onFailed }) as Promise); } - it('reports a publish that failed immediately', async () => { - mocks.createComment.mockResolvedValue(null); + /** + * What `useCreateComment` does: inserts the optimistic row and reports it, then resolves. `inserted` + * is false for the one outcome where it gives up before inserting anything. + */ + function createCommentThat(outcome: unknown, { inserted = true }: { inserted?: boolean } = {}) { + mocks.createComment.mockImplementation( + async (input: { onOptimistic?: (id: string) => void; commentId?: string }) => { + // A retry passes the existing id and no callback; the first attempt passes a callback. + if (inserted) input.onOptimistic?.('comment-1'); + return outcome; + } + ); + } + + it('reports a publish that failed after its row was counted', async () => { + createCommentThat(null); const onFailed = vi.fn(); await publish(onFailed); @@ -175,6 +191,20 @@ describe('usePublishComment reporting a failure', () => { expect(onFailed).toHaveBeenCalledOnce(); }); + /** + * `createComment` can give up before it inserts anything — no cached account, for one — and the old + * path reported a failure regardless. Callers treat the two callbacks as a balanced `+1`/`-1`, so that + * took back a count that was never taken: the Activity total one *low*. + */ + it('reports nothing for a publish that failed before any row existed', async () => { + createCommentThat(null, { inserted: false }); + const onFailed = vi.fn(); + + await publish(onFailed); + + expect(onFailed).not.toHaveBeenCalled(); + }); + it('does not report one that succeeded', async () => { mocks.createComment.mockResolvedValue({ id: 'comment-1', published: true }); const onFailed = vi.fn(); @@ -186,7 +216,7 @@ describe('usePublishComment reporting a failure', () => { // The case the returned value cannot express: retained now, failed later. it('reports a retained publish whose retry fails, and not before', async () => { - mocks.createComment.mockResolvedValue({ id: 'comment-1', published: false }); + createCommentThat({ id: 'comment-1', published: false }); const onFailed = vi.fn(); await publish(onFailed); @@ -196,7 +226,7 @@ describe('usePublishComment reporting a failure', () => { expect(mocks.enqueuePendingAction).toHaveBeenCalledOnce(); const queued = mocks.enqueuePendingAction.mock.calls[0]![0] as { run: () => Promise }; - mocks.createComment.mockResolvedValue(null); + createCommentThat(null); await expect(queued.run()).rejects.toThrow('Comment could not be published'); expect(onFailed).toHaveBeenCalledOnce(); @@ -249,7 +279,7 @@ describe('usePublishComment reporting a failure', () => { }); it('says nothing when the retry succeeds', async () => { - mocks.createComment.mockResolvedValue({ id: 'comment-1', published: false }); + createCommentThat({ id: 'comment-1', published: false }); const onFailed = vi.fn(); await publish(onFailed); diff --git a/apps/web/core/hooks/use-publish-comment.ts b/apps/web/core/hooks/use-publish-comment.ts index 438ef9fd26..31f5d87301 100644 --- a/apps/web/core/hooks/use-publish-comment.ts +++ b/apps/web/core/hooks/use-publish-comment.ts @@ -67,31 +67,41 @@ export function usePublishComment( return true; }; + /** + * Whether the caller is currently counting this comment — one flag for the whole lifecycle, the + * first attempt and every queued retry alike. + * + * The two callbacks are a transition on it rather than an event per attempt, for two reasons + * that turned up one round apart. `PendingActionsRunner` keeps a failed action queued and re-runs + * it, so reporting per attempt took the count down once per retry — unbounded drift from one + * comment. And `createComment` can fail *before* it inserts anything (no cached account, for + * one), so the first attempt reporting a failure unconditionally gave back a count that was never + * taken. A `-1` is only owed for a `+1` that was actually reported. + */ + let countedByCaller = false; + const countRow = (commentId: string) => { + countedByCaller = true; + onOptimistic?.(commentId); + }; + const uncountRow = () => { + if (!countedByCaller) return; + countedByCaller = false; + onFailed?.(); + }; + const result = await createComment({ text, targetSpaceId, ancestorComments, - onOptimistic, + onOptimistic: countRow, }); if (!result) { - onFailed?.(); + uncountRow(); return result; } if (recordIfPublished(result)) return result; - /** - * Whether the caller is currently counting this comment. - * - * `PendingActionsRunner` keeps a failed action queued and its error toast offers a retry that - * runs this again, so `run` is not once-only. Reporting a failure every time it ran took the - * count down once per attempt — unbounded drift from a single comment — and a retry that - * finally succeeded never gave it back. So the two callbacks are a transition on this flag - * rather than an event per attempt: the caller is told at most once that the row is gone, and - * told again when a retry puts it back. - */ - let countedByCaller = true; - enqueuePendingAction({ id: `comment:${targetEntityId}:${result.id}`, label: 'your comment', @@ -106,19 +116,13 @@ export function usePublishComment( if (recordIfPublished(published)) { // A retry after a rollback: the comment exists again, so it counts again. - if (!countedByCaller) { - countedByCaller = true; - onOptimistic?.(result.id); - } + if (!countedByCaller) countRow(result.id); return; } // The row is gone — `useCreateComment` removes it on a failed publish — so anything the // caller counted for this comment comes back before the error is surfaced. - if (countedByCaller) { - countedByCaller = false; - onFailed?.(); - } + uncountRow(); throw new Error('Comment could not be published'); }, }); diff --git a/apps/web/partials/comments/inline-comment-composer.test.tsx b/apps/web/partials/comments/inline-comment-composer.test.tsx index ebf1d63bb1..6db2aee794 100644 --- a/apps/web/partials/comments/inline-comment-composer.test.tsx +++ b/apps/web/partials/comments/inline-comment-composer.test.tsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom/vitest'; -import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { Provider } from 'jotai'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -16,70 +16,6 @@ vi.mock('~/core/hooks/use-publish-comment', () => ({ usePublishComment: () => ({ publishComment: vi.fn(), editComment: vi.fn() }), })); -/** - * Whether a row should be holding its branch open for something the reader wrote. - * - * The rows gate real work on this: `DebateActivityRow` draws its spine and both collapse controls on - * it, `ExtractedClaimRow` mounts its replies on it, and `DebateBranch` lifts the gate on its comments - * query with it — because a comment written a second ago is a branch the server aggregate has not - * heard about yet. - * - * As a latching flag that meant a *rejected* publish left all three enabled for a comment that had - * been taken back out: a collapse control that collapsed nothing, and a fetch looking for a row that - * no longer existed. - */ -describe('useInlineComposer', () => { - it('holds nothing open until something is posted', () => { - const { result } = renderHook(() => useInlineComposer()); - - expect(result.current.hasPosted).toBe(false); - }); - - it('holds the branch open once a comment is posted, and closes the box', () => { - const { result } = renderHook(() => useInlineComposer()); - - act(() => result.current.open()); - expect(result.current.isComposing).toBe(true); - - act(() => result.current.markPosted('comment-1')); - - expect(result.current.hasPosted).toBe(true); - expect(result.current.isComposing).toBe(false); - }); - - it('stops holding it when that publish is rejected', () => { - const { result } = renderHook(() => useInlineComposer()); - - act(() => result.current.markPosted('comment-1')); - act(() => result.current.markPostRejected()); - - expect(result.current.hasPosted).toBe(false); - }); - - // A count rather than a flag, so one rejection does not take the branch away from a comment that - // stood. This is the case a boolean cannot represent in either direction. - it('keeps holding it for an earlier comment that stood', () => { - const { result } = renderHook(() => useInlineComposer()); - - act(() => result.current.markPosted('comment-1')); - act(() => result.current.markPosted('comment-2')); - act(() => result.current.markPostRejected()); - - expect(result.current.hasPosted).toBe(true); - }); - - it('never goes below nothing, however many rejections arrive', () => { - const { result } = renderHook(() => useInlineComposer()); - - act(() => result.current.markPosted('comment-1')); - act(() => result.current.markPostRejected()); - act(() => result.current.markPostRejected()); - act(() => result.current.markPosted('comment-2')); - - expect(result.current.hasPosted).toBe(true); - }); -}); - /** * A signed-out press, and what happens to it. * diff --git a/apps/web/partials/comments/inline-comment-composer.tsx b/apps/web/partials/comments/inline-comment-composer.tsx index c67049fb84..3ebf0fcbd9 100644 --- a/apps/web/partials/comments/inline-comment-composer.tsx +++ b/apps/web/partials/comments/inline-comment-composer.tsx @@ -64,7 +64,7 @@ export function InlineCommentComposer({ const { smartAccount } = useSmartAccount(); const promptSignIn = usePrivySignIn(); const isSignedIn = !!smartAccount; - const { isComposing, close, markPosted, markPostRejected } = composer; + const { isComposing, close } = composer; const adjustActivityPosts = useAdjustActivityPosts(); /** @@ -114,21 +114,15 @@ export function InlineCommentComposer({ void publishComment({ text, ancestorComments: ancestors, - onOptimistic: commentId => { - markPosted(commentId); - // The heading above counts things this section cannot see, so it cannot notice this on - // its own. - adjustActivityPosts(1); - }, + // The heading above counts things this section cannot see, so it cannot notice this on its + // own. Nothing else needs telling: the row's branch reads the live comment list, which the + // optimistic row is already in. + onOptimistic: () => adjustActivityPosts(1), // Whichever way it fails, and there are two: the transaction rejected now, or a publish // retained for a personal space that does not exist yet failing its retry later. Both take - // the optimistic row back out, so the heading gives back the one it counted and this row - // stops holding a branch open for a comment that is no longer there. Reading the returned - // value only ever saw the first of the two. - onFailed: () => { - markPostRejected(); - adjustActivityPosts(-1); - }, + // the optimistic row back out, so the heading gives back the one it counted. The branch + // needs no telling here either — the row leaving the list is the signal. + onFailed: () => adjustActivityPosts(-1), }); close(); }} @@ -148,35 +142,21 @@ export type InlineComposerState = ReturnType; export function useInlineComposer() { const [isComposing, setIsComposing] = React.useState(false); - /** - * How many comments written here are still standing. - * - * Sticky while it is above zero: the row keeps showing its thread even while the server count that - * gated it is still reporting nothing, because the aggregate has not heard about a comment written - * a second ago. - * - * A count rather than a flag, because a publish can be rejected. As a flag it latched true and - * stayed there, so a rejected first comment left the row holding a branch with nothing in it — a - * collapse control that collapsed nothing, and a comments fetch enabled to look for a comment that - * had been taken back out. Counting means a rejection can undo exactly its own post and a reader - * who published two comments and lost one keeps the branch for the one that stood. - */ - const [postedCount, setPostedCount] = React.useState(0); + // No record of what was posted from here, deliberately. There used to be one — a flag, then a count + // so a rejection could undo it — and the rows read it to hold their branch open for a comment the + // server aggregate had not heard about yet. It only knew about comments written from this row since + // this mount: navigate away and back and it reset, while the aggregate still said none, so the + // comment button reported the comment and the branch that would show it was gone. The rows read the + // live comment list instead, which every composer writes to, survives a remount, and drops a + // rejected comment on its own. What is left here is only whether the box is open. return React.useMemo( () => ({ isComposing, - hasPosted: postedCount > 0, toggle: () => setIsComposing(open => !open), open: () => setIsComposing(true), close: () => setIsComposing(false), - markPosted: (_commentId?: string) => { - setPostedCount(posted => posted + 1); - setIsComposing(false); - }, - /** The transaction was rejected and the optimistic row is gone, so this post no longer counts. */ - markPostRejected: () => setPostedCount(posted => Math.max(0, posted - 1)), }), - [isComposing, postedCount] + [isComposing] ); } From 3d6ff60179a881a136eb376b55d6c5af36f61cc5 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 26 Sep 2026 14:42:29 -0700 Subject: [PATCH 33/35] fix(claims): count a retried comment when its row returns, not when it succeeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings. Round 10 re-counted a comment when a retry succeeded, on the assumption that the retry had put its row back. It had not: with an existing id, `useCreateComment` only mapped over the cache, so a row an earlier failure filtered out stayed gone and the Activity heading counted a comment the thread was not drawing. Not re-counting was checked and rejected — it converges to the opposite disagreement once the indexer catches up. The counting was never wrong; its assumption about the row was. So the assumption is made true. `useCreateComment` inserts the optimistic row whenever it is not in the cache, and reports it through `onOptimistic` as a first attempt does; the retry passes the same counter. Every insertion is counted and every removal given back, on the row's own events. A retry whose row survived finds it cached and reports nothing. This touches a hook every comment surface shares, but only on the retry-after-failure path: the queued publish is the only caller that passes an id. The feed ordered debates by `debateDate`, which falls back to `updatedAt` — under a comment saying it ordered by `createdAt` precisely to avoid that. Ordering reads `debateCreatedDate` now, `createdAt` alone; display keeps the fallback. And the comments button announced an activity total as "Comments (N)". It reads its label off `commentsInCount`, the prop that already says the count is broader than comments, rather than a second prop a caller would have to keep in step. --- .../browse/claim-activity-order.test.ts | 32 +++++++ apps/web/core/claims/browse/claim-debates.tsx | 29 ++++--- .../claims/browse/use-claim-activity-rows.tsx | 8 +- .../core/hooks/use-create-comment.test.tsx | 87 +++++++++++++++++++ apps/web/core/hooks/use-create-comment.tsx | 18 +++- .../core/hooks/use-publish-comment.test.ts | 47 +++++++--- apps/web/core/hooks/use-publish-comment.ts | 13 +-- .../comments/entity-comments-button.test.tsx | 20 +++++ .../comments/entity-comments-button.tsx | 7 +- 9 files changed, 227 insertions(+), 34 deletions(-) create mode 100644 apps/web/core/hooks/use-create-comment.test.tsx diff --git a/apps/web/core/claims/browse/claim-activity-order.test.ts b/apps/web/core/claims/browse/claim-activity-order.test.ts index aad8b5be88..ab2468d5f3 100644 --- a/apps/web/core/claims/browse/claim-activity-order.test.ts +++ b/apps/web/core/claims/browse/claim-activity-order.test.ts @@ -4,6 +4,7 @@ import type { ClaimTiming } from '~/core/debates/claim-timing'; import type { TranscriptClaim } from '~/core/debates/transcript-claims'; import { activityTime, mergeActivityRows, orderExtractedClaims, orderNewestFirst } from './claim-activity-order'; +import { debateCreatedDate, debateDate } from './claim-debates'; function claim(id: string, overrides: Partial = {}): TranscriptClaim { return { @@ -360,3 +361,34 @@ describe('orderNewestFirst', () => { expect(orderNewestFirst([{ id: 'undated', createdAt: '' }, older]).map(c => c.id)).toEqual(['c-old', 'undated']); }); }); + +/** + * Where a debate goes in the feed is when it happened. `updatedAt` moves whenever anything touches the + * entity — a backlink from an unrelated edit included — so ordering by it floats an old debate up among + * recent activity. The feed's own comment said as much while it ordered by `debateDate`, which falls + * back to `updatedAt` when `createdAt` is missing. + */ +describe('the date a debate is ordered by', () => { + const touchedYesterday = { createdAt: null, updatedAt: '2026-09-25T10:00:00Z' } as unknown as Parameters< + typeof debateDate + >[0]; + + it('does not borrow `updatedAt` for ordering', () => { + expect(debateCreatedDate(touchedYesterday)).toBeNull(); + }); + + // A label is a different question: an approximate date beats none. + it('still borrows it for display', () => { + expect(debateDate(touchedYesterday)?.toISOString()).toBe('2026-09-25T10:00:00.000Z'); + }); + + it('so a debate with no creation date sorts to the tail rather than to the front', () => { + const comments = [{ id: 'c-week-ago', createdAt: '2026-09-19T10:00:00Z' }]; + const debates = [{ id: 'd-touched', createdAt: debateCreatedDate(touchedYesterday)?.toISOString() ?? '' }]; + + expect(mergeActivityRows(comments, debates, 'newest').map(entry => entry.row.id)).toEqual([ + 'c-week-ago', + 'd-touched', + ]); + }); +}); diff --git a/apps/web/core/claims/browse/claim-debates.tsx b/apps/web/core/claims/browse/claim-debates.tsx index cd98cfd21a..3c9309a331 100644 --- a/apps/web/core/claims/browse/claim-debates.tsx +++ b/apps/web/core/claims/browse/claim-debates.tsx @@ -63,13 +63,7 @@ const VOTE_FETCH_CAP = 500; * Renders nothing when the claim has never been debated. The invitation to be the first belongs * next to the readiness toggle, which is the control that acts on it — not in an empty module here. */ -export function ClaimDebates({ - claimId, - spaceId, -}: { - claimId: string; - spaceId: string; -}) { +export function ClaimDebates({ claimId, spaceId }: { claimId: string; spaceId: string }) { // 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 // left it. @@ -408,15 +402,30 @@ function DebateMeta({ debate, totalVotes }: { debate: Entity; totalVotes: number } /** - * When the debate was published, as a date. + * When the debate was published, as a date to *show*. * * `createdAt` and `updatedAt` are typed as "unix seconds or ISO 8601, varies by backend", so both * shapes are handled rather than assumed. `createdAt` is the one that means "when this debate * happened" — `updatedAt` moves whenever anything touches the entity, including a backlink from - * some unrelated edit. + * some unrelated edit. Falling back to it is acceptable for a label, where an approximate date beats + * none; it is not acceptable for a position — see {@link debateCreatedDate}. */ export function debateDate(debate: Entity): Date | null { - const raw = debate.createdAt ?? debate.updatedAt; + return entityTimestamp(debate.createdAt ?? debate.updatedAt); +} + +/** + * When the debate was published, and nothing else — for placing it among other rows. + * + * No `updatedAt` fallback, which is the whole difference from {@link debateDate}. Ordering by a + * timestamp that moves on any unrelated edit floats an old debate up among recent activity; a debate + * with no `createdAt` is better left undated, which the feed's ordering already sends to the tail. + */ +export function debateCreatedDate(debate: Entity): Date | null { + return entityTimestamp(debate.createdAt); +} + +function entityTimestamp(raw: string | number | null | undefined): Date | null { if (raw === undefined || raw === null) return null; const date = typeof raw === 'number' ? new Date(raw * 1000) : new Date(/^\d+$/.test(raw) ? Number(raw) * 1000 : raw); diff --git a/apps/web/core/claims/browse/use-claim-activity-rows.tsx b/apps/web/core/claims/browse/use-claim-activity-rows.tsx index c135a268ef..6a139148f2 100644 --- a/apps/web/core/claims/browse/use-claim-activity-rows.tsx +++ b/apps/web/core/claims/browse/use-claim-activity-rows.tsx @@ -19,7 +19,7 @@ import type { Entity } from '~/core/types'; import type { CommentActivityRow } from '~/partials/comments/types'; -import { debateDate, relationTargets } from './claim-debates'; +import { debateCreatedDate, debateDate, relationTargets } from './claim-debates'; import { DebateActivityRow } from './debate-activity-row'; import { useDebateKeyframes } from './use-debate-keyframes'; @@ -138,8 +138,10 @@ export function useClaimActivityRows({ spaceId, // `createdAt` rather than `updatedAt`: this row's place in the feed is when the debate // happened, and `updatedAt` moves whenever anything touches the entity — including a backlink - // from an unrelated edit, which would float an old debate to the top of the thread. - createdAt: debateDate(debate)?.toISOString() ?? '', + // from an unrelated edit, which would float an old debate to the top of the thread. This comment + // said so while the line under it used `debateDate`, which falls back to `updatedAt`; ordering + // now reads `createdAt` alone and a debate without one sorts to the tail as undated. + createdAt: debateCreatedDate(debate)?.toISOString() ?? '', content: ( ({ + readCachedSmartAccount: () => ({ account: { address: '0xabc' } }), + readCachedPersonalSpace: () => ({ personalSpaceId: null }), +})); +vi.mock('./use-smart-account', () => ({ useSmartAccount: () => ({ smartAccount: null }) })); +vi.mock('./use-personal-space-id', () => ({ usePersonalSpaceId: () => ({ personalSpaceId: null }) })); +vi.mock('./use-geo-profile', () => ({ useGeoProfile: () => ({ profile: null }) })); +vi.mock('./use-toast', () => ({ useToast: () => [null, vi.fn()] })); +vi.mock('~/core/state/status-bar-store', () => ({ useReportError: () => vi.fn() })); + +const { useCreateComment } = await import('./use-create-comment'); + +const TARGET = 'debate-1'; +const key = ['comments', TARGET]; + +function setup(cached: CommentEntity[] | undefined) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (cached) client.setQueryData(key, cached); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useCreateComment(TARGET), { wrapper }); + return { client, createComment: result.current.createComment }; +} + +function rowsIn(client: QueryClient) { + return (client.getQueryData(key) ?? []).map(comment => comment.id); +} + +/** + * A retry passes the id it was given. It used to skip inserting a row on the assumption that its row + * was still in the cache — which is not so after an earlier attempt failed, because the failure path + * filters that row out. So a retry that then succeeded published a comment the thread did not show, + * while the Activity heading counted it back in. + */ +describe('useCreateComment retrying a comment by id', () => { + beforeEach(() => vi.clearAllMocks()); + + it('puts the row back when an earlier failure took it out, and reports it', async () => { + const { client, createComment } = setup([]); + const onOptimistic = vi.fn(); + + await act(() => + createComment({ text: 'Back again', targetSpaceId: 'space-1', commentId: 'comment-1', onOptimistic }) + ); + + expect(rowsIn(client)).toEqual(['comment-1']); + expect(onOptimistic).toHaveBeenCalledWith('comment-1'); + }); + + // The ordinary retry, for a publish that was retained rather than failed: its row never left, so it + // is neither duplicated nor counted a second time. + it('leaves a row that is still there alone, and does not report it again', async () => { + const existing = { id: 'comment-1', createdAt: '2026-09-26T10:00:00Z', isPendingPublish: true } as CommentEntity; + const { client, createComment } = setup([existing]); + const onOptimistic = vi.fn(); + + await act(() => + createComment({ text: 'Still here', targetSpaceId: 'space-1', commentId: 'comment-1', onOptimistic }) + ); + + expect(rowsIn(client)).toEqual(['comment-1']); + expect(onOptimistic).not.toHaveBeenCalled(); + }); + + it('inserts and reports a first attempt as it always has', async () => { + const { client, createComment } = setup(undefined); + const onOptimistic = vi.fn(); + + await act(() => createComment({ text: 'First', targetSpaceId: 'space-1', onOptimistic })); + + expect(rowsIn(client)).toHaveLength(1); + expect(onOptimistic).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/core/hooks/use-create-comment.tsx b/apps/web/core/hooks/use-create-comment.tsx index 3b82ca3165..fbeea2e31d 100644 --- a/apps/web/core/hooks/use-create-comment.tsx +++ b/apps/web/core/hooks/use-create-comment.tsx @@ -162,7 +162,23 @@ export function useCreateComment(targetEntityId: string) { const commentName = getCommentName(text); const walletAddr = account.account.address; - if (!existingCommentId) { + // Insert the optimistic row whenever it is not in the cache — not only on a first attempt. + // + // A retry passes the id it was given, and used to skip this entirely on the assumption that its row + // was still there. It is not when an earlier attempt failed: the failure path below filters that + // row out. So a retry that then succeeded published a comment the thread did not show until the + // indexer caught up — and `usePublishComment` counted it back into the Activity heading on that + // success, so the heading reported a comment the list below it was not drawing. Putting the row + // back here, and reporting it through `onOptimistic` exactly as a first attempt does, keeps the row + // and every count that follows it moving on the same event. A retry whose row survived finds it + // cached and inserts nothing, so it is not counted twice. + const rowIsCached = + existingCommentId != null && + (queryClient.getQueryData(['comments', targetEntityId]) ?? []).some( + comment => comment.id === existingCommentId + ); + + if (!rowIsCached) { const profileSnapshot = cachedProfileRef.current; const profileAvatarUrl = profileSnapshot?.avatarUrl && profileSnapshot.avatarUrl !== PLACEHOLDER_SPACE_IMAGE diff --git a/apps/web/core/hooks/use-publish-comment.test.ts b/apps/web/core/hooks/use-publish-comment.test.ts index 9195928816..284c0ffc27 100644 --- a/apps/web/core/hooks/use-publish-comment.test.ts +++ b/apps/web/core/hooks/use-publish-comment.test.ts @@ -93,6 +93,8 @@ describe('usePublishComment', () => { targetSpaceId: 'space-1', ancestorComments: undefined, commentId: 'comment-1', + // The retry is counted when its row is put back, so it is handed the same counter. + onOptimistic: expect.any(Function), }); expect(mocks.commentCreated).toHaveBeenCalledTimes(1); expect(mocks.commentCreated).toHaveBeenCalledWith( @@ -238,16 +240,31 @@ describe('usePublishComment reporting a failure', () => { * times, as many times as the reader pressed retry — and a retry that finally succeeded never gave it * back. The two callbacks are a transition, not an event. */ - it('reports the loss once however many retries fail, and reports it back when one succeeds', async () => { - // `useCreateComment` inserts the optimistic row and calls `onOptimistic` when it is handed one, - // which is what the first attempt passes and the retries do not. + /** + * The count follows the row, and the row is what the reader can see. + * + * A failed attempt takes the optimistic row out (`useCreateComment`'s failure path), and a retry puts + * it back — it used to skip that, assuming its row was still there, and the success was counted + * regardless: the Activity heading reported a comment the thread was not drawing. Now every insertion + * is reported and every removal is reversed, so at each step the count equals the rows present. + * + * The stand-in models that cache: a row is inserted, and reported, only when it is not already there. + */ + it('keeps the count equal to the rows present through failed retries and a successful one', async () => { + let rowPresent = false; let nextResult: unknown = { id: 'comment-1', published: false }; mocks.createComment.mockImplementation(async (input: { onOptimistic?: (id: string) => void }) => { - input.onOptimistic?.('comment-1'); + if (!rowPresent) { + rowPresent = true; + input.onOptimistic?.('comment-1'); + } + // A failure takes the row back out, as the real hook does. + if (nextResult == null) rowPresent = false; return nextResult; }); const counted: number[] = []; + const total = () => counted.reduce((a, b) => a + b, 0); const { result } = renderHook(() => usePublishComment('claim-1', 'space-1')); await act(() => @@ -258,24 +275,26 @@ describe('usePublishComment reporting a failure', () => { }) ); - expect(counted).toEqual([1]); + // Retained: its row is there, and so is its count. + expect(total()).toBe(1); + expect(rowPresent).toBe(true); const queued = mocks.enqueuePendingAction.mock.calls[0]![0] as { run: () => Promise }; nextResult = null; - await expect(queued.run()).rejects.toThrow(); - await expect(queued.run()).rejects.toThrow(); - await expect(queued.run()).rejects.toThrow(); - - // Three failures, one row: one rollback. - expect(counted).toEqual([1, -1]); + for (let attempt = 0; attempt < 3; attempt++) { + await expect(queued.run()).rejects.toThrow(); + // Never above the rows actually present, however many attempts fail. + expect(total()).toBe(rowPresent ? 1 : 0); + expect(total()).toBeGreaterThanOrEqual(0); + } nextResult = { id: 'comment-1', published: true }; await queued.run(); - // The comment exists again, so it counts again — and the net is back to one. - expect(counted).toEqual([1, -1, 1]); - expect(counted.reduce((a, b) => a + b, 0)).toBe(1); + // The row is back, so the comment counts — once. + expect(rowPresent).toBe(true); + expect(total()).toBe(1); }); it('says nothing when the retry succeeds', async () => { diff --git a/apps/web/core/hooks/use-publish-comment.ts b/apps/web/core/hooks/use-publish-comment.ts index 31f5d87301..ae44713189 100644 --- a/apps/web/core/hooks/use-publish-comment.ts +++ b/apps/web/core/hooks/use-publish-comment.ts @@ -80,6 +80,7 @@ export function usePublishComment( */ let countedByCaller = false; const countRow = (commentId: string) => { + if (countedByCaller) return; countedByCaller = true; onOptimistic?.(commentId); }; @@ -112,13 +113,15 @@ export function usePublishComment( targetSpaceId, ancestorComments, commentId: result.id, + // Counted when its row is put back, not when the publish succeeds. An earlier failure took + // the row out, and `useCreateComment` reinserts it for this attempt and reports it here — + // so the count follows the row the reader can see. Re-counting on success instead, which + // is what this used to do, counted a comment the thread was not drawing, because nothing + // had put its row back. A retry whose row survived is found cached and not reported. + onOptimistic: countRow, }); - if (recordIfPublished(published)) { - // A retry after a rollback: the comment exists again, so it counts again. - if (!countedByCaller) countRow(result.id); - return; - } + if (recordIfPublished(published)) return; // The row is gone — `useCreateComment` removes it on a failed publish — so anything the // caller counted for this comment comes back before the error is surfaced. diff --git a/apps/web/partials/comments/entity-comments-button.test.tsx b/apps/web/partials/comments/entity-comments-button.test.tsx index 0745a90a8c..9a96f3a652 100644 --- a/apps/web/partials/comments/entity-comments-button.test.tsx +++ b/apps/web/partials/comments/entity-comments-button.test.tsx @@ -31,4 +31,24 @@ describe('EntityCommentsButton', () => { expect(mocks.openComments).toHaveBeenCalledWith('claim-1', 'space-1', 'claim'); }); + + /** + * On the Explore card the number is the claim's whole activity — its debates and the claims extracted + * from them as well as its comments. The button always announced it as "Comments", so a claim with one + * comment and a debate behind it was read out as having dozens of comments. + */ + it('announces an activity total as activity, not as comments', () => { + render( + + ); + + expect(screen.getByRole('button', { name: 'Activity (42)' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Comments/ })).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/partials/comments/entity-comments-button.tsx b/apps/web/partials/comments/entity-comments-button.tsx index 11c777e6e3..41ce99f171 100644 --- a/apps/web/partials/comments/entity-comments-button.tsx +++ b/apps/web/partials/comments/entity-comments-button.tsx @@ -64,7 +64,12 @@ export function EntityCommentsButton({ onActivate ? `Comment on ${targetEntityType}` : `Open ${targetEntityType} comments panel` } data-geo-analytics-intent={onActivate ? 'open_inline_composer' : 'open_comments_panel'} - aria-label={`Comments (${liveCount})`} + // Named for what the number measures. When `commentsInCount` is set the count is broader than this + // entity's comments — on the Explore card it is the claim's whole activity, its debates and the + // claims extracted from them included — and announcing it as "Comments (42)" on a claim with one + // comment tells a screen reader something false. The label reads off the same prop that already + // says the count is broader, rather than a second one a caller would have to keep in step with it. + aria-label={`${commentsInCount != null ? 'Activity' : 'Comments'} (${liveCount})`} aria-expanded={isOpen} onClick={event => { // These rows are commonly wrapped in a link to the entity. From 9f8e4ddee3ae509e42e3f8e1d26c8f2ee5c06a46 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 26 Sep 2026 16:00:46 -0700 Subject: [PATCH 34/35] docs(claims): stop describing the veracity kind as though it still exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2541 answered every claim with Agree/Disagree and removed the `veracity` response kind. Three comments this branch added still described the two vocabularies as current: the extracted claim row's contract said the vote control picks chevrons for veracity, and two were orphaned by the `responseVocabulary` removal during the merge that brought #2541 in — one a pair of lines left above an unrelated call in the claim page, the other a doc comment attached to no prop at all. The historical notes that say the kind was removed, and why, are accurate and stay. Two more stale references are master's own lines rather than this branch's, and are reported rather than changed. --- apps/web/core/claims/browse/claim-page-view.tsx | 2 -- apps/web/core/claims/browse/extracted-claim-row.tsx | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 67b3da9c80..a39ffb36c9 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -509,8 +509,6 @@ function ClaimOverviewTab({ hrefs: { debates: string; claims: string }; onSelectSystemTab?: (tab: ClaimSystemTab) => void; }) { - // The claim's own vocabulary carries into the thread: a debater's side reads Agree/Disagree on - // an opinion claim and Verify/Dispute on a factual one, the same as every commenter's badge. // The same number the claim's card shows in Explore, from the same query — one definition of // "how much has happened here", so the two surfaces cannot disagree. const activityCounts = useClaimActivityCounts(React.useMemo(() => [entityId], [entityId])); diff --git a/apps/web/core/claims/browse/extracted-claim-row.tsx b/apps/web/core/claims/browse/extracted-claim-row.tsx index 41f1f1fee2..374e2f51ed 100644 --- a/apps/web/core/claims/browse/extracted-claim-row.tsx +++ b/apps/web/core/claims/browse/extracted-claim-row.tsx @@ -48,8 +48,8 @@ export type SpeakerProfile = { name?: string | null; avatarUrl?: string | null } * * Built out of what the thread around it already uses: the comment row's own density metrics, the * same `Avatar` framing every other avatar in the app gets, `EntityVoteButtons` (which already - * draws responder faces beside the control and picks its icons from the response kind — thumbs for - * a stance, chevrons for veracity), and `EntityCommentsButton`. Nothing here is a new control. + * draws responder faces beside the control, and answers a claim with Agree/Disagree like every other + * claim surface), and `EntityCommentsButton`. Nothing here is a new control. */ export function ExtractedClaimRow({ claim, @@ -92,7 +92,6 @@ export function ExtractedClaimRow({ * Null when the speaker is unknown or the debate does not record a side for them. */ speakerPosition: boolean | null; - /** The claim page's vocabulary, so the tag reads Agree/Disagree or Verify/Dispute to match. */ /** * How many comments the claim has, counted by the server. * From c8f17da923c50845a5c66021de2fc9d73152241b Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Sat, 26 Sep 2026 16:28:59 -0700 Subject: [PATCH 35/35] fix(debates): let a timecode go when the link stops asking for one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seek latch kept the last request after the timecode was removed, because a missing timecode returned early without clearing it. So `?t=124`, then no `t`, then `?t=124` again — which browser Back produces — matched the stale latch and never seeked, though it was a new navigation asking for that moment. A missing timecode clears the latch now. A render that is merely not ready keeps it, so the same request still cannot fire twice across a buffering stall. --- .../browse/debate-feed-player.test.tsx | 31 +++++++++++++++++-- .../debates/browse/debate-feed-player.tsx | 11 ++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/web/core/debates/browse/debate-feed-player.test.tsx b/apps/web/core/debates/browse/debate-feed-player.test.tsx index d90ab2c214..80952393a7 100644 --- a/apps/web/core/debates/browse/debate-feed-player.test.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.test.tsx @@ -867,7 +867,7 @@ describe('a seek asked for by the link that brought the reader here', () => { return { seeks, /** Re-render as the feed does, optionally with the media now seekable or a different debate. */ - update(next: { ready?: boolean; active?: boolean; debateId?: string; seekSeconds?: number } = {}) { + update(next: { ready?: boolean; active?: boolean; debateId?: string; seekSeconds?: number | null } = {}) { mocks.controller = controllerFixture({ mutedByUser: true, turnSlot: 1, @@ -878,7 +878,7 @@ describe('a seek asked for by the link that brought the reader here', () => { ); }, @@ -940,6 +940,33 @@ describe('a seek asked for by the link that brought the reader here', () => { expect(player.seeks).toEqual([124]); }); + /** + * A timecode, then none, then the same timecode again is a new request for that moment — it is what + * browser Back produces after following a link and then a plain one. The latch kept the first request + * through the gap, so the return matched it and the player never seeked. + */ + it('seeks again for the same moment once the timecode has gone and come back', () => { + const player = renderWithSeek(124); + expect(player.seeks).toEqual([124]); + + player.update({ seekSeconds: null }); + expect(player.seeks).toEqual([124]); + + player.update({ seekSeconds: 124 }); + expect(player.seeks).toEqual([124, 124]); + }); + + // Letting go of the latch is for a missing timecode only. A render that is merely not ready must not + // clear it, or the same request would fire twice across a buffering stall. + it('does not let go of the latch just because the media is not ready', () => { + const player = renderWithSeek(124); + + player.update({ ready: false }); + player.update({ ready: true }); + + expect(player.seeks).toEqual([124]); + }); + it('does nothing at all when the link named no moment', () => { const player = renderWithSeek(null); diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index e6742f7a66..687b134e2f 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -161,7 +161,16 @@ export function DebateFeedPlayer({ */ const appliedInitialSeekRef = React.useRef(null); React.useEffect(() => { - if (initialSeekSeconds == null || !ready) return; + // No moment asked for means the last one has been let go. Without this the latch outlived the + // request that set it: `?t=124`, then the parameter dropped, then `?t=124` again — browser Back + // does exactly that — matched the stale latch and never seeked, though it was a new navigation + // asking for that moment. Only a missing timecode clears it; a render that is merely not ready + // yet keeps it, so the same request still cannot fire twice. + if (initialSeekSeconds == null) { + appliedInitialSeekRef.current = null; + return; + } + if (!ready) return; const request = `${debate.id}@${initialSeekSeconds}`; if (appliedInitialSeekRef.current === request) return; appliedInitialSeekRef.current = request;